From b09fb16a9a632fe0135fc735437f71f3fe70726a Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 20 Apr 2026 11:56:27 -0400 Subject: [PATCH] cron: harden split state loading --- docs/automation/cron-jobs.md | 1 + src/cron/store.test.ts | 35 +++++++++++++++++++++++++++++++++++ src/cron/store.ts | 14 +++++++++++++- 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 868abacfcaf..bf8caa127c0 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -35,6 +35,7 @@ openclaw cron runs --id - Cron runs **inside the Gateway** process (not inside the model). - Job definitions persist at `~/.openclaw/cron/jobs.json` so restarts do not lose schedules. - Runtime execution state persists next to it in `~/.openclaw/cron/jobs-state.json`. If you track cron definitions in git, track `jobs.json` and gitignore `jobs-state.json`. +- After the split, older OpenClaw versions can read `jobs.json` but may treat jobs as fresh because runtime fields now live in `jobs-state.json`. - All cron executions create [background task](/automation/tasks) records. - One-shot jobs (`--at`) auto-delete after success by default. - Isolated cron runs best-effort close tracked browser tabs/processes for their `cron:` session when the run completes, so detached browser automation does not leave orphaned processes behind. diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index fe02b271a0e..d434e78385e 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -186,6 +186,41 @@ describe("cron store", () => { expect(loaded.jobs[0]?.state.nextRunAtMs).toBe(first.jobs[0].createdAtMs + 60_000); }); + it("sanitizes invalid updatedAtMs values from the state sidecar", async () => { + const store = await makeStorePath(); + const job = makeStore("job-1", true).jobs[0]; + const config = { + version: 1, + jobs: [{ ...job, state: {}, updatedAtMs: undefined }], + }; + const statePath = store.storePath.replace(/\.json$/, "-state.json"); + + await fs.mkdir(path.dirname(store.storePath), { recursive: true }); + await fs.writeFile(store.storePath, JSON.stringify(config, null, 2), "utf-8"); + await fs.writeFile( + statePath, + JSON.stringify( + { + version: 1, + jobs: { + [job.id]: { + updatedAtMs: "invalid", + state: { nextRunAtMs: job.createdAtMs + 60_000 }, + }, + }, + }, + null, + 2, + ), + "utf-8", + ); + + const loaded = await loadCronStore(store.storePath); + + expect(loaded.jobs[0]?.updatedAtMs).toBe(job.createdAtMs); + expect(loaded.jobs[0]?.state.nextRunAtMs).toBe(job.createdAtMs + 60_000); + }); + it.skipIf(process.platform === "win32")( "writes store and backup files with secure permissions", async () => { diff --git a/src/cron/store.ts b/src/cron/store.ts index 85d1582c2c4..af1108ece54 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -110,6 +110,18 @@ function backfillMissingRuntimeFields(job: CronStoreFile["jobs"][number]): void } } +function resolveUpdatedAtMs(job: CronStoreFile["jobs"][number], updatedAtMs: unknown): number { + if (typeof updatedAtMs === "number" && Number.isFinite(updatedAtMs)) { + return updatedAtMs; + } + if (typeof job.updatedAtMs === "number" && Number.isFinite(job.updatedAtMs)) { + return job.updatedAtMs; + } + return typeof job.createdAtMs === "number" && Number.isFinite(job.createdAtMs) + ? job.createdAtMs + : Date.now(); +} + export async function loadCronStore(storePath: string): Promise { try { const raw = await fs.promises.readFile(storePath, "utf-8"); @@ -140,7 +152,7 @@ export async function loadCronStore(storePath: string): Promise { for (const job of store.jobs) { const entry = stateFile.jobs[job.id]; if (entry) { - job.updatedAtMs = entry.updatedAtMs ?? job.updatedAtMs; + job.updatedAtMs = resolveUpdatedAtMs(job, entry.updatedAtMs); job.state = (entry.state ?? {}) as never; } else { backfillMissingRuntimeFields(job);