mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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:
@@ -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()]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user