From 9c307a3a50e5d0b2417a1d4a35f619c57cf9f40f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 04:26:01 +0100 Subject: [PATCH] fix: tolerate malformed cron schedule reloads --- CHANGELOG.md | 1 + src/cron/schedule-identity.ts | 24 +++++++++--------------- src/cron/service/store.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cb026400c4..71f7bfc4bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Cron: make scheduler reload schedule comparison tolerate malformed persisted jobs, so one bad cron entry no longer aborts the whole tick. Fixes #75886. Thanks @samfox-ai. - Control UI/chat: keep live replies visible when a raw session alias such as `main` sends the chat turn but Gateway emits events under the canonical session key for the same run. Fixes #73716. Thanks @teebes. - CLI/models: reject `--agent` on `openclaw models set` and `set-image` instead of silently writing agent-scoped requests to global model defaults. Fixes #68391. Thanks @derrickabellard. - CLI: stop treating the legacy singular `openclaw tool ...` token as a plugin id under restrictive `plugins.allow`, so it falls through as a normal unknown/reserved command instead of suggesting a stale allowlist entry. Fixes #64732. Thanks @efe-arv, @SweetSophia, and @hashtag1974. diff --git a/src/cron/schedule-identity.ts b/src/cron/schedule-identity.ts index 99c6f6fa2bc..d7dc2254348 100644 --- a/src/cron/schedule-identity.ts +++ b/src/cron/schedule-identity.ts @@ -61,18 +61,6 @@ function resolveSchedulePayload( return schedulePayloadFromRecord(job); } -function cronScheduleIdentity(job: Pick & { enabled?: boolean }): string { - const schedule = resolveSchedulePayload(job as unknown as Record); - if (!schedule) { - throw new Error("Unsupported cron schedule kind"); - } - return JSON.stringify({ - version: 1, - enabled: job.enabled ?? true, - schedule, - }); -} - export function tryCronScheduleIdentity( job: { schedule?: unknown; enabled?: unknown } & Record, ): string | undefined { @@ -88,8 +76,14 @@ export function tryCronScheduleIdentity( } export function cronSchedulingInputsEqual( - previous: Pick & { enabled?: boolean }, - next: Pick & { enabled?: boolean }, + previous: Pick & { enabled?: unknown }, + next: Pick & { enabled?: unknown }, ): boolean { - return cronScheduleIdentity(previous) === cronScheduleIdentity(next); + const previousIdentity = tryCronScheduleIdentity(previous as Record); + const nextIdentity = tryCronScheduleIdentity(next as Record); + return ( + previousIdentity !== undefined && + nextIdentity !== undefined && + previousIdentity === nextIdentity + ); } diff --git a/src/cron/service/store.test.ts b/src/cron/service/store.test.ts index 48f21fbc823..a28a9da17f4 100644 --- a/src/cron/service/store.test.ts +++ b/src/cron/service/store.test.ts @@ -279,6 +279,36 @@ describe("cron service store seam coverage", () => { expect(findJobOrThrow(state, "reload-cron-expr-job").state.nextRunAtMs).toBe(dueNextRunAtMs); }); + it("clears stale nextRunAtMs without throwing when a force-reloaded schedule is malformed", async () => { + const { storePath } = await makeStorePath(); + const staleNextRunAtMs = STORE_TEST_NOW + 3_600_000; + + await writeSingleJobStore(storePath, { + ...createReloadCronJob({ + state: { nextRunAtMs: staleNextRunAtMs }, + }), + }); + + const state = createStoreTestState(storePath); + await ensureLoaded(state, { skipRecompute: true }); + + await writeSingleJobStore(storePath, { + ...createReloadCronJob({ + updatedAtMs: STORE_TEST_NOW, + state: { nextRunAtMs: staleNextRunAtMs }, + }), + schedule: "0 17 * * *", + }); + + await expect(ensureLoaded(state, { forceReload: true, skipRecompute: true })).resolves.toBe( + undefined, + ); + + const reloadedJob = findJobOrThrow(state, "reload-cron-expr-job"); + expect(reloadedJob.schedule).toBe("0 17 * * *"); + expect(reloadedJob.state.nextRunAtMs).toBeUndefined(); + }); + it("preserves nextRunAtMs after force reload when scheduling inputs are unchanged", async () => { const { storePath } = await makeStorePath(); const originalNextRunAtMs = STORE_TEST_NOW + 3_600_000;