fix(sessions): honor total entry cap with protected history (#123081)

* fix(sessions): count protected rows toward entry cap

* perf(sessions): defer maintenance snapshot loading

* test(sessions): assert total maintenance cap

* fix(sessions): align warning preservation

* fix(sessions): guard malformed maintenance rows
This commit is contained in:
Dallin Romney
2026-08-15 08:03:57 +08:00
committed by GitHub
parent d913796112
commit a01d40bfb9
16 changed files with 332 additions and 267 deletions
+7 -5
View File
@@ -222,10 +222,12 @@ 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.
- `maxEntries` caps the total live session row count. Protected rows are
reported as `keep` and count toward the cap, but they are never automatic
eviction targets. If protected rows prevent cleanup from reaching the cap,
the store remains above it. `--enforce` does not remove that protection;
unarchive, unpin, wait for active work to finish, or explicitly delete
sessions you no longer want to retain.
Flags:
@@ -235,7 +237,7 @@ Flags:
| `--enforce` | Apply maintenance even when `session.maintenance.mode` is `warn`. |
| `--fix-missing` | Remove legacy entries whose archived transcript artifacts are missing or header-only/empty, even if they would not normally age/count out yet. |
| `--fix-dm-scope` | When `session.dmScope` is `main`, retire stale peer-keyed direct-DM rows left behind by earlier `per-peer`, `per-channel-peer`, or `per-account-channel-peer` routing. Use `--dry-run` first; applying removes those rows from SQLite and preserves their legacy transcript artifacts as deleted archives. |
| `--active-key <key>` | Protect a specific active key from disk-budget eviction. Durable external conversation pointers, such as group sessions and thread-scoped chat sessions, are also kept by age/count/disk-budget maintenance. |
| `--active-key <key>` | Protect a specific active key from automatic maintenance. It still counts toward `maxEntries`. Durable external conversation pointers, such as group sessions and thread-scoped chat sessions, are also kept by age/count/disk-budget maintenance. |
| `--agent <id>` | Run cleanup for one configured agent store. |
| `--all-agents` | Run cleanup for all configured agent stores. |
| `--store <path>` | Run against a specific legacy store selector path. |
+8 -6
View File
@@ -196,12 +196,14 @@ 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.
`maxEntries` counts every live session row. Archived or pinned sessions, active
or admitted work, model-locked sessions, and durable external conversation
pointers are protected from automatic eviction, but still consume the cap.
Cleanup removes the oldest unprotected rows until it reaches `maxEntries` or
runs out of eligible victims. The total can therefore remain above the cap when
protected rows alone exceed it or active work temporarily blocks eviction.
Cleanup does not unprotect those rows; unarchive, unpin, wait for active work to
finish, 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-<uuid>` use fixed `24h` retention, but cleanup is
+1 -1
View File
@@ -1263,7 +1263,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 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.
- `maxEntries`: maximum total number of live SQLite session entries (default `500`). Every row counts toward the cap, but archived or pinned sessions, active or admitted work, model-locked sessions, and durable external conversation pointers are never automatic eviction targets. Cleanup removes the oldest unprotected rows; if protection prevents reaching the cap, the store remains above it. Runtime writes batch cleanup with a small high-water buffer for production-sized caps; `openclaw sessions cleanup --enforce` applies the cap immediately but does not unprotect rows. Unarchive, unpin, wait for active work to finish, or explicitly delete protected sessions to reduce the total.
- 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-<uuid>` 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.
@@ -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 eviction-eligible live session rows |
| `maxEntries` | `500` | cap on total live session rows when protection permits |
| `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,11 +70,11 @@ openclaw sessions cleanup --dry-run
openclaw sessions cleanup --enforce
```
`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.
`maxEntries` counts every live session row. 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 are never automatic eviction targets, but they still consume the cap. Cleanup removes the oldest unprotected rows until the total reaches `maxEntries` or no eligible victims remain. The store can therefore remain above the cap when protected rows alone exceed it or active work temporarily blocks eviction. 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.
`--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.
`--dry-run` previews the total-row cap and identifies the unprotected rows that would satisfy it; `--enforce` applies that cleanup immediately but does not remove protection. To reduce protected history, unarchive, unpin, wait for active work to finish, 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 <path>` 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.
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 <path>` 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 total 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.
+1 -1
View File
@@ -385,7 +385,7 @@ describe("updateSessionStoreAfterAgentRun", () => {
});
const persisted = loadPersistedSessionStore(storePath);
expect(Object.keys(persisted).filter((key) => key !== sessionKey)).toHaveLength(42);
expect(Object.keys(persisted)).toHaveLength(42);
expect(persisted[sessionKey]?.sessionId).toBe(sessionId);
expect(persisted["agent:main:stale:44"]).toBeUndefined();
});
+1 -1
View File
@@ -77,7 +77,7 @@ export const AUTOMATION_FIELD_HELP: Record<string, string> = {
"session.maintenance.pruneAfter":
"Removes entries older than this duration (for example `30d` or `12h`) during maintenance passes. Use this as the primary age-retention control and align it with data retention policy.",
"session.maintenance.maxEntries":
"Caps total session entry count retained in the store to prevent unbounded growth over time. Use lower limits for constrained environments, or higher limits when longer history is required.",
"Caps total session entry count retained in the store to prevent unbounded growth over time. Protected entries count toward the limit but are never automatically removed, so the store can remain above the cap when protection alone exceeds it. Use lower limits for constrained environments, or higher limits when longer history is required.",
"session.maintenance.resetArchiveRetention":
"Age-based retention for archived transcripts (`*.reset.<timestamp>` and `*.deleted.<timestamp>`). Defaults to keeping archives until the disk budget evicts them oldest-first; set a duration (for example `30d`) to opt into wall-clock deletion, or `false` to disable it explicitly.",
"session.maintenance.maxDiskBytes":
+1 -2
View File
@@ -26,7 +26,6 @@ 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 {
@@ -396,7 +395,7 @@ async function previewStoreCleanup(params: {
});
const modelRunPruned = shouldRunModelRunPrune({
maintenance: params.maintenance,
entryCount: countSessionEntryMaintenanceEligibleEntries(previewStore, preserveSessionKeys),
entryCount: Object.keys(previewStore).length,
// `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.
@@ -1323,6 +1323,33 @@ describe("sqlite session normalization", () => {
).toEqual({ entry_valid: 0 });
});
it("writes a valid session beside an unrelated malformed legacy row", async () => {
const env = { ...process.env, OPENCLAW_STATE_DIR: paths.stateDir };
const database = openOpenClawAgentDatabase({ agentId: "main", env, path: paths.sqlitePath });
database.db
.prepare(
"INSERT INTO session_nodes (session_key, current_session_id, entry_json, entry_valid, updated_at) VALUES (?, ?, ?, -1, ?)",
)
.run("agent:main:malformed", "malformed-session", "{ malformed", 0);
await expect(
replaceSessionEntry(
{
agentId: "main",
env,
sessionKey: "agent:main:valid",
storePath: paths.sqlitePath,
},
{ sessionId: "valid-session", updatedAt: Date.now() },
),
).resolves.toMatchObject({ sessionId: "valid-session" });
expect(
database.db
.prepare("SELECT entry_json FROM session_nodes WHERE session_key = ?")
.get("agent:main:malformed"),
).toEqual({ entry_json: "{ malformed" });
});
it("exposes same-key rollover lineage when a killed session is replaced", async () => {
const env = { ...process.env, OPENCLAW_STATE_DIR: paths.stateDir };
const sessionKey = "agent:main:telegram:group:-1003774691294:topic:29020";
@@ -1657,10 +1684,10 @@ describe("sqlite session normalization", () => {
env,
storePath: paths.sqlitePath,
}).map((summary) => summary.sessionKey),
).toEqual(["agent:main:active", "agent:main:newer", "agent:main:newest"]);
).toEqual(["agent:main:newer", "agent:main:newest"]);
});
it("keeps protected SQLite rows outside the write-triggered entry allowance", async () => {
it("counts protected SQLite rows while preserving them during write-triggered capping", async () => {
vi.mocked(getRuntimeConfig).mockReturnValue({
session: {
maintenance: {
@@ -1685,20 +1712,19 @@ describe("sqlite session normalization", () => {
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:archived-1"),
() => ({ archivedAt: now - 4, sessionId: "archived-session-1", updatedAt: now - 4 }),
{
fallbackEntry: {
archivedAt: now - 4,
sessionId: "archived-session-1",
updatedAt: now - 4,
},
);
}
replaceEntry: true,
skipMaintenance: true,
},
);
await patchSessionEntryCore(
scopeFor("agent:main:recent-dashboard-1"),
() => ({ sessionId: recentSessionId, updatedAt: now - 2 }),
@@ -1737,13 +1763,7 @@ describe("sqlite session normalization", () => {
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",
]);
).toEqual(["agent:main:archived-1", "agent:main:maintenance-trigger"]);
await expect(
loadTranscriptEvents({
agentId: "main",
@@ -1751,7 +1771,7 @@ describe("sqlite session normalization", () => {
sessionId: recentSessionId,
storePath: paths.sqlitePath,
}),
).resolves.toEqual([recentTranscriptEvent]);
).resolves.toEqual([]);
});
it("preserves pinned SQLite entries and transcripts during write-triggered capping", async () => {
@@ -1815,6 +1835,13 @@ describe("sqlite session normalization", () => {
pinnedAt: 2,
sessionId: pinnedSessionId,
});
expect(
listSessionEntryRows({
agentId: "main",
env,
storePath: paths.sqlitePath,
}).map((summary) => summary.sessionKey),
).toEqual(["agent:main:maintenance-trigger", pinnedKey]);
await expect(
loadTranscriptEvents({
agentId: "main",
@@ -1,4 +1,5 @@
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 {
@@ -10,6 +11,7 @@ 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,
@@ -30,8 +32,10 @@ import {
} from "./session-accessor.sqlite-scope.js";
import { parseSessionEntryJson as parseSessionEntryRow } from "./session-accessor.sqlite-status.js";
import { normalizeStoreSessionKey } from "./store-entry.js";
import { countSessionEntryMaintenanceEligibleEntries } from "./store-maintenance-eligibility.js";
import { collectSessionMaintenancePreserveKeysForStore } from "./store-maintenance-preserve.js";
import {
collectSessionMaintenancePreserveKeys,
collectSessionMaintenancePreserveKeysForStore,
} from "./store-maintenance-preserve.js";
import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js";
import {
capEntryCount,
@@ -61,18 +65,36 @@ function collectSqliteSessionMaintenanceBaseKeys(
return keys;
}
function hasStaleSessionEntryCandidate(
store: Record<string, SessionEntry>,
function hasStaleSqliteSessionEntryCandidate(
database: OpenClawAgentDatabase,
pruneAfterMs: number,
preserveKeys: ReadonlySet<string> | undefined,
): boolean {
const cutoffMs = Date.now() - pruneAfterMs;
return Object.entries(store).some(([key, entry]) => {
if (entry.updatedAt == null || entry.updatedAt >= cutoffMs) {
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.
* The CASE guard keeps malformed legacy bytes from aborting unrelated session writes. */
sql<boolean>`CASE
WHEN json_valid(entry_json) THEN json_extract(entry_json, '$.archivedAt') IS NULL
ELSE 0
END`,
)
.orderBy("updated_at", "asc"),
).rows;
return rows.some((row) => {
const entry = parseSessionEntryRow(row);
if (!entry) {
return false;
}
return !shouldPreserveMaintenanceEntry({
key,
key: normalizeStoreSessionKey(row.session_key),
entry,
preserveKeys,
});
@@ -116,32 +138,26 @@ export function applySessionEntryMaintenance(
return { entryRemovals: [], stateDeletePlans: [] };
}
// 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<string>();
const eligibleEntryCount = countSessionEntryMaintenanceEligibleEntries(store, preserveKeys);
const hasStaleCandidate = hasStaleSessionEntryCandidate(
store,
// Count all rows before loading their payloads. Protection controls eviction candidates, not
// whether a row consumes maxEntries; the full snapshot is needed only when maintenance runs.
const entryCount = readSessionEntryCount(database);
const preserveCandidateKeys = collectSessionMaintenancePreserveKeys([params.activeSessionKey]);
const hasStaleCandidate = hasStaleSqliteSessionEntryCandidate(
database,
maintenance.pruneAfterMs,
preserveKeys,
preserveCandidateKeys,
);
const shouldMaintainStore =
params.forceMaintenance === true ||
eligibleEntryCount > maintenance.maxEntries ||
entryCount > maintenance.maxEntries ||
hasStaleCandidate ||
shouldRunModelRunPrune({
maintenance,
entryCount: eligibleEntryCount,
entryCount,
force: params.forceMaintenance,
}) ||
shouldRunSessionEntryMaintenance({
entryCount: eligibleEntryCount,
entryCount,
maxEntries: maintenance.maxEntries,
force: params.forceMaintenance,
});
@@ -149,6 +165,13 @@ export function applySessionEntryMaintenance(
return { entryRemovals: [], stateDeletePlans: [] };
}
const store = loadSqliteSessionMaintenanceStore(database);
const preserveKeys =
collectSessionMaintenancePreserveKeysForStore({
storePath: params.storePath,
store,
baseKeys: collectSqliteSessionMaintenanceBaseKeys(store, params.activeSessionKey),
}) ?? new Set<string>();
const removedKeys = new Set<string>();
const removedEntriesByKey = new Map<string, SessionEntry>();
const removedSessionIds = new Set<string>();
@@ -159,30 +182,26 @@ export function applySessionEntryMaintenance(
removedSessionIds.add(sessionId);
}
};
let remainingEligibleEntryCount = eligibleEntryCount;
let remainingEntryCount = entryCount;
if (
shouldRunModelRunPrune({
maintenance,
entryCount: remainingEligibleEntryCount,
entryCount: remainingEntryCount,
force: params.forceMaintenance,
})
) {
remainingEligibleEntryCount -= pruneStaleModelRunEntries(
store,
maintenance.modelRunPruneAfterMs,
{
log: false,
onPruned: rememberRemovedEntry,
preserveKeys,
},
);
remainingEntryCount -= pruneStaleModelRunEntries(store, maintenance.modelRunPruneAfterMs, {
log: false,
onPruned: rememberRemovedEntry,
preserveKeys,
});
}
if (
params.forceMaintenance === true ||
hasStaleCandidate ||
remainingEligibleEntryCount > maintenance.maxEntries
remainingEntryCount > maintenance.maxEntries
) {
remainingEligibleEntryCount -= pruneStaleEntries(store, maintenance.pruneAfterMs, {
remainingEntryCount -= pruneStaleEntries(store, maintenance.pruneAfterMs, {
log: false,
onPruned: rememberRemovedEntry,
preserveKeys,
@@ -190,7 +209,7 @@ export function applySessionEntryMaintenance(
}
if (
shouldRunSessionEntryMaintenance({
entryCount: remainingEligibleEntryCount,
entryCount: remainingEntryCount,
maxEntries: maintenance.maxEntries,
force: params.forceMaintenance,
})
@@ -1,15 +0,0 @@
import { shouldPreserveMaintenanceEntry } from "./store-maintenance.js";
import type { SessionEntry } from "./types.js";
export function countSessionEntryMaintenanceEligibleEntries(
store: Record<string, SessionEntry>,
preserveKeys?: ReadonlySet<string>,
): number {
let count = 0;
for (const [key, entry] of Object.entries(store)) {
if (!shouldPreserveMaintenanceEntry({ key, entry, preserveKeys })) {
count++;
}
}
return count;
}
@@ -1,78 +0,0 @@
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<string, SessionEntry> {
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();
});
});
@@ -1,7 +1,6 @@
// 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 {
@@ -103,6 +102,7 @@ async function applyWarnOnlyMaintenance(params: {
maintenance: ResolvedSessionMaintenanceConfig;
beforeCount: number;
shouldRunEntryMaintenance: boolean;
preserveSessionKeys: ReadonlySet<string> | undefined;
}): Promise<void> {
const activeSessionKey = params.operation.activeSessionKey?.trim();
if (activeSessionKey && params.shouldRunEntryMaintenance) {
@@ -111,6 +111,7 @@ async function applyWarnOnlyMaintenance(params: {
activeSessionKey,
pruneAfterMs: params.maintenance.pruneAfterMs,
maxEntries: params.maintenance.maxEntries,
preserveKeys: params.preserveSessionKeys,
});
if (warning) {
params.operation.log.warn(
@@ -205,10 +206,7 @@ async function applyEnforcedMaintenance(params: {
const removedSessionFiles = new Map<string, string | undefined>();
const modelRunPruned = shouldRunModelRunPrune({
maintenance: params.maintenance,
entryCount: countSessionEntryMaintenanceEligibleEntries(
params.operation.store,
params.preserveSessionKeys,
),
entryCount: Object.keys(params.operation.store).length,
force: params.forceMaintenance,
})
? pruneStaleModelRunEntries(params.operation.store, params.maintenance.modelRunPruneAfterMs, {
@@ -224,10 +222,7 @@ async function applyEnforcedMaintenance(params: {
},
preserveKeys: params.preserveSessionKeys,
});
const countAfterPrune = countSessionEntryMaintenanceEligibleEntries(
params.operation.store,
params.preserveSessionKeys,
);
const countAfterPrune = Object.keys(params.operation.store).length;
const shouldRunCapMaintenance =
params.forceMaintenance ||
shouldRunSessionEntryMaintenance({
@@ -296,7 +291,7 @@ export async function applyFileBackedSessionStoreMaintenance(
baseKeys: [params.activeSessionKey],
});
const shouldRunEntryMaintenance = shouldRunSessionEntryMaintenance({
entryCount: countSessionEntryMaintenanceEligibleEntries(params.store, preserveSessionKeys),
entryCount: beforeCount,
maxEntries: maintenance.maxEntries,
force: forceMaintenance,
});
@@ -307,6 +302,7 @@ export async function applyFileBackedSessionStoreMaintenance(
maintenance,
beforeCount,
shouldRunEntryMaintenance,
preserveSessionKeys,
});
return { changedStore: false };
}
@@ -0,0 +1,135 @@
import { describe, expect, it, vi } from "vitest";
import { applyFileBackedSessionStoreMaintenance } from "./store-maintenance-operations.js";
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<string, SessionEntry> {
return Object.fromEntries(entries);
}
function createMaintenanceArtifacts() {
return {
archiveRemovedSessionTranscripts: async () => new Set<string>(),
removeRemovedSessionTrajectoryArtifacts: async () => {},
cleanupArchivedSessionTranscripts: async () => {},
};
}
describe("session maintenance total entry cap", () => {
it("counts archived sessions toward the cap without evicting them", () => {
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(2);
expect(Object.keys(store)).toHaveLength(500);
expect(store["dashboard-1"]).toBeUndefined();
expect(store["dashboard-2"]).toBeUndefined();
expect(store).toHaveProperty("dashboard-3");
expect(store).toHaveProperty("archived-0");
expect(store).toHaveProperty("archived-498");
});
it("uses the remaining total capacity for ordinary sessions", () => {
const pinnedEntries = Array.from({ length: 200 }, (_, index): [string, SessionEntry] => [
`pinned-${index}`,
{ ...makeEntry(index), pinnedAt: index + 1 },
]);
const ordinaryEntries = Array.from({ length: 400 }, (_, index): [string, SessionEntry] => [
`ordinary-${index}`,
makeEntry(index),
]);
const store = makeStore([...pinnedEntries, ...ordinaryEntries]);
expect(capEntryCount(store, 500)).toBe(100);
expect(Object.keys(store)).toHaveLength(500);
expect(store["ordinary-99"]).toBeUndefined();
expect(store).toHaveProperty("ordinary-100");
expect(store).toHaveProperty("ordinary-399");
expect(store).toHaveProperty("pinned-0");
expect(store).toHaveProperty("pinned-199");
});
it("uses total rows when warning that an active session would be capped", () => {
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: 500,
nowMs: now,
}),
).toMatchObject({ wouldCap: true, wouldPrune: false });
});
it("uses enforcement preservation when predicting active-session eviction", async () => {
const now = Date.now();
const storePath = "/tmp/openclaw-sessions/warn-enforce-parity.json";
const makePressureStore = () =>
makeStore([
["archived", { ...makeEntry(now - 2), archivedAt: now }],
["active", makeEntry(now - 1)],
["recent", makeEntry(now)],
]);
const maintenanceConfig = {
mode: "warn" as const,
pruneAfterMs: 30 * DAY_MS,
maxEntries: 2,
modelRunPruneAfterMs: DAY_MS,
resetArchiveRetentionMs: null,
maxDiskBytes: null,
highWaterBytes: null,
};
const onWarn = vi.fn();
await applyFileBackedSessionStoreMaintenance({
storePath,
store: makePressureStore(),
activeSessionKey: "active",
maintenanceConfig,
onWarn,
log: { warn: () => {}, info: () => {} },
artifacts: createMaintenanceArtifacts(),
});
const enforcedStore = makePressureStore();
await applyFileBackedSessionStoreMaintenance({
storePath,
store: enforcedStore,
activeSessionKey: "active",
maintenanceConfig: { ...maintenanceConfig, mode: "enforce" },
log: { warn: () => {}, info: () => {} },
artifacts: createMaintenanceArtifacts(),
});
expect(onWarn).not.toHaveBeenCalled();
expect(enforcedStore).toHaveProperty("archived");
expect(enforcedStore).toHaveProperty("active");
expect(enforcedStore.recent).toBeUndefined();
});
});
+38 -64
View File
@@ -443,15 +443,31 @@ export function shouldPreserveMaintenanceEntry(params: {
);
}
function getSessionEntryMaintenanceEligibleKeys(
function selectSessionEntryCapVictims(
store: Record<string, SessionEntry>,
maxEntries: number,
preserveKeys?: ReadonlySet<string>,
): string[] {
// Maintenance triggers and eviction must share this eligibility boundary.
// Preserved sessions remain outside the ordinary-session allowance.
return Object.keys(store).filter(
const keys = Object.keys(store);
const overflow = keys.length - Math.max(0, maxEntries);
if (overflow <= 0) {
return [];
}
// All persisted rows consume the cap, but protected rows are never victims. If protected rows
// alone exceed the cap, maintenance removes every eligible row and leaves the excess intact.
const eligibleKeys = keys.filter(
(key) => !shouldPreserveMaintenanceEntry({ key, entry: store[key], preserveKeys }),
);
const victimCount = Math.min(overflow, eligibleKeys.length);
if (victimCount === 0) {
return [];
}
// Sort newest first; entries without updatedAt go to the end and are removed first.
return eligibleKeys
.toSorted((a, b) => getEntryUpdatedAt(store[b]) - getEntryUpdatedAt(store[a]))
.slice(-victimCount);
}
export function getActiveSessionMaintenanceWarning(params: {
@@ -460,6 +476,7 @@ export function getActiveSessionMaintenanceWarning(params: {
pruneAfterMs: number;
maxEntries: number;
nowMs?: number;
preserveKeys?: ReadonlySet<string>;
}): SessionMaintenanceWarning | null {
const activeSessionKey = params.activeSessionKey.trim();
if (!activeSessionKey) {
@@ -469,20 +486,24 @@ export function getActiveSessionMaintenanceWarning(params: {
if (!activeEntry) {
return null;
}
if (shouldPreserveMaintenanceEntry({ key: activeSessionKey, entry: activeEntry })) {
if (
shouldPreserveMaintenanceEntry({
key: activeSessionKey,
entry: activeEntry,
preserveKeys: params.preserveKeys,
})
) {
return null;
}
const now = params.nowMs ?? Date.now();
const cutoffMs = now - params.pruneAfterMs;
const wouldPrune = activeEntry.updatedAt != null ? activeEntry.updatedAt < cutoffMs : false;
const keys = Object.keys(params.store);
const wouldCap = wouldCapActiveSession({
store: params.store,
keys,
activeEntry,
activeSessionKey,
maxEntries: params.maxEntries,
});
const wouldCap = selectSessionEntryCapVictims(
params.store,
params.maxEntries,
params.preserveKeys,
).includes(activeSessionKey);
if (!wouldPrune && !wouldCap) {
return null;
@@ -499,47 +520,10 @@ export function getActiveSessionMaintenanceWarning(params: {
};
}
function wouldCapActiveSession(params: {
store: Record<string, SessionEntry>;
keys: string[];
activeEntry: SessionEntry;
activeSessionKey: string;
maxEntries: number;
}): boolean {
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 activeUpdatedAt = getEntryUpdatedAt(params.activeEntry);
let newerOrTieBeforeActive = 0;
let seenActive = false;
for (const key of eligibleKeys) {
if (key === params.activeSessionKey) {
seenActive = true;
continue;
}
const entryUpdatedAt = getEntryUpdatedAt(params.store[key]);
if (entryUpdatedAt > activeUpdatedAt || (!seenActive && entryUpdatedAt === activeUpdatedAt)) {
newerOrTieBeforeActive++;
if (newerOrTieBeforeActive >= params.maxEntries) {
return true;
}
}
}
return false;
}
/**
* 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).
* Cap the total store to N entries by removing the oldest eviction-eligible rows.
* Protected rows count toward the cap but are never removed, so a store whose protected rows
* alone exceed the cap remains above it until protection is released or rows are deleted.
* Mutates `store` in-place.
*/
export function capEntryCount(
@@ -551,20 +535,10 @@ export function capEntryCount(
preserveKeys?: ReadonlySet<string>;
} = {},
): number {
const keys = getSessionEntryMaintenanceEligibleKeys(store, opts.preserveKeys);
const retainedEligibleEntries = Math.max(0, maxEntries);
if (keys.length <= retainedEligibleEntries) {
const toRemove = selectSessionEntryCapVictims(store, maxEntries, opts.preserveKeys);
if (toRemove.length === 0) {
return 0;
}
// Sort by updatedAt descending; entries without updatedAt go to the end (removed first).
const sorted = keys.toSorted((a, b) => {
const aTime = getEntryUpdatedAt(store[a]);
const bTime = getEntryUpdatedAt(store[b]);
return bTime - aTime;
});
const toRemove = sorted.slice(retainedEligibleEntries);
for (const key of toRemove) {
const entry = store[key];
if (entry) {
+23 -19
View File
@@ -378,7 +378,7 @@ describe("applyFileBackedSessionStoreMaintenance", () => {
}
});
it("does not trigger capping when protected sessions alone exceed the high-water mark", async () => {
it("counts protected sessions when triggering capping but never evicts them", async () => {
const now = Date.now();
const store = makeStore([
["archived-1", { ...makeEntry(now - 5), archivedAt: now }],
@@ -408,9 +408,13 @@ describe("applyFileBackedSessionStoreMaintenance", () => {
artifacts: createMaintenanceArtifacts(),
});
expect(capped).toBe(0);
expect(store).toHaveProperty("dashboard-1");
expect(store).toHaveProperty("dashboard-2");
expect(capped).toBe(2);
expect(Object.keys(store)).toHaveLength(3);
expect(store).toHaveProperty("archived-1");
expect(store).toHaveProperty("archived-2");
expect(store).toHaveProperty("archived-3");
expect(store["dashboard-1"]).toBeUndefined();
expect(store["dashboard-2"]).toBeUndefined();
});
it.each([
@@ -479,7 +483,7 @@ describe("applyFileBackedSessionStoreMaintenance", () => {
expect(store).toHaveProperty(key);
}
expect(store["removable-old"]).toBeUndefined();
expect(store).toHaveProperty("removable-recent");
expect(store["removable-recent"]).toBeUndefined();
} finally {
admission.release();
}
@@ -701,13 +705,13 @@ describe("capEntryCount", () => {
const evicted = capEntryCount(store, 3);
expect(evicted).toBe(1);
expect(Object.keys(store)).toHaveLength(4);
expect(evicted).toBe(2);
expect(Object.keys(store)).toHaveLength(3);
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)", () => {
@@ -741,10 +745,10 @@ describe("capEntryCount", () => {
const evicted = capEntryCount(store, 2);
expect(evicted).toBe(0);
expect(evicted).toBe(1);
expect(store).toHaveProperty(lockedKey);
expect(store).toHaveProperty("recent");
expect(store).toHaveProperty("old");
expect(store.old).toBeUndefined();
});
it("preserves archived sessions when capping", () => {
@@ -755,10 +759,10 @@ describe("capEntryCount", () => {
["old", makeEntry(now - DAY_MS)],
]);
expect(capEntryCount(store, 2)).toBe(0);
expect(capEntryCount(store, 2)).toBe(1);
expect(store).toHaveProperty("archived");
expect(store).toHaveProperty("recent");
expect(store).toHaveProperty("old");
expect(store.old).toBeUndefined();
});
it("preserves pinned sessions when capping", () => {
@@ -769,7 +773,7 @@ describe("capEntryCount", () => {
["old", makeEntry(now - DAY_MS)],
]);
expect(capEntryCount(store, 1)).toBe(1);
expect(capEntryCount(store, 2)).toBe(1);
expect(store).toHaveProperty("pinned");
expect(store).toHaveProperty("recent");
expect(store.old).toBeUndefined();
@@ -791,11 +795,11 @@ describe("capEntryCount", () => {
preserveKeys: collectSessionMaintenancePreserveKeys(),
});
expect(evicted).toBe(1);
expect(Object.keys(store)).toHaveLength(3);
expect(evicted).toBe(2);
expect(Object.keys(store)).toHaveLength(2);
expect(store).toHaveProperty(childKey);
expect(store).toHaveProperty("recent-1");
expect(store).toHaveProperty("recent-2");
expect(store["recent-2"]).toBeUndefined();
expect(store.old).toBeUndefined();
} finally {
unregister();
@@ -821,11 +825,11 @@ describe("capEntryCount", () => {
preserveKeys: collectSessionMaintenancePreserveKeys(),
});
expect(evicted).toBe(0);
expect(Object.keys(store)).toHaveLength(3);
expect(evicted).toBe(1);
expect(Object.keys(store)).toHaveLength(2);
expect(store).toHaveProperty(childKey);
expect(store).toHaveProperty("recent-1");
expect(store).toHaveProperty("old");
expect(store.old).toBeUndefined();
} finally {
unregister();
}
+1 -1
View File
@@ -249,7 +249,7 @@ export type SessionMaintenanceConfig = {
mode?: SessionMaintenanceMode;
/** Remove session entries older than this duration (e.g. "30d", "12h"). Default: "30d". */
pruneAfter?: string | number;
/** Maximum number of session entries to keep. Default: 500. */
/** Maximum total session entries to keep when protection permits. Default: 500. */
maxEntries?: number;
/**
* Age-based retention for archived transcripts (`*.reset.<timestamp>` and