From 93fbe6482b49ac51b7911ed3ea5e8246fc0a1414 Mon Sep 17 00:00:00 2001 From: Hudson <258693705+hudson-rivera@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:23:07 -0500 Subject: [PATCH] fix(sessions): archive transcript files when pruning stale entries pruneStaleEntries() removed entries from sessions.json but left the corresponding .jsonl transcript files on disk indefinitely. Added an onPruned callback to collect pruned session IDs, then archives their transcript files via archiveSessionTranscripts() after pruning completes. Only runs in enforce mode. --- src/config/sessions/store.pruning.e2e.test.ts | 38 +++++++++++++++++++ src/config/sessions/store.ts | 21 +++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/config/sessions/store.pruning.e2e.test.ts b/src/config/sessions/store.pruning.e2e.test.ts index 92cd0da77fd..5cc411e0495 100644 --- a/src/config/sessions/store.pruning.e2e.test.ts +++ b/src/config/sessions/store.pruning.e2e.test.ts @@ -86,6 +86,44 @@ describe("Integration: saveSessionStore with pruning", () => { expect(loaded.fresh).toBeDefined(); }); + it("archives transcript files for stale sessions pruned on write", async () => { + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "7d", + maxEntries: 500, + rotateBytes: 10_485_760, + }, + }, + }); + + const now = Date.now(); + const staleSessionId = "stale-session"; + const freshSessionId = "fresh-session"; + const store: Record = { + stale: { sessionId: staleSessionId, updatedAt: now - 30 * DAY_MS }, + fresh: { sessionId: freshSessionId, updatedAt: now }, + }; + const staleTranscript = path.join(testDir, `${staleSessionId}.jsonl`); + const freshTranscript = path.join(testDir, `${freshSessionId}.jsonl`); + await fs.writeFile(staleTranscript, '{"type":"session"}\n', "utf-8"); + await fs.writeFile(freshTranscript, '{"type":"session"}\n', "utf-8"); + + await saveSessionStore(storePath, store); + + const loaded = loadSessionStore(storePath); + expect(loaded.stale).toBeUndefined(); + expect(loaded.fresh).toBeDefined(); + await expect(fs.stat(staleTranscript)).rejects.toThrow(); + await expect(fs.stat(freshTranscript)).resolves.toBeDefined(); + const dirEntries = await fs.readdir(testDir); + const archived = dirEntries.filter((entry) => + entry.startsWith(`${staleSessionId}.jsonl.deleted.`), + ); + expect(archived).toHaveLength(1); + }); + it("saveSessionStore skips enforcement when maintenance mode is warn", async () => { mockLoadConfig.mockReturnValue({ session: { diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index 482b3359077..9890297db7e 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -6,6 +6,7 @@ import type { SessionMaintenanceConfig, SessionMaintenanceMode } from "../types. import { acquireSessionWriteLock } from "../../agents/session-write-lock.js"; import { parseByteSize } from "../../cli/parse-bytes.js"; import { parseDurationMs } from "../../cli/parse-duration.js"; +import { archiveSessionTranscripts } from "../../gateway/session-utils.fs.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { deliveryContextFromSession, @@ -301,13 +302,14 @@ export function resolveMaintenanceConfig(): ResolvedSessionMaintenanceConfig { export function pruneStaleEntries( store: Record, overrideMaxAgeMs?: number, - opts: { log?: boolean } = {}, + opts: { log?: boolean; onPruned?: (params: { key: string; entry: SessionEntry }) => void } = {}, ): number { const maxAgeMs = overrideMaxAgeMs ?? resolveMaintenanceConfig().pruneAfterMs; const cutoffMs = Date.now() - maxAgeMs; let pruned = 0; for (const [key, entry] of Object.entries(store)) { if (entry?.updatedAt != null && entry.updatedAt < cutoffMs) { + opts.onPruned?.({ key, entry }); delete store[key]; pruned++; } @@ -510,8 +512,23 @@ async function saveSessionStoreUnlocked( } } else { // Prune stale entries and cap total count before serializing. - pruneStaleEntries(store, maintenance.pruneAfterMs); + const prunedSessionFiles = new Map(); + pruneStaleEntries(store, maintenance.pruneAfterMs, { + onPruned: ({ entry }) => { + if (!prunedSessionFiles.has(entry.sessionId) || entry.sessionFile) { + prunedSessionFiles.set(entry.sessionId, entry.sessionFile); + } + }, + }); capEntryCount(store, maintenance.maxEntries); + for (const [sessionId, sessionFile] of prunedSessionFiles) { + archiveSessionTranscripts({ + sessionId, + storePath, + sessionFile, + reason: "deleted", + }); + } // Rotate the on-disk file if it exceeds the size threshold. await rotateSessionFile(storePath, maintenance.rotateBytes);