mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-18 21:40:53 +00:00
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:
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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 ");
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user