diff --git a/CHANGELOG.md b/CHANGELOG.md index 5953d378130..501dc62fe88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Pi embedded runs: pass real built-in tools into Pi session creation and then narrow active tool names after custom tool registration, so the runner and compaction paths compile cleanly and keep OpenClaw-managed custom tool allowlists without feeding string arrays into `createAgentSession`. Thanks @vincentkoc. - Agents/OpenAI websocket: route native OpenAI websocket metadata and session-header decisions through the shared endpoint classifier so local mocks and custom `models.providers.openai.baseUrl` endpoints stay out of the native OpenAI path consistently across embedded-runner and websocket transport code. Thanks @vincentkoc. +- Cron/doctor: repair malformed persisted cron job IDs through `openclaw doctor`, including legacy `jobId`, non-string `id`, and missing `id` rows, so `cron list` no longer needs display-layer coercion for corrupt store data. Fixes #70128. - Discord: normalize prefixed channel targets only at the thread-binding API boundary, so `sessions_spawn({ runtime: "acp", thread: true })` can create child threads from Discord channels without breaking current-channel ACP bindings. (#68034) Thanks @Zetarcos. - Discord: harden inbound thread metadata handling against partial Carbon channel getters, so non-command thread messages and queued jobs no longer crash when `name`, `parentId`, `parent`, or `ownerId` requires fetched raw data. - Discord: let `message` tool reactions resolve `user:` DM targets and preserve `channels.discord.guilds..channels..requireMention: false` during reply-stage activation fallback. Fixes #70165 and #69441. diff --git a/src/commands/doctor-cron-store-migration.ts b/src/commands/doctor-cron-store-migration.ts index 377a2d23a97..4395d75d23e 100644 --- a/src/commands/doctor-cron-store-migration.ts +++ b/src/commands/doctor-cron-store-migration.ts @@ -1,4 +1,4 @@ -import { normalizeCronJobIdentityFields } from "../cron/normalize-job-identity.js"; +import { randomUUID } from "node:crypto"; import { parseAbsoluteTimeMs } from "../cron/parse.js"; import { coerceFiniteScheduleNumber } from "../cron/schedule.js"; import { inferLegacyName } from "../cron/service/normalize.js"; @@ -7,12 +7,15 @@ import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, + normalizeOptionalStringifiedId, } from "../shared/string-coerce.js"; import { normalizeLegacyDeliveryInput } from "./doctor-cron-legacy-delivery.js"; import { migrateLegacyCronPayload } from "./doctor-cron-payload-migration.js"; type CronStoreIssueKey = | "jobId" + | "missingId" + | "nonStringId" | "legacyScheduleString" | "legacyScheduleCron" | "legacyPayloadKind" @@ -33,6 +36,38 @@ function incrementIssue(issues: CronStoreIssues, key: CronStoreIssueKey) { issues[key] = (issues[key] ?? 0) + 1; } +function normalizeStoredCronJobIdentity(raw: Record): { + mutated: boolean; + legacyJobIdIssue: boolean; + missingIdIssue: boolean; + nonStringIdIssue: boolean; +} { + const hadIdKey = "id" in raw; + const hadJobIdKey = "jobId" in raw; + const id = normalizeOptionalStringifiedId(raw.id); + const legacyJobId = normalizeOptionalStringifiedId(raw.jobId); + const canonicalId = id ?? legacyJobId ?? `cron-${randomUUID()}`; + const nonStringIdIssue = hadIdKey && raw.id != null && typeof raw.id !== "string"; + const missingIdIssue = !id && !legacyJobId; + let mutated = false; + + if (raw.id !== canonicalId) { + raw.id = canonicalId; + mutated = true; + } + if (hadJobIdKey) { + delete raw.jobId; + mutated = true; + } + + return { + mutated, + legacyJobIdIssue: hadJobIdKey, + missingIdIssue, + nonStringIdIssue, + }; +} + function normalizePayloadKind(payload: Record) { const raw = normalizeOptionalLowercaseString(payload.kind) ?? ""; if (raw === "agentturn") { @@ -213,13 +248,19 @@ export function normalizeStoredCronJobs( mutated = true; } - const idNorm = normalizeCronJobIdentityFields(raw); + const idNorm = normalizeStoredCronJobIdentity(raw); if (idNorm.mutated) { mutated = true; } if (idNorm.legacyJobIdIssue) { trackIssue("jobId"); } + if (idNorm.missingIdIssue) { + trackIssue("missingId"); + } + if (idNorm.nonStringIdIssue) { + trackIssue("nonStringId"); + } if (typeof raw.schedule === "string") { const expr = raw.schedule.trim(); diff --git a/src/commands/doctor-cron.test.ts b/src/commands/doctor-cron.test.ts index 2e854722adb..d44e66c9d45 100644 --- a/src/commands/doctor-cron.test.ts +++ b/src/commands/doctor-cron.test.ts @@ -121,6 +121,44 @@ describe("maybeRepairLegacyCronStore", () => { ); }); + it("repairs malformed persisted cron ids before list rendering sees them", async () => { + const storePath = await makeTempStorePath(); + await writeCronStore(storePath, [ + createLegacyCronJob({ + id: 42, + jobId: undefined, + notify: false, + }), + createLegacyCronJob({ + id: undefined, + jobId: undefined, + name: "Missing id", + notify: false, + }), + ]); + + await maybeRepairLegacyCronStore({ + cfg: createCronConfig(storePath), + options: {}, + prompter: makePrompter(true), + }); + + const persisted = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + jobs: Array>; + }; + expect(persisted.jobs[0]?.id).toBe("42"); + expect(typeof persisted.jobs[1]?.id).toBe("string"); + expect(String(persisted.jobs[1]?.id)).toMatch(/^cron-/); + expect(noteMock).toHaveBeenCalledWith( + expect.stringContaining("stores `id` as a non-string value"), + "Cron", + ); + expect(noteMock).toHaveBeenCalledWith( + expect.stringContaining("missing a canonical string `id`"), + "Cron", + ); + }); + it("warns instead of replacing announce delivery for notify fallback jobs", async () => { const storePath = await makeTempStorePath(); await fs.mkdir(path.dirname(storePath), { recursive: true }); diff --git a/src/commands/doctor-cron.ts b/src/commands/doctor-cron.ts index b7a83744e85..6d06ea5a197 100644 --- a/src/commands/doctor-cron.ts +++ b/src/commands/doctor-cron.ts @@ -25,6 +25,12 @@ function formatLegacyIssuePreview(issues: Partial>): stri if (issues.jobId) { lines.push(`- ${pluralize(issues.jobId, "job")} still uses legacy \`jobId\``); } + if (issues.missingId) { + lines.push(`- ${pluralize(issues.missingId, "job")} is missing a canonical string \`id\``); + } + if (issues.nonStringId) { + lines.push(`- ${pluralize(issues.nonStringId, "job")} stores \`id\` as a non-string value`); + } if (issues.legacyScheduleString) { lines.push( `- ${pluralize(issues.legacyScheduleString, "job")} stores schedule as a bare string`,