fix(cron): tolerate legacy flat schedule identity

This commit is contained in:
Peter Steinberger
2026-04-27 07:56:21 +01:00
parent 53f536b368
commit 37d37d3779
4 changed files with 132 additions and 21 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
- Discord: preserve explicit `user:` and `channel:` delivery targets through plugin routing so cron announcements and failure alerts keep their intended recipient kind. Refs #62777; carries forward #62798. Thanks @neeravmakwana.
- Cron: add `failureAlert.includeSkipped` and `openclaw cron edit --failure-alert-include-skipped` so persistently skipped jobs can alert without counting skips as execution errors or affecting retry backoff. Fixes #60846. Thanks @slideshow-dingo.
- Cron: invalidate stale pending runtime slots after live or offline `jobs.json` schedule edits, while preserving due slots for formatting-only rewrites. Fixes #27996 and #71607; carries forward #71651. Thanks @xialonglee and @fagnersouza666.
- Cron: keep legacy flat `jobs.json` rows loadable while comparing split-state schedule identities, so old cron stores do not crash before in-memory hydration can normalize them. Thanks @codex.
- Cron: omit synthetic `delivery.resolved` errors from `--no-deliver` run records while preserving explicit no-deliver target traces for agent-initiated messages. Fixes #72210; carries forward #72219. Thanks @hatemclawbot-collab and @xydigit-sj.
- Cron: classify isolated runs as errors from structured embedded-run execution-denial metadata, with final-output marker fallback for `SYSTEM_RUN_DENIED`, `INVALID_REQUEST`, and approval-binding refusals, so blocked commands no longer appear green in cron history. Fixes #67172; carries forward #67186. Thanks @oc-gh-dr, @hclsys, and @1yihui.
- Onboarding/GitHub Copilot: add manifest-owned `--github-copilot-token` support for non-interactive setup, including env fallback, tokenRef storage in ref mode, saved-profile reuse, and current Copilot default-model wiring. Refs #50002 and supersedes #50003. Thanks @scottgl9.

View File

@@ -1,34 +1,91 @@
import type { CronJob, CronSchedule } from "./types.js";
import type { CronJob } from "./types.js";
function schedulePayload(
schedule: CronSchedule,
function readString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function readNumber(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key];
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function schedulePayloadFromRecord(
schedule: Record<string, unknown>,
):
| { kind: "at"; at: string }
| { kind: "every"; everyMs: number; anchorMs?: number }
| { kind: "cron"; expr: string; tz?: string; staggerMs?: number } {
switch (schedule.kind) {
case "at":
return { kind: "at", at: schedule.at };
case "every":
return { kind: "every", everyMs: schedule.everyMs, anchorMs: schedule.anchorMs };
case "cron":
return {
kind: "cron",
expr: schedule.expr,
tz: schedule.tz,
staggerMs: schedule.staggerMs,
};
| { kind: "cron"; expr: string; tz?: string; staggerMs?: number }
| undefined {
const rawKind = readString(schedule, "kind")?.toLowerCase();
const expr = readString(schedule, "expr") ?? readString(schedule, "cron");
const at = readString(schedule, "at");
const atMs = readNumber(schedule, "atMs");
const everyMs = readNumber(schedule, "everyMs");
const anchorMs = readNumber(schedule, "anchorMs");
const tz = readString(schedule, "tz");
const staggerMs = readNumber(schedule, "staggerMs");
const kind =
rawKind === "at" || rawKind === "every" || rawKind === "cron"
? rawKind
: at || atMs !== undefined
? "at"
: everyMs !== undefined
? "every"
: expr
? "cron"
: undefined;
if (kind === "at") {
return at
? { kind: "at", at }
: atMs !== undefined
? { kind: "at", at: String(atMs) }
: undefined;
}
throw new Error("Unsupported cron schedule kind");
if (kind === "every" && everyMs !== undefined) {
return { kind: "every", everyMs, anchorMs };
}
if (kind === "cron" && expr) {
return { kind: "cron", expr, tz, staggerMs };
}
return undefined;
}
function resolveSchedulePayload(
job: { schedule?: unknown } & Record<string, unknown>,
): ReturnType<typeof schedulePayloadFromRecord> {
if (job.schedule && typeof job.schedule === "object" && !Array.isArray(job.schedule)) {
return schedulePayloadFromRecord(job.schedule as Record<string, unknown>);
}
return schedulePayloadFromRecord(job);
}
export function cronScheduleIdentity(
job: Pick<CronJob, "schedule"> & { enabled?: boolean },
): string {
const schedule = resolveSchedulePayload(job as unknown as Record<string, unknown>);
if (!schedule) {
throw new Error("Unsupported cron schedule kind");
}
return JSON.stringify({
version: 1,
enabled: job.enabled ?? true,
schedule: schedulePayload(job.schedule),
schedule,
});
}
export function tryCronScheduleIdentity(
job: { schedule?: unknown; enabled?: unknown } & Record<string, unknown>,
): string | undefined {
const schedule = resolveSchedulePayload(job);
if (!schedule) {
return undefined;
}
return JSON.stringify({
version: 1,
enabled: typeof job.enabled === "boolean" ? job.enabled : true,
schedule,
});
}

View File

@@ -138,6 +138,59 @@ describe("cron store", () => {
});
});
it("compares split state identity for flat legacy cron rows", async () => {
const { storePath } = await makeStorePath();
const statePath = storePath.replace(/\.json$/, "-state.json");
await fs.mkdir(path.dirname(storePath), { recursive: true });
await fs.writeFile(
storePath,
JSON.stringify(
{
version: 1,
jobs: [
{
id: "legacy-flat-cron",
name: "legacy flat cron",
enabled: true,
kind: "cron",
cron: "*/10 * * * *",
tz: "UTC",
},
],
},
null,
2,
),
"utf-8",
);
await fs.writeFile(
statePath,
JSON.stringify(
{
version: 1,
jobs: {
"legacy-flat-cron": {
updatedAtMs: 1,
scheduleIdentity: JSON.stringify({
version: 1,
enabled: true,
schedule: { kind: "cron", expr: "0 * * * *", tz: "UTC" },
}),
state: { nextRunAtMs: 123 },
},
},
},
null,
2,
),
"utf-8",
);
const loaded = await loadCronStore(storePath);
expect(loaded.jobs[0]?.state.nextRunAtMs).toBeUndefined();
});
it("does not create a backup file when saving unchanged content", async () => {
const store = await makeStorePath();
const payload = makeStore("job-1", true);

View File

@@ -4,7 +4,7 @@ import path from "node:path";
import { expandHomePrefix } from "../infra/home-dir.js";
import { resolveConfigDir } from "../utils.js";
import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js";
import { cronScheduleIdentity } from "./schedule-identity.js";
import { tryCronScheduleIdentity } from "./schedule-identity.js";
import type { CronStoreFile } from "./types.js";
type SerializedStoreCacheEntry = {
@@ -65,7 +65,7 @@ function extractStateFile(store: CronStoreFile): CronStateFile {
for (const job of store.jobs) {
jobs[job.id] = {
updatedAtMs: job.updatedAtMs,
scheduleIdentity: cronScheduleIdentity(job),
scheduleIdentity: tryCronScheduleIdentity(job as unknown as Record<string, unknown>),
state: job.state ?? {},
};
}
@@ -191,7 +191,7 @@ function mergeStateFileEntry(job: CronStoreFile["jobs"][number], entry: CronStat
job.state = (entry.state ?? {}) as never;
if (
typeof entry.scheduleIdentity === "string" &&
entry.scheduleIdentity !== cronScheduleIdentity(job)
entry.scheduleIdentity !== tryCronScheduleIdentity(job as unknown as Record<string, unknown>)
) {
ensureJobStateObject(job);
job.state.nextRunAtMs = undefined;