Skip to content

Commit 5870f97

Browse files
committed
fix(cron): skip completed restart catchup slots
Closes #101988
1 parent 082bd45 commit 5870f97

2 files changed

Lines changed: 67 additions & 1 deletion

File tree

src/cron/service.restart-catchup.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,60 @@ describe("CronService restart catch-up", () => {
270270
}
271271
});
272272

273+
it("does not defer an isolated cron agent-turn whose persisted due slot already succeeded", async () => {
274+
const store = await makeStorePath();
275+
const startNow = Date.parse("2025-12-13T11:00:00.000Z");
276+
const dueAt = Date.parse("2025-12-13T09:10:00.000Z");
277+
const completedAt = Date.parse("2025-12-13T09:10:30.000Z");
278+
const runIsolatedAgentJob = vi.fn(async () => ({ status: "ok" as const }));
279+
const enqueueSystemEvent = vi.fn();
280+
const requestHeartbeat = vi.fn();
281+
282+
await writeStoreJobs(store.storePath, [
283+
{
284+
id: "startup-isolated-agent-already-ok",
285+
name: "startup isolated agent already ok",
286+
enabled: true,
287+
createdAtMs: Date.parse("2025-12-10T12:00:00.000Z"),
288+
updatedAtMs: completedAt,
289+
schedule: { kind: "cron", expr: "10 9 * * *", tz: "UTC" },
290+
sessionTarget: "isolated",
291+
wakeMode: "next-heartbeat",
292+
payload: { kind: "agentTurn", message: "daily reminder" },
293+
state: {
294+
nextRunAtMs: dueAt,
295+
lastRunAtMs: completedAt,
296+
lastRunStatus: "ok",
297+
},
298+
},
299+
]);
300+
301+
const cron = createRestartCronService({
302+
storePath: store.storePath,
303+
enqueueSystemEvent,
304+
requestHeartbeat,
305+
runIsolatedAgentJob,
306+
nowMs: () => startNow,
307+
startupDeferredMissedAgentJobDelayMs: 120_000,
308+
});
309+
310+
try {
311+
await cron.start();
312+
313+
expect(runIsolatedAgentJob).not.toHaveBeenCalled();
314+
expect(enqueueSystemEvent).not.toHaveBeenCalled();
315+
expect(requestHeartbeat).not.toHaveBeenCalled();
316+
317+
const listedJobs = await cron.list({ includeDisabled: true });
318+
const updated = listedJobs.find((job) => job.id === "startup-isolated-agent-already-ok");
319+
expect(updated?.state.lastRunStatus).toBe("ok");
320+
expect(updated?.state.nextRunAtMs).toBe(Date.parse("2025-12-14T09:10:00.000Z"));
321+
} finally {
322+
cron.stop();
323+
await store.cleanup();
324+
}
325+
});
326+
273327
it("marks interrupted recurring jobs failed instead of replaying them on startup", async () => {
274328
const dueAt = Date.parse("2025-12-13T16:00:00.000Z");
275329
const staleRunningAt = Date.parse("2025-12-13T16:30:00.000Z");

src/cron/service/timer.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1640,7 +1640,19 @@ function isRunnableJob(params: {
16401640
return false;
16411641
}
16421642
if (hasScheduledNextRunAtMs(next) && nowMs >= next) {
1643-
return true;
1643+
// Startup loads persisted state before maintenance recompute; an `ok`
1644+
// run that already covered this stale due slot must fall through to the
1645+
// previous-slot guard so restart catch-up does not duplicate it.
1646+
const alreadyCompletedDueCronSlot =
1647+
params.allowCronMissedRunByLastRun &&
1648+
job.schedule.kind === "cron" &&
1649+
lastRunStatus === "ok" &&
1650+
typeof job.state.lastRunAtMs === "number" &&
1651+
Number.isFinite(job.state.lastRunAtMs) &&
1652+
job.state.lastRunAtMs >= next;
1653+
if (!alreadyCompletedDueCronSlot) {
1654+
return true;
1655+
}
16441656
}
16451657
if (!params.allowCronMissedRunByLastRun || job.schedule.kind !== "cron") {
16461658
return false;

0 commit comments

Comments
 (0)