diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index 480214cd285..b776060dc0e 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -3269,9 +3269,11 @@ describe('shared agent-hook-listener', () => { expect(drained?.payload.state).toBe('done') }) - it('clears a fresh-pane teammate wait card with done when its owner parks', () => { - // Why: the wait is identity-owned; its owner parking must clear the amber - // card instead of leaving it fresh for the stale sweeper. + it('stays silent when a fresh-pane teammate wait owner parks with its row still tracked', () => { + // Why: a parked owner is indistinguishable from a delayed prior-turn stop + // against a newer start of the same reused id — claiming done could retire + // newer real work (the #11353 race). The stale amber card is bounded by + // the stale sweeper instead. claudeEvent({ hook_event_name: 'PermissionRequest', agent_id: 'alane-6d3cb5b52120b7bf', @@ -3282,7 +3284,56 @@ describe('shared agent-hook-listener', () => { hook_event_name: 'SubagentStop', agent_id: 'alane-6d3cb5b52120b7bf' }) - expect(parked?.payload.state).toBe('done') + expect(parked).toBeNull() + }) + + it('never retires a newer same-id start via a delayed prior-turn stop clearing its wait', () => { + claudeEvent({ + hook_event_name: 'PermissionRequest', + agent_id: 'alane-6d3cb5b52120b7bf', + agent_type: 'lane', + tool_name: 'Bash' + }) + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'alane-6d3cb5b52120b7bf', + agent_type: 'lane' + }) + const staleStop = claudeEvent({ + hook_event_name: 'SubagentStop', + agent_id: 'alane-6d3cb5b52120b7bf' + }) + expect(staleStop?.payload.state ?? null).not.toBe('done') + }) + + it('resolves a drain to done when over-length ids fill the inventory', () => { + // Why: untrackable ids must not consume parser cap slots — 33 of them is + // not a truncated inventory, just noise. + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'a1', + agent_type: 'general-purpose' + }) + const junk = Array.from({ length: 33 }, (_, i) => ({ + id: 'x'.repeat(65) + i, + type: 'subagent', + status: 'running' + })) + const drained = claudeEvent({ + hook_event_name: 'SubagentStop', + agent_id: 'a1', + background_tasks: junk + }) + expect(drained?.payload.state).toBe('done') + }) + + it('does not mint a waiting card for an untrackable child id', () => { + const waiting = claudeEvent({ + hook_event_name: 'PermissionRequest', + agent_id: 'w'.repeat(65), + tool_name: 'Bash' + }) + expect(waiting).toBeNull() }) it('resolves a fresh-pane child permission wait whose child dies to done, not phantom working', () => { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index a2c5fa8a2ab..c748d484ead 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2499,9 +2499,19 @@ function normalizeClaudeSubagentLifecycleEvent( ) } } + // Why: a wait owner that still has a roster row was PARKED, not finished — a delayed stop from + // a prior turn can park a newly working reused id, so claiming done there would retire newer + // real work (the #11353 race). The clearing done fires only for an owner with no row left; + // a parked owner's stale amber card is bounded by the stale sweeper instead. + const waitOwnerStillTracked = + clearedOwnWait === 'deleted' && + roster !== undefined && + (eventName === 'TeammateIdle' + ? [...roster.keys()].some((id) => claudeTeammateIdMatchesName(id, lifecycleId)) + : roster.has(lifecycleId)) return buildClaudeChildDrivenStatusPayload(state, eventName, paneKey, hookPayload, { removedRow, - clearedOwnWait: clearedOwnWait === 'deleted' + clearedOwnWait: clearedOwnWait === 'deleted' && !waitOwnerStillTracked }) } @@ -2731,6 +2741,11 @@ function normalizeClaudeEvent( // Why: subagent/teammate events carry `agent_id` (lead's don't); child tool activity keeps its row live but must not become the lead's state or overwrite its tool/prompt caches (a live card would vanish). // Two exceptions take the full path below: waiting-inducing events (a child needs human attention on this pane) and the blocked child's own next tool event (approval granted — clear the wait as for the lead). const isWaitingInducing = stateName === 'waiting' + // Why: an untrackable child id can neither appear in the roster nor reliably clear its wait — + // recording it would mint an unresolvable amber card (STA-2915 review). + if (isWaitingInducing && eventAgentId && !isValidClaudeSubagentId(eventAgentId)) { + return null + } const subagentOriginId = !isWaitingInducing && (eventName === 'PreToolUse' || diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index 250127d4103..3217e0f1ec2 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -191,7 +191,12 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record= AGENT_STATUS_MAX_SUBAGENTS) {