From a5385155f37a3caaf0428161694227c7c0eb8379 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 20 Apr 2026 11:20:29 -0400 Subject: [PATCH] cron: document split runtime state store --- CHANGELOG.md | 1 + docs/automation/cron-jobs.md | 3 ++- docs/automation/tasks.md | 2 +- src/cron/service/jobs.ts | 1 - src/cron/service/ops.ts | 1 - src/cron/store.test.ts | 32 ++++++++++++++++++++++++++++++++ src/cron/types-shared.ts | 1 - 7 files changed, 36 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 403aa4db05f..d26a9f23559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Plugins/tests: reuse plugin loader alias and Jiti config resolution across repeated same-context loads, reducing import-heavy test overhead. (#69316) Thanks @amknight. +- Cron: split runtime execution state into `jobs-state.json` so `jobs.json` stays stable for git-tracked job definitions. (#63105) Thanks @Feelw00. ### Fixes diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 45c59006a38..dd6b70fcc79 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -33,7 +33,8 @@ openclaw cron runs --id ## How cron works - Cron runs **inside the Gateway** process (not inside the model). -- Jobs persist at `~/.openclaw/cron/jobs.json` so restarts do not lose schedules. +- 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`. - 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/docs/automation/tasks.md b/docs/automation/tasks.md index 8b01365c59a..186e2499488 100644 --- a/docs/automation/tasks.md +++ b/docs/automation/tasks.md @@ -301,7 +301,7 @@ See [Task Flow](/automation/taskflow) for details. ### Tasks and cron -A cron job **definition** lives in `~/.openclaw/cron/jobs.json`. **Every** cron execution creates a task record — both main-session and isolated. Main-session cron tasks default to `silent` notify policy so they track without generating notifications. +A cron job **definition** lives in `~/.openclaw/cron/jobs.json`; runtime execution state lives beside it in `~/.openclaw/cron/jobs-state.json`. **Every** cron execution creates a task record — both main-session and isolated. Main-session cron tasks default to `silent` notify policy so they track without generating notifications. See [Cron Jobs](/automation/cron-jobs). diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index ade58ddb3d6..f9905d92a95 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -589,7 +589,6 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo deleteAfterRun, createdAtMs: now, updatedAtMs: now, - configUpdatedAtMs: now, schedule, sessionTarget: input.sessionTarget, wakeMode: input.wakeMode, diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 2df74be42ae..067cf35735f 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -307,7 +307,6 @@ export async function update(state: CronServiceState, id: string, patch: CronJob const enabledChanged = patch.enabled !== undefined; job.updatedAtMs = now; - job.configUpdatedAtMs = now; if (scheduleChanged || enabledChanged) { if (isJobEnabled(job)) { job.state.nextRunAtMs = computeJobNextRunAtMs(job, now); diff --git a/src/cron/store.test.ts b/src/cron/store.test.ts index 68073256f29..fe02b271a0e 100644 --- a/src/cron/store.test.ts +++ b/src/cron/store.test.ts @@ -154,6 +154,38 @@ describe("cron store", () => { await expect(fs.stat(`${store.storePath}.bak`)).rejects.toThrow(); }); + it("keeps state separate for custom store paths without a json suffix", async () => { + const store = await makeStorePath(); + const storePath = store.storePath.replace(/\.json$/, ""); + const statePath = `${storePath}-state.json`; + const first = makeStore("job-1", true); + const second: CronStoreFile = { + ...first, + jobs: first.jobs.map((job) => ({ + ...job, + updatedAtMs: job.updatedAtMs + 60_000, + state: { + ...job.state, + nextRunAtMs: job.createdAtMs + 60_000, + }, + })), + }; + + await saveCronStore(storePath, first); + await saveCronStore(storePath, second); + + const config = JSON.parse(await fs.readFile(storePath, "utf-8")); + expect(Array.isArray(config.jobs)).toBe(true); + expect(config.jobs[0].id).toBe("job-1"); + expect(config.jobs[0].state).toEqual({}); + + const stateFile = JSON.parse(await fs.readFile(statePath, "utf-8")); + expect(stateFile.jobs["job-1"].state.nextRunAtMs).toBe(first.jobs[0].createdAtMs + 60_000); + + const loaded = await loadCronStore(storePath); + expect(loaded.jobs[0]?.state.nextRunAtMs).toBe(first.jobs[0].createdAtMs + 60_000); + }); + it.skipIf(process.platform === "win32")( "writes store and backup files with secure permissions", async () => { diff --git a/src/cron/types-shared.ts b/src/cron/types-shared.ts index f52cfdd132b..68c7f0c97a3 100644 --- a/src/cron/types-shared.ts +++ b/src/cron/types-shared.ts @@ -9,7 +9,6 @@ export type CronJobBase