From a211b8c787cac5a0f4f2c0cb258696fb1597cb10 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:28:29 -0700 Subject: [PATCH] fix(agent-status): prune persisted idle Claude children --- src/main/agent-hooks/server.test.ts | 52 +++++++++++++++++++++++++++++ src/main/agent-hooks/server.ts | 23 +++++++++++-- src/main/claude/hook-settings.ts | 4 +-- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 7423be836dd..4296d5ce081 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -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') diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 513735bd679..d3bca6f60cf 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -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, diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 0c7012b383a..8392d07f1b9 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -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: '' }] } },