diff --git a/src/cron/service.update-recompute-drops-due-every.test.ts b/src/cron/service.update-recompute-drops-due-every.test.ts index 18e3776f5eb6..4c5b7db20ad1 100644 --- a/src/cron/service.update-recompute-drops-due-every.test.ts +++ b/src/cron/service.update-recompute-drops-due-every.test.ts @@ -73,51 +73,91 @@ describe("update() must not drop a due every-job's pending run", () => { cron.stop(); }); - it("re-anchors an every-job to the edit time when the interval actually changes", async () => { - const store = await makeStorePath(); - const base = Date.parse("2025-12-13T00:00:00.000Z"); + it.each([ + { name: "before its first run", previousEveryMs: 10_000, nextEveryMs: 3_600_000 }, + { + name: "after a completed run when the interval increases", + previousEveryMs: 10_000, + nextEveryMs: 3_600_000, + completedRun: true, + }, + { + name: "after a completed run when the interval decreases", + previousEveryMs: 60_000, + nextEveryMs: 10_000, + completedRun: true, + }, + { + name: "at an explicit future anchor after a completed run", + previousEveryMs: 10_000, + nextEveryMs: 3_600_000, + completedRun: true, + futureAnchorOffsetMs: 7_200_000, + }, + ])( + "re-anchors an every-job $name", + async ({ previousEveryMs, nextEveryMs, completedRun, futureAnchorOffsetMs }) => { + const store = await makeStorePath(); + const base = Date.parse("2025-12-13T00:00:00.000Z"); - const finished = createFinishedBarrier(); - const cron = new CronService({ - storePath: store.storePath, - cronEnabled: true, - log: noopLogger, - enqueueSystemEvent: vi.fn(), - requestHeartbeat: vi.fn(), - runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), - onEvent: finished.onEvent, - }); + const finished = createFinishedBarrier(); + const cron = new CronService({ + storePath: store.storePath, + cronEnabled: true, + log: noopLogger, + enqueueSystemEvent: vi.fn(), + requestHeartbeat: vi.fn(), + runIsolatedAgentJob: vi.fn(async () => ({ status: "ok" as const })), + onEvent: finished.onEvent, + }); - await cron.start(); + await cron.start(); - const job = await cron.add({ - name: "every 10s", - enabled: true, - schedule: { kind: "every", everyMs: 10_000 }, - sessionTarget: "isolated", - wakeMode: "next-heartbeat", - payload: { kind: "agentTurn", message: "tick" }, - }); - const jobId = job.id; - expect(job.schedule).toMatchObject({ kind: "every", anchorMs: base }); + const job = await cron.add({ + name: "every 10s", + enabled: true, + schedule: { kind: "every", everyMs: previousEveryMs }, + sessionTarget: "isolated", + wakeMode: "next-heartbeat", + payload: { kind: "agentTurn", message: "tick" }, + }); + const jobId = job.id; + expect(job.schedule).toMatchObject({ kind: "every", anchorMs: base }); - // User edits the interval from 10s to 1h. The control UI omits the internal - // anchorMs, so the new cadence must start from the edit time rather than - // keeping the old phase; nextRunAtMs is one new interval from now. - const editTime = base + 3_000; - vi.setSystemTime(new Date(editTime)); - await cron.update(jobId, { schedule: { kind: "every", everyMs: 3_600_000 } }); + let editTime = base + 3_000; + if (completedRun) { + vi.setSystemTime(new Date(base + previousEveryMs + 5)); + const firstRun = finished.waitForOk(jobId); + await vi.runOnlyPendingTimersAsync(); + await firstRun; + const completedJob = (await cron.list({ includeDisabled: true })).find( + (candidate) => candidate.id === jobId, + )!; + editTime = completedJob.state.lastRunAtMs! + 3_000; + } - const current = (await cron.list({ includeDisabled: true })).find((j) => j.id === jobId)!; - expect(current.schedule).toMatchObject({ - kind: "every", - everyMs: 3_600_000, - anchorMs: editTime, - }); - expect(current.state.nextRunAtMs).toBe(editTime + 3_600_000); + vi.setSystemTime(new Date(editTime)); + const futureAnchorMs = + futureAnchorOffsetMs === undefined ? undefined : editTime + futureAnchorOffsetMs; + await cron.update(jobId, { + schedule: { + kind: "every", + everyMs: nextEveryMs, + ...(futureAnchorMs === undefined ? {} : { anchorMs: futureAnchorMs }), + }, + }); - cron.stop(); - }); + const current = (await cron.list({ includeDisabled: true })).find((j) => j.id === jobId)!; + expect(current.schedule).toMatchObject({ + kind: "every", + everyMs: nextEveryMs, + anchorMs: futureAnchorMs ?? editTime, + }); + expect(current.state.nextRunAtMs).toBe(futureAnchorMs ?? editTime + nextEveryMs); + + cron.stop(); + }, + ); it("preserves a due cron-job nextRunAtMs on an idempotent schedule re-save", async () => { const store = await makeStorePath(); diff --git a/src/cron/service/jobs-scheduling.ts b/src/cron/service/jobs-scheduling.ts index e063b485a793..e57f76842b12 100644 --- a/src/cron/service/jobs-scheduling.ts +++ b/src/cron/service/jobs-scheduling.ts @@ -323,8 +323,13 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und if (everyMs < 1) { return undefined; } + const fallbackAnchorMs = isFiniteTimestamp(job.createdAtMs) ? job.createdAtMs : nowMs; + const anchorMs = resolveEveryAnchorMs({ + schedule: job.schedule, + fallbackAnchorMs, + }); const lastRunAtMs = job.state.lastRunAtMs; - if (isFiniteTimestamp(lastRunAtMs)) { + if (isFiniteTimestamp(lastRunAtMs) && lastRunAtMs >= anchorMs) { const nextFromLastRun = Math.floor(lastRunAtMs) + everyMs; if (!isFiniteTimestamp(nextFromLastRun)) { return undefined; @@ -333,11 +338,6 @@ export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | und return nextFromLastRun; } } - const fallbackAnchorMs = isFiniteTimestamp(job.createdAtMs) ? job.createdAtMs : nowMs; - const anchorMs = resolveEveryAnchorMs({ - schedule: job.schedule, - fallbackAnchorMs, - }); const next = computeNextRunAtMs({ ...job.schedule, everyMs, anchorMs }, nowMs); return isFiniteTimestamp(next) ? next : undefined; }