Skip to content

Commit 5ada3ac

Browse files
feat(doctor): warn about in-flight cron jobs
Squashed from PR #98620 after updating branch with current main and passing CI.
1 parent be2c4c6 commit 5ada3ac

2 files changed

Lines changed: 89 additions & 0 deletions

File tree

src/commands/doctor/cron/index.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,66 @@ describe("maybeRepairLegacyCronStore", () => {
392392
expectNoteContaining("Examples: alias-pinned -> gpt", "Cron");
393393
});
394394

395+
describe("in-flight cron job advisory", () => {
396+
const RUNNING_AT_MS = Date.parse("2026-05-01T00:00:00.000Z");
397+
398+
it("warns about jobs still marked in-flight without touching the store", async () => {
399+
const storePath = await makeTempStorePath();
400+
await writeCurrentCronStore(storePath, [
401+
createCurrentCronJob({ id: "running-job", state: { runningAtMs: RUNNING_AT_MS } }),
402+
]);
403+
const prompter = makePrompter(true);
404+
405+
await maybeRepairLegacyCronStore({
406+
cfg: createCronConfig(storePath),
407+
options: {},
408+
prompter,
409+
});
410+
411+
expectNoteContaining("1 cron job is still marked in-flight", "Cron");
412+
expectNoteContaining("shows it as `running`", "Cron");
413+
expectNoteContaining("marks such runs interrupted the next time it starts", "Cron");
414+
expectNoteContaining("openclaw cron show <id>", "Cron");
415+
416+
// Observer-only: no repair prompt and the running marker is left untouched.
417+
expect(prompter.confirm).not.toHaveBeenCalled();
418+
const jobs = await readPersistedJobs(storePath);
419+
const state = requireRecord(requirePersistedJob(jobs, 0).state, "cron state");
420+
expect(state.runningAtMs).toBe(RUNNING_AT_MS);
421+
expect(state.lastRunStatus).toBeUndefined();
422+
});
423+
424+
it("pluralizes the advisory when multiple jobs are in-flight", async () => {
425+
const storePath = await makeTempStorePath();
426+
await writeCurrentCronStore(storePath, [
427+
createCurrentCronJob({ id: "running-a", state: { runningAtMs: RUNNING_AT_MS } }),
428+
createCurrentCronJob({ id: "running-b", state: { runningAtMs: RUNNING_AT_MS + 1000 } }),
429+
]);
430+
431+
await maybeRepairLegacyCronStore({
432+
cfg: createCronConfig(storePath),
433+
options: {},
434+
prompter: makePrompter(true),
435+
});
436+
437+
expectNoteContaining("2 cron jobs are still marked in-flight", "Cron");
438+
expectNoteContaining("shows them as `running`", "Cron");
439+
});
440+
441+
it("stays silent when no job is marked in-flight", async () => {
442+
const storePath = await makeTempStorePath();
443+
await writeCurrentCronStore(storePath, [createCurrentCronJob({ id: "idle-job" })]);
444+
445+
await maybeRepairLegacyCronStore({
446+
cfg: createCronConfig(storePath),
447+
options: {},
448+
prompter: makePrompter(true),
449+
});
450+
451+
expectNoNoteContaining("still marked in-flight", "Cron");
452+
});
453+
});
454+
395455
it("repairs legacy cron store fields and migrates notify fallback to webhook delivery", async () => {
396456
const storePath = await makeTempStorePath();
397457
await writeCronStore(storePath, [createLegacyCronJob()]);

src/commands/doctor/cron/index.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,22 @@ function errorMessage(err: unknown): string {
6666
return err instanceof Error ? err.message : String(err);
6767
}
6868

69+
// Count jobs the store still marks in-flight (`state.runningAtMs` is a number).
70+
// The scheduler sets this while a run is active and clears it on completion, so a
71+
// leftover marker (gateway killed mid-run) makes `cron list` show the job as
72+
// `running` while nothing executes it. Startup marks exactly these runs interrupted
73+
// (`src/cron/service/ops.ts` `start`), so doctor only reports the count here.
74+
function countInFlightCronJobs(jobs: Array<Record<string, unknown>>): number {
75+
return jobs.filter((job) => {
76+
const state = job.state;
77+
return (
78+
typeof state === "object" &&
79+
state !== null &&
80+
typeof (state as { runningAtMs?: unknown }).runningAtMs === "number"
81+
);
82+
}).length;
83+
}
84+
6985
type LegacyCronRepairState = {
7086
storePath: string;
7187
quarantinePath: string;
@@ -387,6 +403,19 @@ export async function maybeRepairLegacyCronStore(params: {
387403
}
388404
noteCronModelOverrides({ cfg: params.cfg, jobs: rawJobs, storePath });
389405

406+
const inFlightCount = countInFlightCronJobs(rawJobs);
407+
if (inFlightCount > 0) {
408+
const subject = inFlightCount === 1 ? "it" : "them";
409+
note(
410+
[
411+
`${pluralize(inFlightCount, "cron job")} ${inFlightCount === 1 ? "is" : "are"} still marked in-flight (\`state.runningAtMs\` is set), so ${formatCliCommand("openclaw cron list")} shows ${subject} as \`running\`.`,
412+
`- If no gateway is currently executing ${subject}, the marker is left over from an interrupted run; the gateway marks such runs interrupted the next time it starts.`,
413+
`- Review with ${formatCliCommand("openclaw cron list")} or ${formatCliCommand("openclaw cron show <id>")}.`,
414+
].join("\n"),
415+
"Cron",
416+
);
417+
}
418+
390419
const normalized = normalizeStoredCronJobs(rawJobs);
391420
const notifyCount = rawJobs.filter((job) => job.notify === true).length;
392421
const dreamingStaleCount = countStaleDreamingJobs(rawJobs);

0 commit comments

Comments
 (0)