fix(sessions): exclude protected sessions from entry cap (#123014)

This commit is contained in:
Dallin Romney
2026-08-13 15:42:55 +08:00
committed by GitHub
parent fce5b6688a
commit 4afccbdaa4
14 changed files with 369 additions and 137 deletions
+4
View File
@@ -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:
+11 -4
View File
@@ -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-<uuid>` 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
+1 -1
View File
@@ -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-<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 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 <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 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 <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.
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
@@ -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();
});
+2 -1
View File
@@ -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.
@@ -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,
},
@@ -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 () => {
@@ -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<string, SessionEntry>,
pruneAfterMs: number,
preserveKeys: ReadonlySet<string> | 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<boolean>`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<string, SessionEntry> {
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<string, SessionEntry> = {};
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<string>();
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<string, SessionEntry> = {};
for (const row of rows) {
const entry = parseSessionEntryRow(row);
if (entry) {
store[row.session_key] = entry;
}
}
const removedKeys = new Set<string>();
const removedEntriesByKey = new Map<string, SessionEntry>();
const removedSessionIds = new Set<string>();
@@ -164,31 +159,30 @@ export function applySessionEntryMaintenance(
removedSessionIds.add(sessionId);
}
};
const preserveKeys =
collectSessionMaintenancePreserveKeysForStore({
storePath: params.storePath,
store,
baseKeys: collectSqliteSessionMaintenanceBaseKeys(store, params.activeSessionKey),
}) ?? new Set<string>();
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,
})
@@ -0,0 +1,15 @@
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;
}
@@ -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<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,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<string> | undefined;
}): Promise<FileBackedSessionStoreMaintenanceResult> {
const preserveSessionKeys = collectSessionMaintenancePreserveKeysForStore({
storePath: params.operation.storePath,
store: params.operation.store,
baseKeys: [params.operation.activeSessionKey],
});
const removedSessionFiles = new Map<string, string | undefined>();
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,
});
}
+23 -33
View File
@@ -443,6 +443,17 @@ export function shouldPreserveMaintenanceEntry(params: {
);
}
function getSessionEntryMaintenanceEligibleKeys(
store: Record<string, SessionEntry>,
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(
(key) => !shouldPreserveMaintenanceEntry({ key, entry: store[key], preserveKeys }),
);
}
export function getActiveSessionMaintenanceWarning(params: {
store: Record<string, SessionEntry>;
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<string>;
} = {},
): 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) {
+53 -16
View File
@@ -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();
}