From 985bc934a19f0dac2a55d0b1bb22b071945ea2fc Mon Sep 17 00:00:00 2001 From: IWhatsskill Date: Mon, 25 May 2026 11:56:12 +0200 Subject: [PATCH] fix(cron): canonicalize preserved row ids --- src/cron/service/store.test.ts | 76 ++++++++++++++++++++++++++++++++++ src/cron/store.ts | 4 +- 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/cron/service/store.test.ts b/src/cron/service/store.test.ts index 10c89228de5..81e3ff47ee4 100644 --- a/src/cron/service/store.test.ts +++ b/src/cron/service/store.test.ts @@ -223,6 +223,82 @@ describe("cron service store seam coverage", () => { ]); }); + it("skips preserved unsupported rows that collide with supported jobs by canonical id", async () => { + const { storePath } = await makeStorePath(); + + await writeJobStore(storePath, [ + { + id: "trimmed-collision", + name: "supported trimmed collision", + enabled: true, + createdAtMs: STORE_TEST_NOW - 60_000, + updatedAtMs: STORE_TEST_NOW - 60_000, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "tick" }, + state: {}, + }, + { + id: " trimmed-collision ", + name: "stale unsupported padded id", + enabled: true, + createdAtMs: STORE_TEST_NOW - 60_000, + schedule: { kind: "cron", expr: "0 8 * * *", tz: "UTC" }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "command", command: "echo stale" }, + }, + { + id: "legacy-jobid-collision", + name: "supported legacy jobId collision", + enabled: true, + createdAtMs: STORE_TEST_NOW - 60_000, + updatedAtMs: STORE_TEST_NOW - 60_000, + schedule: { kind: "every", everyMs: 120_000 }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "tick legacy" }, + state: {}, + }, + { + jobId: " legacy-jobid-collision ", + name: "stale unsupported legacy jobId", + enabled: true, + createdAtMs: STORE_TEST_NOW - 60_000, + schedule: { kind: "cron", expr: "0 9 * * *", tz: "UTC" }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "agentmessage", message: "summarize stale" }, + }, + ]); + + const state = createStoreTestState(storePath); + await ensureLoaded(state, { skipRecompute: true }); + + expect(state.store?.jobs.map((job) => job.id)).toEqual([ + "trimmed-collision", + "legacy-jobid-collision", + ]); + + await persist(state); + + const config = JSON.parse(await fs.readFile(storePath, "utf8")) as { + jobs: Array>; + }; + expect(config.jobs.map((job) => job.id)).toEqual([ + "trimmed-collision", + "legacy-jobid-collision", + ]); + expect(config.jobs.map((job) => job.name)).toEqual([ + "supported trimmed collision", + "supported legacy jobId collision", + ]); + expect(config.jobs.some((job) => job.jobId === " legacy-jobid-collision ")).toBe(false); + expect(config.jobs.some((job) => job.name === "stale unsupported padded id")).toBe(false); + expect(config.jobs.some((job) => job.name === "stale unsupported legacy jobId")).toBe(false); + }); + it("normalizes jobId-only jobs in memory so scheduler lookups resolve by stable id", async () => { const { storePath } = await makeStorePath(); diff --git a/src/cron/store.ts b/src/cron/store.ts index eb5e7eed299..463ddb60c64 100644 --- a/src/cron/store.ts +++ b/src/cron/store.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { expandHomePrefix } from "../infra/home-dir.js"; import { replaceFileAtomic } from "../infra/replace-file.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; import { resolveConfigDir } from "../utils.js"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; import { tryCronScheduleIdentity } from "./schedule-identity.js"; @@ -86,8 +87,7 @@ function stripJobRuntimeFields(job: CronStoreFile["jobs"][number]): Record): string | null { - const id = job.id; - return typeof id === "string" && id.trim() ? id : null; + return normalizeOptionalString(job.id) ?? normalizeOptionalString(job.jobId) ?? null; } function mergePreservedConfigJobs(