From a202c52b09a2a74473ad80c456d8c3d070fb892f Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 23 Feb 2026 14:26:57 -0500 Subject: [PATCH] Sessions: report applied cleanup stats and tighten doctor hints --- src/commands/doctor-state-integrity.test.ts | 28 +++++++++ src/commands/doctor-state-integrity.ts | 7 ++- src/commands/sessions-cleanup.test.ts | 44 +++++++++++++- src/commands/sessions-cleanup.ts | 64 +++++++++++++++------ src/config/sessions/store.ts | 38 ++++++++++-- 5 files changed, 155 insertions(+), 26 deletions(-) diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index 7a89677175c..ba889d28bdf 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -143,4 +143,32 @@ describe("doctor state integrity oauth dir checks", () => { const files = fs.readdirSync(sessionsDir); expect(files.some((name) => name.startsWith("orphan-session.jsonl.deleted."))).toBe(true); }); + + it("prints openclaw-only verification hints when recent sessions are missing transcripts", async () => { + const cfg: OpenClawConfig = {}; + setupSessionState(cfg, process.env, process.env.HOME ?? ""); + const storePath = resolveStorePath(cfg.session?.store, { agentId: "main" }); + fs.writeFileSync( + storePath, + JSON.stringify( + { + "agent:main:main": { + sessionId: "missing-transcript", + updatedAt: Date.now(), + }, + }, + null, + 2, + ), + ); + + await noteStateIntegrity(cfg, { confirmSkipInNonInteractive: vi.fn(async () => false) }); + + const text = stateIntegrityText(); + 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).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 be2c99a3f20..9f4a7544af0 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -207,6 +207,7 @@ export async function noteStateIntegrity( const displayStateDir = shortenHomePath(stateDir); const displayOauthDir = shortenHomePath(oauthDir); const displaySessionsDir = shortenHomePath(sessionsDir); + const displayStorePath = shortenHomePath(storePath); const displayStoreDir = shortenHomePath(storeDir); const displayConfigPath = configPath ? shortenHomePath(configPath) : undefined; const requireOAuthDir = shouldRequireOAuthDir(cfg, env); @@ -410,7 +411,11 @@ export async function noteStateIntegrity( }); if (missing.length > 0) { warnings.push( - `- ${missing.length}/${recent.length} recent sessions are missing transcripts. Check for deleted session files or split state dirs.`, + [ + `- ${missing.length}/${recent.length} recent sessions are missing transcripts.`, + ` Verify sessions in store: openclaw sessions --store "${displayStorePath}"`, + ` Preview cleanup impact: openclaw sessions cleanup --store "${displayStorePath}" --dry-run`, + ].join("\n"), ); } diff --git a/src/commands/sessions-cleanup.test.ts b/src/commands/sessions-cleanup.test.ts index e96de78c67e..31ece2c3501 100644 --- a/src/commands/sessions-cleanup.test.ts +++ b/src/commands/sessions-cleanup.test.ts @@ -97,6 +97,41 @@ describe("sessionsCleanupCommand", () => { .mockReturnValueOnce({ fresh: { sessionId: "fresh", updatedAt: 2 }, }); + mocks.updateSessionStore.mockImplementation( + async ( + _storePath: string, + mutator: (store: Record) => Promise | void, + opts?: { + onMaintenanceApplied?: (report: { + mode: "warn" | "enforce"; + beforeCount: number; + afterCount: number; + pruned: number; + capped: number; + diskBudget: Record | null; + }) => Promise | void; + }, + ) => { + await mutator({}); + await opts?.onMaintenanceApplied?.({ + mode: "enforce", + beforeCount: 3, + afterCount: 1, + pruned: 0, + capped: 2, + diskBudget: { + totalBytesBefore: 1200, + totalBytesAfter: 800, + removedFiles: 0, + removedEntries: 0, + freedBytes: 400, + maxBytes: 1000, + highWaterBytes: 800, + overBudget: true, + }, + }); + }, + ); const { runtime, logs } = makeRuntime(); await sessionsCleanupCommand( @@ -112,12 +147,14 @@ describe("sessionsCleanupCommand", () => { const payload = JSON.parse(logs[0] ?? "{}") as Record; expect(payload.applied).toBe(true); expect(payload.mode).toBe("enforce"); - expect(payload.beforeCount).toBe(2); + expect(payload.beforeCount).toBe(3); expect(payload.appliedCount).toBe(1); + expect(payload.pruned).toBe(0); + expect(payload.capped).toBe(2); expect(payload.diskBudget).toEqual( expect.objectContaining({ - removedFiles: 1, - removedEntries: 1, + removedFiles: 0, + removedEntries: 0, }), ); expect(mocks.updateSessionStore).toHaveBeenCalledWith( @@ -126,6 +163,7 @@ describe("sessionsCleanupCommand", () => { expect.objectContaining({ activeSessionKey: "agent:main:main", maintenanceOverride: { mode: "enforce" }, + onMaintenanceApplied: expect.any(Function), }), ); }); diff --git a/src/commands/sessions-cleanup.ts b/src/commands/sessions-cleanup.ts index f1df9055afc..d09d986aea0 100644 --- a/src/commands/sessions-cleanup.ts +++ b/src/commands/sessions-cleanup.ts @@ -7,6 +7,7 @@ import { resolveMaintenanceConfig, updateSessionStore, type SessionEntry, + type SessionMaintenanceApplyReport, } from "../config/sessions.js"; import type { RuntimeEnv } from "../runtime.js"; import { isRich, theme } from "../terminal/theme.js"; @@ -299,6 +300,9 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti const appliedSummaries: SessionCleanupSummary[] = []; for (const target of targets) { + const appliedReportRef: { current: SessionMaintenanceApplyReport | null } = { + current: null, + }; await updateSessionStore( target.storePath, async () => { @@ -309,27 +313,53 @@ export async function sessionsCleanupCommand(opts: SessionsCleanupOptions, runti maintenanceOverride: { mode, }, + onMaintenanceApplied: (report) => { + appliedReportRef.current = report; + }, }, ); const afterStore = loadSessionStore(target.storePath, { skipCache: true }); const preview = previewResults.find((result) => result.summary.storePath === target.storePath); - const summary: SessionCleanupSummary = { - ...(preview?.summary ?? { - agentId: target.agentId, - storePath: target.storePath, - mode, - dryRun: false, - beforeCount: 0, - afterCount: 0, - pruned: 0, - capped: 0, - diskBudget: null, - wouldMutate: false, - }), - dryRun: false, - applied: true, - appliedCount: Object.keys(afterStore).length, - }; + const appliedReport = appliedReportRef.current; + const summary: SessionCleanupSummary = + appliedReport === null + ? { + ...(preview?.summary ?? { + agentId: target.agentId, + storePath: target.storePath, + mode, + dryRun: false, + beforeCount: 0, + afterCount: 0, + pruned: 0, + capped: 0, + diskBudget: null, + wouldMutate: false, + }), + dryRun: false, + applied: true, + appliedCount: Object.keys(afterStore).length, + } + : { + agentId: target.agentId, + storePath: target.storePath, + mode: appliedReport.mode, + dryRun: false, + beforeCount: appliedReport.beforeCount, + afterCount: appliedReport.afterCount, + pruned: appliedReport.pruned, + capped: appliedReport.capped, + diskBudget: appliedReport.diskBudget, + wouldMutate: + appliedReport.pruned > 0 || + appliedReport.capped > 0 || + Boolean( + (appliedReport.diskBudget?.removedEntries ?? 0) > 0 || + (appliedReport.diskBudget?.removedFiles ?? 0) > 0, + ), + applied: true, + appliedCount: Object.keys(afterStore).length, + }; appliedSummaries.push(summary); } diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 378b9737bc9..210ebc99963 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -20,7 +20,7 @@ import { import { getFileMtimeMs, isCacheEnabled, resolveCacheTtlMs } from "../cache-utils.js"; import { loadConfig } from "../config.js"; import type { SessionMaintenanceConfig, SessionMaintenanceMode } from "../types.base.js"; -import { enforceSessionDiskBudget } from "./disk-budget.js"; +import { enforceSessionDiskBudget, type SessionDiskBudgetSweepResult } from "./disk-budget.js"; import { deriveSessionMetaPatch } from "./metadata.js"; import { mergeSessionEntry, type SessionEntry } from "./types.js"; @@ -312,6 +312,15 @@ export type SessionMaintenanceWarning = { wouldCap: boolean; }; +export type SessionMaintenanceApplyReport = { + mode: SessionMaintenanceMode; + beforeCount: number; + afterCount: number; + pruned: number; + capped: number; + diskBudget: SessionDiskBudgetSweepResult | null; +}; + type ResolvedSessionMaintenanceConfig = { mode: SessionMaintenanceMode; pruneAfterMs: number; @@ -620,6 +629,8 @@ type SaveSessionStoreOptions = { activeSessionKey?: string; /** Optional callback for warn-only maintenance. */ onWarn?: (warning: SessionMaintenanceWarning) => void | Promise; + /** Optional callback with maintenance stats after a save. */ + onMaintenanceApplied?: (report: SessionMaintenanceApplyReport) => void | Promise; /** Optional overrides used by maintenance commands. */ maintenanceOverride?: Partial; }; @@ -638,6 +649,7 @@ async function saveSessionStoreUnlocked( // Resolve maintenance config once (avoids repeated loadConfig() calls). const maintenance = { ...resolveMaintenanceConfig(), ...opts?.maintenanceOverride }; const shouldWarnOnly = maintenance.mode === "warn"; + const beforeCount = Object.keys(store).length; if (shouldWarnOnly) { const activeSessionKey = opts?.activeSessionKey?.trim(); @@ -659,7 +671,7 @@ async function saveSessionStoreUnlocked( await opts?.onWarn?.(warning); } } - await enforceSessionDiskBudget({ + const diskBudget = await enforceSessionDiskBudget({ store, storePath, activeSessionKey: opts?.activeSessionKey, @@ -667,17 +679,25 @@ async function saveSessionStoreUnlocked( warnOnly: true, log, }); + await opts?.onMaintenanceApplied?.({ + mode: maintenance.mode, + beforeCount, + afterCount: Object.keys(store).length, + pruned: 0, + capped: 0, + diskBudget, + }); } else { // Prune stale entries and cap total count before serializing. const removedSessionFiles = new Map(); - pruneStaleEntries(store, maintenance.pruneAfterMs, { + const pruned = pruneStaleEntries(store, maintenance.pruneAfterMs, { onPruned: ({ entry }) => { if (!removedSessionFiles.has(entry.sessionId) || entry.sessionFile) { removedSessionFiles.set(entry.sessionId, entry.sessionFile); } }, }); - capEntryCount(store, maintenance.maxEntries, { + const capped = capEntryCount(store, maintenance.maxEntries, { onCapped: ({ entry }) => { if (!removedSessionFiles.has(entry.sessionId) || entry.sessionFile) { removedSessionFiles.set(entry.sessionId, entry.sessionFile); @@ -725,7 +745,7 @@ async function saveSessionStoreUnlocked( // Rotate the on-disk file if it exceeds the size threshold. await rotateSessionFile(storePath, maintenance.rotateBytes); - await enforceSessionDiskBudget({ + const diskBudget = await enforceSessionDiskBudget({ store, storePath, activeSessionKey: opts?.activeSessionKey, @@ -733,6 +753,14 @@ async function saveSessionStoreUnlocked( warnOnly: false, log, }); + await opts?.onMaintenanceApplied?.({ + mode: maintenance.mode, + beforeCount, + afterCount: Object.keys(store).length, + pruned, + capped, + diskBudget, + }); } }