fix(cron): repair malformed cron job ids via doctor

This commit is contained in:
Peter Steinberger
2026-04-22 22:00:56 +01:00
parent 2e38e09b04
commit b6fbf46eca
4 changed files with 88 additions and 2 deletions

View File

@@ -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:<id>` DM targets and preserve `channels.discord.guilds.<guild>.channels.<channel>.requireMention: false` during reply-stage activation fallback. Fixes #70165 and #69441.

View File

@@ -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<string, unknown>): {
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<string, unknown>) {
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();

View File

@@ -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<Record<string, unknown>>;
};
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 });

View File

@@ -25,6 +25,12 @@ function formatLegacyIssuePreview(issues: Partial<Record<string, number>>): 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`,