fix(cron): honor updated recurring schedule anchors (#128893)

This commit is contained in:
Peter Steinberger
2026-08-24 16:18:48 -07:00
committed by GitHub
parent 2ec0757c2c
commit 9abfc1b4a3
2 changed files with 85 additions and 45 deletions
@@ -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();
+6 -6
View File
@@ -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;
}