fix(update): stop listing missing recovery originals as cleanup candidates (#174241)

openclaw update cleanup listed hundreds of recovery originals that no longer exist (83% of entries in the report) and counted their manifest-recorded sizes as reclaimable bytes. The recovery inventory now reports one count of missing originals with zero phantom bytes, while preserving Doctor's recovery receipts and interrupted-disposal handling. Update-capture directories already have a listing and standalone Doctor's 30-day retention owner; extending retirement to protected/incomplete captures is a policy question left unchanged.

Fixes #174153
This commit is contained in:
Peter Steinberger authored and GitHub committed 2026-10-16 06:34:01 -07:00
1 parent 0869909221
commit fd2d5d0ae8
6 files changed
+138 -11

No files matched your search

+7 -3
View File
@@ -882,6 +882,10 @@ plugins, or creating state.
Candidate bytes still require identity verification; historical artifacts are
listed separately as requiring verification. Protected and blocked artifacts
include reason codes.
Missing originals referenced by recovery manifests appear only as one aggregate
count, without contributing recorded sizes to byte totals. Their recovery
receipts remain available to Doctor. Interrupted disposal still appears so
cleanup can finish it.
Before applying, stop the Gateway for that same profile/state directory and wait
for other SQLite maintenance commands to finish. Stop database readers too,
@@ -963,7 +967,7 @@ Retiring a capture deletes its whole directory, including any `candidate` and
the rest of cleanup. An interrupted removal leaves the remainder attributed to the
same run, so rerunning cleanup finishes it.
The JSON result contains `stateDir`, `status`, `artifacts`, and `totals`. Each
The JSON result contains `stateDir`, `status`, `artifacts`, `missingOriginals`, and `totals`. Each
artifact reports its path, run ids, logical bytes, outcome, and reason; update
captures also carry `kind: "update-capture"`. Totals separate candidates,
verification-required, protected, blocked, and removed bytes, including update
@@ -971,8 +975,8 @@ captures. Removal failures exit nonzero. Keep the recovery manifests and rerun
cleanup to finish recorded interrupted work; a retry does not delete a recreated
file. Removed logical bytes do not promise
equivalent physical space reclamation on cloned or snapshotted filesystems.
When a path cannot be inspected, its logical size comes from recorded artifact
metadata when available. Cleanup records durable intent before removal and uses
Only present files contribute logical bytes; absent originals and disposal
claims contribute zero. Cleanup records durable intent before removal and uses
exclusive no-copy publication. Failures are reported; retries reconcile file
operations that already completed. Manifest files are synchronized before removal;
parent directories are synchronized where supported. Windows does not provide the
+1
View File
@@ -39,6 +39,7 @@ it.each(["refused", "blocked"] as const)(
async (status) => {
const report: RecoveryCleanupReport = {
stateDir: "/fixture/state",
missingOriginals: 0,
artifacts: [
{
path: "/fixture/original",
+5
View File
@@ -11,6 +11,11 @@ import { updateMaintenanceError } from "./update-maintenance-error.js";
function renderCleanup(report: RecoveryCleanupReport): void {
defaultRuntime.log(`Recovery cleanup: ${report.stateDir}`);
if (report.missingOriginals > 0) {
defaultRuntime.log(
`Missing recovery originals: ${report.missingOriginals} (historical receipts retained; no bytes on disk).`,
);
}
for (const item of report.artifacts) {
defaultRuntime.log(
` ${item.outcome}: ${item.path} (${item.bytes} bytes; ${item.reason})${item.detail ? ` ${item.detail}` : ""}`,
@@ -0,0 +1,96 @@
import fs from "node:fs";
import path from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { readMigrationArtifactIdentity } from "../../infra/session-sqlite-migration-artifact.js";
import {
createSessionSqliteMigrationRun,
recordCompletedMigrationMoves,
recordPlannedMigrationMoves,
updateMigrationManifestTarget,
writeSessionSqliteMigrationManifest,
type SessionSqliteMigrationMove,
} from "../../infra/session-sqlite-migration-manifest.js";
import { resolveTargetSqlitePath } from "../../infra/session-sqlite-migration-readers.js";
import { defaultRuntime } from "../../runtime.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
import { updateCleanupCommand } from "./cleanup.js";
afterEach(() => vi.restoreAllMocks());
it("summarizes missing originals once without phantom paths or bytes in preview and apply", async () => {
await withOpenClawTestState({ label: "cleanup-missing-originals" }, async (state) => {
const storePath = path.join(state.sessionsDir(), "sessions.json");
const target = {
agentId: "main",
storePath,
sqlitePath: resolveTargetSqlitePath({ agentId: "main", storePath }, state.env),
};
const archiveDir = state.statePath("agents", "main", "session-sqlite-import-archive");
fs.mkdirSync(archiveDir, { recursive: true });
const moves: SessionSqliteMigrationMove[] = [
"historical",
"protected",
"disposed",
"present",
].map((name) => {
const archivePath = path.join(archiveDir, `${name}.jsonl.imported-old`);
fs.writeFileSync(archivePath, "original\n");
const move: SessionSqliteMigrationMove = {
kind: "unreferenced-jsonl",
sourcePath: path.join(state.sessionsDir(), `${name}.jsonl`),
archivePath,
};
if (name !== "historical") {
move.artifact = {
identity: readMigrationArtifactIdentity(archivePath),
classification: "protected",
reason: "unreferenced-history",
dependencies: [],
disposal:
name === "disposed"
? { state: "disposed", disposedAt: "2026-09-01T00:00:00Z" }
: { state: "retained" },
};
}
if (name !== "present") {
fs.unlinkSync(archivePath);
}
return move;
});
for (let copy = 0; copy < 2; copy++) {
const run = createSessionSqliteMigrationRun(state.env, [target]);
recordPlannedMigrationMoves(run, target, moves);
recordCompletedMigrationMoves(run, target, moves);
updateMigrationManifestTarget(run, target, [], { validationBeforeArchive: "passed" });
run.manifest.completedAt = run.manifest.startedAt;
writeSessionSqliteMigrationManifest(run);
}
const writeJson = vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {});
const log = vi.spyOn(defaultRuntime, "log").mockImplementation(() => {});
for (const dryRun of [true, false]) {
writeJson.mockClear();
await updateCleanupCommand({ dryRun, yes: true, json: true });
const report = writeJson.mock.calls[0]![0];
expect(report).toMatchObject({
status: dryRun ? "preview" : "complete",
missingOriginals: 3,
artifacts: [{ path: moves[3]!.archivePath, outcome: "protected", bytes: 9 }],
totals: {
candidateBytes: 0,
verificationRequiredBytes: 0,
protectedBytes: 9,
blockedBytes: 0,
removedBytes: 0,
removedFiles: 0,
},
});
expect(fs.readFileSync(moves[3]!.archivePath, "utf8")).toBe("original\n");
}
await updateCleanupCommand({ dryRun: true });
const output = log.mock.calls.flat().join("\n");
expect(output.match(/Missing recovery originals: 3/g)).toHaveLength(1);
for (const move of moves.slice(0, 3)) {
expect(output).not.toContain(move.archivePath);
}
});
});
@@ -86,6 +86,7 @@ export function collectRecoveryInventory(params: { cfg: OpenClawConfig; env: Nod
const references = new Map<string, RecoveryArtifactReference[]>();
const manifestPaths: string[] = [];
const artifacts: RecoveryCleanupArtifact[] = [];
const missingOriginals = new Set<string>();
const protectUnmanifested = (filePath: string, bytes: number, reason: string) => {
artifacts.push({ path: filePath, runs: [], bytes, outcome: "protected", reason });
};
@@ -158,10 +159,17 @@ export function collectRecoveryInventory(params: { cfg: OpenClawConfig; env: Nod
? fs.lstatSync(evidence.disposal.claimPath, { bigint: true, throwIfNoEntry: false })
: undefined;
const current = stat ?? claim;
if (
refs.every((ref) => ref.trusted) &&
!current &&
evidence?.disposal.state !== "pending-disposal"
) {
missingOriginals.add(archivePath);
}
const item: RecoveryCleanupArtifact = {
path: archivePath,
runs: [...new Set(refs.map((ref) => ref.run.manifest.runId))],
bytes: current?.isFile() ? Number(current.size) : (evidence?.identity.size ?? 0),
bytes: current?.isFile() ? Number(current.size) : 0,
outcome: "candidate",
reason: "producer-verified-original",
consequence:
@@ -329,9 +337,16 @@ export function collectRecoveryInventory(params: { cfg: OpenClawConfig; env: Nod
}
protectRecoveryDependencies(artifacts, references);
return {
// Doctor still needs absent receipts to reconcile interrupted duplicate retirement.
artifacts,
references,
manifestPaths,
report: summarizeRecoveryCleanup(root, artifacts, "preview"),
report: summarizeRecoveryCleanup(
root,
artifacts.filter((item) => !missingOriginals.has(item.path)),
"preview",
missingOriginals.size,
),
};
}
@@ -414,6 +429,7 @@ export function summarizeRecoveryCleanup(
stateDir: string,
artifacts: RecoveryCleanupArtifact[],
status: "preview" | "refused" | "complete" | "blocked",
missingOriginals = 0,
) {
const totals = {
candidateBytes: 0,
@@ -440,7 +456,7 @@ export function summarizeRecoveryCleanup(
totals.removedFiles += 1;
}
}
return { stateDir, artifacts, totals, status };
return { stateDir, artifacts, totals, status, missingOriginals };
}
/** Update cleanup also reviews original-state captures; Doctor's session repair does not. */
@@ -462,6 +478,7 @@ export function collectUpdateCleanupInventory(params: {
inventory.report.stateDir,
[...inventory.report.artifacts, ...captures.artifacts],
"preview",
inventory.report.missingOriginals,
),
};
},
@@ -79,7 +79,7 @@ export async function settleDuplicateSessionSqliteArchives(params: {
}) {
const {
claims,
inventory: { report, references },
inventory: { artifacts, references },
} = collectHistoricalArchiveSources(params);
const failures: Array<{
target: SessionSqliteMigrationTargetInput;
@@ -122,9 +122,7 @@ export async function settleDuplicateSessionSqliteArchives(params: {
continue;
}
for (const refs of group.filter((candidate) => candidate !== survivor)) {
const item = report.artifacts.find(
(candidate) => candidate.path === refs[0]!.move.archivePath,
)!;
const item = artifacts.find((candidate) => candidate.path === refs[0]!.move.archivePath)!;
survivors.set(item.path, survivor);
try {
assertSurvivor(refs);
@@ -252,7 +250,12 @@ export async function retireSessionSqliteRecovery(params: {
}
// Verified historical dependencies affect selection now, but stay provisional until consent.
protectRecoveryDependencies(report.artifacts, references, adoptions);
const verified = summarizeRecoveryCleanup(report.stateDir, report.artifacts, "preview");
const verified = summarizeRecoveryCleanup(
report.stateDir,
report.artifacts,
"preview",
report.missingOriginals,
);
const candidates = verified.artifacts.filter((item) => item.outcome === "candidate");
const selected = candidates.filter((item) => item.kind !== "update-capture");
const captures = candidates.filter((item) => item.kind === "update-capture");
@@ -331,6 +334,7 @@ export async function retireSessionSqliteRecovery(params: {
report.artifacts.some((item) => item.outcome === "failed" || item.outcome === "blocked")
? "blocked"
: "complete",
report.missingOriginals,
);
},
});