From 71e45ceecced41893c7d41110d6514c2f0f52419 Mon Sep 17 00:00:00 2001 From: SidQin-cyber Date: Thu, 26 Feb 2026 20:21:47 +0800 Subject: [PATCH] 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 (cherry picked from commit 690d3d596bf8e7347f0f0dc14e357268f2e7c441) --- .../register.status-health-sessions.test.ts | 2 + .../register.status-health-sessions.ts | 10 +++ src/commands/doctor-state-integrity.test.ts | 3 + src/commands/doctor-state-integrity.ts | 1 + src/commands/sessions-cleanup.test.ts | 34 ++++++++- src/commands/sessions-cleanup.ts | 76 ++++++++++++++++++- 6 files changed, 121 insertions(+), 5 deletions(-) diff --git a/src/cli/program/register.status-health-sessions.test.ts b/src/cli/program/register.status-health-sessions.test.ts index ac84bb5c1..5a45b4d29 100644 --- a/src/cli/program/register.status-health-sessions.test.ts +++ b/src/cli/program/register.status-health-sessions.test.ts @@ -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, }), diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index b708d42e6..3a3d81abc 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -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 ", "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), }, diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index a9d28e397..dd33786c3 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -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 "); }); diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 7b056a27b..1e599f0f4 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -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"), ); } diff --git a/src/commands/sessions-cleanup.test.ts b/src/commands/sessions-cleanup.test.ts index 31ece2c35..6dc9556ca 100644 --- a/src/commands/sessions-cleanup.test.ts +++ b/src/commands/sessions-cleanup.test.ts @@ -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; + 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({ diff --git a/src/commands/sessions-cleanup.ts b/src/commands/sessions-cleanup.ts index d09d986ae..151fa531e 100644 --- a/src/commands/sessions-cleanup.ts +++ b/src/commands/sessions-cleanup.ts @@ -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>; @@ -60,10 +70,14 @@ type SessionCleanupSummary = { function resolveSessionCleanupAction(params: { key: string; + missingKeys: Set; staleKeys: Set; cappedKeys: Set; budgetEvictedKeys: Set; }): 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; + missingKeys: Set; staleKeys: Set; cappedKeys: Set; budgetEvictedKeys: Set; @@ -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; + 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(); const cappedKeys = new Set(); + const missingKeys = new Set(); + 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(