mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 02:45:38 -06:00
fix(sessions): exclude protected sessions from entry cap (#123014)
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user