cron: harden split state loading

This commit is contained in:
Gustavo Madeira Santana
2026-04-20 11:56:27 -04:00
parent a1db26707d
commit b09fb16a9a
3 changed files with 49 additions and 1 deletions

View File

@@ -35,6 +35,7 @@ openclaw cron runs --id <job-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:<jobId>` session when the run completes, so detached browser automation does not leave orphaned processes behind.

View File

@@ -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 () => {

View File

@@ -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<CronStoreFile> {
try {
const raw = await fs.promises.readFile(storePath, "utf-8");
@@ -140,7 +152,7 @@ export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
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);