fix: reduce session.messages() calls with event-based caching to prevent memory leaks

- Replace session.messages() fetch in context-window-monitor with message.updated event cache
- Replace session.messages() fetch in preemptive-compaction with message.updated event cache
- Add per-session transcript cache (5min TTL) to avoid full rebuild per tool call
- Remove session.messages() from background-agent polling (use event-based progress)
- Add TTL pruning to todo-continuation-enforcer session state Map
- Add setInterval.unref() to tool-input-cache cleanup timer

Fixes #1222
This commit is contained in:
popododo0720
2026-02-12 11:38:11 +09:00
parent e4be8cea75
commit eb56701996
9 changed files with 703 additions and 374 deletions
@@ -1,33 +1,69 @@
import type { SessionState } from "./types"
// TTL for idle session state entries (10 minutes)
const SESSION_STATE_TTL_MS = 10 * 60 * 1000
// Prune interval (every 2 minutes)
const SESSION_STATE_PRUNE_INTERVAL_MS = 2 * 60 * 1000
interface TrackedSessionState {
state: SessionState
lastAccessedAt: number
}
export interface SessionStateStore {
getState: (sessionID: string) => SessionState
getExistingState: (sessionID: string) => SessionState | undefined
cancelCountdown: (sessionID: string) => void
cleanup: (sessionID: string) => void
cancelAllCountdowns: () => void
shutdown: () => void
}
export function createSessionStateStore(): SessionStateStore {
const sessions = new Map<string, SessionState>()
const sessions = new Map<string, TrackedSessionState>()
// Periodic pruning of stale session states to prevent unbounded Map growth
let pruneInterval: ReturnType<typeof setInterval> | undefined
pruneInterval = setInterval(() => {
const now = Date.now()
for (const [sessionID, tracked] of sessions.entries()) {
if (now - tracked.lastAccessedAt > SESSION_STATE_TTL_MS) {
cancelCountdown(sessionID)
sessions.delete(sessionID)
}
}
}, SESSION_STATE_PRUNE_INTERVAL_MS)
// Allow process to exit naturally even if interval is running
if (typeof pruneInterval === "object" && "unref" in pruneInterval) {
pruneInterval.unref()
}
function getState(sessionID: string): SessionState {
const existingState = sessions.get(sessionID)
if (existingState) return existingState
const existing = sessions.get(sessionID)
if (existing) {
existing.lastAccessedAt = Date.now()
return existing.state
}
const state: SessionState = {}
sessions.set(sessionID, state)
sessions.set(sessionID, { state, lastAccessedAt: Date.now() })
return state
}
function getExistingState(sessionID: string): SessionState | undefined {
return sessions.get(sessionID)
const existing = sessions.get(sessionID)
if (existing) {
existing.lastAccessedAt = Date.now()
return existing.state
}
return undefined
}
function cancelCountdown(sessionID: string): void {
const state = sessions.get(sessionID)
if (!state) return
const tracked = sessions.get(sessionID)
if (!tracked) return
const state = tracked.state
if (state.countdownTimer) {
clearTimeout(state.countdownTimer)
state.countdownTimer = undefined
@@ -52,11 +88,18 @@ export function createSessionStateStore(): SessionStateStore {
}
}
function shutdown(): void {
clearInterval(pruneInterval)
cancelAllCountdowns()
sessions.clear()
}
return {
getState,
getExistingState,
cancelCountdown,
cleanup,
cancelAllCountdowns,
shutdown,
}
}