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>
This commit is contained in:
大猫子
2026-02-12 13:33:15 +08:00
committed by GitHub
parent b0dfb83952
commit a88ea42ec7
3 changed files with 98 additions and 1 deletions

View File

@@ -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.

View File

@@ -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");

View File

@@ -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;