fix: tolerate malformed cron schedule reloads

This commit is contained in:
Peter Steinberger
2026-05-02 04:26:01 +01:00
parent 65404ceabb
commit 9c307a3a50
3 changed files with 40 additions and 15 deletions

View File

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

View File

@@ -61,18 +61,6 @@ function resolveSchedulePayload(
return schedulePayloadFromRecord(job);
}
function cronScheduleIdentity(job: Pick<CronJob, "schedule"> & { enabled?: boolean }): string {
const schedule = resolveSchedulePayload(job as unknown as Record<string, unknown>);
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, unknown>,
): string | undefined {
@@ -88,8 +76,14 @@ export function tryCronScheduleIdentity(
}
export function cronSchedulingInputsEqual(
previous: Pick<CronJob, "schedule"> & { enabled?: boolean },
next: Pick<CronJob, "schedule"> & { enabled?: boolean },
previous: Pick<CronJob, "schedule"> & { enabled?: unknown },
next: Pick<CronJob, "schedule"> & { enabled?: unknown },
): boolean {
return cronScheduleIdentity(previous) === cronScheduleIdentity(next);
const previousIdentity = tryCronScheduleIdentity(previous as Record<string, unknown>);
const nextIdentity = tryCronScheduleIdentity(next as Record<string, unknown>);
return (
previousIdentity !== undefined &&
nextIdentity !== undefined &&
previousIdentity === nextIdentity
);
}

View File

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