diff --git a/src/config/sessions/session-accessor.conformance.test.ts b/src/config/sessions/session-accessor.conformance.test.ts index 36d303641025..a226bc533f69 100644 --- a/src/config/sessions/session-accessor.conformance.test.ts +++ b/src/config/sessions/session-accessor.conformance.test.ts @@ -1660,6 +1660,77 @@ describe("sqlite session normalization", () => { ).toEqual(["agent:main:newer", "agent:main:newest"]); }); + it("preserves pinned SQLite entries and transcripts during write-triggered capping", async () => { + vi.mocked(getRuntimeConfig).mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "365d", + maxEntries: 2, + }, + }, + }); + const env = { ...process.env, OPENCLAW_STATE_DIR: paths.stateDir }; + const scopeFor = (sessionKey: string) => ({ + agentId: "main", + env, + sessionKey, + storePath: paths.sqlitePath, + }); + const pinnedKey = "agent:main:pinned-dashboard"; + const pinnedSessionId = "pinned-dashboard-session"; + const pinnedTranscriptEvent = { + id: "pinned-event", + timestamp: new Date().toISOString(), + type: "metadata", + }; + + await patchSessionEntryCore( + scopeFor(pinnedKey), + () => ({ sessionId: pinnedSessionId, updatedAt: 1, pinnedAt: 2 }), + { + fallbackEntry: { sessionId: pinnedSessionId, updatedAt: 1, pinnedAt: 2 }, + replaceEntry: true, + skipMaintenance: true, + }, + ); + await appendTranscriptEvent( + { ...scopeFor(pinnedKey), sessionId: pinnedSessionId }, + pinnedTranscriptEvent, + ); + await patchSessionEntryCore( + scopeFor("agent:main:recent-dashboard"), + () => ({ sessionId: "recent-dashboard-session", updatedAt: 3 }), + { + fallbackEntry: { sessionId: "recent-dashboard-session", updatedAt: 3 }, + replaceEntry: true, + skipMaintenance: true, + }, + ); + + await patchSessionEntryCore( + scopeFor("agent:main:maintenance-trigger"), + () => ({ sessionId: "maintenance-trigger-session", updatedAt: 4 }), + { + fallbackEntry: { sessionId: "maintenance-trigger-session", updatedAt: 4 }, + replaceEntry: true, + }, + ); + + expect(loadSessionEntry(scopeFor(pinnedKey))).toMatchObject({ + pinnedAt: 2, + sessionId: pinnedSessionId, + }); + await expect( + loadTranscriptEvents({ + agentId: "main", + env, + sessionId: pinnedSessionId, + storePath: paths.sqlitePath, + }), + ).resolves.toEqual([pinnedTranscriptEvent]); + }); + it("preserves an admitted SQLite session when another session triggers maintenance", async () => { vi.mocked(getRuntimeConfig).mockReturnValue({ session: { diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index ecabc57142d0..70dbd34cefae 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -428,8 +428,8 @@ export function shouldPreserveMaintenanceEntry(params: { entry: SessionEntry | undefined; preserveKeys?: ReadonlySet; }): boolean { - // Archived sessions are user-shelved; only an explicit sessions.delete may remove them. - if (params.entry?.archivedAt !== undefined) { + // Archived and pinned sessions are user-retained; only an explicit user action may release them. + if (params.entry?.archivedAt !== undefined || params.entry?.pinnedAt !== undefined) { return true; } // A model lock is durable harness ownership, not merely a UI restriction. diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index a5ef6e11a16a..5ad86652ac26 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -142,6 +142,20 @@ describe("pruneStaleEntries", () => { expect(pruneStaleEntries(store, 30 * DAY_MS)).toBe(1); expect(store.archived).toBeUndefined(); }); + + it("preserves pinned entries until they are unpinned", () => { + const now = Date.now(); + const store = makeStore([ + ["pinned", { ...makeEntry(now - 31 * DAY_MS), pinnedAt: now - DAY_MS }], + ]); + + expect(pruneStaleEntries(store, 30 * DAY_MS)).toBe(0); + expect(store).toHaveProperty("pinned"); + + delete store.pinned?.pinnedAt; + expect(pruneStaleEntries(store, 30 * DAY_MS)).toBe(1); + expect(store.pinned).toBeUndefined(); + }); }); describe("resolveQuotaSuspensionEntryMaintenance", () => { @@ -710,6 +724,20 @@ describe("capEntryCount", () => { expect(store.old).toBeUndefined(); }); + it("preserves pinned sessions when capping", () => { + const now = Date.now(); + const store = makeStore([ + ["pinned", { ...makeEntry(now - 10 * DAY_MS), pinnedAt: now - 5 * DAY_MS }], + ["recent", makeEntry(now)], + ["old", makeEntry(now - DAY_MS)], + ]); + + expect(capEntryCount(store, 2)).toBe(1); + expect(store).toHaveProperty("pinned"); + expect(store).toHaveProperty("recent"); + expect(store.old).toBeUndefined(); + }); + it("preserves runtime-provided pending subagent sessions when capping", () => { const now = Date.now(); const childKey = "agent:main:subagent:child";