fix(agent-status): keep Claude in-process teammates visible as idle sidebar rows (#9850)

* fix(agent-status): keep Claude in-process teammates visible as idle sidebar rows

Claude Code 2.1.21x runs named Agent-tool agents as turn-based in-process
teammates: SubagentStop and TeammateIdle fire at every TURN end while the
teammate stays alive awaiting mail (verified live on 2.1.217). Treating
those events as finish signals deleted the child row seconds after each
burst, so the sidebar showed no subagents for most of a teammate's life.

Root-cause fix: the roster now tracks a working/idle state per child.

- One-shot children (hyphen-free ids) keep remove-on-stop: their
  SubagentStop is a true finish.
- Teammate-shaped rows park as idle on SubagentStop/TeammateIdle and
  revive to working via the next SubagentStart (same lifecycle id,
  first-observed startedAt preserved).
- Idle rows never gate the pane 'working' (#8825's done-gate rule).
- Only TeammateIdle-confirmed idle rows survive a complete lead-Stop
  fold; a stopped workflow lane wearing a teammate-shaped id is reaped
  there (or immediately, once a fold tagged it listedAsSubagentTask), so
  the pre-#8825 idle pile cannot rebuild.
- At the wire cap, the oldest idle row is evicted to admit a working
  spawn; working children are never displaced.
- Hydrate keeps pruning idle snapshots: idle-teammate liveness cannot be
  proven across a restart, and a live teammate re-earns its row.

* fix(agent-status): restore inventory-confirmed workflow lanes
This commit is contained in:
Brennan Benson 2026-07-22 11:42:34 -07:00 committed by GitHub
parent 05ec4b749b
commit c4d903ff21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 344 additions and 108 deletions

View File

@ -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

View File

@ -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: '' }] } },

View File

@ -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<name>-<hex>` 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', () => {

View File

@ -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,

View File

@ -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()
})
})

View File

@ -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<string, TrackedClaudeSubagent>
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<name>-<hex>` 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<string, unkno
* never saw start (Orca/relay restart mid-run) recreate it;
* - an unlisted entry is finished or dead (its SubagentStop was lost)
* remove it UNLESS it is teammate-shaped, live-tracked, never
* subagent-listed, and the list still shows teammate-typed tasks: that is
* a named agent mid-run whose id simply never appears, and removing it
* would drop the pane's done-gate. */
* subagent-listed, the list still shows teammate-typed tasks, AND it is
* working or TeammateIdle-confirmed: that is a named teammate whose id
* simply never appears, and removing it would drop the pane's done-gate
* (working) or its parked idle row (confirmed). */
export function foldClaudeBackgroundTasksIntoRoster(
roster: ClaudeSubagentRoster,
tasks: ClaudeBackgroundAgentTask[],
@ -183,6 +221,9 @@ export function foldClaudeBackgroundTasksIntoRoster(
pendingRunningTasks.delete(task.id)
continue
}
// Why: a Stop can park the row before the lead inventory confirms the
// same workflow lane is still running; the authoritative task wins.
existing.state = 'working'
existing.agentType = task.agentType ?? existing.agentType
existing.description = task.description ?? existing.description
existing.listedAsSubagentTask = true
@ -217,7 +258,11 @@ export function foldClaudeBackgroundTasksIntoRoster(
hasTeammateTypedTask &&
!tracked.backgroundTasksAuthoritative &&
tracked.listedAsSubagentTask !== true &&
isClaudeTeammateLifecycleId(id)
isClaudeTeammateLifecycleId(id) &&
// Why: an idle row that no TeammateIdle ever confirmed is a finished
// workflow lane wearing a teammate-shaped id — reap it here or the
// pre-#8825 idle pile rebuilds one lane per lead turn.
(tracked.state === 'working' || tracked.confirmedTeammate === true)
) {
continue
}
@ -251,25 +296,37 @@ export function claudeTeammateIdMatchesName(id: string, name: string): boolean {
return id.startsWith(prefix) && !id.slice(prefix.length).includes('-')
}
/** Remove a teammate's rows from a TeammateIdle hook, which is keyed by name.
* Idle means not working, and only working children keep rows this is the
* fallback finish signal when a SubagentStop was lost. Named teammates embed
* their name in `agent_id` (`a<name>-<hex>`), 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<name>-<hex>`), 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

View File

@ -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 - <type>` 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<name>-<hex>) 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', () => {