fix(agent-status): prune persisted idle Claude children

This commit is contained in:
Brennan Benson
2026-07-14 23:28:29 -07:00
parent 80f7511aa5
commit a211b8c787
3 changed files with 75 additions and 4 deletions
+52
View File
@@ -6046,6 +6046,58 @@ describe('Last-status persistence', () => {
}
})
it('drops persisted idle Claude child rows from hydration replay', async () => {
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
const receivedAt = recentTs()
writeFileSync(
lastStatusPath(),
JSON.stringify({
version: 2,
entries: {
[PANE]: {
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
receivedAt,
stateStartedAt: recentTs(-1000),
payload: {
state: 'done',
prompt: 'finished orchestration',
agentType: 'claude',
subagents: [
{ id: 'aweb-research-8a76b7d7', state: 'idle', startedAt: receivedAt - 5000 },
{ id: 'apr-history-9b87c6e6', state: 'idle', startedAt: receivedAt - 4000 }
]
}
}
}
}),
'utf8'
)
const server = new AgentHookServer()
await server.start({ env: 'production', userDataPath })
try {
const listener = vi.fn()
server.setListener(listener)
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
payload: expect.objectContaining({ subagents: undefined })
})
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
state: 'done',
prompt: 'finished orchestration',
subagents: undefined
})
])
} finally {
server.stop()
}
})
it('treats a corrupt file as empty hydration without throwing', async () => {
mkdirSync(join(userDataPath, 'agent-hooks'), { recursive: true })
writeFileSync(lastStatusPath(), 'not-json{{', 'utf8')
+21 -2
View File
@@ -174,6 +174,24 @@ export function isValidPaneKey(value: unknown): value is string {
)
}
function dropHydratedIdleClaudeSubagents(
payload: ParsedAgentStatusPayload
): ParsedAgentStatusPayload {
if (
payload.agentType !== 'claude' ||
!payload.subagents?.some((subagent) => subagent.state === 'idle')
) {
return payload
}
const workingSubagents = payload.subagents.filter((subagent) => subagent.state === 'working')
// Why: older builds persisted finished Claude children as idle rows. Prune
// them from the replay payload itself so restart cannot resurrect the pile.
return {
...payload,
subagents: workingSubagents.length > 0 ? workingSubagents : undefined
}
}
function sanitizeHydratedEntry(
paneKey: string,
rawEntry: unknown
@@ -226,10 +244,11 @@ function sanitizeHydratedEntry(
} else {
return null
}
const payload = normalizeAgentStatusPayload(record.payload)
if (!payload) {
const normalizedPayload = normalizeAgentStatusPayload(record.payload)
if (!normalizedPayload) {
return null
}
const payload = dropHydratedIdleClaudeSubagents(normalizedPayload)
return {
paneKey,
launchToken: typeof record.launchToken === 'string' ? record.launchToken : undefined,
+2 -2
View File
@@ -34,8 +34,8 @@ export const CLAUDE_EVENTS = [
{ eventName: 'StopFailure', definition: { hooks: [{ type: 'command', command: '' }] } },
// Why: subagent/teammate lifecycle feeds the sidebar's child rows and keeps
// a pane 'working' while background children outlive the lead's turn.
// TeammateIdle is required because idle-but-alive teammates still report
// status "running" in Stop's background_tasks.
// TeammateIdle retires the working-only row when SubagentStop is lost;
// idle teammates still report status "running" in Stop's background_tasks.
// Older Claude builds ignore unregistered event names (StopFailure precedent).
{ eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } },
{ eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } },