diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b1745b4f6..60c5e1729c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/sessions: prune old unreferenced transcript, compaction checkpoint, and trajectory artifacts during normal `sessions cleanup`, so gateway restart or crash orphans do not accumulate indefinitely outside `sessions.json`. Fixes #77608. Thanks @slideshow-dingo. - Video generation: wait up to 20 minutes for slow fal/MiniMax queue-backed jobs, stop forwarding unsupported Google Veo generated-audio options, and normalize MiniMax `720P` requests to its supported `768P` resolution with the usual override warning/details instead of failing fallback. - Update/restart: probe managed Gateway restarts with the service environment and add a Docker product lane that exercises candidate-owned `openclaw update --yes --json` restarts, so SecretRef-backed local gateway auth cannot regress behind mocked restart checks. Thanks @vincentkoc. - Webhooks/Gmail/Windows: resolve `gcloud`, `gog`, and `tailscale` PATH/PATHEXT shims before setup and watcher spawns, using the Windows-safe `.cmd` wrapper for long-lived `gog serve` processes. (#74881, fixes #54470) Thanks @Angfr95. diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index 58bab6fdbd9..6a510b3e129 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -99,6 +99,7 @@ openclaw sessions cleanup --json `openclaw sessions cleanup` uses `session.maintenance` settings from config: - Scope note: `openclaw sessions cleanup` maintains session stores, transcripts, and trajectory sidecars. It does not prune cron run logs (`cron/runs/.jsonl`), which are managed by `cron.runLog.maxBytes` and `cron.runLog.keepLines` in [Cron configuration](/automation/cron-jobs#configuration) and explained in [Cron maintenance](/automation/cron-jobs#maintenance). +- Cleanup also prunes unreferenced primary transcripts, compaction checkpoints, and trajectory sidecars older than `session.maintenance.pruneAfter`; files still referenced by `sessions.json` are preserved. - `--dry-run`: preview how many entries would be pruned/capped without writing. - In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) so you can see what would be kept vs removed. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index 4b1e8dfa240..b5f03433641 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -85,7 +85,7 @@ Session persistence has automatic maintenance controls (`session.maintenance`) f - `maxDiskBytes`: optional sessions-directory budget - `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`) -Normal Gateway writes flow through a per-store session writer that serializes in-process mutations without taking a runtime file lock. Hot-path patch helpers borrow the validated mutable cache while they hold that writer slot, so large `sessions.json` files are not cloned or reread for every metadata update. Runtime code should prefer `updateSessionStore(...)` or `updateSessionStoreEntry(...)`; direct whole-store saves are compatibility and offline-maintenance tools. When a Gateway is reachable, non-dry-run `openclaw sessions cleanup` and `openclaw agents delete` delegate store mutations to the Gateway so cleanup joins the same writer queue; `--store ` is the explicit offline repair path for direct file maintenance. `maxEntries` cleanup is still batched for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. Session store reads do not prune or cap entries during Gateway startup; use writes or `openclaw sessions cleanup --enforce` for cleanup. `openclaw sessions cleanup --enforce` still applies the configured cap immediately. +Normal Gateway writes flow through a per-store session writer that serializes in-process mutations without taking a runtime file lock. Hot-path patch helpers borrow the validated mutable cache while they hold that writer slot, so large `sessions.json` files are not cloned or reread for every metadata update. Runtime code should prefer `updateSessionStore(...)` or `updateSessionStoreEntry(...)`; direct whole-store saves are compatibility and offline-maintenance tools. When a Gateway is reachable, non-dry-run `openclaw sessions cleanup` and `openclaw agents delete` delegate store mutations to the Gateway so cleanup joins the same writer queue; `--store ` is the explicit offline repair path for direct file maintenance. `maxEntries` cleanup is still batched for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. Session store reads do not prune or cap entries during Gateway startup; use writes or `openclaw sessions cleanup --enforce` for cleanup. `openclaw sessions cleanup --enforce` still applies the configured cap immediately and prunes old unreferenced transcript, checkpoint, and trajectory artifacts even when no disk budget is configured. Maintenance keeps durable external conversation pointers such as group sessions and thread-scoped chat sessions, but synthetic runtime entries for cron, hooks, diff --git a/src/commands/sessions-cleanup.test.ts b/src/commands/sessions-cleanup.test.ts index 9e458dfb86b..323a23a2f73 100644 --- a/src/commands/sessions-cleanup.test.ts +++ b/src/commands/sessions-cleanup.test.ts @@ -395,6 +395,12 @@ describe("sessionsCleanupCommand", () => { missing: 0, pruned: 1, capped: 0, + unreferencedArtifacts: { + scannedFiles: 5, + removedFiles: 2, + freedBytes: 128, + olderThanMs: 604800000, + }, diskBudget: null, wouldMutate: true, }, @@ -420,6 +426,7 @@ describe("sessionsCleanupCommand", () => { ); expect(logs.some((line) => line.includes("Planned session actions:"))).toBe(true); + expect(logs.some((line) => line.includes("Would prune unreferenced artifacts: 2"))).toBe(true); expect(logs.some((line) => line.includes("Action") && line.includes("Key"))).toBe(true); expect(logs.some((line) => line.includes("fresh") && line.includes("keep"))).toBe(true); expect(logs.some((line) => line.includes("stale") && line.includes("prune-stale"))).toBe(true); diff --git a/src/commands/sessions-cleanup.ts b/src/commands/sessions-cleanup.ts index a84485a3c52..80366e97a9b 100644 --- a/src/commands/sessions-cleanup.ts +++ b/src/commands/sessions-cleanup.ts @@ -93,6 +93,11 @@ function renderStoreDryRunPlan(params: { 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.unreferencedArtifacts?.scannedFiles) { + params.runtime.log( + `Would prune unreferenced artifacts: ${params.summary.unreferencedArtifacts.removedFiles}`, + ); + } if (params.summary.diskBudget) { params.runtime.log( `Would enforce disk budget: ${params.summary.diskBudget.totalBytesBefore} -> ${params.summary.diskBudget.totalBytesAfter} bytes (files ${params.summary.diskBudget.removedFiles}, entries ${params.summary.diskBudget.removedEntries})`, @@ -141,6 +146,11 @@ function renderAppliedSummaries(params: { } params.runtime.log(`Session store: ${summary.storePath}`); params.runtime.log(`Applied maintenance. Current entries: ${summary.appliedCount ?? 0}`); + if (summary.unreferencedArtifacts?.removedFiles) { + params.runtime.log( + `Pruned unreferenced artifacts: ${summary.unreferencedArtifacts.removedFiles}`, + ); + } } } diff --git a/src/config/sessions/cleanup-service.ts b/src/config/sessions/cleanup-service.ts index 46b550abc89..1da353be7c1 100644 --- a/src/config/sessions/cleanup-service.ts +++ b/src/config/sessions/cleanup-service.ts @@ -1,10 +1,16 @@ import fs from "node:fs"; +import path from "node:path"; import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveStoredSessionOwnerAgentId } from "../../gateway/session-store-key.js"; import { getLogger } from "../../logging/logger.js"; import { normalizeAgentId } from "../../routing/session-key.js"; import type { OpenClawConfig } from "../types.openclaw.js"; -import { enforceSessionDiskBudget } from "./disk-budget.js"; +import { + enforceSessionDiskBudget, + pruneUnreferencedSessionArtifacts, + resolveSessionArtifactCanonicalPathsForEntry, + type SessionUnreferencedArtifactSweepResult, +} from "./disk-budget.js"; import { resolveSessionFilePath, resolveSessionFilePathOptions, @@ -54,6 +60,7 @@ export type SessionCleanupSummary = { missing: number; pruned: number; capped: number; + unreferencedArtifacts: SessionUnreferencedArtifactSweepResult; diskBudget: Awaited>; wouldMutate: boolean; applied?: true; @@ -143,14 +150,35 @@ function pruneMissingTranscriptEntries(params: { return removed; } +function addEntryArtifactPathsToSet(params: { + paths: Set; + store: Record; + storePath: string; + keys: ReadonlySet; +}): void { + const sessionsDir = path.dirname(params.storePath); + for (const key of params.keys) { + const entry = params.store[key]; + if (!entry) { + continue; + } + for (const artifactPath of resolveSessionArtifactCanonicalPathsForEntry({ + sessionsDir, + entry, + })) { + params.paths.add(artifactPath); + } + } +} + async function previewStoreCleanup(params: { target: SessionStoreTarget; + maintenance: ResolvedSessionMaintenanceConfig; mode: ResolvedSessionMaintenanceConfig["mode"]; dryRun: boolean; activeKey?: string; fixMissing?: boolean; }) { - const maintenance = resolveMaintenanceConfig(); const beforeStore = loadSessionStore(params.target.storePath, { skipCache: true }); const previewStore = cloneSessionStoreRecord(beforeStore); const staleKeys = new Set(); @@ -166,26 +194,50 @@ async function previewStoreCleanup(params: { }, }) : 0; - const pruned = pruneStaleEntries(previewStore, maintenance.pruneAfterMs, { + const pruned = pruneStaleEntries(previewStore, params.maintenance.pruneAfterMs, { log: false, onPruned: ({ key }) => { staleKeys.add(key); }, }); - const capped = capEntryCount(previewStore, maintenance.maxEntries, { + const capped = capEntryCount(previewStore, params.maintenance.maxEntries, { log: false, onCapped: ({ key }) => { cappedKeys.add(key); }, }); + const entryCleanupArtifactPaths = new Set(); + addEntryArtifactPathsToSet({ + paths: entryCleanupArtifactPaths, + store: beforeStore, + storePath: params.target.storePath, + keys: staleKeys, + }); + addEntryArtifactPathsToSet({ + paths: entryCleanupArtifactPaths, + store: beforeStore, + storePath: params.target.storePath, + keys: cappedKeys, + }); const beforeBudgetStore = cloneSessionStoreRecord(previewStore); + const budgetRemovedFilePaths = new Set(); const diskBudget = await enforceSessionDiskBudget({ store: previewStore, storePath: params.target.storePath, activeSessionKey: params.activeKey, - maintenance, + maintenance: params.maintenance, warnOnly: false, dryRun: true, + onRemoveFile: (canonicalPath) => { + budgetRemovedFilePaths.add(canonicalPath); + }, + }); + const unreferencedArtifacts = await pruneUnreferencedSessionArtifacts({ + store: previewStore, + storePath: params.target.storePath, + olderThanMs: params.maintenance.pruneAfterMs, + dryRun: true, + excludeCanonicalPaths: new Set([...budgetRemovedFilePaths, ...entryCleanupArtifactPaths]), }); const budgetEvictedKeys = new Set(); for (const key of Object.keys(beforeBudgetStore)) { @@ -199,6 +251,7 @@ async function previewStoreCleanup(params: { missing > 0 || pruned > 0 || capped > 0 || + unreferencedArtifacts.removedFiles > 0 || (diskBudget?.removedEntries ?? 0) > 0 || (diskBudget?.removedFiles ?? 0) > 0; @@ -212,6 +265,7 @@ async function previewStoreCleanup(params: { missing, pruned, capped, + unreferencedArtifacts, diskBudget, wouldMutate, }; @@ -232,7 +286,8 @@ export async function runSessionsCleanup(params: { targets?: SessionStoreTarget[]; }): Promise { const { cfg, opts } = params; - const mode = opts.enforce ? "enforce" : resolveMaintenanceConfig().mode; + const maintenance = resolveMaintenanceConfig(); + const mode = opts.enforce ? "enforce" : maintenance.mode; const targets = params.targets ?? resolveSessionStoreTargets(cfg, { @@ -245,6 +300,7 @@ export async function runSessionsCleanup(params: { for (const target of targets) { const result = await previewStoreCleanup({ target, + maintenance, mode, dryRun: Boolean(opts.dryRun), activeKey: opts.activeKey, @@ -281,6 +337,20 @@ export async function runSessionsCleanup(params: { }, ); const afterStore = loadSessionStore(target.storePath, { skipCache: true }); + const unreferencedArtifacts = + mode === "warn" + ? { + scannedFiles: 0, + removedFiles: 0, + freedBytes: 0, + olderThanMs: maintenance.pruneAfterMs, + } + : await pruneUnreferencedSessionArtifacts({ + store: afterStore, + storePath: target.storePath, + olderThanMs: maintenance.pruneAfterMs, + dryRun: false, + }); const preview = previewResults.find( (result) => result.summary.storePath === target.storePath, ); @@ -298,10 +368,14 @@ export async function runSessionsCleanup(params: { missing: 0, pruned: 0, capped: 0, + unreferencedArtifacts, diskBudget: null, wouldMutate: false, }), dryRun: false, + unreferencedArtifacts, + wouldMutate: + (preview?.summary.wouldMutate ?? false) || unreferencedArtifacts.removedFiles > 0, applied: true, appliedCount: Object.keys(afterStore).length, } @@ -315,11 +389,13 @@ export async function runSessionsCleanup(params: { missing: missingApplied, pruned: appliedReport.pruned, capped: appliedReport.capped, + unreferencedArtifacts, diskBudget: appliedReport.diskBudget, wouldMutate: missingApplied > 0 || appliedReport.pruned > 0 || appliedReport.capped > 0 || + unreferencedArtifacts.removedFiles > 0 || (appliedReport.diskBudget?.removedEntries ?? 0) > 0 || (appliedReport.diskBudget?.removedFiles ?? 0) > 0, applied: true, diff --git a/src/config/sessions/disk-budget.ts b/src/config/sessions/disk-budget.ts index f201413e93e..a0f1de2c72b 100644 --- a/src/config/sessions/disk-budget.ts +++ b/src/config/sessions/disk-budget.ts @@ -34,6 +34,13 @@ export type SessionDiskBudgetSweepResult = { overBudget: boolean; }; +export type SessionUnreferencedArtifactSweepResult = { + scannedFiles: number; + removedFiles: number; + freedBytes: number; + olderThanMs: number; +}; + export type SessionDiskBudgetLogger = { warn: (message: string, context?: Record) => void; info: (message: string, context?: Record) => void; @@ -147,6 +154,13 @@ function resolveSessionArtifactPathsForEntry(params: { return paths; } +export function resolveSessionArtifactCanonicalPathsForEntry(params: { + sessionsDir: string; + entry: SessionEntry; +}): string[] { + return resolveSessionArtifactPathsForEntry(params).map(canonicalizePathForComparison); +} + function resolveReferencedSessionArtifactPaths(params: { sessionsDir: string; store: Record; @@ -154,11 +168,11 @@ function resolveReferencedSessionArtifactPaths(params: { const referenced = new Set(); const resolvedSessionsDir = canonicalizePathForComparison(params.sessionsDir); for (const entry of Object.values(params.store)) { - for (const resolved of resolveSessionArtifactPathsForEntry({ + for (const resolved of resolveSessionArtifactCanonicalPathsForEntry({ sessionsDir: params.sessionsDir, entry, })) { - referenced.add(canonicalizePathForComparison(resolved)); + referenced.add(resolved); } for (const checkpoint of entry.compactionCheckpoints ?? []) { const checkpointFile = checkpoint.preCompaction.sessionFile?.trim(); @@ -200,6 +214,30 @@ async function readSessionsDirFiles(sessionsDir: string): Promise, + referencedPaths: ReadonlySet, +): boolean { + if (referencedPaths.has(file.canonicalPath)) { + return false; + } + return ( + isCompactionCheckpointTranscriptFileName(file.name) || + isTrajectorySessionArtifactName(file.name) || + isPrimarySessionTranscriptFileName(file.name) + ); +} + +function isDiskBudgetRemovableSessionFile( + file: Pick, + referencedPaths: ReadonlySet, +): boolean { + return ( + isSessionArchiveArtifactName(file.name) || + isUnreferencedSessionArtifactFile(file, referencedPaths) + ); +} + async function removeFileIfExists(filePath: string): Promise { const stat = await fs.promises.stat(filePath).catch(() => null); if (!stat?.isFile()) { @@ -215,6 +253,7 @@ async function removeFileForBudget(params: { dryRun: boolean; fileSizesByPath: Map; simulatedRemovedPaths: Set; + onRemovedPath?: (canonicalPath: string) => void; }): Promise { const resolvedPath = path.resolve(params.filePath); const canonicalPath = params.canonicalPath ?? canonicalizePathForComparison(resolvedPath); @@ -227,9 +266,66 @@ async function removeFileForBudget(params: { return 0; } params.simulatedRemovedPaths.add(canonicalPath); + params.onRemovedPath?.(canonicalPath); return size; } - return removeFileIfExists(resolvedPath); + const size = await removeFileIfExists(resolvedPath); + if (size > 0) { + params.onRemovedPath?.(canonicalPath); + } + return size; +} + +export async function pruneUnreferencedSessionArtifacts(params: { + store: Record; + storePath: string; + olderThanMs: number; + dryRun?: boolean; + excludeCanonicalPaths?: ReadonlySet; +}): Promise { + const olderThanMs = + Number.isFinite(params.olderThanMs) && params.olderThanMs > 0 ? params.olderThanMs : 0; + const sessionsDir = path.dirname(params.storePath); + const files = await readSessionsDirFiles(sessionsDir); + const fileSizesByPath = new Map(files.map((file) => [file.canonicalPath, file.size])); + const simulatedRemovedPaths = new Set(); + const referencedPaths = resolveReferencedSessionArtifactPaths({ + sessionsDir, + store: params.store, + }); + const cutoffMs = Date.now() - olderThanMs; + const removableFiles = files + .filter( + (file) => + !params.excludeCanonicalPaths?.has(file.canonicalPath) && + file.mtimeMs <= cutoffMs && + isUnreferencedSessionArtifactFile(file, referencedPaths), + ) + .toSorted((a, b) => a.mtimeMs - b.mtimeMs); + + let removedFiles = 0; + let freedBytes = 0; + for (const file of removableFiles) { + const deletedBytes = await removeFileForBudget({ + filePath: file.path, + canonicalPath: file.canonicalPath, + dryRun: params.dryRun === true, + fileSizesByPath, + simulatedRemovedPaths, + }); + if (deletedBytes <= 0) { + continue; + } + removedFiles += 1; + freedBytes += deletedBytes; + } + + return { + scannedFiles: files.length, + removedFiles, + freedBytes, + olderThanMs, + }; } export async function enforceSessionDiskBudget(params: { @@ -240,6 +336,7 @@ export async function enforceSessionDiskBudget(params: { warnOnly: boolean; dryRun?: boolean; log?: SessionDiskBudgetLogger; + onRemoveFile?: (canonicalPath: string) => void; }): Promise { const maxBytes = params.maintenance.maxDiskBytes; const highWaterBytes = params.maintenance.highWaterBytes; @@ -299,14 +396,7 @@ export async function enforceSessionDiskBudget(params: { store: params.store, }); const removableFileQueue = files - .filter( - (file) => - isSessionArchiveArtifactName(file.name) || - (isCompactionCheckpointTranscriptFileName(file.name) && - !referencedPaths.has(file.canonicalPath)) || - (isTrajectorySessionArtifactName(file.name) && !referencedPaths.has(file.canonicalPath)) || - (isPrimarySessionTranscriptFileName(file.name) && !referencedPaths.has(file.canonicalPath)), - ) + .filter((file) => isDiskBudgetRemovableSessionFile(file, referencedPaths)) .toSorted((a, b) => a.mtimeMs - b.mtimeMs); for (const file of removableFileQueue) { if (total <= highWaterBytes) { @@ -318,6 +408,7 @@ export async function enforceSessionDiskBudget(params: { dryRun, fileSizesByPath, simulatedRemovedPaths, + onRemovedPath: params.onRemoveFile, }); if (deletedBytes <= 0) { continue; @@ -379,6 +470,7 @@ export async function enforceSessionDiskBudget(params: { dryRun, fileSizesByPath, simulatedRemovedPaths, + onRemovedPath: params.onRemoveFile, }); if (deletedBytes <= 0) { continue; diff --git a/src/config/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index 4578c143759..e6e2699c34e 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -16,6 +16,7 @@ vi.mock("../config.js", async () => ({ })); import { getRuntimeConfig } from "../config.js"; +import { runSessionsCleanup } from "./cleanup-service.js"; import { clearSessionStoreCacheForTest, loadSessionStore, @@ -211,6 +212,190 @@ describe("Integration: saveSessionStore with pruning", () => { await expect(fs.stat(freshPointer)).resolves.toBeDefined(); }); + it("sessions cleanup prunes old unreferenced session artifacts without touching referenced files", async () => { + applyEnforcedMaintenanceConfig(mockLoadConfig); + + const now = Date.now(); + const oldDate = new Date(now - 10 * DAY_MS); + const freshDate = new Date(now); + const referencedCheckpointPath = path.join( + testDir, + "fresh-session.checkpoint.22222222-2222-4222-8222-222222222222.jsonl", + ); + const store: Record = { + fresh: { + sessionId: "fresh-session", + updatedAt: now, + compactionCheckpoints: [ + { + checkpointId: "referenced", + sessionKey: "fresh", + sessionId: "fresh-session", + createdAt: now, + reason: "manual", + preCompaction: { + sessionId: "fresh-session", + sessionFile: referencedCheckpointPath, + leafId: "leaf", + }, + postCompaction: { sessionId: "fresh-session" }, + }, + ], + }, + }; + const referencedTranscript = path.join(testDir, "fresh-session.jsonl"); + const oldOrphanTranscript = path.join(testDir, "orphan-session.jsonl"); + const freshOrphanTranscript = path.join(testDir, "fresh-orphan.jsonl"); + const orphanRuntime = path.join(testDir, "orphan-session.trajectory.jsonl"); + const orphanPointer = path.join(testDir, "orphan-session.trajectory-path.json"); + const orphanCheckpoint = path.join( + testDir, + "orphan-session.checkpoint.11111111-1111-4111-8111-111111111111.jsonl", + ); + await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); + await fs.writeFile(referencedTranscript, "referenced", "utf-8"); + await fs.writeFile(referencedCheckpointPath, "referenced checkpoint", "utf-8"); + await fs.writeFile(oldOrphanTranscript, "orphan transcript", "utf-8"); + await fs.writeFile(freshOrphanTranscript, "fresh orphan", "utf-8"); + await fs.writeFile(orphanRuntime, "orphan runtime", "utf-8"); + await fs.writeFile(orphanPointer, "orphan pointer", "utf-8"); + await fs.writeFile(orphanCheckpoint, "orphan checkpoint", "utf-8"); + for (const file of [ + referencedTranscript, + referencedCheckpointPath, + oldOrphanTranscript, + orphanRuntime, + orphanPointer, + orphanCheckpoint, + ]) { + await fs.utimes(file, oldDate, oldDate); + } + await fs.utimes(freshOrphanTranscript, freshDate, freshDate); + + const dryRun = await runSessionsCleanup({ + cfg: {}, + opts: { store: storePath, dryRun: true, enforce: true }, + targets: [{ agentId: "main", storePath }], + }); + expect(dryRun.previewResults[0]?.summary.unreferencedArtifacts).toEqual( + expect.objectContaining({ + removedFiles: 4, + }), + ); + await expect(fs.stat(oldOrphanTranscript)).resolves.toBeDefined(); + await expect(fs.stat(orphanRuntime)).resolves.toBeDefined(); + await expect(fs.stat(orphanPointer)).resolves.toBeDefined(); + await expect(fs.stat(orphanCheckpoint)).resolves.toBeDefined(); + + const applied = await runSessionsCleanup({ + cfg: {}, + opts: { store: storePath, enforce: true }, + targets: [{ agentId: "main", storePath }], + }); + + expect(applied.appliedSummaries[0]?.unreferencedArtifacts).toEqual( + expect.objectContaining({ + removedFiles: 4, + }), + ); + await expect(fs.stat(oldOrphanTranscript)).rejects.toThrow(); + await expect(fs.stat(orphanRuntime)).rejects.toThrow(); + await expect(fs.stat(orphanPointer)).rejects.toThrow(); + await expect(fs.stat(orphanCheckpoint)).rejects.toThrow(); + await expect(fs.stat(referencedTranscript)).resolves.toBeDefined(); + await expect(fs.stat(referencedCheckpointPath)).resolves.toBeDefined(); + await expect(fs.stat(freshOrphanTranscript)).resolves.toBeDefined(); + }); + + it("sessions cleanup dry-run does not double-count artifacts already covered by disk budget", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "7d", + maxEntries: 500, + maxDiskBytes: 1000, + highWaterBytes: 900, + }, + }, + }); + + const store: Record = { + fresh: { sessionId: "fresh-session", updatedAt: Date.now() }, + }; + const oldOrphanTranscript = path.join(testDir, "orphan-session.jsonl"); + await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); + await fs.writeFile(oldOrphanTranscript, "x".repeat(2000), "utf-8"); + const oldDate = new Date(Date.now() - 10 * DAY_MS); + await fs.utimes(oldOrphanTranscript, oldDate, oldDate); + + const dryRun = await runSessionsCleanup({ + cfg: {}, + opts: { store: storePath, dryRun: true, enforce: true }, + targets: [{ agentId: "main", storePath }], + }); + + expect(dryRun.previewResults[0]?.summary.diskBudget).toEqual( + expect.objectContaining({ + removedFiles: 1, + }), + ); + expect(dryRun.previewResults[0]?.summary.unreferencedArtifacts).toEqual( + expect.objectContaining({ + removedFiles: 0, + }), + ); + await expect(fs.stat(oldOrphanTranscript)).resolves.toBeDefined(); + }); + + it("sessions cleanup dry-run excludes stale and capped entry transcripts from orphan counts", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "7d", + maxEntries: 1, + }, + }, + }); + + const now = Date.now(); + const store: Record = { + stale: { sessionId: "stale-session", updatedAt: now - 30 * DAY_MS }, + capped: { sessionId: "capped-session", updatedAt: now - DAY_MS }, + fresh: { sessionId: "fresh-session", updatedAt: now }, + }; + const staleTranscript = path.join(testDir, "stale-session.jsonl"); + const cappedTranscript = path.join(testDir, "capped-session.jsonl"); + const freshTranscript = path.join(testDir, "fresh-session.jsonl"); + await fs.writeFile(storePath, JSON.stringify(store, null, 2), "utf-8"); + await fs.writeFile(staleTranscript, "stale", "utf-8"); + await fs.writeFile(cappedTranscript, "capped", "utf-8"); + await fs.writeFile(freshTranscript, "fresh", "utf-8"); + const oldDate = new Date(now - 10 * DAY_MS); + await fs.utimes(staleTranscript, oldDate, oldDate); + await fs.utimes(cappedTranscript, oldDate, oldDate); + + const dryRun = await runSessionsCleanup({ + cfg: {}, + opts: { store: storePath, dryRun: true, enforce: true }, + targets: [{ agentId: "main", storePath }], + }); + + expect(dryRun.previewResults[0]?.summary).toEqual( + expect.objectContaining({ + pruned: 1, + capped: 1, + unreferencedArtifacts: expect.objectContaining({ + removedFiles: 0, + }), + }), + ); + await expect(fs.stat(staleTranscript)).resolves.toBeDefined(); + await expect(fs.stat(cappedTranscript)).resolves.toBeDefined(); + await expect(fs.stat(freshTranscript)).resolves.toBeDefined(); + }); + it("cleans up archived transcripts older than the prune window", async () => { applyEnforcedMaintenanceConfig(mockLoadConfig);