mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:00:42 +00:00
fix(cron): repair malformed cron job ids via doctor
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user