diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b53d52eba1..e6711b49316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ Docs: https://docs.openclaw.ai - Cron/Timer: keep a watchdog recheck timer armed while `onTimer` is actively executing so the scheduler continues polling even if a due-run tick stalls for an extended period. (#23628) Thanks @dsgraves. - Cron/Run: enforce the same per-job timeout guard for manual `cron.run` executions as timer-driven runs, including abort propagation for isolated agent jobs, so forced runs cannot wedge indefinitely. (#23704) Thanks @tkuehnl. - Cron/Status: split execution outcome (`lastRunStatus`) from delivery outcome (`lastDeliveryStatus`) in persisted cron state, finished events, and run history so failed/unknown announcement delivery is visible without conflating it with run errors. +- Cron/Schedule: for `every` jobs, prefer `lastRunAtMs + everyMs` when still in the future after restarts, then fall back to anchor scheduling for catch-up windows, so NEXT timing matches the last successful cadence. (#22895) Thanks @SidQin-cyber. - Agents/Compaction: restore embedded compaction safeguard/context-pruning extension loading in production by wiring bundled extension factories into the resource loader instead of runtime file-path resolution. (#22349) Thanks @Glucksberg. - Feishu/Media: for inbound video messages that include both `file_key` (video) and `image_key` (thumbnail), prefer `file_key` when downloading media so video attachments are saved instead of silently failing on thumbnail keys. (#23633) - Hooks/Cron: suppress duplicate main-session events for delivered hook turns and mark `SILENT_REPLY_TOKEN` (`NO_REPLY`) early exits as delivered to prevent hook context pollution. (#20678) Thanks @JonathanWorks. diff --git a/src/cron/service.issue-22895-every-next-run.test.ts b/src/cron/service.issue-22895-every-next-run.test.ts new file mode 100644 index 00000000000..0104d53e040 --- /dev/null +++ b/src/cron/service.issue-22895-every-next-run.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { computeJobNextRunAtMs } from "./service/jobs.js"; +import type { CronJob } from "./types.js"; + +const EVERY_30_MIN_MS = 30 * 60_000; +const ANCHOR_MS = Date.parse("2026-02-22T09:14:00.000Z"); + +function createEveryJob(state: CronJob["state"]): CronJob { + return { + id: "issue-22895", + name: "every-30-min", + enabled: true, + createdAtMs: ANCHOR_MS, + updatedAtMs: ANCHOR_MS, + schedule: { kind: "every", everyMs: EVERY_30_MIN_MS, anchorMs: ANCHOR_MS }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "check cadence" }, + delivery: { mode: "none" }, + state, + }; +} + +describe("Cron issue #22895 interval scheduling", () => { + it("uses lastRunAtMs cadence when the next interval is still in the future", () => { + const nowMs = Date.parse("2026-02-22T10:10:00.000Z"); + const job = createEveryJob({ + lastRunAtMs: Date.parse("2026-02-22T10:04:00.000Z"), + }); + + const nextFromLast = computeJobNextRunAtMs(job, nowMs); + const nextFromAnchor = computeJobNextRunAtMs( + { ...job, state: { ...job.state, lastRunAtMs: undefined } }, + nowMs, + ); + + expect(nextFromLast).toBe(job.state.lastRunAtMs! + EVERY_30_MIN_MS); + expect(nextFromAnchor).toBe(Date.parse("2026-02-22T10:14:00.000Z")); + expect(nextFromLast).toBeGreaterThan(nextFromAnchor!); + }); + + it("falls back to anchor scheduling when lastRunAtMs cadence is already in the past", () => { + const nowMs = Date.parse("2026-02-22T10:40:00.000Z"); + const job = createEveryJob({ + lastRunAtMs: Date.parse("2026-02-22T10:04:00.000Z"), + }); + + const next = computeJobNextRunAtMs(job, nowMs); + expect(next).toBe(Date.parse("2026-02-22T10:44:00.000Z")); + }); +}); diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 623ee9132da..19b8d26e91b 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -113,11 +113,19 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und return undefined; } if (job.schedule.kind === "every") { + const everyMs = Math.max(1, Math.floor(job.schedule.everyMs)); + const lastRunAtMs = job.state.lastRunAtMs; + if (typeof lastRunAtMs === "number" && Number.isFinite(lastRunAtMs)) { + const nextFromLastRun = Math.floor(lastRunAtMs) + everyMs; + if (nextFromLastRun > nowMs) { + return nextFromLastRun; + } + } const anchorMs = resolveEveryAnchorMs({ schedule: job.schedule, fallbackAnchorMs: job.createdAtMs, }); - return computeNextRunAtMs({ ...job.schedule, anchorMs }, nowMs); + return computeNextRunAtMs({ ...job.schedule, everyMs, anchorMs }, nowMs); } if (job.schedule.kind === "at") { // One-shot jobs stay due until they successfully finish.