fix(agent-status): cap the live agent-status map to stop Windows renderer OOM (#9872)

The live `agentStatusByPaneKey` map had no size cap. `setAgentStatus` rewrites it
with a full spread copy on every status ping and keeps rows until a pane/tab
teardown event removes them; a missed teardown (agent killed without a Stop hook,
pane/tab closed while its status lingered) orphans a row forever. On long
multi-agent sessions orphaned heavy rows accumulate without bound, and because
each ping spread-copies the whole map, once it is ~1.9 GB one ping transiently
doubles it past the 3586 MB old-space limit -> renderer crash (exit -36861).

Cap the live map at MAX_LIVE_AGENT_STATUSES=500, shedding only rows whose pane is
provably gone (a mounted tab's rooted layout no longer lists the leaf) or long
idle, and never an open pane's row (any state, incl. needs-input waiting/blocked).
Rootless/empty-snapshot, not-yet-hydrated, and no-renderer-tab (orchestration
worker) rows are unprovable: kept while a fresh agent could own them, shed only
past the stale window or by a hard-cap fallback that guarantees the bound.
Eviction bumps the status/sort epochs so the retention sync snapshots disappeared
done rows. Cost is one Object.keys length check on the reducer's already-O(n)
spread under the cap; the layout walk + sweeps run only on the rare over-cap ping.
This commit is contained in:
OrcaWin
2026-07-22 13:54:43 -07:00
committed by Neil
parent 53e018a1a6
commit 865187503e
2 changed files with 422 additions and 4 deletions
@@ -0,0 +1,326 @@
/** Regression for #9872: missed pane teardown must not let heavy live status rows grow unbounded. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
AGENT_STATUS_STALE_AFTER_MS,
type ParsedAgentStatusPayload
} from '../../../../shared/agent-status-types'
import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume'
import { MAX_LIVE_AGENT_STATUSES } from './agent-status'
import { createTestStore, makeTab, makeWorktree } from './store-test-helpers'
import type { AppState } from '../types'
// Use the production payload limits so the regression represents the leaked byte weight.
const BIG_ASSISTANT_MESSAGE = 'a'.repeat(8 * 1024)
const BIG_INTERACTIVE_PROMPT = 'q'.repeat(16 * 1024)
function seedWorktree(store: ReturnType<typeof createTestStore>): void {
store.setState({
repos: [
{
id: 'repo-1',
path: '/repo',
displayName: 'Repo',
badgeColor: '#999999',
addedAt: 1,
kind: 'git'
}
],
worktreesByRepo: {
'repo-1': [makeWorktree({ id: 'wt-1', repoId: 'repo-1', path: '/repo/wt-1' })]
},
tabsByWorktree: {
'wt-1': [makeTab({ id: 'tab-live', worktreeId: 'wt-1' })]
},
terminalLayoutsByTabId: {
'tab-live': {
root: { type: 'leaf', leafId: 'leaf-live' },
activeLeafId: 'leaf-live',
expandedLeafId: null
}
}
} as Partial<AppState>)
}
function donePayload(index: number): ParsedAgentStatusPayload {
return {
state: 'done',
prompt: `prompt ${index}`,
agentType: 'claude',
lastAssistantMessage: BIG_ASSISTANT_MESSAGE,
interactivePrompt: BIG_INTERACTIVE_PROMPT
} as ParsedAgentStatusPayload
}
function workingPayload(index: number): ParsedAgentStatusPayload {
return {
state: 'working',
prompt: `busy ${index}`,
agentType: 'claude'
} as ParsedAgentStatusPayload
}
function sleepingRecord(paneKey: string): SleepingAgentSessionRecord {
return {
paneKey,
tabId: paneKey.slice(0, paneKey.indexOf(':')),
worktreeId: 'wt-1',
agent: 'claude',
providerSession: { key: 'session_id', id: `session-${paneKey}` },
prompt: '',
state: 'working',
capturedAt: 1,
updatedAt: 1,
origin: 'live'
}
}
function setAgentAt(
store: ReturnType<typeof createTestStore>,
paneKey: string,
payload: ParsedAgentStatusPayload,
updatedAt?: number
): void {
const tabId = paneKey.slice(0, paneKey.indexOf(':'))
store
.getState()
.setAgentStatus(
paneKey,
payload,
undefined,
updatedAt === undefined ? undefined : { updatedAt },
{
tabId,
worktreeId: 'wt-1'
}
)
}
/** Add rows for leaves absent from a mounted tab's rooted layout. */
function churnDeadLeaves(
store: ReturnType<typeof createTestStore>,
count: number,
makePayload: (i: number) => ParsedAgentStatusPayload = donePayload
): void {
for (let i = 0; i < count; i++) {
setAgentAt(store, `tab-live:dead-${i}`, makePayload(i))
}
}
/** Add fresh rows whose pane liveness cannot be proven from renderer layouts. */
function churnFreshUnprovable(store: ReturnType<typeof createTestStore>, count: number): void {
for (let i = 0; i < count; i++) {
setAgentAt(store, `gone-${i}:leaf-${i}`, workingPayload(i))
}
}
describe('agentStatusByPaneKey stays bounded (leak regression #9872)', () => {
afterEach(() => {
vi.useRealTimers()
})
it('caps the live map at the limit, evicting the oldest dead-leaf orphans', () => {
const store = createTestStore()
seedWorktree(store)
const total = MAX_LIVE_AGENT_STATUSES + 1500
churnDeadLeaves(store, total)
const live = store.getState().agentStatusByPaneKey
expect(Object.keys(live).length).toBe(MAX_LIVE_AGENT_STATUSES)
expect(live[`tab-live:dead-${total - 1}`]).toBeDefined()
expect(live['tab-live:dead-0']).toBeUndefined()
})
it("never evicts a live pane's working agent", () => {
const store = createTestStore()
seedWorktree(store)
setAgentAt(store, 'tab-live:leaf-live', workingPayload(0))
churnDeadLeaves(store, MAX_LIVE_AGENT_STATUSES + 1500)
expect(store.getState().agentStatusByPaneKey['tab-live:leaf-live']?.state).toBe('working')
})
it("never evicts a live pane's waiting or blocked row, even under the hard-cap fallback", () => {
const store = createTestStore()
seedWorktree(store)
store.setState({
terminalLayoutsByTabId: {
'tab-live': {
root: { type: 'leaf', leafId: 'leaf-live' },
activeLeafId: 'leaf-live',
expandedLeafId: null
},
'tab-w': {
root: { type: 'leaf', leafId: 'leaf-w' },
activeLeafId: 'leaf-w',
expandedLeafId: null
},
'tab-b': {
root: { type: 'leaf', leafId: 'leaf-b' },
activeLeafId: 'leaf-b',
expandedLeafId: null
}
}
} as Partial<AppState>)
setAgentAt(store, 'tab-w:leaf-w', {
state: 'waiting',
prompt: 'needs input',
agentType: 'claude'
} as ParsedAgentStatusPayload)
setAgentAt(store, 'tab-b:leaf-b', {
state: 'blocked',
prompt: 'perm prompt',
agentType: 'claude'
} as ParsedAgentStatusPayload)
churnFreshUnprovable(store, MAX_LIVE_AGENT_STATUSES + 1500)
const live = store.getState().agentStatusByPaneKey
expect(live['tab-w:leaf-w']?.state).toBe('waiting')
expect(live['tab-b:leaf-b']?.state).toBe('blocked')
})
it('bounds the map when fresh unprovable rows dominate, without evicting a live pane', () => {
const store = createTestStore()
seedWorktree(store)
setAgentAt(store, 'tab-live:leaf-live', donePayload(-1))
churnFreshUnprovable(store, MAX_LIVE_AGENT_STATUSES + 200)
const live = store.getState().agentStatusByPaneKey
expect(Object.keys(live).length).toBe(MAX_LIVE_AGENT_STATUSES)
expect(live['tab-live:leaf-live']?.state).toBe('done')
})
it('keeps fresh rows of rootless / empty-snapshot / no-renderer-tab panes (live agents, #2962)', () => {
const store = createTestStore()
seedWorktree(store)
store.setState({
tabsByWorktree: {
'wt-1': [
makeTab({ id: 'tab-live', worktreeId: 'wt-1' }),
makeTab({ id: 'bg-empty', worktreeId: 'wt-1' })
]
},
terminalLayoutsByTabId: {
'tab-live': {
root: { type: 'leaf', leafId: 'leaf-live' },
activeLeafId: 'leaf-live',
expandedLeafId: null
},
'bg-empty': { root: null, activeLeafId: null, expandedLeafId: null },
'bg-bound': {
root: null,
activeLeafId: 'leaf-bound',
expandedLeafId: null,
ptyIdsByLeafId: { 'leaf-bound': 'pty-1' }
}
}
} as Partial<AppState>)
setAgentAt(store, 'bg-empty:leaf-bg', {
state: 'waiting',
prompt: 'needs input',
agentType: 'claude'
} as ParsedAgentStatusPayload)
setAgentAt(store, 'bg-bound:leaf-bound', donePayload(-2))
setAgentAt(store, 'worker-tab:leaf-worker', workingPayload(-3))
churnDeadLeaves(store, MAX_LIVE_AGENT_STATUSES + 200)
const live = store.getState().agentStatusByPaneKey
expect(live['bg-empty:leaf-bg']?.state).toBe('waiting')
expect(live['bg-bound:leaf-bound']?.state).toBe('done')
expect(live['worker-tab:leaf-worker']?.state).toBe('working')
})
it('evicts an idle unprovable orphan once past the stale window, keeping fresh ones', () => {
vi.useFakeTimers()
const late = new Date('2026-07-22T00:00:00.000Z').getTime()
vi.setSystemTime(late)
const store = createTestStore()
seedWorktree(store)
setAgentAt(store, 'gone-stale:leaf', workingPayload(0), late - AGENT_STATUS_STALE_AFTER_MS - 1)
for (let i = 0; i < MAX_LIVE_AGENT_STATUSES; i++) {
setAgentAt(store, `gone-fresh-${i}:leaf`, workingPayload(i), late)
}
const live = store.getState().agentStatusByPaneKey
expect(Object.keys(live).length).toBe(MAX_LIVE_AGENT_STATUSES)
expect(live['gone-stale:leaf']).toBeUndefined()
expect(live['gone-fresh-0:leaf']?.state).toBe('working')
})
it('under cap: no eviction', () => {
const store = createTestStore()
seedWorktree(store)
churnDeadLeaves(store, 10)
expect(Object.keys(store.getState().agentStatusByPaneKey).length).toBe(10)
})
it('purges recovery and launch records for evicted pane keys', () => {
const store = createTestStore()
seedWorktree(store)
churnDeadLeaves(store, MAX_LIVE_AGENT_STATUSES)
const evictedPaneKey = 'tab-live:dead-0'
const keptPaneKey = 'tab-live:dead-1'
store.setState({
sleepingAgentSessionsByPaneKey: {
[evictedPaneKey]: sleepingRecord(evictedPaneKey),
[keptPaneKey]: sleepingRecord(keptPaneKey)
},
agentLaunchConfigByPaneKey: {
[evictedPaneKey]: {
launchConfig: { agentArgs: '', agentEnv: {} },
registeredAt: 1,
identity: {}
},
[keptPaneKey]: {
launchConfig: { agentArgs: '', agentEnv: {} },
registeredAt: 1,
identity: {}
}
}
} as Partial<AppState>)
setAgentAt(store, 'tab-live:dead-extra', donePayload(MAX_LIVE_AGENT_STATUSES))
const state = store.getState()
expect(state.agentStatusByPaneKey[evictedPaneKey]).toBeUndefined()
expect(state.sleepingAgentSessionsByPaneKey[evictedPaneKey]).toBeUndefined()
expect(state.agentLaunchConfigByPaneKey[evictedPaneKey]).toBeUndefined()
expect(state.sleepingAgentSessionsByPaneKey[keptPaneKey]).toBeDefined()
expect(state.agentLaunchConfigByPaneKey[keptPaneKey]).toBeDefined()
})
it('bumps epochs when eviction is the only sort-relevant change', () => {
const store = createTestStore()
seedWorktree(store)
churnDeadLeaves(store, MAX_LIVE_AGENT_STATUSES, workingPayload)
const existingPaneKey = 'tab-live:dead-0'
const existing = store.getState().agentStatusByPaneKey[existingPaneKey]
store.setState({
agentStatusByPaneKey: {
...store.getState().agentStatusByPaneKey,
'tab-live:dead-extra': {
...existing,
paneKey: 'tab-live:dead-extra'
}
}
} as Partial<AppState>)
const statusEpochBefore = store.getState().agentStatusEpoch
const sortEpochBefore = store.getState().sortEpoch
setAgentAt(store, existingPaneKey, workingPayload(0), existing.updatedAt)
const state = store.getState()
expect(Object.keys(state.agentStatusByPaneKey).length).toBe(MAX_LIVE_AGENT_STATUSES)
expect(state.agentStatusEpoch).toBe(statusEpochBefore + 1)
expect(state.sortEpoch).toBe(sortEpochBefore + 1)
})
})
+96 -4
View File
@@ -26,7 +26,7 @@ import {
shouldSuppressInheritedTerminalStatus
} from '../../../../shared/agent-status-identity'
import { isCommandCodeNewTurnWhileWorking } from '../../../../shared/command-code-turn-boundary'
import type { TerminalTab } from '../../../../shared/types'
import type { TerminalPaneLayoutNode, TerminalTab } from '../../../../shared/types'
import {
getRepoExecutionHostId,
getWorktreeExecutionHostId
@@ -268,6 +268,82 @@ function capRetainedAgents(
return capped
}
// Why: missed pane teardown can leak heavy live rows in any state and amplify every status-map copy (#9872).
export const MAX_LIVE_AGENT_STATUSES = 500
type PaneLiveness = 'live' | 'dead' | 'unprovable'
// Why: only a rooted tab proves which leaves are mounted; rootless and headless rows may still be live (#2962).
function classifyPaneKeyLiveness(state: AppState): (paneKey: string) => PaneLiveness {
const rootedLeafKeys = new Set<string>()
const rootedTabIds = new Set<string>()
for (const [tabId, layout] of Object.entries(state.terminalLayoutsByTabId)) {
if (!layout?.root) {
continue
}
rootedTabIds.add(tabId)
const stack: TerminalPaneLayoutNode[] = [layout.root]
while (stack.length > 0) {
const node = stack.pop()!
if (node.type === 'leaf') {
rootedLeafKeys.add(`${tabId}:${node.leafId}`)
} else {
stack.push(node.first, node.second)
}
}
}
return (paneKey) => {
if (rootedLeafKeys.has(paneKey)) {
return 'live'
}
const tabId = getTabIdFromPaneKey(paneKey)
return tabId !== null && rootedTabIds.has(tabId) ? 'dead' : 'unprovable'
}
}
// Why: mutate the caller-owned spread so eviction does not allocate another heavy-map copy.
function capLiveAgentStatusesInPlace(
freshLive: Record<string, AgentStatusEntry>,
protectedPaneKey: string,
buildClassifier: () => (paneKey: string) => PaneLiveness,
now: number,
maxEntries = MAX_LIVE_AGENT_STATUSES
): string[] {
const keys = Object.keys(freshLive)
let overflow = keys.length - maxEntries
if (overflow <= 0) {
return []
}
const classify = buildClassifier()
const evictedPaneKeys: string[] = []
const sweep = (canEvict: (liveness: PaneLiveness, entry: AgentStatusEntry) => boolean): void => {
for (const key of keys) {
if (overflow <= 0) {
break
}
if (key === protectedPaneKey || !(key in freshLive)) {
continue
}
const liveness = classify(key)
if (liveness === 'live' || !canEvict(liveness, freshLive[key])) {
continue
}
delete freshLive[key]
overflow -= 1
evictedPaneKeys.push(key)
}
}
// Prefer rows that are provably dead or too stale to represent a live agent.
sweep(
(liveness, entry) => liveness === 'dead' || now - entry.updatedAt > AGENT_STATUS_STALE_AFTER_MS
)
// Shed fresh unprovable rows only when needed; rooted live panes make this a soft cap.
if (overflow > 0) {
sweep(() => true)
}
return evictedPaneKeys
}
function paneKeyMatchesAnyTabPrefix(paneKey: string, tabPrefixes: string[]): boolean {
for (const prefix of tabPrefixes) {
if (paneKey.startsWith(prefix)) {
@@ -1916,19 +1992,35 @@ export const createAgentStatusSlice: StateCreator<AppState, [], [], AgentStatusS
nextSleepingAgentSessions = { ...s.sleepingAgentSessionsByPaneKey }
delete nextSleepingAgentSessions[paneKey]
}
const nextLive = { ...s.agentStatusByPaneKey, [paneKey]: entry }
// Why: cap the live map so a huge map's per-ping spread copy can't OOM the renderer (#9872).
const evictedPaneKeys = capLiveAgentStatusesInPlace(
nextLive,
paneKey,
() => classifyPaneKeyLiveness(s),
updatedAt
)
const evictedOrphans = evictedPaneKeys.length > 0
if (evictedOrphans) {
const evictedPaneKeySet = new Set(evictedPaneKeys)
nextSleepingAgentSessions = removePaneKeys(nextSleepingAgentSessions, evictedPaneKeySet)
nextLaunchConfigs = removePaneKeys(nextLaunchConfigs, evictedPaneKeySet)
}
return {
agentStatusByPaneKey: { ...s.agentStatusByPaneKey, [paneKey]: entry },
agentStatusByPaneKey: nextLive,
retainedAgentsByPaneKey: nextRetainedAgents,
sleepingAgentSessionsByPaneKey: nextSleepingAgentSessions,
agentLaunchConfigByPaneKey: nextLaunchConfigs,
migrationUnsupportedByPtyId: migrationUnsupported.next,
retentionSuppressedPaneKeys: nextRetentionSuppressedPaneKeys,
agentStatusEpoch:
retentionRelevantChange || migrationUnsupported.changed
retentionRelevantChange || migrationUnsupported.changed || evictedOrphans
? s.agentStatusEpoch + 1
: s.agentStatusEpoch,
sortEpoch:
sortRelevantChange || migrationUnsupported.changed ? s.sortEpoch + 1 : s.sortEpoch
sortRelevantChange || migrationUnsupported.changed || evictedOrphans
? s.sortEpoch + 1
: s.sortEpoch
}
})
if (suppressedInheritedTerminalStatus) {