diff --git a/src/config/sessions/session-accessor.conformance.test.ts b/src/config/sessions/session-accessor.conformance.test.ts index b46e5eaa877d..70f842d8b4a7 100644 --- a/src/config/sessions/session-accessor.conformance.test.ts +++ b/src/config/sessions/session-accessor.conformance.test.ts @@ -1561,6 +1561,61 @@ describe("sqlite session normalization", () => { ).toEqual(["agent:main:newer", "agent:main:newest"]); }); + it("preserves an active SQLite cron entry when durable entries exceed maxEntries", async () => { + vi.mocked(getRuntimeConfig).mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "365d", + maxEntries: 1, + }, + }, + }); + const env = { ...process.env, OPENCLAW_STATE_DIR: paths.stateDir }; + const scopeFor = (sessionKey: string) => ({ + agentId: "main", + env, + sessionKey, + storePath: paths.sqlitePath, + }); + const cronKey = "agent:main:cron:job-1"; + const cronEntry = { + lifecycleRevision: "cron-revision-1", + sessionId: "cron-session", + updatedAt: Date.now(), + }; + + for (const [sessionKey, sessionId] of [ + ["agent:main:slack:channel:C1", "channel-session-1"], + ["agent:main:slack:channel:C2", "channel-session-2"], + ] as const) { + await patchSqliteSessionEntry( + scopeFor(sessionKey), + () => ({ sessionId, updatedAt: Date.now() - 1 }), + { + fallbackEntry: { sessionId, updatedAt: Date.now() - 1 }, + replaceEntry: true, + skipMaintenance: true, + }, + ); + } + + await patchSqliteSessionEntry(scopeFor(cronKey), () => cronEntry, { + fallbackEntry: cronEntry, + replaceEntry: true, + }); + await patchSqliteSessionEntry(scopeFor(cronKey), () => ({ + model: "gpt-5.5", + updatedAt: Date.now() + 1, + })); + + expect(loadSqliteSessionEntry(scopeFor(cronKey))).toMatchObject({ + lifecycleRevision: "cron-revision-1", + model: "gpt-5.5", + sessionId: "cron-session", + }); + }); + it("evicts old SQLite transcript rows only when no remaining entry references them", async () => { vi.mocked(getRuntimeConfig).mockReturnValue({ session: { diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index b420c5bb758e..53b38e385b5b 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -531,14 +531,13 @@ function wouldCapActiveSession(params: { */ export function capEntryCount( store: Record, - overrideMax?: number, + maxEntries: number, opts: { log?: boolean; onCapped?: (params: { key: string; entry: SessionEntry }) => void; preserveKeys?: ReadonlySet; } = {}, ): number { - const maxEntries = overrideMax ?? resolveMaintenanceConfigFromInput().maxEntries; const preservedCount = Object.entries(store).filter(([key, entry]) => shouldPreserveMaintenanceEntry({ key, entry, preserveKeys: opts.preserveKeys }), ).length; diff --git a/src/config/sessions/store.pruning.integration.test.ts b/src/config/sessions/store.pruning.integration.test.ts index 4741139969f3..7c78ae4d654b 100644 --- a/src/config/sessions/store.pruning.integration.test.ts +++ b/src/config/sessions/store.pruning.integration.test.ts @@ -1483,6 +1483,37 @@ describe("Integration: saveSessionStore with pruning", () => { expect(loaded).toHaveProperty("session-50"); }); + it("updateSessionStore honors configured maxEntries without an explicit override", async () => { + const now = Date.now(); + await fs.writeFile( + storePath, + JSON.stringify({ + oldest: makeEntry(now - 2), + recent: makeEntry(now - 1), + }), + "utf-8", + ); + mockLoadConfig.mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "365d", + maxEntries: 2, + }, + }, + }); + + await updateSessionStore(storePath, (store) => { + store.newest = makeEntry(now); + }); + + const loaded = loadSessionStore(storePath, { skipCache: true }); + expect(Object.keys(loaded)).toHaveLength(2); + expect(loaded).toHaveProperty("newest"); + expect(loaded).toHaveProperty("recent"); + expect(loaded.oldest).toBeUndefined(); + }); + it("loadSessionStore honors configured maxEntries without an explicit override", async () => { mockLoadConfig.mockReturnValue({ session: {