mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-26 10:13:27 +08:00
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:
1 parent
0869909221
commit
fd2d5d0ae8
6 files changed
+138
-11
No files matched your search
@@ -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
|
||||
|
||||
@@ -39,6 +39,7 @@ it.each(["refused", "blocked"] as const)(
|
||||
async (status) => {
|
||||
const report: RecoveryCleanupReport = {
|
||||
stateDir: "/fixture/state",
|
||||
missingOriginals: 0,
|
||||
artifacts: [
|
||||
{
|
||||
path: "/fixture/original",
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user