Skip to content

Commit 0549437

Browse files
committed
fix(cron): add doctor migration for oversized quarantine sidecars
1 parent d1ed81f commit 0549437

5 files changed

Lines changed: 142 additions & 19 deletions

File tree

src/commands/doctor/cron/index.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,43 @@ describe("collectLegacyCronStoreHealthFindings", () => {
291291
await expect(readPersistedJobs(storePath)).resolves.toEqual([]);
292292
});
293293

294+
it("reports an oversized cron quarantine sidecar as a legacy repair finding", async () => {
295+
const storePath = await makeTempStorePath();
296+
await writeCurrentCronStore(storePath, []);
297+
const quarantinePath = resolveCronQuarantinePath(storePath);
298+
await fs.mkdir(path.dirname(quarantinePath), { recursive: true });
299+
await fs.writeFile(quarantinePath, "x".repeat(8 * 1024 * 1024 + 1), "utf-8");
300+
301+
const findings = await collectLegacyCronStoreHealthFindings({
302+
cfg: createCronConfig(storePath),
303+
});
304+
expect(findings).toEqual([
305+
expect.objectContaining({
306+
checkId: "core/doctor/legacy-cron-store",
307+
path: quarantinePath,
308+
requirement: "cron-quarantine-oversized",
309+
}),
310+
]);
311+
});
312+
313+
it("archives an oversized cron quarantine sidecar when repairing", async () => {
314+
const storePath = await makeTempStorePath();
315+
await writeCurrentCronStore(storePath, []);
316+
const quarantinePath = resolveCronQuarantinePath(storePath);
317+
await fs.mkdir(path.dirname(quarantinePath), { recursive: true });
318+
await fs.writeFile(quarantinePath, "x".repeat(8 * 1024 * 1024 + 1), "utf-8");
319+
320+
await maybeRepairLegacyCronStore({
321+
cfg: createCronConfig(storePath),
322+
options: {},
323+
prompter: makePrompter(true),
324+
});
325+
326+
await expect(fs.access(quarantinePath)).rejects.toThrow(/ENOENT/);
327+
const archives = await fs.readdir(path.dirname(quarantinePath));
328+
expect(archives.some((name) => name.endsWith(".archive.json"))).toBe(true);
329+
});
330+
294331
it("returns no findings for an already-normalized empty cron store", async () => {
295332
const storePath = await makeTempStorePath();
296333
await writeCurrentCronStore(storePath, []);

src/commands/doctor/cron/index.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22
import { note } from "../../../../packages/terminal-core/src/note.js";
33
import { formatCliCommand } from "../../../cli/command-format.js";
44
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
5-
import { loadCronQuarantineFile, resolveCronJobsStorePath } from "../../../cron/store.js";
5+
import {
6+
CRON_QUARANTINE_MAX_BYTES,
7+
loadCronQuarantineFile,
8+
resolveCronJobsStorePath,
9+
} from "../../../cron/store.js";
610
import type { HealthFinding } from "../../../flows/health-checks.js";
711
import { shortenHomePath } from "../../../utils.js";
812
import type { DoctorPrompter, DoctorOptions } from "../../doctor-prompter.js";
@@ -13,6 +17,11 @@ import {
1317
type LegacyCronRepairResult,
1418
type LegacyCronRepairState,
1519
} from "./legacy-repair.js";
20+
import {
21+
archiveOversizedCronQuarantineSidecar,
22+
formatArchivedQuarantineNote,
23+
isOversizedCronQuarantineSidecar,
24+
} from "./oversized-quarantine.js";
1625
import {
1726
formatLegacyIssuePreview,
1827
formatUnresolvedCommandPromptAdvisory,
@@ -138,6 +147,18 @@ export async function collectLegacyCronStoreHealthFindings(params: {
138147
rawJobs,
139148
} = state;
140149

150+
if (isOversizedCronQuarantineSidecar(quarantinePath)) {
151+
findings.push(
152+
legacyCronStoreFinding({
153+
message: `Cron quarantine sidecar at ${shortenHomePath(quarantinePath)} exceeds the ${CRON_QUARANTINE_MAX_BYTES} byte safety cap.`,
154+
path: quarantinePath,
155+
requirement: "cron-quarantine-oversized",
156+
fixHint: `Run ${formatCliCommand("openclaw doctor --fix")} to archive the oversized sidecar and start fresh.`,
157+
}),
158+
);
159+
return findings;
160+
}
161+
141162
try {
142163
const quarantine = await loadCronQuarantineFile(quarantinePath);
143164
if (quarantine.jobs.length > 0) {
@@ -276,6 +297,26 @@ export async function maybeRepairLegacyCronStore(params: {
276297
sqliteProjectionBackfillCount,
277298
rawJobs,
278299
} = state;
300+
if (isOversizedCronQuarantineSidecar(quarantinePath)) {
301+
note(
302+
[
303+
`Cron quarantine sidecar at ${shortenHomePath(quarantinePath)} exceeds the ${CRON_QUARANTINE_MAX_BYTES} byte safety cap.`,
304+
`- The runtime cannot safely load the existing sidecar; it must be archived before new quarantine writes can proceed.`,
305+
].join("\n"),
306+
"Cron",
307+
);
308+
const shouldArchive = await params.prompter.confirm({
309+
message: "Archive the oversized quarantine sidecar now?",
310+
initialValue: true,
311+
});
312+
if (shouldArchive) {
313+
const archivePath = archiveOversizedCronQuarantineSidecar(quarantinePath);
314+
note(
315+
formatArchivedQuarantineNote({ archivePath, originalPath: quarantinePath }),
316+
"Doctor changes",
317+
);
318+
}
319+
}
279320
try {
280321
const quarantine = await loadCronQuarantineFile(quarantinePath);
281322
if (quarantine.jobs.length > 0) {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { randomBytes } from "node:crypto";
2+
// Doctor migration for cron quarantine sidecars that exceed the bounded read cap.
3+
import fs from "node:fs";
4+
import { CRON_QUARANTINE_MAX_BYTES } from "../../../cron/store.js";
5+
import { shortenHomePath } from "../../../utils.js";
6+
7+
/** Returns true when a regular quarantine sidecar exists and is above the cap. */
8+
export function isOversizedCronQuarantineSidecar(filePath: string): boolean {
9+
try {
10+
const stat = fs.statSync(filePath);
11+
return stat.isFile() && stat.size > CRON_QUARANTINE_MAX_BYTES;
12+
} catch (err) {
13+
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
14+
return false;
15+
}
16+
throw err;
17+
}
18+
}
19+
20+
/**
21+
* Moves an oversized quarantine sidecar to a timestamped archive path so the
22+
* runtime can start fresh without discarding the legacy artifact.
23+
*/
24+
export function archiveOversizedCronQuarantineSidecar(filePath: string): string {
25+
const timestamp = Date.now();
26+
const random = randomBytes(4).toString("hex");
27+
const archivePath = `${filePath}.oversized.${timestamp}.${random}.archive.json`;
28+
fs.renameSync(filePath, archivePath);
29+
return archivePath;
30+
}
31+
32+
/** Human-readable note describing the archived sidecar. */
33+
export function formatArchivedQuarantineNote(params: {
34+
archivePath: string;
35+
originalPath: string;
36+
}): string {
37+
return `Archived oversized cron quarantine sidecar (${CRON_QUARANTINE_MAX_BYTES} bytes) from ${shortenHomePath(params.originalPath)} to ${shortenHomePath(params.archivePath)}.`;
38+
}

src/cron/store.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,7 @@ describe("cron store", () => {
324324
await fs.writeFile(quarantinePath, "x".repeat(8 * 1024 * 1024 + 1), "utf-8");
325325

326326
await expect(loadCronQuarantineFile(quarantinePath)).rejects.toThrow(
327-
/File exceeds 8388608 bytes/,
327+
/Cron quarantine file at .* exceeds 8388608 bytes/,
328328
);
329329
});
330330

src/cron/store.ts

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,21 @@ import { replaceFileAtomic } from "../infra/replace-file.js";
1111

1212
// Cron quarantine sidecars are small JSON files; cap reads so a corrupted or
1313
// hostile file cannot OOM the cron load path.
14-
const CRON_QUARANTINE_MAX_BYTES = 8 * 1024 * 1024;
14+
export const CRON_QUARANTINE_MAX_BYTES = 8 * 1024 * 1024;
15+
16+
/** Thrown when a persisted cron quarantine sidecar exceeds the bounded read cap. */
17+
export class CronQuarantineOversizedError extends Error {
18+
override readonly name = "CronQuarantineOversizedError";
19+
readonly code = "CRON_QUARANTINE_OVERSIZED";
20+
constructor(
21+
readonly filePath: string,
22+
readonly maxBytes: number,
23+
) {
24+
super(
25+
`Cron quarantine file at ${filePath} exceeds ${maxBytes} bytes; run openclaw doctor --fix to archive it.`,
26+
);
27+
}
28+
}
1529
import {
1630
openOpenClawStateDatabase,
1731
runOpenClawStateWriteTransaction,
@@ -273,6 +287,9 @@ export async function loadCronQuarantineFile(pathLocal: string): Promise<CronQua
273287
if ((err as { code?: unknown })?.code === "ENOENT") {
274288
return { version: 1, jobs: [] };
275289
}
290+
if (err instanceof Error && err.message.includes("exceeds")) {
291+
throw new CronQuarantineOversizedError(pathLocal, CRON_QUARANTINE_MAX_BYTES);
292+
}
276293
throw err;
277294
}
278295
}
@@ -331,22 +348,12 @@ export async function saveCronQuarantineFile(params: {
331348
}
332349
const quarantinePath = resolveCronQuarantinePath(params.storePath);
333350

334-
// Load existing quarantine entries for deduplication. If the file does not
335-
// exist or is too large to read safely, start with empty state. Unexpected
336-
// shapes (e.g. unrecognized version) must propagate to avoid data loss.
337-
let existing: CronQuarantineFile = { version: 1, jobs: [] };
338-
let existingKeys = new Set<string>();
339-
try {
340-
existing = await loadCronQuarantineFile(quarantinePath);
341-
existingKeys = new Set(existing.jobs.map(quarantineEntryKey));
342-
} catch (error: unknown) {
343-
const err = error as Error;
344-
if (err && (err.message.includes("ENOENT") || err.message.includes("exceeds"))) {
345-
// File missing or exceeds the read cap — start fresh.
346-
} else {
347-
throw error;
348-
}
349-
}
351+
// Load existing quarantine entries for deduplication. A missing file starts
352+
// with empty state; an oversized sidecar is a legacy-data migration issue
353+
// that must be resolved with `openclaw doctor --fix` rather than silently
354+
// discarded here.
355+
const existing = await loadCronQuarantineFile(quarantinePath);
356+
const existingKeys = new Set(existing.jobs.map(quarantineEntryKey));
350357

351358
const seen = new Set(existingKeys);
352359
const nextJobs = existing.jobs.slice();

0 commit comments

Comments
 (0)