mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user