From a88ea42ec76e8c7b4dab2b5aac4dc2849abeef25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A7=E7=8C=AB=E5=AD=90?= Date: Thu, 12 Feb 2026 13:33:15 +0800 Subject: [PATCH] fix(cron): prevent one-shot at jobs from re-firing on restart after skip/error (#13845) (#13878) Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/cron/service.issue-regressions.test.ts | 93 ++++++++++++++++++++++ src/cron/service/timer.ts | 5 +- 3 files changed, 98 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6845649dae..aeff0e600a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Cron: prevent one-shot `at` jobs from re-firing on gateway restart when previously skipped or errored. (#13845) - Discord: add exec approval cleanup option to delete DMs after approval/denial/timeout. (#13205) Thanks @thewilloftheshadow. - Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @Glucksberg, @gumadeiras. - CI: Implement pipeline and workflow order. Thanks @quotentiroler. diff --git a/src/cron/service.issue-regressions.test.ts b/src/cron/service.issue-regressions.test.ts index cac5b0bab45..83d7cab8060 100644 --- a/src/cron/service.issue-regressions.test.ts +++ b/src/cron/service.issue-regressions.test.ts @@ -304,6 +304,99 @@ describe("Cron issue regressions", () => { await store.cleanup(); }); + it("#13845: one-shot job with lastStatus=skipped does not re-fire on restart", async () => { + const store = await makeStorePath(); + const pastAt = Date.parse("2026-02-06T09:00:00.000Z"); + // Simulate a one-shot job that was previously skipped (e.g. main session busy). + // On the old code, runMissedJobs only checked lastStatus === "ok", so a + // skipped job would pass through and fire again on every restart. + const skippedJob: CronJob = { + id: "oneshot-skipped", + name: "reminder", + enabled: true, + deleteAfterRun: true, + createdAtMs: pastAt - 60_000, + updatedAtMs: pastAt, + schedule: { kind: "at", at: new Date(pastAt).toISOString() }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "⏰ Reminder" }, + state: { + nextRunAtMs: pastAt, + lastStatus: "skipped", + lastRunAtMs: pastAt, + }, + }; + await fs.writeFile( + store.storePath, + JSON.stringify({ version: 1, jobs: [skippedJob] }, null, 2), + "utf-8", + ); + + const enqueueSystemEvent = vi.fn(); + const cron = new CronService({ + cronEnabled: true, + storePath: store.storePath, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }), + }); + + // start() calls runMissedJobs internally + await cron.start(); + + // The skipped one-shot job must NOT be re-enqueued + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + + cron.stop(); + await store.cleanup(); + }); + + it("#13845: one-shot job with lastStatus=error does not re-fire on restart", async () => { + const store = await makeStorePath(); + const pastAt = Date.parse("2026-02-06T09:00:00.000Z"); + const errorJob: CronJob = { + id: "oneshot-errored", + name: "reminder", + enabled: true, + deleteAfterRun: true, + createdAtMs: pastAt - 60_000, + updatedAtMs: pastAt, + schedule: { kind: "at", at: new Date(pastAt).toISOString() }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "⏰ Reminder" }, + state: { + nextRunAtMs: pastAt, + lastStatus: "error", + lastRunAtMs: pastAt, + lastError: "heartbeat failed", + }, + }; + await fs.writeFile( + store.storePath, + JSON.stringify({ version: 1, jobs: [errorJob] }, null, 2), + "utf-8", + ); + + const enqueueSystemEvent = vi.fn(); + const cron = new CronService({ + cronEnabled: true, + storePath: store.storePath, + log: noopLogger, + enqueueSystemEvent, + requestHeartbeatNow: vi.fn(), + runIsolatedAgentJob: vi.fn().mockResolvedValue({ status: "ok" }), + }); + + await cron.start(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + + cron.stop(); + await store.cleanup(); + }); + it("records per-job start time and duration for batched due jobs", async () => { const store = await makeStorePath(); const dueAt = Date.parse("2026-02-06T10:05:01.000Z"); diff --git a/src/cron/service/timer.ts b/src/cron/service/timer.ts index 802ff63b706..653c4d1a017 100644 --- a/src/cron/service/timer.ts +++ b/src/cron/service/timer.ts @@ -369,7 +369,10 @@ export async function runMissedJobs(state: CronServiceState) { return false; } const next = j.state.nextRunAtMs; - if (j.schedule.kind === "at" && j.state.lastStatus === "ok") { + if (j.schedule.kind === "at" && j.state.lastStatus) { + // Any terminal status (ok, error, skipped) means the job already + // ran at least once. Don't re-fire it on restart — applyJobResult + // disables one-shot jobs, but guard here defensively (#13845). return false; } return typeof next === "number" && now >= next;