feat(doctor): warn about in-flight cron jobs

Squashed from PR #98620 after updating branch with current main and passing CI.
This commit is contained in:
Masato Hoshino
2026-07-01 06:41:37 -07:00
committed by GitHub
parent be2c4c65ab
commit 5ada3acb5a
2 changed files with 89 additions and 0 deletions
+60
View File
@@ -392,6 +392,66 @@ describe("maybeRepairLegacyCronStore", () => {
expectNoteContaining("Examples: alias-pinned -> gpt", "Cron");
});
describe("in-flight cron job advisory", () => {
const RUNNING_AT_MS = Date.parse("2026-05-01T00:00:00.000Z");
it("warns about jobs still marked in-flight without touching the store", async () => {
const storePath = await makeTempStorePath();
await writeCurrentCronStore(storePath, [
createCurrentCronJob({ id: "running-job", state: { runningAtMs: RUNNING_AT_MS } }),
]);
const prompter = makePrompter(true);
await maybeRepairLegacyCronStore({
cfg: createCronConfig(storePath),
options: {},
prompter,
});
expectNoteContaining("1 cron job is still marked in-flight", "Cron");
expectNoteContaining("shows it as `running`", "Cron");
expectNoteContaining("marks such runs interrupted the next time it starts", "Cron");
expectNoteContaining("openclaw cron show <id>", "Cron");
// Observer-only: no repair prompt and the running marker is left untouched.
expect(prompter.confirm).not.toHaveBeenCalled();
const jobs = await readPersistedJobs(storePath);
const state = requireRecord(requirePersistedJob(jobs, 0).state, "cron state");
expect(state.runningAtMs).toBe(RUNNING_AT_MS);
expect(state.lastRunStatus).toBeUndefined();
});
it("pluralizes the advisory when multiple jobs are in-flight", async () => {
const storePath = await makeTempStorePath();
await writeCurrentCronStore(storePath, [
createCurrentCronJob({ id: "running-a", state: { runningAtMs: RUNNING_AT_MS } }),
createCurrentCronJob({ id: "running-b", state: { runningAtMs: RUNNING_AT_MS + 1000 } }),
]);
await maybeRepairLegacyCronStore({
cfg: createCronConfig(storePath),
options: {},
prompter: makePrompter(true),
});
expectNoteContaining("2 cron jobs are still marked in-flight", "Cron");
expectNoteContaining("shows them as `running`", "Cron");
});
it("stays silent when no job is marked in-flight", async () => {
const storePath = await makeTempStorePath();
await writeCurrentCronStore(storePath, [createCurrentCronJob({ id: "idle-job" })]);
await maybeRepairLegacyCronStore({
cfg: createCronConfig(storePath),
options: {},
prompter: makePrompter(true),
});
expectNoNoteContaining("still marked in-flight", "Cron");
});
});
it("repairs legacy cron store fields and migrates notify fallback to webhook delivery", async () => {
const storePath = await makeTempStorePath();
await writeCronStore(storePath, [createLegacyCronJob()]);
+29
View File
@@ -66,6 +66,22 @@ function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
// Count jobs the store still marks in-flight (`state.runningAtMs` is a number).
// The scheduler sets this while a run is active and clears it on completion, so a
// leftover marker (gateway killed mid-run) makes `cron list` show the job as
// `running` while nothing executes it. Startup marks exactly these runs interrupted
// (`src/cron/service/ops.ts` `start`), so doctor only reports the count here.
function countInFlightCronJobs(jobs: Array<Record<string, unknown>>): number {
return jobs.filter((job) => {
const state = job.state;
return (
typeof state === "object" &&
state !== null &&
typeof (state as { runningAtMs?: unknown }).runningAtMs === "number"
);
}).length;
}
type LegacyCronRepairState = {
storePath: string;
quarantinePath: string;
@@ -387,6 +403,19 @@ export async function maybeRepairLegacyCronStore(params: {
}
noteCronModelOverrides({ cfg: params.cfg, jobs: rawJobs, storePath });
const inFlightCount = countInFlightCronJobs(rawJobs);
if (inFlightCount > 0) {
const subject = inFlightCount === 1 ? "it" : "them";
note(
[
`${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\`.`,
`- 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.`,
`- Review with ${formatCliCommand("openclaw cron list")} or ${formatCliCommand("openclaw cron show <id>")}.`,
].join("\n"),
"Cron",
);
}
const normalized = normalizeStoredCronJobs(rawJobs);
const notifyCount = rawJobs.filter((job) => job.notify === true).length;
const dreamingStaleCount = countStaleDreamingJobs(rawJobs);