From 4afccbdaa435c8a2407998dec91ac6e3b2b63dee Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Thu, 13 Aug 2026 15:42:55 +0800 Subject: [PATCH] fix(sessions): exclude protected sessions from entry cap (#123014) --- docs/cli/sessions.md | 4 + docs/concepts/session.md | 15 ++- docs/gateway/config-agents.md | 2 +- .../session-management-compaction.md | 8 +- src/agents/command/session-store.test.ts | 2 +- src/config/sessions/cleanup-service.ts | 3 +- .../incognito-session-transcript.test.ts | 5 +- .../session-accessor.conformance.test.ts | 96 +++++++++++++- .../session-accessor.sqlite-maintenance.ts | 120 +++++++++--------- .../sessions/store-maintenance-eligibility.ts | 15 +++ .../store-maintenance-eligible-quota.test.ts | 78 ++++++++++++ .../sessions/store-maintenance-operations.ts | 33 +++-- src/config/sessions/store-maintenance.ts | 56 ++++---- src/config/sessions/store.pruning.test.ts | 69 +++++++--- 14 files changed, 369 insertions(+), 137 deletions(-) create mode 100644 src/config/sessions/store-maintenance-eligibility.ts create mode 100644 src/config/sessions/store-maintenance-eligible-quota.test.ts diff --git a/docs/cli/sessions.md b/docs/cli/sessions.md index b0412cd6033c..30d48786a168 100644 --- a/docs/cli/sessions.md +++ b/docs/cli/sessions.md @@ -222,6 +222,10 @@ openclaw sessions cleanup --json pressure-gated: it only removes stale probe rows when session-entry maintenance/cap pressure is reached. When it runs, model-run cleanup happens before global stale cleanup and capping. +- `maxEntries` caps only eviction-eligible rows. Protected rows are reported as + `keep` and stay outside the allowance, so the total row count can exceed the + configured cap. `--enforce` does not remove that protection; unarchive, + unpin, or explicitly delete sessions you no longer want to retain. Flags: diff --git a/docs/concepts/session.md b/docs/concepts/session.md index cd10f46bf3b9..031532c0a128 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -196,6 +196,13 @@ Session store reads do not prune or cap entries during Gateway startup, so startup and isolated cron sessions do not pay for a full store cleanup. `openclaw sessions cleanup --enforce` applies the cap immediately. +`maxEntries` counts only eviction-eligible session rows. Protected rows - +archived or pinned sessions, active or admitted work, model-locked sessions, +and durable external conversation pointers - stay outside that allowance, so +the total stored row count can exceed `maxEntries`. Cleanup does not unprotect +those rows; unarchive, unpin, or explicitly delete sessions you no longer want +to retain. + Gateway model-run probe sessions are short-lived by default. Rows matching `agent:*:explicit:model-run-` use fixed `24h` retention, but cleanup is pressure-gated: it only removes stale probe rows when session-entry @@ -207,10 +214,10 @@ Maintenance preserves durable external conversation pointers, including group sessions and thread-scoped chat sessions, while still allowing synthetic cron, hook, heartbeat, ACP, and sub-agent entries to age out. -Archived sessions are user-shelved and exempt from every automatic maintenance -path, including age pruning, entry caps, model-run cleanup, and disk-budget -eviction. They remain archived until you unarchive them or explicitly delete -them. +Archived and pinned sessions are user-protected and exempt from every automatic +maintenance path, including age pruning, entry caps, model-run cleanup, and +disk-budget eviction. They remain protected until you unarchive, unpin, or +explicitly delete them. If you previously used DM isolation and later returned `session.dmScope` to `main`, preview stale peer-keyed DM rows with diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index e374d0406e9e..c9be8ff998a2 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -1248,7 +1248,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - **`maintenance`**: session-store cleanup + retention controls. - `mode`: `enforce` applies cleanup and is the default; `warn` emits warnings only. - `pruneAfter`: age cutoff for stale entries (default `30d`). - - `maxEntries`: maximum number of SQLite session entries (default `500`). Runtime writes batch cleanup with a small high-water buffer for production-sized caps; `openclaw sessions cleanup --enforce` applies the cap immediately. + - `maxEntries`: maximum number of eviction-eligible SQLite session entries (default `500`). Archived or pinned sessions, active or admitted work, model-locked sessions, and durable external conversation pointers stay outside the allowance, so the total row count can exceed this value. Runtime writes batch cleanup with a small high-water buffer for production-sized caps; `openclaw sessions cleanup --enforce` applies the eligible-row cap immediately but does not unprotect rows. Unarchive, unpin, or explicitly delete protected sessions to reduce their count. - Short-lived gateway model-run probe sessions use fixed `24h` retention, but cleanup is pressure-gated: it only removes stale strict model-run probe rows when session-entry maintenance/cap pressure is reached. Only strict explicit probe keys matching `agent:*:explicit:model-run-` are eligible; normal direct, group, thread, cron, hook, heartbeat, ACP, and sub-agent sessions do not inherit this 24h retention. When model-run cleanup runs, it runs before the broader `pruneAfter` stale-entry cleanup and `maxEntries` cap. - Legacy `rotateBytes` is rejected by the current schema; `openclaw doctor --fix` removes it from older configs. - `resetArchiveRetention`: age-based retention for reset/deleted transcript archives. By default, archives remain until disk-budget eviction; set a duration to opt into wall-clock deletion, or `false` to disable it explicitly. diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index f9fe3da59a65..f55f9b0e4575 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -48,7 +48,7 @@ Per agent, on the Gateway host (resolved via `src/config/sessions.ts`): | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------- | | `mode` | `"enforce"` | or `"warn"` (report only, no mutation) | | `pruneAfter` | `"30d"` | stale-entry age cutoff | -| `maxEntries` | `500` | cap on session entries | +| `maxEntries` | `500` | cap on eviction-eligible live session rows | | `resetArchiveRetention` | keep (no age cutoff) | age cutoff for `*.reset.*`/`*.deleted.*` transcript archives; a duration opts into deletion | | `maxDiskBytes` | `10gb` | per-agent sessions disk budget; `false`, `0`, or `"0"` disables | | `highWaterBytes` | 80% of `maxDiskBytes` | target after cleanup; zero-resolving values use the default, and negatives are invalid | @@ -70,9 +70,11 @@ openclaw sessions cleanup --dry-run openclaw sessions cleanup --enforce ``` -Maintenance keeps durable external conversation pointers such as group sessions and thread-scoped chat sessions, but synthetic runtime entries (cron, hooks, heartbeat, ACP, sub-agents) can still be removed once they exceed the configured age, count, or disk budget. Isolated cron runs use a separate `cron.sessionRetention` control, independent of model-run probe retention. +`maxEntries` excludes protected rows: archived or pinned sessions, active or admitted work, model-locked sessions, and durable external conversation pointers such as group sessions and thread-scoped chat sessions. Those rows do not consume the allowance, so the total live session row count can exceed `maxEntries`. Synthetic runtime entries (cron, hooks, heartbeat, ACP, sub-agents) can still be removed once they exceed the configured age, count, or disk budget. Isolated cron runs use a separate `cron.sessionRetention` control, independent of model-run probe retention. -Normal Gateway writes flow through the session accessor, which serializes per-agent SQLite mutations through the runtime writer path. Runtime code should prefer the accessor helpers in `src/config/sessions/session-accessor.ts`; legacy `sessions.json` helpers are migration 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 a selected legacy store and always stays local (as does `--dry-run`). `maxEntries` cleanup is batched for production-sized stores, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it down. Reads never prune or cap entries during Gateway startup - only writes or `openclaw sessions cleanup --enforce` do, and the latter also applies the cap immediately and prunes old unreferenced legacy transcript, checkpoint, and trajectory artifacts even with no disk budget configured. +`--dry-run` previews maintenance against the eligible population; `--enforce` applies that cleanup immediately but does not remove protection. To reduce protected history, unarchive, unpin, or explicitly delete sessions you no longer want to retain. + +Normal Gateway writes flow through the session accessor, which serializes per-agent SQLite mutations through the runtime writer path. Runtime code should prefer the accessor helpers in `src/config/sessions/session-accessor.ts`; legacy `sessions.json` helpers are migration 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 a selected legacy store and always stays local (as does `--dry-run`). `maxEntries` cleanup is batched for production-sized stores, so the eligible population may briefly exceed the configured cap before the next high-water cleanup rewrites it down. Reads never prune or cap entries during Gateway startup - only writes or `openclaw sessions cleanup --enforce` do, and the latter also applies the cap immediately and prunes old unreferenced legacy transcript, checkpoint, and trajectory artifacts even with no disk budget configured. OpenClaw no longer creates automatic `sessions.json.bak.*` rotation backups during Gateway writes. The current schema rejects the legacy `session.maintenance.rotateBytes` key, and `openclaw doctor --fix` removes it from older configs. diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index fbb341557fde..3bf757ba1413 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -382,7 +382,7 @@ describe("updateSessionStoreAfterAgentRun", () => { }); const persisted = loadPersistedSessionStore(storePath); - expect(Object.keys(persisted)).toHaveLength(42); + expect(Object.keys(persisted).filter((key) => key !== sessionKey)).toHaveLength(42); expect(persisted[sessionKey]?.sessionId).toBe(sessionId); expect(persisted["agent:main:stale:44"]).toBeUndefined(); }); diff --git a/src/config/sessions/cleanup-service.ts b/src/config/sessions/cleanup-service.ts index 54b07a8fb989..f2d72d651a75 100644 --- a/src/config/sessions/cleanup-service.ts +++ b/src/config/sessions/cleanup-service.ts @@ -26,6 +26,7 @@ import { inspectSqliteSessionHistoryDiskBudget, } from "./session-history-eviction.js"; import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; +import { countSessionEntryMaintenanceEligibleEntries } from "./store-maintenance-eligibility.js"; import { collectSessionMaintenancePreserveKeysForStore } from "./store-maintenance-preserve.js"; import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js"; import { @@ -395,7 +396,7 @@ async function previewStoreCleanup(params: { }); const modelRunPruned = shouldRunModelRunPrune({ maintenance: params.maintenance, - entryCount: Object.keys(previewStore).length, + entryCount: countSessionEntryMaintenanceEligibleEntries(previewStore, preserveSessionKeys), // `sessions cleanup` applies the cap immediately (apply path forces maintenance and the // preview caps unconditionally below), so mirror that here: prune stale probes before the // forced cap can evict real sessions in their place. diff --git a/src/config/sessions/incognito-session-transcript.test.ts b/src/config/sessions/incognito-session-transcript.test.ts index dc219cd8292d..33958088f4cc 100644 --- a/src/config/sessions/incognito-session-transcript.test.ts +++ b/src/config/sessions/incognito-session-transcript.test.ts @@ -112,13 +112,14 @@ describe("incognito transcript access", () => { storePath, }; const now = Date.now(); + const staleUpdatedAt = now - 366 * 24 * 60 * 60 * 1000; try { await patchSessionEntryCore( staleScope, - () => ({ sessionId: "incognito-stale-session", updatedAt: now }), + () => ({ sessionId: "incognito-stale-session", updatedAt: staleUpdatedAt }), { - fallbackEntry: { sessionId: "incognito-stale-session", updatedAt: now }, + fallbackEntry: { sessionId: "incognito-stale-session", updatedAt: staleUpdatedAt }, replaceEntry: true, skipMaintenance: true, }, diff --git a/src/config/sessions/session-accessor.conformance.test.ts b/src/config/sessions/session-accessor.conformance.test.ts index a226bc533f69..c3023872f132 100644 --- a/src/config/sessions/session-accessor.conformance.test.ts +++ b/src/config/sessions/session-accessor.conformance.test.ts @@ -1657,7 +1657,101 @@ describe("sqlite session normalization", () => { env, storePath: paths.sqlitePath, }).map((summary) => summary.sessionKey), - ).toEqual(["agent:main:newer", "agent:main:newest"]); + ).toEqual(["agent:main:active", "agent:main:newer", "agent:main:newest"]); + }); + + it("keeps protected SQLite rows outside the write-triggered entry allowance", async () => { + vi.mocked(getRuntimeConfig).mockReturnValue({ + session: { + maintenance: { + mode: "enforce", + pruneAfter: "365d", + maxEntries: 2, + }, + }, + }); + const env = { ...process.env, OPENCLAW_STATE_DIR: paths.stateDir }; + const now = Date.now(); + const scopeFor = (sessionKey: string) => ({ + agentId: "main", + env, + sessionKey, + storePath: paths.sqlitePath, + }); + const recentSessionId = "recent-dashboard-session-1"; + const recentTranscriptEvent = { + id: "recent-dashboard-event", + timestamp: new Date().toISOString(), + type: "metadata", + }; + + for (const [sessionKey, sessionId, updatedAt] of [ + ["agent:main:archived-1", "archived-session-1", now - 4], + ["agent:main:archived-2", "archived-session-2", now - 3], + ] as const) { + await patchSessionEntryCore( + scopeFor(sessionKey), + () => ({ archivedAt: updatedAt, sessionId, updatedAt }), + { + fallbackEntry: { archivedAt: updatedAt, sessionId, updatedAt }, + replaceEntry: true, + skipMaintenance: true, + }, + ); + } + await patchSessionEntryCore( + scopeFor("agent:main:recent-dashboard-1"), + () => ({ sessionId: recentSessionId, updatedAt: now - 2 }), + { + fallbackEntry: { sessionId: recentSessionId, updatedAt: now - 2 }, + replaceEntry: true, + skipMaintenance: true, + }, + ); + await appendTranscriptEvent( + { ...scopeFor("agent:main:recent-dashboard-1"), sessionId: recentSessionId }, + recentTranscriptEvent, + ); + await patchSessionEntryCore( + scopeFor("agent:main:recent-dashboard-2"), + () => ({ sessionId: "recent-dashboard-session-2", updatedAt: now - 1 }), + { + fallbackEntry: { sessionId: "recent-dashboard-session-2", updatedAt: now - 1 }, + replaceEntry: true, + skipMaintenance: true, + }, + ); + + await patchSessionEntryCore( + scopeFor("agent:main:maintenance-trigger"), + () => ({ sessionId: "maintenance-trigger-session", updatedAt: now }), + { + fallbackEntry: { sessionId: "maintenance-trigger-session", updatedAt: now }, + replaceEntry: true, + }, + ); + + expect( + listSessionEntryRows({ + agentId: "main", + env, + storePath: paths.sqlitePath, + }).map((summary) => summary.sessionKey), + ).toEqual([ + "agent:main:archived-1", + "agent:main:archived-2", + "agent:main:maintenance-trigger", + "agent:main:recent-dashboard-1", + "agent:main:recent-dashboard-2", + ]); + await expect( + loadTranscriptEvents({ + agentId: "main", + env, + sessionId: recentSessionId, + storePath: paths.sqlitePath, + }), + ).resolves.toEqual([recentTranscriptEvent]); }); it("preserves pinned SQLite entries and transcripts during write-triggered capping", async () => { diff --git a/src/config/sessions/session-accessor.sqlite-maintenance.ts b/src/config/sessions/session-accessor.sqlite-maintenance.ts index da84e16d321d..9215741a3b06 100644 --- a/src/config/sessions/session-accessor.sqlite-maintenance.ts +++ b/src/config/sessions/session-accessor.sqlite-maintenance.ts @@ -1,5 +1,4 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { sql } from "kysely"; import { executeSqliteQuerySync } from "../../infra/kysely-sync.js"; import { getChildLogger } from "../../logging/logger.js"; import { @@ -11,7 +10,6 @@ import { type SessionStateDeletePlan, } from "./session-accessor.sqlite-archive.js"; import type { SessionLifecycleArchivedTranscript } from "./session-accessor.sqlite-contract.js"; -import { readSessionEntryCount } from "./session-accessor.sqlite-entry-store.js"; import { emitCommittedSessionEntryRemovals } from "./session-accessor.sqlite-identity.js"; import { assertPlannedLifecycleArtifactEntriesUnchanged, @@ -32,10 +30,8 @@ import { } from "./session-accessor.sqlite-scope.js"; import { parseSessionEntryJson as parseSessionEntryRow } from "./session-accessor.sqlite-status.js"; import { normalizeStoreSessionKey } from "./store-entry.js"; -import { - collectSessionMaintenancePreserveKeys, - collectSessionMaintenancePreserveKeysForStore, -} from "./store-maintenance-preserve.js"; +import { countSessionEntryMaintenanceEligibleEntries } from "./store-maintenance-eligibility.js"; +import { collectSessionMaintenancePreserveKeysForStore } from "./store-maintenance-preserve.js"; import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js"; import { capEntryCount, @@ -65,38 +61,42 @@ function collectSqliteSessionMaintenanceBaseKeys( return keys; } -function hasStaleSqliteSessionEntryCandidate( - database: OpenClawAgentDatabase, +function hasStaleSessionEntryCandidate( + store: Record, pruneAfterMs: number, preserveKeys: ReadonlySet | undefined, ): boolean { const cutoffMs = Date.now() - pruneAfterMs; - const db = getSessionKysely(database.db); - const rows = executeSqliteQuerySync( - database.db, - db - .selectFrom("session_nodes") - .select(["entry_json", "session_key"]) - .where("updated_at", "<", cutoffMs) - .where( - /* kysely-allow-raw: archivedAt lives inside the canonical JSON entry, not a SQL column. */ - sql`json_extract(entry_json, '$.archivedAt') IS NULL`, - ) - .orderBy("updated_at", "asc"), - ).rows; - return rows.some((row) => { - const entry = parseSessionEntryRow(row); - if (!entry) { + return Object.entries(store).some(([key, entry]) => { + if (entry.updatedAt == null || entry.updatedAt >= cutoffMs) { return false; } return !shouldPreserveMaintenanceEntry({ - key: normalizeStoreSessionKey(row.session_key), + key, entry, preserveKeys, }); }); } +function loadSqliteSessionMaintenanceStore( + database: OpenClawAgentDatabase, +): Record { + const db = getSessionKysely(database.db); + const rows = executeSqliteQuerySync( + database.db, + db.selectFrom("session_nodes").select(["session_key", "entry_json"]).orderBy("session_key"), + ).rows; + const store: Record = {}; + for (const row of rows) { + const entry = parseSessionEntryRow(row); + if (entry) { + store[row.session_key] = entry; + } + } + return store; +} + export function applySessionEntryMaintenance( database: OpenClawAgentDatabase, params: { @@ -116,44 +116,39 @@ export function applySessionEntryMaintenance( return { entryRemovals: [], stateDeletePlans: [] }; } - const entryCount = readSessionEntryCount(database); - const preserveCandidateKeys = collectSessionMaintenancePreserveKeys([params.activeSessionKey]); - const hasStaleCandidate = hasStaleSqliteSessionEntryCandidate( - database, + // Trigger and eviction decisions must use the same snapshot and preservation boundary. + // A preliminary count can otherwise miss active-work aliases or race the later mutation plan. + const store = loadSqliteSessionMaintenanceStore(database); + const preserveKeys = + collectSessionMaintenancePreserveKeysForStore({ + storePath: params.storePath, + store, + baseKeys: collectSqliteSessionMaintenanceBaseKeys(store, params.activeSessionKey), + }) ?? new Set(); + const eligibleEntryCount = countSessionEntryMaintenanceEligibleEntries(store, preserveKeys); + const hasStaleCandidate = hasStaleSessionEntryCandidate( + store, maintenance.pruneAfterMs, - preserveCandidateKeys, + preserveKeys, ); - const shouldLoadStore = + const shouldMaintainStore = params.forceMaintenance === true || - entryCount > maintenance.maxEntries || + eligibleEntryCount > maintenance.maxEntries || hasStaleCandidate || shouldRunModelRunPrune({ maintenance, - entryCount, + entryCount: eligibleEntryCount, force: params.forceMaintenance, }) || shouldRunSessionEntryMaintenance({ - entryCount, + entryCount: eligibleEntryCount, maxEntries: maintenance.maxEntries, force: params.forceMaintenance, }); - if (!shouldLoadStore) { + if (!shouldMaintainStore) { return { entryRemovals: [], stateDeletePlans: [] }; } - const db = getSessionKysely(database.db); - const rows = executeSqliteQuerySync( - database.db, - db.selectFrom("session_nodes").select(["session_key", "entry_json"]).orderBy("session_key"), - ).rows; - const store: Record = {}; - for (const row of rows) { - const entry = parseSessionEntryRow(row); - if (entry) { - store[row.session_key] = entry; - } - } - const removedKeys = new Set(); const removedEntriesByKey = new Map(); const removedSessionIds = new Set(); @@ -164,31 +159,30 @@ export function applySessionEntryMaintenance( removedSessionIds.add(sessionId); } }; - const preserveKeys = - collectSessionMaintenancePreserveKeysForStore({ - storePath: params.storePath, - store, - baseKeys: collectSqliteSessionMaintenanceBaseKeys(store, params.activeSessionKey), - }) ?? new Set(); + let remainingEligibleEntryCount = eligibleEntryCount; if ( shouldRunModelRunPrune({ maintenance, - entryCount: Object.keys(store).length, + entryCount: remainingEligibleEntryCount, force: params.forceMaintenance, }) ) { - pruneStaleModelRunEntries(store, maintenance.modelRunPruneAfterMs, { - log: false, - onPruned: rememberRemovedEntry, - preserveKeys, - }); + remainingEligibleEntryCount -= pruneStaleModelRunEntries( + store, + maintenance.modelRunPruneAfterMs, + { + log: false, + onPruned: rememberRemovedEntry, + preserveKeys, + }, + ); } if ( params.forceMaintenance === true || hasStaleCandidate || - Object.keys(store).length > maintenance.maxEntries + remainingEligibleEntryCount > maintenance.maxEntries ) { - pruneStaleEntries(store, maintenance.pruneAfterMs, { + remainingEligibleEntryCount -= pruneStaleEntries(store, maintenance.pruneAfterMs, { log: false, onPruned: rememberRemovedEntry, preserveKeys, @@ -196,7 +190,7 @@ export function applySessionEntryMaintenance( } if ( shouldRunSessionEntryMaintenance({ - entryCount: Object.keys(store).length, + entryCount: remainingEligibleEntryCount, maxEntries: maintenance.maxEntries, force: params.forceMaintenance, }) diff --git a/src/config/sessions/store-maintenance-eligibility.ts b/src/config/sessions/store-maintenance-eligibility.ts new file mode 100644 index 000000000000..fb4c6a87b093 --- /dev/null +++ b/src/config/sessions/store-maintenance-eligibility.ts @@ -0,0 +1,15 @@ +import { shouldPreserveMaintenanceEntry } from "./store-maintenance.js"; +import type { SessionEntry } from "./types.js"; + +export function countSessionEntryMaintenanceEligibleEntries( + store: Record, + preserveKeys?: ReadonlySet, +): number { + let count = 0; + for (const [key, entry] of Object.entries(store)) { + if (!shouldPreserveMaintenanceEntry({ key, entry, preserveKeys })) { + count++; + } + } + return count; +} diff --git a/src/config/sessions/store-maintenance-eligible-quota.test.ts b/src/config/sessions/store-maintenance-eligible-quota.test.ts new file mode 100644 index 000000000000..c7ef72b3496d --- /dev/null +++ b/src/config/sessions/store-maintenance-eligible-quota.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { capEntryCount, getActiveSessionMaintenanceWarning } from "./store-maintenance.js"; +import type { SessionEntry } from "./types.js"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function makeEntry(updatedAt: number): SessionEntry { + return { sessionId: `session-${updatedAt}`, updatedAt }; +} + +function makeStore(entries: Array<[string, SessionEntry]>): Record { + return Object.fromEntries(entries); +} + +describe("session maintenance eligible quota", () => { + it("keeps 499 archived sessions outside the ordinary-session allowance", () => { + const now = Date.now(); + const archivedEntries = Array.from({ length: 499 }, (_, index): [string, SessionEntry] => [ + `archived-${index}`, + { ...makeEntry(index), archivedAt: now }, + ]); + const store = makeStore([ + ...archivedEntries, + ["dashboard-1", makeEntry(now - 2)], + ["dashboard-2", makeEntry(now - 1)], + ["dashboard-3", makeEntry(now)], + ]); + + expect(capEntryCount(store, 500)).toBe(0); + expect(Object.keys(store)).toHaveLength(502); + expect(store).toHaveProperty("dashboard-1"); + expect(store).toHaveProperty("dashboard-2"); + expect(store).toHaveProperty("dashboard-3"); + }); + + it("removes only the oldest eligible session above the allowance", () => { + const now = Date.now(); + const archivedEntries = Array.from({ length: 499 }, (_, index): [string, SessionEntry] => [ + `archived-${index}`, + { ...makeEntry(index), archivedAt: now }, + ]); + const eligibleEntries = Array.from({ length: 501 }, (_, index): [string, SessionEntry] => [ + `eligible-${index}`, + makeEntry(index), + ]); + const store = makeStore([...archivedEntries, ...eligibleEntries]); + + expect(capEntryCount(store, 500)).toBe(1); + expect(store["eligible-0"]).toBeUndefined(); + expect(store).toHaveProperty("eligible-1"); + expect(store).toHaveProperty("eligible-500"); + expect(store).toHaveProperty("archived-0"); + expect(store).toHaveProperty("archived-498"); + }); + + it("does not count archived sessions against the active-session allowance", () => { + const now = Date.now(); + const archivedEntries = Array.from({ length: 499 }, (_, index): [string, SessionEntry] => [ + `archived-${index}`, + { ...makeEntry(index), archivedAt: now }, + ]); + const store = makeStore([ + ...archivedEntries, + ["recent", makeEntry(now)], + ["active", makeEntry(now - 1)], + ]); + + expect( + getActiveSessionMaintenanceWarning({ + store, + activeSessionKey: "active", + pruneAfterMs: DAY_MS, + maxEntries: 2, + nowMs: now, + }), + ).toBeNull(); + }); +}); diff --git a/src/config/sessions/store-maintenance-operations.ts b/src/config/sessions/store-maintenance-operations.ts index be4fdb42bde1..242be5c941fb 100644 --- a/src/config/sessions/store-maintenance-operations.ts +++ b/src/config/sessions/store-maintenance-operations.ts @@ -1,6 +1,7 @@ // Storage-neutral session maintenance operations for the file-backed session store. import path from "node:path"; import { enforceSessionDiskBudget, type SessionDiskBudgetSweepResult } from "./disk-budget.js"; +import { countSessionEntryMaintenanceEligibleEntries } from "./store-maintenance-eligibility.js"; import { collectSessionMaintenancePreserveKeysForStore } from "./store-maintenance-preserve.js"; import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js"; import { @@ -199,32 +200,34 @@ async function applyEnforcedMaintenance(params: { maintenance: ResolvedSessionMaintenanceConfig; beforeCount: number; forceMaintenance: boolean; + preserveSessionKeys: ReadonlySet | undefined; }): Promise { - const preserveSessionKeys = collectSessionMaintenancePreserveKeysForStore({ - storePath: params.operation.storePath, - store: params.operation.store, - baseKeys: [params.operation.activeSessionKey], - }); const removedSessionFiles = new Map(); const modelRunPruned = shouldRunModelRunPrune({ maintenance: params.maintenance, - entryCount: params.beforeCount, + entryCount: countSessionEntryMaintenanceEligibleEntries( + params.operation.store, + params.preserveSessionKeys, + ), force: params.forceMaintenance, }) ? pruneStaleModelRunEntries(params.operation.store, params.maintenance.modelRunPruneAfterMs, { onPruned: ({ entry }) => { rememberRemovedSessionFile(removedSessionFiles, entry); }, - preserveKeys: preserveSessionKeys, + preserveKeys: params.preserveSessionKeys, }) : 0; const pruned = pruneStaleEntries(params.operation.store, params.maintenance.pruneAfterMs, { onPruned: ({ entry }) => { rememberRemovedSessionFile(removedSessionFiles, entry); }, - preserveKeys: preserveSessionKeys, + preserveKeys: params.preserveSessionKeys, }); - const countAfterPrune = Object.keys(params.operation.store).length; + const countAfterPrune = countSessionEntryMaintenanceEligibleEntries( + params.operation.store, + params.preserveSessionKeys, + ); const shouldRunCapMaintenance = params.forceMaintenance || shouldRunSessionEntryMaintenance({ @@ -236,7 +239,7 @@ async function applyEnforcedMaintenance(params: { onCapped: ({ entry }) => { rememberRemovedSessionFile(removedSessionFiles, entry); }, - preserveKeys: preserveSessionKeys, + preserveKeys: params.preserveSessionKeys, }) : 0; const referencedSessionIds = collectReferencedSessionIds(params.operation.store); @@ -254,7 +257,7 @@ async function applyEnforcedMaintenance(params: { store: params.operation.store, storePath: params.operation.storePath, activeSessionKey: params.operation.activeSessionKey, - preserveKeys: preserveSessionKeys, + preserveKeys: params.preserveSessionKeys, maintenance: params.maintenance, warnOnly: false, log: params.operation.log, @@ -287,8 +290,13 @@ export async function applyFileBackedSessionStoreMaintenance( const maintenance = resolveMaintenanceForOperation(params); const beforeCount = Object.keys(params.store).length; const forceMaintenance = params.maintenanceOverride !== undefined; + const preserveSessionKeys = collectSessionMaintenancePreserveKeysForStore({ + storePath: params.storePath, + store: params.store, + baseKeys: [params.activeSessionKey], + }); const shouldRunEntryMaintenance = shouldRunSessionEntryMaintenance({ - entryCount: beforeCount, + entryCount: countSessionEntryMaintenanceEligibleEntries(params.store, preserveSessionKeys), maxEntries: maintenance.maxEntries, force: forceMaintenance, }); @@ -308,5 +316,6 @@ export async function applyFileBackedSessionStoreMaintenance( maintenance, beforeCount, forceMaintenance, + preserveSessionKeys, }); } diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index 70dbd34cefae..865953dc8444 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -443,6 +443,17 @@ export function shouldPreserveMaintenanceEntry(params: { ); } +function getSessionEntryMaintenanceEligibleKeys( + store: Record, + preserveKeys?: ReadonlySet, +): string[] { + // Maintenance triggers and eviction must share this eligibility boundary. + // Preserved sessions remain outside the ordinary-session allowance. + return Object.keys(store).filter( + (key) => !shouldPreserveMaintenanceEntry({ key, entry: store[key], preserveKeys }), + ); +} + export function getActiveSessionMaintenanceWarning(params: { store: Record; activeSessionKey: string; @@ -495,39 +506,28 @@ function wouldCapActiveSession(params: { activeSessionKey: string; maxEntries: number; }): boolean { - if (params.keys.length <= params.maxEntries) { + const eligibleKeys = params.keys.filter( + (key) => !shouldPreserveMaintenanceEntry({ key, entry: params.store[key] }), + ); + if (eligibleKeys.length <= params.maxEntries) { return false; } if (params.maxEntries <= 0) { return true; } - const protectedCount = params.keys.filter( - (key) => - key !== params.activeSessionKey && - shouldPreserveMaintenanceEntry({ key, entry: params.store[key] }), - ).length; - const maxRemovableEntries = Math.max(0, params.maxEntries - protectedCount); - // If protected entries fill the cap, the active unprotected session would be the one removed. - if (maxRemovableEntries <= 0) { - return true; - } - const activeUpdatedAt = getEntryUpdatedAt(params.activeEntry); let newerOrTieBeforeActive = 0; let seenActive = false; - for (const key of params.keys) { + for (const key of eligibleKeys) { if (key === params.activeSessionKey) { seenActive = true; continue; } - if (shouldPreserveMaintenanceEntry({ key, entry: params.store[key] })) { - continue; - } const entryUpdatedAt = getEntryUpdatedAt(params.store[key]); if (entryUpdatedAt > activeUpdatedAt || (!seenActive && entryUpdatedAt === activeUpdatedAt)) { newerOrTieBeforeActive++; - if (newerOrTieBeforeActive >= maxRemovableEntries) { + if (newerOrTieBeforeActive >= params.maxEntries) { return true; } } @@ -537,7 +537,8 @@ function wouldCapActiveSession(params: { } /** - * Cap the store to the N most recently updated entries. + * Cap eviction-eligible sessions to the N most recently updated entries. + * Preserved sessions remain outside the quota. * Entries without `updatedAt` are sorted last (removed first when over limit). * Mutates `store` in-place. */ @@ -550,20 +551,9 @@ export function capEntryCount( preserveKeys?: ReadonlySet; } = {}, ): number { - const preservedCount = Object.entries(store).filter(([key, entry]) => - shouldPreserveMaintenanceEntry({ key, entry, preserveKeys: opts.preserveKeys }), - ).length; - const maxRemovableEntries = Math.max(0, maxEntries - preservedCount); - // Protected entries reduce the removable budget instead of being counted as deletion targets. - const keys = Object.keys(store).filter( - (key) => - !shouldPreserveMaintenanceEntry({ - key, - entry: store[key], - preserveKeys: opts.preserveKeys, - }), - ); - if (keys.length <= maxRemovableEntries) { + const keys = getSessionEntryMaintenanceEligibleKeys(store, opts.preserveKeys); + const retainedEligibleEntries = Math.max(0, maxEntries); + if (keys.length <= retainedEligibleEntries) { return 0; } @@ -574,7 +564,7 @@ export function capEntryCount( return bTime - aTime; }); - const toRemove = sorted.slice(maxRemovableEntries); + const toRemove = sorted.slice(retainedEligibleEntries); for (const key of toRemove) { const entry = store[key]; if (entry) { diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index 5ad86652ac26..d0ce4980c222 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -378,6 +378,41 @@ describe("applyFileBackedSessionStoreMaintenance", () => { } }); + it("does not trigger capping when protected sessions alone exceed the high-water mark", async () => { + const now = Date.now(); + const store = makeStore([ + ["archived-1", { ...makeEntry(now - 5), archivedAt: now }], + ["archived-2", { ...makeEntry(now - 4), archivedAt: now }], + ["archived-3", { ...makeEntry(now - 3), archivedAt: now }], + ["dashboard-1", makeEntry(now - 2)], + ["dashboard-2", makeEntry(now - 1)], + ]); + let capped: number | undefined; + + await applyFileBackedSessionStoreMaintenance({ + storePath: "/tmp/openclaw-sessions/protected-quota.json", + store, + maintenanceConfig: { + mode: "enforce", + pruneAfterMs: 30 * DAY_MS, + maxEntries: 2, + modelRunPruneAfterMs: DAY_MS, + resetArchiveRetentionMs: null, + maxDiskBytes: null, + highWaterBytes: null, + }, + onMaintenanceApplied: (report) => { + capped = report.capped; + }, + log: { warn: () => {}, info: () => {} }, + artifacts: createMaintenanceArtifacts(), + }); + + expect(capped).toBe(0); + expect(store).toHaveProperty("dashboard-1"); + expect(store).toHaveProperty("dashboard-2"); + }); + it.each([ { name: "preserves every active admission instead of only the writer session", @@ -414,7 +449,8 @@ describe("applyFileBackedSessionStoreMaintenance", () => { key, { sessionId, updatedAt: now - preserved.length - 1 + index }, ]), - ["removable", { sessionId: "removable-session", updatedAt: now - 1 }], + ["removable-old", { sessionId: "removable-old-session", updatedAt: now - 2 }], + ["removable-recent", { sessionId: "removable-recent-session", updatedAt: now - 1 }], ]); const admission = await beginSessionWorkAdmission({ scope: storePath, @@ -442,7 +478,8 @@ describe("applyFileBackedSessionStoreMaintenance", () => { for (const [key] of preserved) { expect(store).toHaveProperty(key); } - expect(store.removable).toBeUndefined(); + expect(store["removable-old"]).toBeUndefined(); + expect(store).toHaveProperty("removable-recent"); } finally { admission.release(); } @@ -664,13 +701,13 @@ describe("capEntryCount", () => { const evicted = capEntryCount(store, 3); - expect(evicted).toBe(2); - expect(Object.keys(store)).toHaveLength(3); + expect(evicted).toBe(1); + expect(Object.keys(store)).toHaveLength(4); expect(store).toHaveProperty(threadKey); expect(store).toHaveProperty("newest"); expect(store).toHaveProperty("recent"); + expect(store).toHaveProperty("old"); expect(store.oldest).toBeUndefined(); - expect(store.old).toBeUndefined(); }); it("never evicts the agent primary main session even when protected entries fill the cap (#112637)", () => { @@ -704,10 +741,10 @@ describe("capEntryCount", () => { const evicted = capEntryCount(store, 2); - expect(evicted).toBe(1); + expect(evicted).toBe(0); expect(store).toHaveProperty(lockedKey); expect(store).toHaveProperty("recent"); - expect(store.old).toBeUndefined(); + expect(store).toHaveProperty("old"); }); it("preserves archived sessions when capping", () => { @@ -718,10 +755,10 @@ describe("capEntryCount", () => { ["old", makeEntry(now - DAY_MS)], ]); - expect(capEntryCount(store, 2)).toBe(1); + expect(capEntryCount(store, 2)).toBe(0); expect(store).toHaveProperty("archived"); expect(store).toHaveProperty("recent"); - expect(store.old).toBeUndefined(); + expect(store).toHaveProperty("old"); }); it("preserves pinned sessions when capping", () => { @@ -732,7 +769,7 @@ describe("capEntryCount", () => { ["old", makeEntry(now - DAY_MS)], ]); - expect(capEntryCount(store, 2)).toBe(1); + expect(capEntryCount(store, 1)).toBe(1); expect(store).toHaveProperty("pinned"); expect(store).toHaveProperty("recent"); expect(store.old).toBeUndefined(); @@ -754,11 +791,11 @@ describe("capEntryCount", () => { preserveKeys: collectSessionMaintenancePreserveKeys(), }); - expect(evicted).toBe(2); - expect(Object.keys(store)).toHaveLength(2); + expect(evicted).toBe(1); + expect(Object.keys(store)).toHaveLength(3); expect(store).toHaveProperty(childKey); expect(store).toHaveProperty("recent-1"); - expect(store["recent-2"]).toBeUndefined(); + expect(store).toHaveProperty("recent-2"); expect(store.old).toBeUndefined(); } finally { unregister(); @@ -784,11 +821,11 @@ describe("capEntryCount", () => { preserveKeys: collectSessionMaintenancePreserveKeys(), }); - expect(evicted).toBe(1); - expect(Object.keys(store)).toHaveLength(2); + expect(evicted).toBe(0); + expect(Object.keys(store)).toHaveLength(3); expect(store).toHaveProperty(childKey); expect(store).toHaveProperty("recent-1"); - expect(store.old).toBeUndefined(); + expect(store).toHaveProperty("old"); } finally { unregister(); }