diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index ef201bfe5..06dfb1515 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -166,7 +166,7 @@ function dropHydratedIdleClaudeSubagents( return payload } const activeSubagents = payload.subagents.filter((subagent) => subagent.state !== 'idle') - // Why: older builds persisted finished Claude children as idle rows; prune them so restart can't resurrect the pile. + // Why: an idle teammate's liveness can't be proven across a restart (its TeammateIdle confirmation is in-memory); prune so a dead pile can't resurrect — a live teammate re-earns its row via SubagentStart. return { ...payload, subagents: activeSubagents.length > 0 ? activeSubagents : undefined diff --git a/src/main/claude/hook-settings.ts b/src/main/claude/hook-settings.ts index 1059a9a09..b13c98ed6 100644 --- a/src/main/claude/hook-settings.ts +++ b/src/main/claude/hook-settings.ts @@ -35,8 +35,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 retires the working-only row when SubagentStop is lost; - // idle teammates still report status "running" in Stop's background_tasks. + // TeammateIdle parks turn-based teammates without trusting their permanently + // "running" background_tasks entry to gate the pane. // Older Claude builds ignore unregistered event names (StopFailure precedent). { eventName: 'SubagentStart', definition: { hooks: [{ type: 'command', command: '' }] } }, { eventName: 'SubagentStop', definition: { hooks: [{ type: 'command', command: '' }] } }, diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index cc99a8078..c96de6e0e 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -2698,12 +2698,12 @@ describe('shared agent-hook-listener', () => { expect(stopped?.payload.state).toBe('done') }) - it('removes a finished teammate/named agent on SubagentStop despite its task reading running', () => { - // Why: the interactive agent-teams / orchestration shape observed live — + it('parks a teammate as a persistent idle row across its stop/idle/lead-Stop cycle', () => { + // Why: the interactive agent-teams shape observed live on 2.1.217 — // lifecycle events use `a-` agent ids while background_tasks - // uses unrelated `type: "teammate"` task ids that report "running" - // forever, even after the named agent finished. The finished row must - // leave the sidebar at once (the reported "long idle list" symptom). + // uses unrelated `type: "teammate"` task ids. SubagentStop + TeammateIdle + // fire at every TURN end while the teammate stays alive awaiting mail, + // so the row must park idle and survive lead Stops, not vanish. claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'spawn probe' }) claudeEvent({ hook_event_name: 'SubagentStart', @@ -2725,15 +2725,16 @@ describe('shared agent-hook-listener', () => { expect.objectContaining({ id: 'aprobe1-6d3cb5b52120b7bf', state: 'working' }) ]) - // SubagentStop is the reliable finish signal — the row goes even though - // its teammate task is still listed "running". + // Turn boundary: the row parks idle instead of leaving the sidebar. const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'aprobe1-6d3cb5b52120b7bf', agent_type: 'probe1', background_tasks: [teammateTask] }) - expect(stopped?.payload.subagents).toBeUndefined() + expect(stopped?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'aprobe1-6d3cb5b52120b7bf', state: 'idle' }) + ]) claudeEvent({ hook_event_name: 'TeammateIdle', @@ -2741,15 +2742,19 @@ describe('shared agent-hook-listener', () => { team_name: 'session-56c87269' }) + // The confirmed idle row survives the lead Stop (its teammate task is + // still listed) without pinning the pane working. const wakeStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [teammateTask] }) expect(wakeStop?.payload.state).toBe('done') - expect(wakeStop?.payload.subagents).toBeUndefined() + expect(wakeStop?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'aprobe1-6d3cb5b52120b7bf', state: 'idle' }) + ]) }) - it('removes a working teammate via TeammateIdle when its id prefix matches the name', () => { + it('parks a working teammate via TeammateIdle when its id prefix matches the name', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'spawn reviewer' }) claudeEvent({ hook_event_name: 'SubagentStart', @@ -2765,15 +2770,17 @@ describe('shared agent-hook-listener', () => { // Why: teammate name and agent type are separate Agent-tool inputs; the // lifecycle id embeds the former while the hook reports the latter. - // TeammateIdle keyed by name reaps it via the id prefix (fallback when - // its SubagentStop was lost), so the finished row leaves and the pane - // can settle back to the lead's done state. + // TeammateIdle keyed by name parks it via the id prefix (fallback when + // its SubagentStop was lost), so the pane settles back to the lead's + // done state while the row stays visible as idle. const idled = claudeEvent({ hook_event_name: 'TeammateIdle', teammate_name: 'reviewer', team_name: 'session-x' }) - expect(idled?.payload.subagents).toBeUndefined() + expect(idled?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'areviewer-6d3cb5b52120b7bf', state: 'idle' }) + ]) expect(idled?.payload.state).toBe('done') }) @@ -3018,8 +3025,10 @@ describe('shared agent-hook-listener', () => { teammate_name: 'lane-hooks', team_name: 'session-x' }) - // Why: idle means finished — the exact-name match reaps the row. - expect(idled?.payload.subagents).toBeUndefined() + // Why: the exact-name match parks the row idle (turn over, still alive). + expect(idled?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'alane-hooks-6d3cb5b5', state: 'idle' }) + ]) }) it('keeps an inferred interrupt terminal across later child lifecycle events', () => { diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index 009ed2799..66e28b64d 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -32,10 +32,10 @@ import { claudeRosterHasWorkingSubagent, claudeRosterToSnapshots, claudeTeammateIdMatchesName, - finishClaudeSubagent, foldClaudeBackgroundTasksIntoRoster, + idleClaudeTeammateByName, readClaudeBackgroundAgentTasks, - removeClaudeTeammateByName, + stopClaudeSubagent, upsertWorkingClaudeSubagent, type ClaudeSubagentRoster } from './claude-subagent-roster' @@ -2350,8 +2350,8 @@ function normalizeClaudeSubagentLifecycleEvent( if (!teammateName) { return null } - // Why: only working children keep a row; TeammateIdle is the fallback finish signal when a named agent's SubagentStop was lost (its background_tasks never stops reading "running"). - removeClaudeTeammateByName(roster, teammateName) + // Why: on claude 2.1.21x teammates are turn-based — TeammateIdle means "turn over, awaiting mail", not finished. The row parks as idle (confirmed teammate) instead of leaving, so the sidebar keeps showing resumable children. + idleClaudeTeammateByName(roster, teammateName) clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => claudeTeammateIdMatchesName(waitingAgentId, teammateName) ) @@ -2368,8 +2368,8 @@ function normalizeClaudeSubagentLifecycleEvent( Date.now() ) } else { - // Why: SubagentStop is the reliable finish signal even for teammate-shaped ids (their background_tasks stay "running" forever); a resumed teammate re-earns its row. - finishClaudeSubagent(roster, agentId) + // Why: one-shot stops are true finishes (row removed); teammate-shaped stops are turn ends on 2.1.21x — the row parks idle and a later SubagentStart revives it. + stopClaudeSubagent(roster, agentId) // Why: a blocked child that dies without another tool event would pin its permission/question wait on the pane forever — nothing else references that agent again. clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) => waitingAgentId === agentId) } @@ -2393,11 +2393,12 @@ export function seedClaudeSubagentRosterFromSnapshots( } const roster = getOrCreateClaudeSubagentRoster(state, paneKey) for (const snapshot of snapshots) { - // Why: the roster tracks only working children now; a persisted idle snapshot (from a build that kept idle rows) is finished — drop it so restart doesn't resurrect the stale pile. + // Why: idle-teammate liveness can't be proven across a restart (its TeammateIdle confirmation is gone); only working seeds restore, and a live teammate re-earns its row via SubagentStart. if (snapshot.state !== 'working') { continue } roster.set(snapshot.id, { + state: 'working', startedAt: snapshot.startedAt, agentType: snapshot.agentType, description: snapshot.description, diff --git a/src/shared/claude-subagent-roster.test.ts b/src/shared/claude-subagent-roster.test.ts index 7d91bd455..4c5502309 100644 --- a/src/shared/claude-subagent-roster.test.ts +++ b/src/shared/claude-subagent-roster.test.ts @@ -4,10 +4,10 @@ import { claudeRosterHasWorkingSubagent, claudeRosterToSnapshots, claudeTeammateIdMatchesName, - finishClaudeSubagent, foldClaudeBackgroundTasksIntoRoster, + idleClaudeTeammateByName, readClaudeBackgroundAgentTasks, - removeClaudeTeammateByName, + stopClaudeSubagent, upsertWorkingClaudeSubagent, type ClaudeSubagentRoster } from './claude-subagent-roster' @@ -31,39 +31,88 @@ describe('claude-subagent-roster', () => { // Why: retaining finished children as idle rows piled up dozens of dead // "Idle - general-purpose" sidebar rows over a long workflow session. - finishClaudeSubagent(roster, 'a1') + stopClaudeSubagent(roster, 'a1') expect(roster.size).toBe(0) expect(claudeRosterToSnapshots(roster)).toBeUndefined() }) - it('removes a finished teammate-shaped named agent on stop', () => { + it('parks a teammate-shaped named agent as idle on stop', () => { const roster: ClaudeSubagentRoster = new Map() - // Why: named/workflow agents report teammate-shaped ids, and their - // background_tasks teammate entries never stop reading "running" — so - // SubagentStop is the only reliable finish signal and must remove the row. + // Why: on claude 2.1.21x in-process teammates emit SubagentStop at every + // TURN end while staying alive/resumable — the row must survive as idle + // (the reported "sidebar never shows my subagents" regression) without + // gating the pane 'working'. upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) - finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') - expect(roster.has('aprobe1-6d3cb5b5')).toBe(false) + stopClaudeSubagent(roster, 'aprobe1-6d3cb5b5') + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle' }) + expect(claudeRosterHasWorkingSubagent(roster)).toBe(false) + expect(claudeRosterToSnapshots(roster)).toEqual([ + expect.objectContaining({ id: 'aprobe1-6d3cb5b5', state: 'idle' }) + ]) }) - it('re-adds a resumed agent as working with a fresh startedAt', () => { + it('removes a stopped workflow lane despite its teammate-shaped id', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'alane-hooks-6d3cb5b5', { agentType: 'lane-hooks' }, 100) + // Why: a fold proved this id is a subagent-typed background task (workflow + // lane) — its stop is a true finish, not a teammate turn boundary. + foldClaudeBackgroundTasksIntoRoster( + roster, + [task({ id: 'alane-hooks-6d3cb5b5', agentType: 'lane-hooks' })], + 150 + ) + stopClaudeSubagent(roster, 'alane-hooks-6d3cb5b5') + expect(roster.has('alane-hooks-6d3cb5b5')).toBe(false) + }) + + it('restores a parked workflow lane to working when the inventory reports it running', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'alane-hooks-6d3cb5b5', { agentType: 'lane-hooks' }, 100) + stopClaudeSubagent(roster, 'alane-hooks-6d3cb5b5') + + // Why: lifecycle hooks and the lead Stop inventory can arrive around the + // same boundary; an authoritative running task must keep the pane gated. + foldClaudeBackgroundTasksIntoRoster( + roster, + [task({ id: 'alane-hooks-6d3cb5b5', agentType: 'lane-hooks' })], + 150 + ) + + expect(roster.get('alane-hooks-6d3cb5b5')).toMatchObject({ + state: 'working', + listedAsSubagentTask: true + }) + expect(claudeRosterHasWorkingSubagent(roster)).toBe(true) + }) + + it('revives an idle teammate as working while keeping its first-observed startedAt', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) - finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5') + stopClaudeSubagent(roster, 'aprobe1-6d3cb5b5') upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { description: 'round two' }, 200) expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ - startedAt: 200, + state: 'working', + startedAt: 100, description: 'round two' }) }) - it('ignores unknown ids on finishClaudeSubagent', () => { + it('re-adds a resumed one-shot as working with a fresh startedAt', () => { const roster: ClaudeSubagentRoster = new Map() - finishClaudeSubagent(roster, 'ghost') + upsertWorkingClaudeSubagent(roster, 'a1', { agentType: 'general-purpose' }, 100) + stopClaudeSubagent(roster, 'a1') + upsertWorkingClaudeSubagent(roster, 'a1', { description: 'round two' }, 200) + expect(roster.get('a1')).toMatchObject({ startedAt: 200, description: 'round two' }) + }) + + it('ignores unknown ids on stopClaudeSubagent', () => { + const roster: ClaudeSubagentRoster = new Map() + stopClaudeSubagent(roster, 'ghost') + stopClaudeSubagent(roster, 'aghost-6d3cb5b5') expect(roster.size).toBe(0) }) - it('drops new spawns at the cap rather than evicting live children', () => { + it('drops new spawns at the cap rather than evicting working children', () => { const roster: ClaudeSubagentRoster = new Map() for (let i = 0; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { upsertWorkingClaudeSubagent(roster, `a${i}`, {}, i) @@ -75,12 +124,30 @@ describe('claude-subagent-roster', () => { expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) // Once a child finishes, a new spawn takes the freed slot. - finishClaudeSubagent(roster, 'a0') + stopClaudeSubagent(roster, 'a0') upsertWorkingClaudeSubagent(roster, 'replacement', {}, 1000) expect(roster.has('replacement')).toBe(true) expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) }) + it('evicts the oldest idle teammate to admit a new spawn at the cap', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'aold-teammate-6d3cb5b5', {}, 1) + upsertWorkingClaudeSubagent(roster, 'anew-teammate-6d3cb5b5', {}, 2) + stopClaudeSubagent(roster, 'aold-teammate-6d3cb5b5') + stopClaudeSubagent(roster, 'anew-teammate-6d3cb5b5') + for (let i = 2; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { + upsertWorkingClaudeSubagent(roster, `a${i}`, {}, 10 + i) + } + // Why: a parked idle row is the only thing safe to displace — a working + // spawn must never be dropped just because idle teammates fill the cap. + upsertWorkingClaudeSubagent(roster, 'overflow', {}, 999) + expect(roster.has('overflow')).toBe(true) + expect(roster.has('aold-teammate-6d3cb5b5')).toBe(false) + expect(roster.has('anew-teammate-6d3cb5b5')).toBe(true) + expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS) + }) + it('reconciles stale entries before adding replacement tasks at the cap', () => { const roster: ClaudeSubagentRoster = new Map() for (let i = 0; i < AGENT_STATUS_MAX_SUBAGENTS; i++) { @@ -300,6 +367,7 @@ describe('claude-subagent-roster', () => { // authoritative — a present list omitting it removes it even though its // id is teammate-shaped. roster.set('aprobe1-6d3cb5b5', { + state: 'working', startedAt: 100, agentType: 'probe1', backgroundTasksAuthoritative: true @@ -311,6 +379,7 @@ describe('claude-subagent-roster', () => { it('keeps a re-tracked working named agent missing from a present list', () => { const roster: ClaudeSubagentRoster = new Map() roster.set('aprobe1-6d3cb5b5', { + state: 'working', startedAt: 100, agentType: 'probe1', backgroundTasksAuthoritative: true @@ -337,40 +406,74 @@ describe('claude-subagent-roster', () => { expect(claudeTeammateIdMatchesName('aprobe1', 'probe1')).toBe(false) }) - it('removes teammates by the name embedded in agent_id', () => { + it('parks teammates idle by the name embedded in agent_id and confirms them', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) upsertWorkingClaudeSubagent(roster, 'aother-123', { agentType: 'other' }, 100) - // Why: TeammateIdle is keyed by name — idle means finished, so the row goes. - expect(removeClaudeTeammateByName(roster, 'probe1')).toBe(true) - expect(roster.has('aprobe1-6d3cb5b5')).toBe(false) - expect(roster.has('aother-123')).toBe(true) - // Repeat/unknown removals are no-ops so lifecycle refreshes don't churn. - expect(removeClaudeTeammateByName(roster, 'probe1')).toBe(false) - expect(removeClaudeTeammateByName(roster, 'ghost')).toBe(false) + // Why: TeammateIdle means "turn over, awaiting mail" on 2.1.21x — the row + // parks as a confirmed teammate instead of leaving the sidebar. + expect(idleClaudeTeammateByName(roster, 'probe1')).toBe(true) + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ + state: 'idle', + confirmedTeammate: true + }) + expect(roster.get('aother-123')).toMatchObject({ state: 'working' }) + // Repeat/unknown idles are no-ops so lifecycle refreshes don't churn. + expect(idleClaudeTeammateByName(roster, 'probe1')).toBe(false) + expect(idleClaudeTeammateByName(roster, 'ghost')).toBe(false) }) - it('does not remove an unrelated one-shot whose agent_type matches the teammate name', () => { + it('does not idle an unrelated one-shot whose agent_type matches the teammate name', () => { const roster: ClaudeSubagentRoster = new Map() // Why: a teammate's start hook may be missing (restart, cap, or lost - // delivery). Agent type is not identity, so its idle hook must not reap + // delivery). Agent type is not identity, so its idle hook must not park // another live child that happens to use the same type name. upsertWorkingClaudeSubagent(roster, 'aoneshot00000001', { agentType: 'reviewer' }, 100) - expect(removeClaudeTeammateByName(roster, 'reviewer')).toBe(false) - expect(roster.has('aoneshot00000001')).toBe(true) + expect(idleClaudeTeammateByName(roster, 'reviewer')).toBe(false) + expect(roster.get('aoneshot00000001')).toMatchObject({ state: 'working' }) + }) + + it('keeps a confirmed idle teammate through folds that list teammate tasks', () => { + const roster: ClaudeSubagentRoster = new Map() + upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100) + stopClaudeSubagent(roster, 'aprobe1-6d3cb5b5') + idleClaudeTeammateByName(roster, 'probe1') + // Why: the parked teammate is alive between turns; while the inventory + // still shows teammate-typed tasks its idle row must survive lead Stops. + foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'tprobe1', teammate: true })], 200) + expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle' }) + + // A complete inventory with no teammate-typed task proves it is gone. + foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'aunrelated0000001' })], 300) + expect(roster.has('aprobe1-6d3cb5b5')).toBe(false) + }) + + it('reaps an unconfirmed idle teammate-shaped row at the next complete fold', () => { + const roster: ClaudeSubagentRoster = new Map() + // A finished workflow lane wears a teammate-shaped id but never receives + // a TeammateIdle; only its SubagentStop arrives. + upsertWorkingClaudeSubagent(roster, 'alane-hooks-6d3cb5b5', { agentType: 'lane-hooks' }, 100) + stopClaudeSubagent(roster, 'alane-hooks-6d3cb5b5') + expect(roster.get('alane-hooks-6d3cb5b5')).toMatchObject({ state: 'idle' }) + + // Why: without the TeammateIdle confirmation the idle row is a finished + // lane — surviving folds would rebuild the pre-#8825 idle pile. + foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'tteam1', teammate: true })], 200) + expect(roster.has('alane-hooks-6d3cb5b5')).toBe(false) }) it('serializes snapshots deterministically ordered by startedAt then id', () => { const roster: ClaudeSubagentRoster = new Map() upsertWorkingClaudeSubagent(roster, 'b', {}, 200) upsertWorkingClaudeSubagent(roster, 'z', {}, 100) - upsertWorkingClaudeSubagent(roster, 'a', {}, 100) + upsertWorkingClaudeSubagent(roster, 'aidle-6d3cb5b5', {}, 100) + stopClaudeSubagent(roster, 'aidle-6d3cb5b5') const snapshots = claudeRosterToSnapshots(roster) - expect(snapshots?.map((s) => s.id)).toEqual(['a', 'z', 'b']) - // Why: only working children are tracked, so every emitted row is working. - expect(snapshots?.every((s) => s.state === 'working')).toBe(true) + expect(snapshots?.map((s) => s.id)).toEqual(['aidle-6d3cb5b5', 'z', 'b']) + // Why: idle rows serialize their parked state so the sidebar renders them. + expect(snapshots?.map((s) => s.state)).toEqual(['idle', 'working', 'working']) expect(claudeRosterToSnapshots(new Map())).toBeUndefined() }) }) diff --git a/src/shared/claude-subagent-roster.ts b/src/shared/claude-subagent-roster.ts index a94cbae0a..421bddf4c 100644 --- a/src/shared/claude-subagent-roster.ts +++ b/src/shared/claude-subagent-roster.ts @@ -5,21 +5,31 @@ import { AGENT_STATUS_MAX_SUBAGENTS, type AgentSubagentSnapshot } from './agent- * invisible in the emitted snapshots (which drop such ids). */ const CLAUDE_SUBAGENT_ID_MAX_LENGTH = 64 -/** Currently WORKING subagents/teammates tracked for one Claude pane, keyed - * by the provider-assigned `agent_id` from SubagentStart/SubagentStop - * payloads. The roster intentionally holds only working children: a child - * that finished leaves the sidebar immediately. Claude gives no other - * finish signal for named agents — their `background_tasks` teammate - * entries stay `status: "running"` forever, even after they complete - * (verified live on 2.1.210) — so retaining "idle" rows piled up dead - * entries for hours. A teammate resumed later re-earns its row via - * SubagentStart. */ +/** Live subagents/teammates tracked for one Claude pane, keyed by the + * provider-assigned `agent_id` from SubagentStart/SubagentStop payloads. + * One-shot children (hyphen-free ids) are tracked only while working — their + * SubagentStop means finished and removes the row. Teammate-shaped ids are + * turn-based on claude 2.1.21x (`in_process_teammate`): SubagentStop / + * TeammateIdle fire at every TURN end while the teammate stays alive and + * resumable, so those rows flip to 'idle' instead of leaving; a later + * SubagentStart flips them back to working. Idle rows never gate the pane + * 'working' (the #8825 idle-squat rule), and only TeammateIdle-confirmed + * ones survive a lead-Stop fold — see foldClaudeBackgroundTasksIntoRoster. */ export type ClaudeSubagentRoster = Map export type TrackedClaudeSubagent = { agentType?: string description?: string startedAt: number + /** 'idle' = teammate between mailbox turns: alive/resumable, row stays + * visible but must not gate the pane 'working'. */ + state: 'working' | 'idle' + /** A TeammateIdle matched this id by name — proof it is a persistent + * in-process teammate, not a workflow lane that merely reuses the + * `a-` id shape. Never cleared: identity can't change mid-life. + * Unconfirmed idle rows are reaped at the next complete lead-Stop fold so + * finished lanes can't rebuild the pre-#8825 idle pile. */ + confirmedTeammate?: true /** The id came from a persisted snapshot or background_tasks, not live * lifecycle events, so it may be a phantom whose SubagentStop was never * observed (Orca restart). A present complete task list omitting it @@ -67,6 +77,7 @@ export function upsertWorkingClaudeSubagent( } const existing = roster.get(id) if (existing) { + existing.state = 'working' existing.agentType = fields.agentType ?? existing.agentType existing.description = fields.description ?? existing.description // Why: live activity proves the lifecycle stream owns this id again; @@ -75,24 +86,50 @@ export function upsertWorkingClaudeSubagent( existing.backgroundTasksAuthoritative = undefined return } - // Why: beyond the wire cap extra rows would be invisible anyway; with only - // working entries tracked there is nothing safe to evict. - if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS) { + // Why: beyond the wire cap extra rows would be invisible anyway; idle + // teammates are the only safe eviction — never displace a working child. + if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS && !evictOldestIdleClaudeSubagent(roster)) { return } roster.set(id, { + state: 'working', startedAt: now, agentType: fields.agentType, description: fields.description }) } -/** SubagentStop: the finished child leaves the sidebar immediately. This - * applies to teammates/named agents too — SubagentStop is their only - * reliable finish signal (their background_tasks entries never stop - * "running"), and a resumed teammate re-earns its row via SubagentStart. */ -export function finishClaudeSubagent(roster: ClaudeSubagentRoster, id: string): void { - roster.delete(id) +function evictOldestIdleClaudeSubagent(roster: ClaudeSubagentRoster): boolean { + let oldestId: string | null = null + let oldestStartedAt = Infinity + for (const [id, tracked] of roster) { + if (tracked.state === 'idle' && tracked.startedAt < oldestStartedAt) { + oldestId = id + oldestStartedAt = tracked.startedAt + } + } + if (oldestId === null) { + return false + } + roster.delete(oldestId) + return true +} + +/** SubagentStop. A one-shot child is finished — the row leaves immediately. + * A teammate-shaped id is only ending a TURN on claude 2.1.21x (the teammate + * stays alive awaiting mail), so its row flips to idle instead — unless a + * fold proved the id is really a workflow lane (listedAsSubagentTask), whose + * stop is a true finish. */ +export function stopClaudeSubagent(roster: ClaudeSubagentRoster, id: string): void { + const tracked = roster.get(id) + if (!tracked) { + return + } + if (!isClaudeTeammateLifecycleId(id) || tracked.listedAsSubagentTask === true) { + roster.delete(id) + return + } + tracked.state = 'idle' } /** Read the agent-typed entries of a hook payload's `background_tasks` field. @@ -153,9 +190,10 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record-`), which is the only unambiguous +/** Flip a teammate's rows to idle from a TeammateIdle hook, which is keyed by + * name. On claude 2.1.21x idle means "turn over, awaiting mail" — the + * teammate is alive and resumable, so the row stays (as idle) and is marked + * confirmedTeammate so lead-Stop folds keep it. Named teammates embed their + * name in `agent_id` (`a-`), which is the only unambiguous * mapping. Agent types are independent of teammate names, so a type fallback - * could remove unrelated live work when the teammate's start hook was lost. */ -export function removeClaudeTeammateByName(roster: ClaudeSubagentRoster, name: string): boolean { + * could idle unrelated live work when the teammate's start hook was lost. */ +export function idleClaudeTeammateByName(roster: ClaudeSubagentRoster, name: string): boolean { let changed = false - for (const id of roster.keys()) { + for (const [id, tracked] of roster) { if (claudeTeammateIdMatchesName(id, name)) { - roster.delete(id) - changed = true + changed = changed || tracked.state !== 'idle' || tracked.confirmedTeammate !== true + tracked.state = 'idle' + tracked.confirmedTeammate = true } } return changed } +/** Only WORKING children gate the pane 'working' — idle teammates are + * alive-but-parked and must not pin a finished pane's spinner (#8825). */ export function claudeRosterHasWorkingSubagent(roster: ClaudeSubagentRoster | undefined): boolean { - return roster !== undefined && roster.size > 0 + if (!roster) { + return false + } + for (const tracked of roster.values()) { + if (tracked.state === 'working') { + return true + } + } + return false } export function claudeRosterToSnapshots( @@ -282,7 +339,7 @@ export function claudeRosterToSnapshots( for (const [id, tracked] of roster) { snapshots.push({ id, - state: 'working', + state: tracked.state, startedAt: tracked.startedAt, agentType: tracked.agentType, description: tracked.description diff --git a/src/shared/claude-subagent-row-lifecycle.test.ts b/src/shared/claude-subagent-row-lifecycle.test.ts index 4401367a3..ebd633ca2 100644 --- a/src/shared/claude-subagent-row-lifecycle.test.ts +++ b/src/shared/claude-subagent-row-lifecycle.test.ts @@ -1,14 +1,14 @@ /** - * Regression spec for the two reported sidebar symptoms (live-reproduced in a - * dev instance before the fix): + * Regression spec for the three reported sidebar symptoms (each + * live-reproduced before its fix): * * 1. "Really long idle list" under ultracode/orchestration: finished * subagents left permanent `Idle - ` child rows for the rest of the * session — including named/workflow agents, whose background_tasks * entries report `type: "teammate"` and never stop reading "running" - * (captured live on 2.1.210). Fixed: the roster tracks ONLY working - * children; SubagentStop (and its TeammateIdle fallback) removes a - * finished child outright, so no idle rows can accumulate. + * (captured live on 2.1.210). Fixed: one-shot SubagentStop removes the + * row outright, and idle teammate-shaped rows survive lead-Stop folds + * only when a TeammateIdle confirmed a live teammate owns the id. * * 2. "Never disappear even when killed from Orca": a subagent killed without * its SubagentStop hook (SIGKILL'd process tree / lost event) stayed @@ -17,6 +17,13 @@ * and teammate-shaped rows once a complete inventory shows no * teammate-typed task at all. * + * 3. "Sidebar never shows my subagents" on claude 2.1.21x: in-process + * teammates are turn-based — SubagentStop + TeammateIdle fire at every + * TURN end while the teammate stays alive awaiting mail (verified live on + * 2.1.217) — so remove-on-stop hid them for all but their brief working + * bursts. Fixed: teammate rows park as idle (never gating the pane + * 'working') and revive via the next SubagentStart. + * * Drives the real production pipeline (normalizeHookPayload) whose * `payload.subagents` snapshots the sidebar renders 1:1 as child rows. */ @@ -111,11 +118,11 @@ describe('claude subagent sidebar row lifecycle', () => { expect(finalStop?.payload.subagents).toBeUndefined() }) - it('removes finished named agents on SubagentStop even while their teammate task stays "running"', () => { + it('reaps stopped never-idle-confirmed named lanes at the lead Stop, not on their own stop', () => { // Exact shape captured live (claude 2.1.210): named background agents get // teammate-shaped ids (a-) AND appear in background_tasks as // `type: "teammate"` entries (unrelated ids) that report "running" - // forever — even after the agent finished. Pre-fix these squatted as + // forever — even after the agent finished. Pre-#8825 these squatted as // permanent idle rows (the 11-row gar "Orchestration Messages" pile). claudeEvent({ hook_event_name: 'UserPromptSubmit', @@ -142,26 +149,29 @@ describe('claude subagent sidebar row lifecycle', () => { expect(midStop?.payload.state).toBe('working') expect(midStop?.payload.subagents).toHaveLength(2) - // web-research finishes. Its SubagentStop still lists both teammate tasks - // as "running", but the finished row must leave immediately. + // web-research stops. On 2.1.21x that may be a mere turn boundary, so the + // row parks as idle — visible but no longer gating the pane. const afterFirst = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'aweb-research-8a76b7d7595ce04e', background_tasks: teammateTasks }) expect(afterFirst?.payload.subagents).toEqual([ - expect.objectContaining({ id: 'aoss-hunt-95a28c160dc99e5e', state: 'working' }) + expect.objectContaining({ id: 'aoss-hunt-95a28c160dc99e5e', state: 'working' }), + expect.objectContaining({ id: 'aweb-research-8a76b7d7595ce04e', state: 'idle' }) ]) - // oss-hunt finishes too — roster empties and the pane resolves done, even - // though background_tasks STILL reports both teammate tasks running. + // oss-hunt stops too. No TeammateIdle ever confirmed either id as a live + // teammate, so the next complete fold reaps both parked rows — the pane + // resolves done with no idle pile, even though background_tasks STILL + // reports both teammate tasks running. claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'aoss-hunt-95a28c160dc99e5e' }) const finalStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: teammateTasks }) expect(finalStop?.payload.state).toBe('done') expect(finalStop?.payload.subagents).toBeUndefined() }) - it('reaps a named agent via its TeammateIdle fallback when SubagentStop is lost', () => { + it('parks a TeammateIdle-confirmed teammate as a persistent idle row without gating done', () => { claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'orchestration (ultracode)' }) claudeEvent({ hook_event_name: 'SubagentStart', @@ -169,21 +179,77 @@ describe('claude subagent sidebar row lifecycle', () => { agent_type: 'review-standards' }) - // No SubagentStop arrives (lost/interrupt race), but claude still emits - // TeammateIdle keyed by name once the agent goes idle — the row must go. + // TeammateIdle = "turn over, awaiting mail" (verified live on 2.1.217). + // The row parks as idle instead of leaving — this is the reported + // "sidebar never shows my subagents" regression. const idled = claudeEvent({ hook_event_name: 'TeammateIdle', teammate_name: 'review-standards', team_name: 'orchestration' }) - expect(idled?.payload.subagents).toBeUndefined() + expect(idled?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'areview-standards-2750dacd', state: 'idle' }) + ]) + // The confirmed idle row survives lead Stops that still list teammate + // tasks, and never pins the pane working. const stop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [{ id: 'tstd', type: 'teammate', status: 'running' }] }) expect(stop?.payload.state).toBe('done') - expect(stop?.payload.subagents).toBeUndefined() + expect(stop?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'areview-standards-2750dacd', state: 'idle' }) + ]) + + // A complete inventory with no teammate-typed task left proves the + // teammate is gone — only then does the parked row leave. + const teardown = claudeEvent({ hook_event_name: 'Stop', background_tasks: [] }) + expect(teardown?.payload.state).toBe('done') + expect(teardown?.payload.subagents).toBeUndefined() + }) + + it('keeps a turn-based teammate visible across its work/idle cycle and revives it on resume', () => { + // The reported repro (claude 2.1.217): a named Explore teammate does a + // ~50s turn, idles awaiting mail, is resumed via SendMessage, then idles + // again — pre-fix the sidebar showed it only during the brief bursts. + claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'map the polling pipeline' }) + claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'apoll-map-74e71b7bd45975f7', + agent_type: 'poll-map' + }) + const teammateTasks = [{ id: 'ta5jpcars', type: 'teammate', status: 'running' }] + + // Turn ends: SubagentStop then TeammateIdle (order captured live). + claudeEvent({ + hook_event_name: 'SubagentStop', + agent_id: 'apoll-map-74e71b7bd45975f7', + background_tasks: teammateTasks + }) + const idled = claudeEvent({ hook_event_name: 'TeammateIdle', teammate_name: 'poll-map' }) + expect(idled?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'apoll-map-74e71b7bd45975f7', state: 'idle' }) + ]) + + // The lead keeps working, then its turn ends — the parked row survives. + const leadStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: teammateTasks }) + expect(leadStop?.payload.state).toBe('done') + expect(leadStop?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'apoll-map-74e71b7bd45975f7', state: 'idle' }) + ]) + + // SendMessage wakes the teammate: same lifecycle id, row revives working + // and gates the (done) pane back to working. + const revived = claudeEvent({ + hook_event_name: 'SubagentStart', + agent_id: 'apoll-map-74e71b7bd45975f7', + agent_type: 'poll-map' + }) + expect(revived?.payload.state).toBe('working') + expect(revived?.payload.subagents).toEqual([ + expect.objectContaining({ id: 'apoll-map-74e71b7bd45975f7', state: 'working' }) + ]) }) it('reaps a killed named agent at the lead Stop when no teammate task remains', () => {