mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 16:03:26 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user