import type { SharedSessionInputRecord } from './session-intents.js'; import type { SharedSessionMessage, SharedSessionRecord } from './session-types.js'; export interface SharedSessionGcStore { readonly sessions: Map; readonly messages: Map; readonly inputs: Map; } export interface SharedSessionGcOptions { readonly idleEmptyMs: number; readonly idleLongMs: number; /** * Age (ms since closedAt) at which a CLOSED session's record + bodies are * PERMANENTLY deleted from the store. Closed sessions are HISTORY: by default * this is `Number.POSITIVE_INFINITY` (retain indefinitely, a closed session * is never swept off disk), and deletion happens only when a caller opts into * a finite retention window or invokes an explicit delete verb. * * NOTE ON MEMORY vs PERSISTENCE (divergence from the companion manager): * the broker's durable store is a FULL SNAPSHOT of these in-memory maps * (createSessionBrokerSnapshot), so there is no separate "memory handle" to * evict independently of disk, dropping a closed session's bodies from these * maps would also drop them from the next persisted snapshot. Retention is * therefore all-or-nothing here: retained closed sessions stay both listable * and on disk; memory stays bounded by the per-session message cap * (MAX_PERSISTED_MESSAGES_PER_SESSION) rather than by body eviction. */ readonly deletionRetentionMs: number; readonly publishUpdate: (event: string, payload: unknown) => void; /** * Liveness probe for a session whose work runs somewhere these records cannot * see. Returns true while that work is genuinely in flight. * * Why this exists: the reaper used to judge liveness ONLY by signals a * locally-executed turn emits, `lastActivityAt`, `activeAgentId`, * `pendingInputCount`, participant heartbeats. A HOSTED turn emits none of * them while it runs: its transcript lives on the hosted record, so the * broker's `messageCount` stays 0 (judging the session against the 10-minute * idleEmptyMs window rather than the 24-hour one), and its heartbeat is * driven by an intake tick that is itself blocked awaiting the turn. The * observed result was a session closed 'idle-reaped' at 21:45Z whose turn * demonstrably went on running until 22:26Z, the record contradicted the * work. * * So liveness is no longer inferred solely from what this store happens to * have been told. A subsystem that runs turns out of the broker's sight * supplies this probe and gets asked directly. */ readonly isExternallyLive?: ((session: SharedSessionRecord) => boolean) | undefined; } export declare function sweepSharedSessions(store: SharedSessionGcStore, options: SharedSessionGcOptions): boolean; /** One session the boot sweep closed, for the caller to disclose. */ export interface OrphanedSessionClosure { readonly sessionId: string; readonly kind: string; readonly messageCount: number; /** How long the record had been sitting active, in ms, at sweep time. */ readonly staleForMs: number; } /** What the boot sweep needs to decide a session is an orphan. */ export interface BootOrphanSweepOptions { readonly idleEmptyMs: number; readonly idleLongMs: number; readonly now?: number; } /** * Close sessions a dead process left behind, at the boot that finds them. * * The case: a pty-forked second instance died without closing its session and * left a record reading `status: 'active'`, 0 messages, permanently, because * nothing ever revisited it with the right verdict. * * NOT "every active session at boot". A session deliberately SURVIVES a daemon * restart: surfaces outlive the daemon, re-register on their next heartbeat, * and their sessions are expected to still be listed active afterwards. * Closing all of them would have broken that contract (and did, two existing * restart tests caught it). So the predicate is the same one the idle reaper * uses: a session already past its idle window at the moment the store is * loaded, with no message traffic and no fresh participant, is an orphan. * Anything newer than its window is a live session mid-restart and is left * alone. * * The value over waiting for the periodic reaper is honesty and timing. The * reaper runs on a 60s timer and would eventually close the ghost as * 'idle-reaped', which says the conversation went quiet, when what actually * happened is the process holding it died. This closes it at boot and says so. * * Records are CLOSED WITH A REASON, never deleted: silent deletion is * indistinguishable from data loss. And `'boot-orphaned'` is a SYSTEM close, so * a surface that really is still alive reopens automatically on its next * register heartbeat (see isSystemClosedSession), a false positive costs one * reopen, never a conversation. */ export declare function closeOrphanedSessionsAtBoot(sessions: Map, options: BootOrphanSweepOptions): OrphanedSessionClosure[]; /** * Run the boot orphan sweep and DISCLOSE what it closed. * * Lives here rather than in the broker so the sweep, its reason vocabulary and * its disclosure stay in one file, and so the broker (a grandfathered * shrink-only monolith) takes one call rather than a block. * * Disclosure is not optional: these sessions close without anyone asking, so * the log line naming each one is the only thing standing between "the system * tidied up after a dead process" and "my session vanished". */ export declare function applyBootOrphanSweep(sessions: Map, options: BootOrphanSweepOptions, publishUpdate: (event: string, payload: unknown) => void): readonly OrphanedSessionClosure[]; //# sourceMappingURL=session-broker-gc.d.ts.map