Skip to content

Commit 29f8715

Browse files
IWhatsskillclawsweeper[bot]Takhoffman
authored
[AI-assisted] fix(cron): preserve legacy array stores (#84433)
Summary: - The PR changes cron store loading to normalize legacy top-level array `jobs.json` files into the versioned store shape and adds store, service, doctor, gateway tests plus a changelog entry. - Reproducibility: yes. Current `main` clearly maps a top-level parsed array to `{}` before reading `.jobs`, and the PR body supplies before/after runtime output for the load/add/save path. Automerge notes: - PR branch already contained follow-up commit before automerge: [AI-assisted] fix(cron): preserve legacy array stores Validation: - ClawSweeper review passed for head 446014b. - Required merge gates passed before the squash merge. Prepared head SHA: 446014b Review: #84433 (comment) Co-authored-by: JARVIS-Glasses <284122573+JARVIS-Glasses@users.noreply.github.com> Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: takhoffman Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
1 parent 5c39e00 commit 29f8715

6 files changed

Lines changed: 246 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
1717
- Codex: give `image_generate` dynamic-tool calls a 120s default watchdog when no per-call or configured image timeout is set, so image generation no longer falls back to the generic 30s bridge timeout. (#84254) Thanks @moritzmmayerhofer.
1818
- Codex: avoid duplicate dynamic tool terminal diagnostics while large diagnostic backlogs drain without blocking tool responses. (#82937) Thanks @galiniliev.
1919
- CLI/message: include a stable top-level `messageId` in `openclaw message --json` output when channel sends return one. (#84191) Thanks @100menotu001.
20+
- Cron: preserve legacy top-level array `jobs.json` stores when loading or adding scheduled jobs so old cron jobs are no longer treated as an empty store during upgrade. Fixes #60799. (#84433) Thanks @IWhatsskill.
2021
- Gateway/agents: use an agent's `identity.name` in Gateway agent summaries when `agents.list[].name` is unset, so configured agent labels remain visible in clients. (#84355; refs #57835) Thanks @luoyanglang.
2122
- Plugins/hooks: apply a default 30-second timeout to `before_compaction` and `after_compaction` hooks so a hung plugin handler no longer blocks compaction completion. (#84153)
2223
- Discord: preserve disabled presentation buttons when adapting and rendering Discord message controls. (#84188) Thanks @100menotu001.

src/commands/doctor-cron.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ async function writeCronStore(storePath: string, jobs: Array<Record<string, unkn
8080
);
8181
}
8282

83+
async function writeLegacyCronArrayStore(storePath: string, jobs: Array<Record<string, unknown>>) {
84+
await fs.mkdir(path.dirname(storePath), { recursive: true });
85+
await fs.writeFile(storePath, JSON.stringify(jobs, null, 2), "utf-8");
86+
}
87+
8388
async function readPersistedJobs(storePath: string): Promise<Array<Record<string, unknown>>> {
8489
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
8590
jobs: Array<Record<string, unknown>>;
@@ -300,6 +305,29 @@ describe("maybeRepairLegacyCronStore", () => {
300305
expectNoteContaining("Cron store normalized", "Doctor changes");
301306
});
302307

308+
it("repairs legacy top-level array cron stores instead of treating them as empty (#60799)", async () => {
309+
const storePath = await makeTempStorePath();
310+
await writeLegacyCronArrayStore(storePath, [createLegacyCronJob()]);
311+
312+
await maybeRepairLegacyCronStore({
313+
cfg: createCronConfig(storePath),
314+
options: {},
315+
prompter: makePrompter(true),
316+
});
317+
318+
const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
319+
version?: unknown;
320+
jobs?: Array<Record<string, unknown>>;
321+
};
322+
const job = requirePersistedJob(persisted.jobs ?? [], 0);
323+
expect(persisted.version).toBe(1);
324+
expect(job.jobId).toBeUndefined();
325+
expect(job.id).toBe("legacy-job");
326+
expect(job.notify).toBeUndefined();
327+
expectNoteContaining("Legacy cron job storage detected", "Cron");
328+
expectNoteContaining("Cron store normalized", "Doctor changes");
329+
});
330+
303331
it("repairs malformed persisted cron ids before list rendering sees them", async () => {
304332
const storePath = await makeTempStorePath();
305333
await writeCronStore(storePath, [

src/cron/service/ops.test.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { findTaskByRunId, resetTaskRegistryForTests } from "../../tasks/task-reg
66
import { setupCronServiceSuite, writeCronStoreSnapshot } from "../service.test-harness.js";
77
import { loadCronStore } from "../store.js";
88
import type { CronJob } from "../types.js";
9-
import { run, start, stop, update } from "./ops.js";
9+
import { add, run, start, stop, update } from "./ops.js";
1010
import { createCronServiceState } from "./state.js";
1111
import { runMissedJobs } from "./timer.js";
1212

@@ -101,6 +101,11 @@ async function writeDueIsolatedJobSnapshot(storePath: string, now: number) {
101101
});
102102
}
103103

104+
async function writeLegacyCronArraySnapshot(storePath: string, jobs: CronJob[]) {
105+
await fs.mkdir(path.dirname(storePath), { recursive: true });
106+
await fs.writeFile(storePath, JSON.stringify(jobs, null, 2), "utf-8");
107+
}
108+
104109
async function expectDueIsolatedManualRunProgresses(storePath: string, now: number) {
105110
const state = createOkIsolatedCronState({ storePath, now, summary: "done" });
106111

@@ -153,6 +158,59 @@ function createMissedIsolatedJob(now: number): CronJob {
153158
}
154159

155160
describe("cron service ops seam coverage", () => {
161+
it("preserves legacy top-level array jobs when adding a new job (#60799)", async () => {
162+
const { storePath } = await makeStorePath();
163+
const now = Date.parse("2026-05-20T08:00:00.000Z");
164+
const legacyJobs: CronJob[] = [
165+
{
166+
id: "legacy-alpha",
167+
name: "legacy alpha",
168+
enabled: true,
169+
createdAtMs: now - 120_000,
170+
updatedAtMs: now - 120_000,
171+
schedule: { kind: "every", everyMs: 3_600_000 },
172+
sessionTarget: "main",
173+
wakeMode: "next-heartbeat",
174+
payload: { kind: "systemEvent", text: "alpha" },
175+
state: { nextRunAtMs: now + 3_600_000 },
176+
},
177+
{
178+
id: "legacy-beta",
179+
name: "legacy beta",
180+
enabled: true,
181+
createdAtMs: now - 60_000,
182+
updatedAtMs: now - 60_000,
183+
schedule: { kind: "every", everyMs: 7_200_000 },
184+
sessionTarget: "main",
185+
wakeMode: "next-heartbeat",
186+
payload: { kind: "systemEvent", text: "beta" },
187+
state: { nextRunAtMs: now + 7_200_000 },
188+
},
189+
];
190+
await writeLegacyCronArraySnapshot(storePath, legacyJobs);
191+
const state = createOkIsolatedCronState({ storePath, now });
192+
193+
const newJob = await add(state, {
194+
name: "new after upgrade",
195+
enabled: true,
196+
schedule: { kind: "every", everyMs: 10_800_000 },
197+
sessionTarget: "main",
198+
wakeMode: "next-heartbeat",
199+
payload: { kind: "systemEvent", text: "new" },
200+
});
201+
if (state.timer) {
202+
clearTimeout(state.timer);
203+
}
204+
205+
const loaded = await loadCronStore(storePath);
206+
const raw = JSON.parse(await fs.readFile(storePath, "utf-8")) as {
207+
jobs: Array<Record<string, unknown>>;
208+
};
209+
210+
expect(loaded.jobs.map((job) => job.id)).toEqual(["legacy-alpha", "legacy-beta", newJob.id]);
211+
expect(raw.jobs.map((job) => job.id)).toEqual(["legacy-alpha", "legacy-beta", newJob.id]);
212+
});
213+
156214
it("start marks interrupted running jobs failed, persists, and arms the timer", async () => {
157215
const { storePath } = await makeStorePath();
158216
const now = Date.parse("2026-03-23T12:00:00.000Z");

src/cron/store.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,66 @@ describe("cron store", () => {
136136
expect(loaded.jobs[0]?.enabled).toBe(true);
137137
});
138138

139+
it("loads legacy top-level array stores instead of treating them as empty", async () => {
140+
const store = await makeStorePath();
141+
const first = makeStore("legacy-array-1", true).jobs[0];
142+
const second = makeStore("legacy-array-2", false).jobs[0];
143+
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
144+
await fs.writeFile(
145+
store.storePath,
146+
JSON.stringify([first, "bad-row", null, second], null, 2),
147+
"utf-8",
148+
);
149+
150+
const loaded = await loadCronStore(store.storePath);
151+
152+
expect(loaded.version).toBe(1);
153+
expect(loaded.jobs.map((job) => job.id)).toEqual(["legacy-array-1", "legacy-array-2"]);
154+
expect(loaded.jobs[0]?.state).toStrictEqual(first.state);
155+
expect(loaded.jobs[1]?.enabled).toBe(false);
156+
});
157+
158+
it("loads legacy top-level array stores synchronously", async () => {
159+
const store = await makeStorePath();
160+
const job = makeStore("legacy-array-sync", true).jobs[0];
161+
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
162+
await fs.writeFile(store.storePath, JSON.stringify([job], null, 2), "utf-8");
163+
164+
const loaded = loadCronStoreSync(store.storePath);
165+
166+
expect(loaded.jobs).toHaveLength(1);
167+
expect(loaded.jobs[0]?.id).toBe("legacy-array-sync");
168+
expect(loaded.jobs[0]?.state).toStrictEqual(job.state);
169+
});
170+
171+
it("preserves legacy top-level array jobs through a load-add-save round trip", async () => {
172+
const store = await makeStorePath();
173+
const legacy = makeStore("legacy-array-preserved", true).jobs[0];
174+
legacy.state = { nextRunAtMs: legacy.createdAtMs + 60_000 };
175+
const added = makeStore("new-job", true).jobs[0];
176+
await fs.mkdir(path.dirname(store.storePath), { recursive: true });
177+
await fs.writeFile(store.storePath, JSON.stringify([legacy], null, 2), "utf-8");
178+
179+
const loaded = await loadCronStore(store.storePath);
180+
loaded.jobs.push(added);
181+
await saveCronStore(store.storePath, loaded);
182+
183+
const config = JSON.parse(await fs.readFile(store.storePath, "utf-8")) as {
184+
version?: unknown;
185+
jobs?: Array<Record<string, unknown>>;
186+
};
187+
const stateFile = JSON.parse(
188+
await fs.readFile(store.storePath.replace(/\.json$/, "-state.json"), "utf-8"),
189+
) as { jobs: Record<string, { state?: Record<string, unknown> }> };
190+
191+
expect(config.version).toBe(1);
192+
expect(config.jobs?.map((job) => job.id)).toEqual(["legacy-array-preserved", "new-job"]);
193+
expect(config.jobs?.[0]?.state).toStrictEqual({});
194+
expect(stateFile.jobs["legacy-array-preserved"]?.state?.nextRunAtMs).toBe(
195+
legacy.createdAtMs + 60_000,
196+
);
197+
});
198+
139199
it("skips non-object persisted jobs instead of hydrating scalar rows", async () => {
140200
const store = await makeStorePath();
141201
const valid = makeStore("job-valid", true).jobs[0];

src/cron/store.ts

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ function isRecord(value: unknown): value is Record<string, unknown> {
5454
return !!value && typeof value === "object" && !Array.isArray(value);
5555
}
5656

57+
function normalizeCronStoreFile(parsed: unknown): CronStoreFile {
58+
const rawJobs = Array.isArray(parsed)
59+
? parsed
60+
: isRecord(parsed) && Array.isArray(parsed.jobs)
61+
? parsed.jobs
62+
: [];
63+
return {
64+
version: 1,
65+
jobs: rawJobs.filter(isRecord) as never as CronStoreFile["jobs"],
66+
};
67+
}
68+
5769
function stripRuntimeOnlyCronFields(store: CronStoreFile): unknown {
5870
return {
5971
version: store.version,
@@ -156,10 +168,7 @@ function loadStateFileSync(statePath: string): CronStateFile | null {
156168

157169
function hasInlineState(jobs: Array<Record<string, unknown> | null | undefined>): boolean {
158170
return jobs.some(
159-
(job) =>
160-
job != null &&
161-
isRecord(job.state) &&
162-
Object.keys(job.state).length > 0,
171+
(job) => job != null && isRecord(job.state) && Object.keys(job.state).length > 0,
163172
);
164173
}
165174

@@ -215,23 +224,13 @@ export async function loadCronStore(storePath: string): Promise<CronStoreFile> {
215224
cause: err,
216225
});
217226
}
218-
const parsedRecord =
219-
parsed && typeof parsed === "object" && !Array.isArray(parsed)
220-
? (parsed as Record<string, unknown>)
221-
: {};
222-
const jobs = Array.isArray(parsedRecord.jobs)
223-
? (parsedRecord.jobs.filter(isRecord) as never[])
224-
: [];
225-
const store = {
226-
version: 1 as const,
227-
jobs: jobs.filter(Boolean) as never as CronStoreFile["jobs"],
228-
};
227+
const store = normalizeCronStoreFile(parsed);
228+
const jobs = store.jobs as unknown as Array<Record<string, unknown>>;
229229

230230
// Load state file and merge.
231231
const statePath = resolveStatePath(storePath);
232232
const stateFile = await loadStateFile(statePath);
233-
const hasLegacyInlineState =
234-
!stateFile && hasInlineState(jobs as unknown as Array<Record<string, unknown>>);
233+
const hasLegacyInlineState = !stateFile && hasInlineState(jobs);
235234

236235
if (stateFile) {
237236
// State file exists: merge state by job ID. Inline state in jobs.json is ignored.
@@ -285,21 +284,11 @@ export function loadCronStoreSync(storePath: string): CronStoreFile {
285284
cause: err,
286285
});
287286
}
288-
const parsedRecord =
289-
parsed && typeof parsed === "object" && !Array.isArray(parsed)
290-
? (parsed as Record<string, unknown>)
291-
: {};
292-
const jobs = Array.isArray(parsedRecord.jobs)
293-
? (parsedRecord.jobs.filter(isRecord) as never[])
294-
: [];
295-
const store = {
296-
version: 1 as const,
297-
jobs: jobs.filter(Boolean) as never as CronStoreFile["jobs"],
298-
};
287+
const store = normalizeCronStoreFile(parsed);
288+
const jobs = store.jobs as unknown as Array<Record<string, unknown>>;
299289

300290
const stateFile = loadStateFileSync(resolveStatePath(storePath));
301-
const hasLegacyInlineState =
302-
!stateFile && hasInlineState(jobs as unknown as Array<Record<string, unknown>>);
291+
const hasLegacyInlineState = !stateFile && hasInlineState(jobs);
303292

304293
if (stateFile) {
305294
for (const job of store.jobs) {

src/gateway/server.cron.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,85 @@ describe("gateway server cron", () => {
527527
}
528528
});
529529

530+
test("cron.add preserves legacy top-level array stores (#60799)", async () => {
531+
const { prevSkipCron } = await setupCronTestRun({
532+
tempPrefix: "openclaw-gw-cron-legacy-array-",
533+
cronEnabled: false,
534+
});
535+
const storePath = testState.cronStorePath;
536+
expect(typeof storePath).toBe("string");
537+
const now = Date.parse("2026-05-20T08:00:00.000Z");
538+
await fs.writeFile(
539+
storePath as string,
540+
JSON.stringify(
541+
[
542+
{
543+
id: "gw-legacy-alpha",
544+
name: "gateway legacy alpha",
545+
enabled: true,
546+
createdAtMs: now - 120_000,
547+
updatedAtMs: now - 120_000,
548+
schedule: { kind: "every", everyMs: 3_600_000 },
549+
sessionTarget: "main",
550+
wakeMode: "next-heartbeat",
551+
payload: { kind: "systemEvent", text: "alpha" },
552+
state: { nextRunAtMs: now + 3_600_000 },
553+
},
554+
{
555+
id: "gw-legacy-beta",
556+
name: "gateway legacy beta",
557+
enabled: true,
558+
createdAtMs: now - 60_000,
559+
updatedAtMs: now - 60_000,
560+
schedule: { kind: "every", everyMs: 7_200_000 },
561+
sessionTarget: "main",
562+
wakeMode: "next-heartbeat",
563+
payload: { kind: "systemEvent", text: "beta" },
564+
state: { nextRunAtMs: now + 7_200_000 },
565+
},
566+
],
567+
null,
568+
2,
569+
),
570+
"utf-8",
571+
);
572+
const cronState = await createDirectCronState();
573+
574+
try {
575+
const addRes = await directCronReq(cronState, "cron.add", {
576+
name: "gateway new after upgrade",
577+
enabled: true,
578+
schedule: { kind: "every", everyMs: 10_800_000 },
579+
sessionTarget: "main",
580+
wakeMode: "next-heartbeat",
581+
payload: { kind: "systemEvent", text: "new" },
582+
});
583+
const newJobId = expectCronJobIdFromResponse(addRes);
584+
585+
const persisted = JSON.parse(await fs.readFile(storePath as string, "utf-8")) as {
586+
version?: unknown;
587+
jobs?: Array<Record<string, unknown>>;
588+
};
589+
expect(persisted.version).toBe(1);
590+
expect(persisted.jobs?.map((job) => job.id)).toEqual([
591+
"gw-legacy-alpha",
592+
"gw-legacy-beta",
593+
newJobId,
594+
]);
595+
596+
const listRes = await directCronReq(cronState, "cron.list", { includeDisabled: true });
597+
expect(listRes.ok).toBe(true);
598+
const listedJobs = (listRes.payload as { jobs?: Array<Record<string, unknown>> } | null)
599+
?.jobs;
600+
expect(listedJobs?.map((job) => job.id)).toEqual(
601+
expect.arrayContaining(["gw-legacy-alpha", "gw-legacy-beta", newJobId]),
602+
);
603+
expect(listedJobs).toHaveLength(3);
604+
} finally {
605+
await cleanupCronTestRun({ cronState, prevSkipCron });
606+
}
607+
});
608+
530609
test("handles cron patch merge and validation semantics", { timeout: 45_000 }, async () => {
531610
const { prevSkipCron } = await setupCronTestRun({
532611
tempPrefix: "openclaw-gw-cron-patch-",

0 commit comments

Comments
 (0)