Files
openclaw/src/sessions/session-key-utils.ts
Gustavo Madeira Santana e19a23520c fix: unify session maintenance and cron run pruning (#13083)
* fix: prune stale session entries, cap entry count, and rotate sessions.json

The sessions.json file grows unbounded over time. Every heartbeat tick (default: 30m)
triggers multiple full rewrites, and session keys from groups, threads, and DMs
accumulate indefinitely with large embedded objects (skillsSnapshot,
systemPromptReport). At >50MB the synchronous JSON parse blocks the event loop,
causing Telegram webhook timeouts and effectively taking the bot down.

Three mitigations, all running inside saveSessionStoreUnlocked() on every write:

1. Prune stale entries: remove entries with updatedAt older than 30 days
   (configurable via session.maintenance.pruneDays in openclaw.json)

2. Cap entry count: keep only the 500 most recently updated entries
   (configurable via session.maintenance.maxEntries). Entries without updatedAt
   are evicted first.

3. File rotation: if the existing sessions.json exceeds 10MB before a write,
   rename it to sessions.json.bak.{timestamp} and keep only the 3 most recent
   backups (configurable via session.maintenance.rotateBytes).

All three thresholds are configurable under session.maintenance in openclaw.json
with Zod validation. No env vars.

Existing tests updated to use Date.now() instead of epoch-relative timestamps
(1, 2, 3) that would be incorrectly pruned as stale.

27 new tests covering pruning, capping, rotation, and integration scenarios.

* feat: auto-prune expired cron run sessions (#12289)

Add TTL-based reaper for isolated cron run sessions that accumulate
indefinitely in sessions.json.

New config option:
  cron.sessionRetention: string | false  (default: '24h')

The reaper runs piggy-backed on the cron timer tick, self-throttled
to sweep at most every 5 minutes. It removes session entries matching
the pattern cron:<jobId>:run:<uuid> whose updatedAt + retention < now.

Design follows the Kubernetes ttlSecondsAfterFinished pattern:
- Sessions are persisted normally (observability/debugging)
- A periodic reaper prunes expired entries
- Configurable retention with sensible default
- Set to false to disable pruning entirely

Files changed:
- src/config/types.cron.ts: Add sessionRetention to CronConfig
- src/config/zod-schema.ts: Add Zod validation for sessionRetention
- src/cron/session-reaper.ts: New reaper module (sweepCronRunSessions)
- src/cron/session-reaper.test.ts: 12 tests covering all paths
- src/cron/service/state.ts: Add cronConfig/sessionStorePath to deps
- src/cron/service/timer.ts: Wire reaper into onTimer tick
- src/gateway/server-cron.ts: Pass config and session store path to deps

Closes #12289

* fix: sweep cron session stores per agent

* docs: add changelog for session maintenance (#13083) (thanks @skyfallsin, @Glucksberg)

* fix: add warn-only session maintenance mode

* fix: warn-only maintenance defaults to active session

* fix: deliver maintenance warnings to active session

* docs: add session maintenance examples

* fix: accept duration and size maintenance thresholds

* refactor: share cron run session key check

* fix: format issues and replace defaultRuntime.warn with console.warn

---------

Co-authored-by: Pradeep Elankumaran <pradeepe@gmail.com>
Co-authored-by: Glucksberg <markuscontasul@gmail.com>
Co-authored-by: max <40643627+quotentiroler@users.noreply.github.com>
Co-authored-by: quotentiroler <max.nussbaumer@maxhealth.tech>
2026-02-09 20:42:35 -08:00

84 lines
2.1 KiB
TypeScript

export type ParsedAgentSessionKey = {
agentId: string;
rest: string;
};
export function parseAgentSessionKey(
sessionKey: string | undefined | null,
): ParsedAgentSessionKey | null {
const raw = (sessionKey ?? "").trim();
if (!raw) {
return null;
}
const parts = raw.split(":").filter(Boolean);
if (parts.length < 3) {
return null;
}
if (parts[0] !== "agent") {
return null;
}
const agentId = parts[1]?.trim();
const rest = parts.slice(2).join(":");
if (!agentId || !rest) {
return null;
}
return { agentId, rest };
}
export function isCronRunSessionKey(sessionKey: string | undefined | null): boolean {
const parsed = parseAgentSessionKey(sessionKey);
if (!parsed) {
return false;
}
return /^cron:[^:]+:run:[^:]+$/.test(parsed.rest);
}
export function isSubagentSessionKey(sessionKey: string | undefined | null): boolean {
const raw = (sessionKey ?? "").trim();
if (!raw) {
return false;
}
if (raw.toLowerCase().startsWith("subagent:")) {
return true;
}
const parsed = parseAgentSessionKey(raw);
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("subagent:"));
}
export function isAcpSessionKey(sessionKey: string | undefined | null): boolean {
const raw = (sessionKey ?? "").trim();
if (!raw) {
return false;
}
const normalized = raw.toLowerCase();
if (normalized.startsWith("acp:")) {
return true;
}
const parsed = parseAgentSessionKey(raw);
return Boolean((parsed?.rest ?? "").toLowerCase().startsWith("acp:"));
}
const THREAD_SESSION_MARKERS = [":thread:", ":topic:"];
export function resolveThreadParentSessionKey(
sessionKey: string | undefined | null,
): string | null {
const raw = (sessionKey ?? "").trim();
if (!raw) {
return null;
}
const normalized = raw.toLowerCase();
let idx = -1;
for (const marker of THREAD_SESSION_MARKERS) {
const candidate = normalized.lastIndexOf(marker);
if (candidate > idx) {
idx = candidate;
}
}
if (idx <= 0) {
return null;
}
const parent = raw.slice(0, idx).trim();
return parent ? parent : null;
}