Sessions: report applied cleanup stats and tighten doctor hints

This commit is contained in:
Gustavo Madeira Santana
2026-02-23 14:26:57 -05:00
parent 59663702cc
commit a202c52b09
5 changed files with 155 additions and 26 deletions

View File

@@ -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 ");
});
});

View File

@@ -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"),
);
}

View File

@@ -97,6 +97,41 @@ describe("sessionsCleanupCommand", () => {
.mockReturnValueOnce({
fresh: { sessionId: "fresh", updatedAt: 2 },
});
mocks.updateSessionStore.mockImplementation(
async (
_storePath: string,
mutator: (store: Record<string, SessionEntry>) => Promise<void> | void,
opts?: {
onMaintenanceApplied?: (report: {
mode: "warn" | "enforce";
beforeCount: number;
afterCount: number;
pruned: number;
capped: number;
diskBudget: Record<string, unknown> | null;
}) => Promise<void> | 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<string, unknown>;
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),
}),
);
});

View File

@@ -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);
}

View File

@@ -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<void>;
/** Optional callback with maintenance stats after a save. */
onMaintenanceApplied?: (report: SessionMaintenanceApplyReport) => void | Promise<void>;
/** Optional overrides used by maintenance commands. */
maintenanceOverride?: Partial<ResolvedSessionMaintenanceConfig>;
};
@@ -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<string, string | undefined>();
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,
});
}
}