fix(sessions): add fix-missing cleanup path for orphaned store entries

Introduce a sessions cleanup flag to prune entries whose transcript files are missing and surface the exact remediation command from doctor to resolve missing-transcript deadlocks.

Made-with: Cursor
This commit is contained in:
SidQin-cyber
2026-02-26 20:21:47 +08:00
parent 46eba86b45
commit 690d3d596b
6 changed files with 121 additions and 5 deletions

View File

@@ -171,6 +171,7 @@ describe("registerStatusHealthSessionsCommands", () => {
"/tmp/sessions.json",
"--dry-run",
"--enforce",
"--fix-missing",
"--active-key",
"agent:main:main",
"--json",
@@ -183,6 +184,7 @@ describe("registerStatusHealthSessionsCommands", () => {
allAgents: false,
dryRun: true,
enforce: true,
fixMissing: true,
activeKey: "agent:main:main",
json: true,
}),

View File

@@ -163,6 +163,11 @@ export function registerStatusHealthSessionsCommands(program: Command) {
.option("--all-agents", "Run maintenance across all configured agents", false)
.option("--dry-run", "Preview maintenance actions without writing", false)
.option("--enforce", "Apply maintenance even when configured mode is warn", false)
.option(
"--fix-missing",
"Remove store entries whose transcript files are missing (bypasses age/count retention)",
false,
)
.option("--active-key <key>", "Protect this session key from budget-eviction")
.option("--json", "Output JSON", false)
.addHelpText(
@@ -170,6 +175,10 @@ export function registerStatusHealthSessionsCommands(program: Command) {
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["openclaw sessions cleanup --dry-run", "Preview stale/cap cleanup."],
[
"openclaw sessions cleanup --dry-run --fix-missing",
"Also preview pruning entries with missing transcript files.",
],
["openclaw sessions cleanup --enforce", "Apply maintenance now."],
["openclaw sessions cleanup --agent work --dry-run", "Preview one agent store."],
["openclaw sessions cleanup --all-agents --dry-run", "Preview all agent stores."],
@@ -196,6 +205,7 @@ export function registerStatusHealthSessionsCommands(program: Command) {
allAgents: Boolean(opts.allAgents || parentOpts?.allAgents),
dryRun: Boolean(opts.dryRun),
enforce: Boolean(opts.enforce),
fixMissing: Boolean(opts.fixMissing),
activeKey: opts.activeKey as string | undefined,
json: Boolean(opts.json || parentOpts?.json),
},

View File

@@ -168,6 +168,9 @@ describe("doctor state integrity oauth dir checks", () => {
expect(text).toContain("recent sessions are missing transcripts");
expect(text).toMatch(/openclaw sessions --store ".*sessions\.json"/);
expect(text).toMatch(/openclaw sessions cleanup --store ".*sessions\.json" --dry-run/);
expect(text).toMatch(
/openclaw sessions cleanup --store ".*sessions\.json" --enforce --fix-missing/,
);
expect(text).not.toContain("--active");
expect(text).not.toContain(" ls ");
});

View File

@@ -438,6 +438,7 @@ export async function noteStateIntegrity(
`- ${missing.length}/${recentTranscriptCandidates.length} recent sessions are missing transcripts.`,
` Verify sessions in store: ${formatCliCommand(`openclaw sessions --store "${absoluteStorePath}"`)}`,
` Preview cleanup impact: ${formatCliCommand(`openclaw sessions cleanup --store "${absoluteStorePath}" --dry-run`)}`,
` Prune missing entries: ${formatCliCommand(`openclaw sessions cleanup --store "${absoluteStorePath}" --enforce --fix-missing`)}`,
].join("\n"),
);
}

View File

@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
resolveSessionStoreTargets: vi.fn(),
resolveMaintenanceConfig: vi.fn(),
loadSessionStore: vi.fn(),
resolveSessionFilePath: vi.fn(),
resolveSessionFilePathOptions: vi.fn(),
pruneStaleEntries: vi.fn(),
capEntryCount: vi.fn(),
updateSessionStore: vi.fn(),
@@ -24,6 +26,8 @@ vi.mock("./session-store-targets.js", () => ({
vi.mock("../config/sessions.js", () => ({
resolveMaintenanceConfig: mocks.resolveMaintenanceConfig,
loadSessionStore: mocks.loadSessionStore,
resolveSessionFilePath: mocks.resolveSessionFilePath,
resolveSessionFilePathOptions: mocks.resolveSessionFilePathOptions,
pruneStaleEntries: mocks.pruneStaleEntries,
capEntryCount: mocks.capEntryCount,
updateSessionStore: mocks.updateSessionStore,
@@ -74,8 +78,12 @@ describe("sessionsCleanupCommand", () => {
return 0;
},
);
mocks.resolveSessionFilePathOptions.mockReturnValue({});
mocks.resolveSessionFilePath.mockImplementation(
(sessionId: string) => `/missing/${sessionId}.jsonl`,
);
mocks.capEntryCount.mockImplementation(() => 0);
mocks.updateSessionStore.mockResolvedValue(undefined);
mocks.updateSessionStore.mockResolvedValue(0);
mocks.enforceSessionDiskBudget.mockResolvedValue({
totalBytesBefore: 1000,
totalBytesAfter: 700,
@@ -130,6 +138,7 @@ describe("sessionsCleanupCommand", () => {
overBudget: true,
},
});
return 0;
},
);
@@ -196,6 +205,29 @@ describe("sessionsCleanupCommand", () => {
);
});
it("counts missing transcript entries when --fix-missing is enabled in dry-run", async () => {
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
mocks.loadSessionStore.mockReturnValue({
missing: { sessionId: "missing-transcript", updatedAt: 1 },
});
const { runtime, logs } = makeRuntime();
await sessionsCleanupCommand(
{
json: true,
dryRun: true,
fixMissing: true,
},
runtime,
);
expect(logs).toHaveLength(1);
const payload = JSON.parse(logs[0] ?? "{}") as Record<string, unknown>;
expect(payload.beforeCount).toBe(1);
expect(payload.afterCount).toBe(0);
expect(payload.missing).toBe(1);
});
it("renders a dry-run action table with keep/prune actions", async () => {
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
mocks.loadSessionStore.mockReturnValue({

View File

@@ -1,7 +1,10 @@
import fs from "node:fs";
import { loadConfig } from "../config/config.js";
import {
capEntryCount,
enforceSessionDiskBudget,
resolveSessionFilePath,
resolveSessionFilePathOptions,
loadSessionStore,
pruneStaleEntries,
resolveMaintenanceConfig,
@@ -33,9 +36,15 @@ export type SessionsCleanupOptions = {
enforce?: boolean;
activeKey?: string;
json?: boolean;
fixMissing?: boolean;
};
type SessionCleanupAction = "keep" | "prune-stale" | "cap-overflow" | "evict-budget";
type SessionCleanupAction =
| "keep"
| "prune-missing"
| "prune-stale"
| "cap-overflow"
| "evict-budget";
const ACTION_PAD = 12;
@@ -50,6 +59,7 @@ type SessionCleanupSummary = {
dryRun: boolean;
beforeCount: number;
afterCount: number;
missing: number;
pruned: number;
capped: number;
diskBudget: Awaited<ReturnType<typeof enforceSessionDiskBudget>>;
@@ -60,10 +70,14 @@ type SessionCleanupSummary = {
function resolveSessionCleanupAction(params: {
key: string;
missingKeys: Set<string>;
staleKeys: Set<string>;
cappedKeys: Set<string>;
budgetEvictedKeys: Set<string>;
}): SessionCleanupAction {
if (params.missingKeys.has(params.key)) {
return "prune-missing";
}
if (params.staleKeys.has(params.key)) {
return "prune-stale";
}
@@ -84,6 +98,9 @@ function formatCleanupActionCell(action: SessionCleanupAction, rich: boolean): s
if (action === "keep") {
return theme.muted(label);
}
if (action === "prune-missing") {
return theme.error(label);
}
if (action === "prune-stale") {
return theme.warn(label);
}
@@ -95,6 +112,7 @@ function formatCleanupActionCell(action: SessionCleanupAction, rich: boolean): s
function buildActionRows(params: {
beforeStore: Record<string, SessionEntry>;
missingKeys: Set<string>;
staleKeys: Set<string>;
cappedKeys: Set<string>;
budgetEvictedKeys: Set<string>;
@@ -103,6 +121,7 @@ function buildActionRows(params: {
...row,
action: resolveSessionCleanupAction({
key: row.key,
missingKeys: params.missingKeys,
staleKeys: params.staleKeys,
cappedKeys: params.cappedKeys,
budgetEvictedKeys: params.budgetEvictedKeys,
@@ -110,17 +129,52 @@ function buildActionRows(params: {
}));
}
function pruneMissingTranscriptEntries(params: {
store: Record<string, SessionEntry>;
storePath: string;
onPruned?: (key: string) => void;
}): number {
const sessionPathOpts = resolveSessionFilePathOptions({
storePath: params.storePath,
});
let removed = 0;
for (const [key, entry] of Object.entries(params.store)) {
if (!entry?.sessionId) {
continue;
}
const transcriptPath = resolveSessionFilePath(entry.sessionId, entry, sessionPathOpts);
if (!fs.existsSync(transcriptPath)) {
delete params.store[key];
removed += 1;
params.onPruned?.(key);
}
}
return removed;
}
async function previewStoreCleanup(params: {
target: SessionStoreTarget;
mode: "warn" | "enforce";
dryRun: boolean;
activeKey?: string;
fixMissing?: boolean;
}) {
const maintenance = resolveMaintenanceConfig();
const beforeStore = loadSessionStore(params.target.storePath, { skipCache: true });
const previewStore = structuredClone(beforeStore);
const staleKeys = new Set<string>();
const cappedKeys = new Set<string>();
const missingKeys = new Set<string>();
const missing =
params.fixMissing === true
? pruneMissingTranscriptEntries({
store: previewStore,
storePath: params.target.storePath,
onPruned: (key) => {
missingKeys.add(key);
},
})
: 0;
const pruned = pruneStaleEntries(previewStore, maintenance.pruneAfterMs, {
log: false,
onPruned: ({ key }) => {
@@ -151,6 +205,7 @@ async function previewStoreCleanup(params: {
const beforeCount = Object.keys(beforeStore).length;
const afterPreviewCount = Object.keys(previewStore).length;
const wouldMutate =
missing > 0 ||
pruned > 0 ||
capped > 0 ||
Boolean((diskBudget?.removedEntries ?? 0) > 0 || (diskBudget?.removedFiles ?? 0) > 0);
@@ -162,6 +217,7 @@ async function previewStoreCleanup(params: {
dryRun: params.dryRun,
beforeCount,
afterCount: afterPreviewCount,
missing,
pruned,
capped,
diskBudget,
@@ -175,6 +231,7 @@ async function previewStoreCleanup(params: {
staleKeys,
cappedKeys,
budgetEvictedKeys,
missingKeys,
}),
};
}
@@ -196,6 +253,7 @@ function renderStoreDryRunPlan(params: {
params.runtime.log(
`Entries: ${params.summary.beforeCount} -> ${params.summary.afterCount} (remove ${params.summary.beforeCount - params.summary.afterCount})`,
);
params.runtime.log(`Would prune missing transcripts: ${params.summary.missing}`);
params.runtime.log(`Would prune stale: ${params.summary.pruned}`);
params.runtime.log(`Would cap overflow: ${params.summary.capped}`);
if (params.summary.diskBudget) {
@@ -256,6 +314,7 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
mode,
dryRun: Boolean(opts.dryRun),
activeKey: opts.activeKey,
fixMissing: Boolean(opts.fixMissing),
});
previewResults.push(result);
}
@@ -303,10 +362,16 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
const appliedReportRef: { current: SessionMaintenanceApplyReport | null } = {
current: null,
};
await updateSessionStore(
const missingApplied = await updateSessionStore(
target.storePath,
async () => {
// Maintenance runs in saveSessionStoreUnlocked(); no direct store mutation needed here.
async (store) => {
if (!opts.fixMissing) {
return 0;
}
return pruneMissingTranscriptEntries({
store,
storePath: target.storePath,
});
},
{
activeSessionKey: opts.activeKey,
@@ -331,6 +396,7 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
dryRun: false,
beforeCount: 0,
afterCount: 0,
missing: 0,
pruned: 0,
capped: 0,
diskBudget: null,
@@ -347,10 +413,12 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti
dryRun: false,
beforeCount: appliedReport.beforeCount,
afterCount: appliedReport.afterCount,
missing: missingApplied,
pruned: appliedReport.pruned,
capped: appliedReport.capped,
diskBudget: appliedReport.diskBudget,
wouldMutate:
missingApplied > 0 ||
appliedReport.pruned > 0 ||
appliedReport.capped > 0 ||
Boolean(