fix(agent-status): reap finished Claude named agents/teammates from the sidebar roster

#8522 stopped one-shot subagents from squatting as idle rows, but named
background agents (Workflow/orchestration/ultracode lanes and agent-teams
teammates) still piled up permanent "Idle - <type>" child rows for the rest
of the session — the reported regression (11 idle rows under an
"Orchestration Messages" pane, all idle 3-6h after finishing).

Root cause, confirmed against live hook captures (claude 2.1.210): named
agents get teammate-shaped ids (a<name>-<hex>) AND now appear in Stop's
`background_tasks` as `type: "teammate"` entries whose status stays "running"
forever — even after the agent finished. The old code read that shape as a
"resumable teammate", so SubagentStop only marked it idle and the fold never
reaped it (a present teammate task kept hasTeammateTypedTask true). The rows
never left.

Fix: the roster now tracks only WORKING children.
- SubagentStop removes the child outright (teammate-shaped or not) — it is
  the reliable finish signal; the teammate task's "running" status is not.
- TeammateIdle removes by name as the fallback when a SubagentStop is lost.
- A lead Stop's background_tasks still reaps unlisted children: hyphen-free
  one-shots always, and teammate-shaped rows once a complete inventory shows
  no teammate-typed task at all. A live named agent whose id never appears is
  kept only while a teammate-typed task is still present (the done-gate).
- Hydration drops persisted idle snapshots so a restart can't re-pile them.

Verified live in a dev Electron instance driving a real Claude TUI that spawns
four named background agents: pre-fix the pane resolves to done with four
persistent "Idle - <probe>" rows; post-fix each row disappears the instant its
agent finishes and the roster drains to empty (done, zero child rows).

Tests: roster + row-lifecycle + hook-listener suites rewritten to the
working-only semantics, grounded in the captured 2.1.210 hook stream
(126 passing). Typecheck + oxlint clean.
This commit is contained in:
Brennan Benson
2026-07-14 23:13:47 -07:00
parent 42ee45f392
commit 80f7511aa5
5 changed files with 356 additions and 495 deletions
+42 -54
View File
@@ -2350,92 +2350,83 @@ describe('shared agent-hook-listener', () => {
expect(stopped?.payload.state).toBe('done')
})
it('resolves teams-mode teammates to done despite background_tasks reporting running', () => {
// Why: this is the interactive agent-teams shape observed live —
it('removes a finished teammate/named agent on SubagentStop despite its task reading running', () => {
// Why: the interactive agent-teams / orchestration shape observed live —
// lifecycle events use `a<name>-<hex>` agent ids while background_tasks
// uses unrelated task ids and keeps idle-but-alive teammates as
// status "running". The unmatched task entry must neither create a
// duplicate child row nor keep the pane spinning forever.
// 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).
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'spawn probe' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aprobe1-6d3cb5b52120b7bf',
agent_type: 'probe1'
})
const teammateTask = {
id: 'tlkjjs0jv',
type: 'teammate',
status: 'running',
description: 'Run the shell command: sleep 25.'
}
const spawnStop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [
{
id: 'tlkjjs0jv',
type: 'teammate',
status: 'running',
description: 'Run the shell command: sleep 25.'
}
]
background_tasks: [teammateTask]
})
expect(spawnStop?.payload.state).toBe('working')
expect(spawnStop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'aprobe1-6d3cb5b52120b7bf', state: 'working' })
])
claudeEvent({
// SubagentStop is the reliable finish signal — the row goes even though
// its teammate task is still listed "running".
const stopped = claudeEvent({
hook_event_name: 'SubagentStop',
agent_id: 'aprobe1-6d3cb5b52120b7bf',
agent_type: 'probe1'
agent_type: 'probe1',
background_tasks: [teammateTask]
})
const idled = claudeEvent({
expect(stopped?.payload.subagents).toBeUndefined()
claudeEvent({
hook_event_name: 'TeammateIdle',
teammate_name: 'probe1',
team_name: 'session-56c87269'
})
expect(idled?.payload.subagents).toEqual([
expect.objectContaining({ id: 'aprobe1-6d3cb5b52120b7bf', state: 'idle' })
])
const wakeStop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [
{
id: 'tlkjjs0jv',
type: 'teammate',
status: 'running',
description: 'Run the shell command: sleep 25.'
}
]
background_tasks: [teammateTask]
})
expect(wakeStop?.payload.state).toBe('done')
expect(wakeStop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'aprobe1-6d3cb5b52120b7bf', state: 'idle' })
])
expect(wakeStop?.payload.subagents).toBeUndefined()
})
it('keeps a teammate whose name differs from its configured agent type', () => {
it('removes 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',
agent_id: 'areviewer-6d3cb5b52120b7bf',
agent_type: 'security-reviewer'
})
// Lead turn ends while the teammate works; pane stays working.
const stop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [{ id: 'trev', type: 'teammate', status: 'running' }]
})
expect(stop?.payload.state).toBe('working')
// Why: teammate name and agent type are separate Agent-tool inputs; the
// lifecycle id embeds the former while the hook reports the latter.
claudeEvent({
hook_event_name: 'SubagentStop',
agent_id: 'areviewer-6d3cb5b52120b7bf',
agent_type: 'security-reviewer'
})
// 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.
const idled = claudeEvent({
hook_event_name: 'TeammateIdle',
teammate_name: 'reviewer',
team_name: 'session-x'
})
expect(idled?.payload.subagents).toEqual([
expect.objectContaining({
id: 'areviewer-6d3cb5b52120b7bf',
agentType: 'security-reviewer',
state: 'idle'
})
])
expect(idled?.payload.subagents).toBeUndefined()
expect(idled?.payload.state).toBe('done')
})
it('scopes subagent rosters per pane', () => {
@@ -2679,9 +2670,8 @@ describe('shared agent-hook-listener', () => {
teammate_name: 'lane-hooks',
team_name: 'session-x'
})
expect(idled?.payload.subagents).toEqual([
expect.objectContaining({ id: 'alane-hooks-6d3cb5b5', state: 'idle' })
])
// Why: idle means finished — the exact-name match reaps the row.
expect(idled?.payload.subagents).toBeUndefined()
})
it('keeps an inferred interrupt terminal across later child lifecycle events', () => {
@@ -2703,7 +2693,11 @@ describe('shared agent-hook-listener', () => {
expect(idled?.payload.interrupted).toBe(true)
})
it('seeds the roster from persisted snapshots so a teammate-bearing Stop keeps child rows', () => {
it('does not resurrect persisted idle child rows after a restart', () => {
// Why: the roster tracks only working children now. A persisted idle
// snapshot (from a build that kept idle rows) is a finished child, so
// hydration must drop it — otherwise restart would re-pile the exact
// squatting rows this fix removes.
seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, [
{
id: 'aprobe2-6d3cb5b52120b7bf',
@@ -2720,13 +2714,7 @@ describe('shared agent-hook-listener', () => {
]
})
expect(stop?.payload.state).toBe('done')
expect(stop?.payload.subagents).toEqual([
expect.objectContaining({
id: 'aprobe2-6d3cb5b52120b7bf',
agentType: 'security-reviewer',
state: 'idle'
})
])
expect(stop?.payload.subagents).toBeUndefined()
})
it('rebuilds a running one-shot subagent from background_tasks after restart', () => {
+17 -17
View File
@@ -40,9 +40,8 @@ import {
claudeTeammateIdMatchesName,
finishClaudeSubagent,
foldClaudeBackgroundTasksIntoRoster,
isClaudeTeammateLifecycleId,
markClaudeTeammateIdleByName,
readClaudeBackgroundAgentTasks,
removeClaudeTeammateByName,
upsertWorkingClaudeSubagent,
type ClaudeSubagentRoster
} from './claude-subagent-roster'
@@ -2436,7 +2435,10 @@ function normalizeClaudeSubagentLifecycleEvent(
if (!teammateName) {
return null
}
markClaudeTeammateIdleByName(roster, teammateName)
// Why: idle means not working, and only working children keep a row. This
// is the fallback finish signal for a named agent whose SubagentStop was
// lost — its background_tasks entry never stops reading "running".
removeClaudeTeammateByName(roster, teammateName)
clearClaudePendingWaitForAgent(state, paneKey, (waitingAgentId) =>
claudeTeammateIdMatchesName(waitingAgentId, teammateName)
)
@@ -2453,14 +2455,11 @@ function normalizeClaudeSubagentLifecycleEvent(
Date.now()
)
} else {
// Why: SubagentStop carries the session's task inventory; a stopping
// agent listed id-exact as a subagent task is a workflow/named one-shot
// (teammate lifecycle ids never appear there), not a resumable teammate.
const stopTasks = readClaudeBackgroundAgentTasks(hookPayload)
finishClaudeSubagent(roster, agentId, {
listedAsSubagentTask:
stopTasks.present && stopTasks.tasks.some((task) => !task.teammate && task.id === agentId)
})
// Why: a finished child (one-shot, workflow lane, or named teammate)
// leaves the sidebar at once. SubagentStop is the reliable finish
// signal even for teammate-shaped ids — their background_tasks entries
// stay "running" forever — and a resumed teammate re-earns its row.
finishClaudeSubagent(roster, agentId)
// Why: a blocked child that dies (killed, errored) without another tool
// event would otherwise pin its permission/question wait on the pane
// forever — nothing else references that agent again.
@@ -2493,18 +2492,19 @@ export function seedClaudeSubagentRosterFromSnapshots(
}
const roster = getOrCreateClaudeSubagentRoster(state, paneKey)
for (const snapshot of snapshots) {
// Why: the roster only tracks working children now. A persisted idle
// snapshot (from a build that kept idle rows) is a finished child — drop
// it so restart doesn't resurrect the stale pile this fix removes.
if (snapshot.state !== 'working') {
continue
}
roster.set(snapshot.id, {
state: snapshot.state === 'working' ? 'working' : 'idle',
startedAt: snapshot.startedAt,
agentType: snapshot.agentType,
description: snapshot.description,
// Why: teammate name and agent type can differ, but the provider id
// shape survives persistence and keeps the row across restart folds.
...(isClaudeTeammateLifecycleId(snapshot.id) ? { teammate: true as const } : {}),
// Why: the seed can be a phantom (child finished while Orca was down,
// its SubagentStop lost). Let a PRESENT background_tasks list that
// omits the id remove it (or demote a teammate) instead of gating the
// pane 'working' forever.
// omits the id remove it instead of gating the pane 'working' forever.
backgroundTasksAuthoritative: true
})
}
+140 -210
View File
@@ -6,117 +6,53 @@ import {
claudeTeammateIdMatchesName,
finishClaudeSubagent,
foldClaudeBackgroundTasksIntoRoster,
markClaudeTeammateIdleByName,
readClaudeBackgroundAgentTasks,
removeClaudeTeammateByName,
upsertWorkingClaudeSubagent,
type ClaudeSubagentRoster
} from './claude-subagent-roster'
const task = (
over: Partial<ReturnType<typeof readClaudeBackgroundAgentTasks>['tasks'][number]>
) => ({
id: 'a1',
agentType: undefined,
description: undefined,
running: true,
teammate: false,
...over
})
describe('claude-subagent-roster', () => {
it('removes a finished one-shot subagent on stop', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(roster, 'a1', { agentType: 'general-purpose' }, 100)
expect(claudeRosterHasWorkingSubagent(roster)).toBe(true)
// Why: retaining finished one-shots as idle rows piled up dozens of dead
// 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')
expect(roster.size).toBe(0)
expect(claudeRosterToSnapshots(roster)).toBeUndefined()
})
it('idles a teammate on stop instead of removing it', () => {
it('removes a finished teammate-shaped named agent 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.
upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100)
finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5')
expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle', teammate: true })
expect(roster.has('aprobe1-6d3cb5b5')).toBe(false)
})
it('removes a finished workflow lane despite its teammate-shaped id when task-listed', () => {
const roster: ClaudeSubagentRoster = new Map()
// Why: workflow lanes get name-embedding ids (afinder-C-<hex>) like
// teammates, but their SubagentStop payload lists them id-exact as a
// subagent task — real teammate lifecycle ids never appear there.
upsertWorkingClaudeSubagent(
roster,
'afinder-C-5d713c0781b7f8d2',
{ agentType: 'finder-C' },
100
)
finishClaudeSubagent(roster, 'afinder-C-5d713c0781b7f8d2', { listedAsSubagentTask: true })
expect(roster.size).toBe(0)
})
it('reclassifies a task-listed teammate-shaped entry as a one-shot', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(
roster,
'av1-streaming-0b1c2d3e',
{ agentType: 'v1-streaming' },
100
)
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{
id: 'av1-streaming-0b1c2d3e',
agentType: 'v1-streaming',
description: undefined,
running: true,
teammate: false
},
{
id: 'tteam1',
agentType: undefined,
description: undefined,
running: true,
teammate: true
}
],
200
)
// A later stop without its own inventory still removes it: the fold
// already proved the id is a task id, not a teammate.
finishClaudeSubagent(roster, 'av1-streaming-0b1c2d3e')
expect(roster.has('av1-streaming-0b1c2d3e')).toBe(false)
})
it('removes teammate-shaped leftovers when a complete inventory lists no teammates', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(roster, 'acr-triage-1-c5a0588e', { agentType: 'cr-triage-1' }, 100)
finishClaudeSubagent(roster, 'acr-triage-1-c5a0588e')
expect(roster.get('acr-triage-1-c5a0588e')).toMatchObject({ state: 'idle' })
upsertWorkingClaudeSubagent(roster, 'afix-main-11223344', { agentType: 'fix-main' }, 150)
// Why: a teams session lists its teammates (even idle) as teammate-typed
// tasks; an inventory with none proves these name-shaped rows are dead
// workflow lanes, not resumable teammates.
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{
id: 'aunrelated0000001',
agentType: 'general-purpose',
description: undefined,
running: true,
teammate: false
}
],
200
)
expect(roster.has('acr-triage-1-c5a0588e')).toBe(false)
expect(roster.has('afix-main-11223344')).toBe(false)
expect(roster.has('aunrelated0000001')).toBe(true)
})
it('re-marks an idle teammate working without resetting startedAt', () => {
it('re-adds a resumed agent as working, keeping its original startedAt', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100)
finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5')
upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { description: 'round two' }, 200)
expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({
state: 'working',
startedAt: 100,
startedAt: 200,
description: 'round two'
})
})
@@ -127,22 +63,21 @@ describe('claude-subagent-roster', () => {
expect(roster.size).toBe(0)
})
it('caps roster size, evicting the oldest idle entry first', () => {
it('drops new spawns at the cap rather than evicting live children', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 0)
for (let i = 1; i < AGENT_STATUS_MAX_SUBAGENTS; i++) {
for (let i = 0; i < AGENT_STATUS_MAX_SUBAGENTS; i++) {
upsertWorkingClaudeSubagent(roster, `a${i}`, {}, i)
}
// Why: all working — a new spawn cannot evict live children and is dropped.
// Why: every tracked entry is working — nothing is safe to evict, so the
// overflow spawn is dropped (it would be invisible past the wire cap).
upsertWorkingClaudeSubagent(roster, 'overflow', {}, 999)
expect(roster.has('overflow')).toBe(false)
expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS)
// Why: only teammates hold idle entries now; an idle teammate is the
// eviction pool when a burst of new spawns hits the cap.
finishClaudeSubagent(roster, 'aprobe1-6d3cb5b5')
// Once a child finishes, a new spawn takes the freed slot.
finishClaudeSubagent(roster, 'a0')
upsertWorkingClaudeSubagent(roster, 'replacement', {}, 1000)
expect(roster.has('replacement')).toBe(true)
expect(roster.has('aprobe1-6d3cb5b5')).toBe(false)
expect(roster.size).toBe(AGENT_STATUS_MAX_SUBAGENTS)
})
@@ -197,70 +132,102 @@ describe('claude-subagent-roster', () => {
expect(result.truncated).toBe(true)
})
it('folds background_tasks in without trusting ambiguous entries', () => {
it('trusts id-exact subagent matches and ignores teammate-typed entries', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(roster, 'a1', {}, 100)
// A named agent mid-run: teammate-shaped id, never a task id.
upsertWorkingClaudeSubagent(roster, 'ateam-6d3cb5b5', { agentType: 'security-reviewer' }, 150)
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{
id: 'a1',
agentType: 'general-purpose',
description: 'review loop',
running: true,
teammate: false
},
task({ id: 'a1', agentType: 'general-purpose', description: 'review loop' }),
// Why: teammate task ids never match lifecycle agent_ids; unmatched
// teammate entries must not create phantom duplicate children.
{
id: 'tlkjjs0jv',
agentType: undefined,
description: 'teammate task',
running: true,
teammate: true
}
task({ id: 'tlkjjs0jv', description: 'teammate task', teammate: true })
],
200
)
expect(roster.size).toBe(2)
// Why: id-exact matches are one-shot subagents whose run state IS reliable.
expect(roster.get('a1')).toMatchObject({ state: 'working', description: 'review loop' })
// Why: the working lifecycle-tracked teammate is not listed by id, but
// omission proves nothing for teammates — it must survive the fold.
expect(roster.get('ateam-6d3cb5b5')).toMatchObject({
state: 'working',
agentType: 'security-reviewer'
})
expect(roster.get('a1')).toMatchObject({ description: 'review loop' })
// Why: the working named agent is not listed by id, but a teammate-typed
// task is present — so omission proves nothing and it survives the fold.
expect(roster.get('ateam-6d3cb5b5')).toMatchObject({ agentType: 'security-reviewer' })
})
it('removes an id-matched task reported not running', () => {
it('removes an id-matched subagent task reported not running', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(roster, 'a1', { agentType: 'general-purpose' }, 100)
foldClaudeBackgroundTasksIntoRoster(
roster,
[{ id: 'a1', agentType: undefined, description: undefined, running: false, teammate: false }],
200
)
foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'a1', running: false })], 200)
expect(roster.size).toBe(0)
})
it('removes a killed one-shot missing from a present list', () => {
const roster: ClaudeSubagentRoster = new Map()
// Why: a running one-shot is always listed id-exact at a lead Stop, so a
// working non-teammate missing from the list is dead (SubagentStop lost);
// keeping it pinned the pane 'working' forever.
// working hyphen-free child missing from the list is dead (SubagentStop
// lost); keeping it pinned the pane 'working' forever.
upsertWorkingClaudeSubagent(roster, 'akilled0000000001', { agentType: 'general-purpose' }, 100)
foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'other', teammate: true })], 200)
expect(roster.size).toBe(0)
})
it('keeps a live named agent whose teammate-typed id never appears in the list', () => {
const roster: ClaudeSubagentRoster = new Map()
// Why: this is the core regression — a running named agent (teammate-shaped
// id, lifecycle-tracked, never a task id) must survive a lead Stop whose
// list still shows teammate-typed tasks, or the pane resolves done early.
upsertWorkingClaudeSubagent(
roster,
'aweb-research-8a76b7d7',
{ agentType: 'web-research' },
100
)
foldClaudeBackgroundTasksIntoRoster(
roster,
[task({ id: 't6s2brfv7', description: 'named agent task', teammate: true })],
200
)
expect(roster.has('aweb-research-8a76b7d7')).toBe(true)
})
it('removes teammate-shaped leftovers when a complete inventory lists no teammates', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(roster, 'acr-triage-1-c5a0588e', { agentType: 'cr-triage-1' }, 100)
// Why: a session with named agents/teammates always lists at least one
// teammate-typed task while any is alive; an inventory with none proves
// this teammate-shaped row is dead (its SubagentStop was lost).
foldClaudeBackgroundTasksIntoRoster(
roster,
[task({ id: 'aunrelated0000001', agentType: 'general-purpose' })],
200
)
expect(roster.has('acr-triage-1-c5a0588e')).toBe(false)
expect(roster.has('aunrelated0000001')).toBe(true)
})
it('removes a leftover once a subagent-typed task listed its id id-exact', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(
roster,
'av1-streaming-0b1c2d3e',
{ agentType: 'v1-streaming' },
100
)
// A subagent-typed task lists it id-exact → tag it listedAsSubagentTask.
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{ id: 'other', agentType: undefined, description: undefined, running: true, teammate: true }
task({ id: 'av1-streaming-0b1c2d3e', agentType: 'v1-streaming' }),
task({ id: 'tteam1', teammate: true })
],
200
)
expect(roster.size).toBe(0)
// A later Stop omits it while a teammate-typed task is still present: the
// subagent-listing proof overrides the teammate-shape protection.
foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'tteam1', teammate: true })], 300)
expect(roster.has('av1-streaming-0b1c2d3e')).toBe(false)
})
it('retains an unlisted live child when the background task inventory was truncated', () => {
@@ -285,25 +252,13 @@ describe('claude-subagent-roster', () => {
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{
id: 'a9',
agentType: 'general-purpose',
description: 'long build',
running: true,
teammate: false
},
{
id: 'gone',
agentType: undefined,
description: undefined,
running: false,
teammate: false
}
task({ id: 'a9', agentType: 'general-purpose', description: 'long build' }),
task({ id: 'gone', running: false })
],
500
)
expect(roster.get('a9')).toMatchObject({ state: 'working', startedAt: 500 })
// Why: a finished unmatched one-shot leaves no reason to add an idle row.
expect(roster.get('a9')).toMatchObject({ startedAt: 500 })
// Why: a finished unmatched one-shot leaves no reason to add a row.
expect(roster.has('gone')).toBe(false)
})
@@ -314,86 +269,46 @@ describe('claude-subagent-roster', () => {
expect(roster.size).toBe(0)
})
it('removes non-teammate authoritative entries and keeps live teammates on omission', () => {
it('does not clear the roster on an empty but incomplete (truncated) inventory', () => {
const roster: ClaudeSubagentRoster = new Map()
roster.set('a-phantom', {
state: 'working',
startedAt: 100,
backgroundTasksAuthoritative: true
})
upsertWorkingClaudeSubagent(roster, 'ateam-6d3cb5b5', { agentType: 'security-reviewer' }, 150)
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{ id: 'other', agentType: undefined, description: undefined, running: true, teammate: true }
],
200
)
expect(roster.has('a-phantom')).toBe(false)
expect(roster.get('ateam-6d3cb5b5')).toMatchObject({ state: 'working' })
upsertWorkingClaudeSubagent(roster, 'a1', {}, 100)
foldClaudeBackgroundTasksIntoRoster(roster, [], 200, { inventoryComplete: false })
expect(roster.has('a1')).toBe(true)
})
it('demotes a seeded teammate phantom missing from a present list to idle', () => {
it('removes a seeded phantom missing from a present list', () => {
const roster: ClaudeSubagentRoster = new Map()
// Why: a teammate seeded from a pre-restart snapshot may be dead, but its
// task id never appears in background_tasks — demote instead of delete so
// a live idle teammate keeps its row while a phantom stops gating the pane.
// A snapshot-seeded entry (child finished while Orca was down) is
// 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',
teammate: true,
backgroundTasksAuthoritative: true
})
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{ id: 'other', agentType: undefined, description: undefined, running: true, teammate: true }
],
200
)
expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle', teammate: true })
foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'other', teammate: true })], 200)
expect(roster.has('aprobe1-6d3cb5b5')).toBe(false)
})
it('removes fold-recreated entries missing from a later present list', () => {
const roster: ClaudeSubagentRoster = new Map()
foldClaudeBackgroundTasksIntoRoster(
roster,
[{ id: 'a9', agentType: undefined, description: undefined, running: true, teammate: false }],
100
)
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{ id: 'other', agentType: undefined, description: undefined, running: true, teammate: true }
],
200
)
expect(roster.has('a9')).toBe(false)
})
it('keeps a re-tracked working teammate missing from a present list', () => {
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',
teammate: true,
backgroundTasksAuthoritative: true
})
// Why: live activity clears the authoritative flag; a busy teammate must
// not be demoted by a Stop that (as always) omits its lifecycle id.
// Why: live activity clears the authoritative flag; a busy named agent
// must not be reaped by a Stop that (as always) omits its lifecycle id.
upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 150)
foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'other', teammate: true })], 200)
expect(roster.has('aprobe1-6d3cb5b5')).toBe(true)
})
foldClaudeBackgroundTasksIntoRoster(
roster,
[
{ id: 'other', agentType: undefined, description: undefined, running: true, teammate: true }
],
200
)
expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'working' })
it('removes fold-recreated one-shots missing from a later present list', () => {
const roster: ClaudeSubagentRoster = new Map()
foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'a9' })], 100)
foldClaudeBackgroundTasksIntoRoster(roster, [task({ id: 'other', teammate: true })], 200)
expect(roster.has('a9')).toBe(false)
})
it('matches teammate ids by name only up to the hyphen-free suffix', () => {
@@ -404,17 +319,29 @@ describe('claude-subagent-roster', () => {
expect(claudeTeammateIdMatchesName('aprobe1', 'probe1')).toBe(false)
})
it('marks teammates idle by name via agent_type or agent_id prefix', () => {
it('removes teammates by name via agent_id prefix or agent_type fallback', () => {
const roster: ClaudeSubagentRoster = new Map()
upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100)
upsertWorkingClaudeSubagent(roster, 'aother-123', { agentType: 'other' }, 100)
expect(markClaudeTeammateIdleByName(roster, 'probe1')).toBe(true)
expect(roster.get('aprobe1-6d3cb5b5')?.state).toBe('idle')
expect(roster.get('aother-123')?.state).toBe('working')
// Why: repeat idles are no-ops so lifecycle refreshes don't churn state.
expect(markClaudeTeammateIdleByName(roster, 'probe1')).toBe(false)
expect(markClaudeTeammateIdleByName(roster, 'ghost')).toBe(false)
// 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)
})
it('falls back to agent_type only when no id matches the teammate name', () => {
const roster: ClaudeSubagentRoster = new Map()
// A one-shot whose agent_type collides with a teammate name, plus the
// real teammate. The id match wins; the colliding one-shot is spared.
upsertWorkingClaudeSubagent(roster, 'areviewer-6d3cb5b5', { agentType: 'reviewer' }, 100)
upsertWorkingClaudeSubagent(roster, 'aoneshot00000001', { agentType: 'reviewer' }, 100)
removeClaudeTeammateByName(roster, 'reviewer')
expect(roster.has('areviewer-6d3cb5b5')).toBe(false)
expect(roster.has('aoneshot00000001')).toBe(true)
})
it('serializes snapshots deterministically ordered by startedAt then id', () => {
@@ -422,7 +349,10 @@ describe('claude-subagent-roster', () => {
upsertWorkingClaudeSubagent(roster, 'b', {}, 200)
upsertWorkingClaudeSubagent(roster, 'z', {}, 100)
upsertWorkingClaudeSubagent(roster, 'a', {}, 100)
expect(claudeRosterToSnapshots(roster)?.map((s) => s.id)).toEqual(['a', 'z', 'b'])
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(claudeRosterToSnapshots(new Map())).toBeUndefined()
})
})
+82 -144
View File
@@ -5,28 +5,33 @@ 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
/** Live subagents/teammates tracked for one Claude pane, keyed by the
* provider-assigned `agent_id` from SubagentStart/SubagentStop payloads. */
/** 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. */
export type ClaudeSubagentRoster = Map<string, TrackedClaudeSubagent>
export type TrackedClaudeSubagent = {
agentType?: string
description?: string
state: 'working' | 'idle'
startedAt: number
/** Known agent-teams teammate: its id embeds its name (`a<name>-<hex>`)
* while one-shot ids are hyphen-free (`a<hex>`). Teammates are long-lived —
* SubagentStop means "finished a task", not "gone" — and their lifecycle
* ids never appear in background_tasks, so omission proves nothing. */
teammate?: true
/** The id came from a persisted snapshot or background_tasks, not live
* lifecycle events. Only matters for teammate-shaped seeds now: a PRESENT
* list omitting a seeded-working teammate demotes it to idle so a phantom
* seeded before restart can't gate the pane 'working' forever (teams
* sessions never send an empty list). Cleared once live activity re-tracks
* the id. Non-teammate entries omitted from a present list are removed
* outright regardless of this flag. */
* lifecycle events, so it may be a phantom whose SubagentStop was never
* observed (Orca restart). A present complete task list omitting it
* removes it even when teammate-shaped, so it can't gate the pane
* 'working' forever. Cleared once live activity re-tracks the id. */
backgroundTasksAuthoritative?: boolean
/** A subagent-typed background task listed this lifecycle id id-exact
* (workflow/named lanes) — proof the task list tracks this id, so a later
* complete list omitting it means finished/killed even though the id is
* teammate-shaped. Never cleared: the listing mode of an id can't change
* mid-life. */
listedAsSubagentTask?: true
}
/** One agent entry from the `background_tasks` array Claude attaches to Stop
@@ -37,14 +42,15 @@ export type ClaudeBackgroundAgentTask = {
agentType?: string
description?: string
running: boolean
/** True for `type: "teammate"` entries, whose ids never match lifecycle
* agent_ids and whose "running" status persists while idle. */
/** True for `type: "teammate"` entries. Their ids never match lifecycle
* agent_ids and they report "running" permanently — even after the named
* agent finished — so they carry no per-agent state at all. */
teammate: boolean
}
/** Agent-team lifecycle ids are `a<teammate-name>-<hex>`. The teammate name
* and agent type are independent spawn fields, so the id shape is the only
* reliable discriminator available on SubagentStart/SubagentStop hooks. */
/** Agent-team/named-agent lifecycle ids are `a<name>-<hex>` while one-shot
* ids are hyphen-free (`a<hex>`). Such ids are never listed as task ids in
* `background_tasks`, so omission from the list proves nothing for them. */
export function isClaudeTeammateLifecycleId(id: string): boolean {
const separator = id.lastIndexOf('-')
return separator > 1 && id.startsWith('a') && /^[0-9a-f]+$/i.test(id.slice(separator + 1))
@@ -59,73 +65,33 @@ export function upsertWorkingClaudeSubagent(
if (id.length === 0 || id.length > CLAUDE_SUBAGENT_ID_MAX_LENGTH) {
return
}
const teammate = isClaudeTeammateLifecycleId(id)
const existing = roster.get(id)
if (existing) {
existing.state = 'working'
existing.agentType = fields.agentType ?? existing.agentType
existing.description = fields.description ?? existing.description
if (teammate) {
existing.teammate = true
}
// Why: live activity proves the lifecycle stream owns this id again;
// background_tasks absence must stop demoting it (teammate ids never
// appear there). The fold re-tags its own recreations after this call.
// background_tasks omission must stop reaping it (teammate-shaped ids
// never appear there). The fold re-tags its own recreations after this.
existing.backgroundTasksAuthoritative = undefined
return
}
if (roster.size >= AGENT_STATUS_MAX_SUBAGENTS && !evictOldestIdleClaudeSubagent(roster)) {
// 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) {
return
}
roster.set(id, {
state: 'working',
startedAt: now,
agentType: fields.agentType,
description: fields.description,
...(teammate ? { teammate: true as const } : {})
description: fields.description
})
}
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 finished one-shot subagent leaves the sidebar immediately —
* retaining it as an idle row made long workflow/ultracode sessions pile up
* dozens of dead rows. Teammates only idle (alive + resumable): SubagentStop
* fires each time a teammate finishes a task, not just at shutdown.
*
* Workflow/named one-shots share the teammate id shape (`a<label>-<hex>`),
* but unlike real teammates they ARE listed id-exact as `type: "subagent"`
* background tasks — including inside their own SubagentStop payload
* (verified against live hook captures). `listedAsSubagentTask` carries that
* corroboration so a finished workflow lane is removed instead of squatting
* as a phantom idle teammate. */
export function finishClaudeSubagent(
roster: ClaudeSubagentRoster,
id: string,
options?: { listedAsSubagentTask?: boolean }
): void {
const existing = roster.get(id)
if (!existing) {
return
}
if (existing.teammate && options?.listedAsSubagentTask !== true) {
existing.state = 'idle'
return
}
/** 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)
}
@@ -173,28 +139,23 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
/** Fold a lead Stop's `background_tasks` into the lifecycle-tracked roster.
*
* The list is authoritative for non-teammate children: a running one-shot is
* always listed under its lifecycle `agent_id` (verified against live hook
* captures), foreground children cannot span a lead Stop, and finished tasks
* are dropped from the list entirely. So:
* The list is authoritative for subagent-typed entries only: a running
* one-shot/workflow lane is always listed under its lifecycle `agent_id`,
* foreground children cannot span a lead Stop, and finished tasks drop from
* the list. Teammate-typed entries prove nothing per-agent (unrelated ids,
* permanently "running") — but their PRESENCE proves the session has
* named-agent/teammate machinery, and their total absence from a complete
* inventory proves no teammate-shaped child can still be alive. So:
* - an empty list proves nothing is left alive → clear the roster;
* - an id-exact match that is running is trusted fully (state + enrichment);
* one reported not running is finished → remove it;
* - an unmatched RUNNING non-teammate entry is a one-shot subagent this
* listener never saw start (Orca/relay restart mid-run) → recreate it so
* the pane doesn't read done while the child still runs;
* - a non-teammate roster entry missing from the present list is finished or
* dead (its SubagentStop was killed/lost) → remove it, otherwise it pins
* the pane 'working' forever;
* - teammates are exempt: their task ids never match lifecycle agent_ids, so
* omission proves nothing — except a snapshot-seeded phantom
* (backgroundTasksAuthoritative), which demotes to idle so it cannot gate
* the pane while teams sessions never send an empty list;
* - teammate-SHAPED entries are reclassified as one-shots when a subagent-
* typed task lists their lifecycle id, and are removed on omission when a
* complete inventory lists no teammate-typed task at all (a teams session
* always lists its teammates, even idle ones) — workflow/named one-shot
* lanes share the id shape and must not squat as phantom teammates. */
* - an id-exact subagent-typed match that is running is trusted fully and
* tagged listedAsSubagentTask; one reported not running is removed;
* - an unmatched RUNNING subagent-typed entry is a one-shot this listener
* 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. */
export function foldClaudeBackgroundTasksIntoRoster(
roster: ClaudeSubagentRoster,
tasks: ClaudeBackgroundAgentTask[],
@@ -210,6 +171,9 @@ export function foldClaudeBackgroundTasksIntoRoster(
const listedIds = new Set<string>()
const hasTeammateTypedTask = tasks.some((task) => task.teammate)
for (const task of tasks) {
if (task.teammate) {
continue
}
listedIds.add(task.id)
const existing = roster.get(task.id)
if (existing) {
@@ -217,18 +181,12 @@ export function foldClaudeBackgroundTasksIntoRoster(
roster.delete(task.id)
continue
}
existing.state = 'working'
existing.agentType = task.agentType ?? existing.agentType
existing.description = task.description ?? existing.description
// Why: real teammate lifecycle ids never appear as task ids, so an
// id-exact subagent-typed listing proves this teammate-SHAPED entry is
// a workflow/named one-shot; unflag it so its stop/omission removes it.
if (!task.teammate) {
existing.teammate = undefined
}
existing.listedAsSubagentTask = true
continue
}
if (task.teammate || !task.running) {
if (!task.running) {
continue
}
upsertWorkingClaudeSubagent(
@@ -240,6 +198,7 @@ export function foldClaudeBackgroundTasksIntoRoster(
const created = roster.get(task.id)
if (created) {
created.backgroundTasksAuthoritative = true
created.listedAsSubagentTask = true
}
}
if (options?.inventoryComplete === false) {
@@ -249,18 +208,12 @@ export function foldClaudeBackgroundTasksIntoRoster(
if (listedIds.has(id)) {
continue
}
if (tracked.teammate) {
// Why: a teams session lists its teammates as teammate-typed tasks even
// while they idle. A complete inventory with NONE proves no teammate is
// alive — so teammate-SHAPED leftovers are dead workflow/named one-shots
// (e.g. killed lanes whose SubagentStop was lost) and must go.
if (!hasTeammateTypedTask) {
roster.delete(id)
continue
}
if (tracked.backgroundTasksAuthoritative && tracked.state === 'working') {
tracked.state = 'idle'
}
if (
hasTeammateTypedTask &&
!tracked.backgroundTasksAuthoritative &&
tracked.listedAsSubagentTask !== true &&
isClaudeTeammateLifecycleId(id)
) {
continue
}
roster.delete(id)
@@ -276,34 +229,27 @@ export function claudeTeammateIdMatchesName(id: string, name: string): boolean {
return id.startsWith(prefix) && !id.slice(prefix.length).includes('-')
}
/** Mark a teammate idle from a TeammateIdle hook, which is keyed by name.
* Named teammates embed their name in `agent_id` (`a<name>-<hex>`); prefer
* that exact signal. Fall back to `agent_type === name` only when no id
* matches, so a one-shot subagent whose agent_type happens to collide with a
* teammate's name isn't wrongly idled alongside it. */
export function markClaudeTeammateIdleByName(roster: ClaudeSubagentRoster, name: string): boolean {
let matchedById = false
/** 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>`); prefer that exact signal. Fall
* back to `agent_type === name` only when no id matches, so a one-shot
* subagent whose agent_type happens to collide with a teammate's name isn't
* wrongly removed alongside it. */
export function removeClaudeTeammateByName(roster: ClaudeSubagentRoster, name: string): boolean {
let changed = false
for (const [id, tracked] of roster) {
if (!claudeTeammateIdMatchesName(id, name)) {
continue
}
matchedById = true
// Why: TeammateIdle is teammate-only proof; the flag keeps this entry
// exempt from one-shot removal on SubagentStop and fold omission.
tracked.teammate = true
if (tracked.state !== 'idle') {
tracked.state = 'idle'
for (const id of roster.keys()) {
if (claudeTeammateIdMatchesName(id, name)) {
roster.delete(id)
changed = true
}
}
if (matchedById) {
return changed
if (changed) {
return true
}
for (const tracked of roster.values()) {
if (tracked.agentType === name && tracked.state !== 'idle') {
tracked.teammate = true
tracked.state = 'idle'
for (const [id, tracked] of roster) {
if (tracked.agentType === name) {
roster.delete(id)
changed = true
}
}
@@ -311,15 +257,7 @@ export function markClaudeTeammateIdleByName(roster: ClaudeSubagentRoster, name:
}
export function claudeRosterHasWorkingSubagent(roster: ClaudeSubagentRoster | undefined): boolean {
if (!roster) {
return false
}
for (const tracked of roster.values()) {
if (tracked.state === 'working') {
return true
}
}
return false
return roster !== undefined && roster.size > 0
}
export function claudeRosterToSnapshots(
@@ -332,7 +270,7 @@ export function claudeRosterToSnapshots(
for (const [id, tracked] of roster) {
snapshots.push({
id,
state: tracked.state,
state: 'working',
startedAt: tracked.startedAt,
agentType: tracked.agentType,
description: tracked.description
@@ -2,17 +2,20 @@
* Regression spec for the two reported sidebar symptoms (live-reproduced in a
* dev instance before the fix):
*
* 1. "Really long idle list" under ultracode: finished workflow subagents
* left permanent `Idle - general-purpose` child rows for the rest of the
* session. Fixed: SubagentStop removes a one-shot subagent from the
* roster; only alive-but-idle teammates keep an idle row.
* 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.
*
* 2. "Never disappear even when killed from Orca": a subagent killed without
* its SubagentStop hook (SIGKILL'd process tree / lost event) stayed
* `working` forever and pinned the pane working. Fixed: a lead Stop's
* background_tasks is authoritative for non-teammate children — running
* ones are always listed id-exact (verified against live hook captures),
* so an unlisted non-teammate is finished/dead and is removed.
* background_tasks reaps unlisted children — hyphen-free one-shots always,
* and teammate-shaped rows once a complete inventory shows no
* teammate-typed task at all.
*
* Drives the real production pipeline (normalizeHookPayload) whose
* `payload.subagents` snapshots the sidebar renders 1:1 as child rows.
@@ -108,90 +111,92 @@ describe('claude subagent sidebar row lifecycle', () => {
expect(finalStop?.payload.subagents).toBeUndefined()
})
it('keeps an alive-but-idle teammate row while dropping finished one-shots', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'teams session' })
it('removes finished named agents on SubagentStop even while their teammate task stays "running"', () => {
// 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
// permanent idle rows (the 11-row gar "Orchestration Messages" pile).
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'areviewer-6d3cb5b52120b7bf',
agent_type: 'security-reviewer'
hook_event_name: 'UserPromptSubmit',
prompt: '--- Orchestration Messages (1) --- (ultracode)'
})
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aoneshot000000001',
agent_type: 'general-purpose'
agent_id: 'aweb-research-8a76b7d7595ce04e',
agent_type: 'web-research'
})
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aoss-hunt-95a28c160dc99e5e',
agent_type: 'oss-hunt'
})
claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'aoneshot000000001' })
claudeEvent({
// The lead's turn ends while both are still working — the pane must not
// read done, and the two rows show as working.
const teammateTasks = [
{ id: 'tws2g167l', type: 'teammate', status: 'running', description: 'web research' },
{ id: 't6s2brfv7', type: 'teammate', status: 'running', description: 'oss hunt' }
]
const midStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: teammateTasks })
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.
const afterFirst = claudeEvent({
hook_event_name: 'SubagentStop',
agent_id: 'areviewer-6d3cb5b52120b7bf',
agent_type: 'security-reviewer'
agent_id: 'aweb-research-8a76b7d7595ce04e',
background_tasks: teammateTasks
})
const idled = claudeEvent({
hook_event_name: 'TeammateIdle',
teammate_name: 'reviewer',
team_name: 'session-repro'
})
// Teammate stays (alive + resumable); the finished one-shot is gone.
expect(idled?.payload.subagents).toEqual([
expect.objectContaining({ id: 'areviewer-6d3cb5b52120b7bf', state: 'idle' })
expect(afterFirst?.payload.subagents).toEqual([
expect.objectContaining({ id: 'aoss-hunt-95a28c160dc99e5e', state: 'working' })
])
// Teams Stops list teammate tasks under unrelated ids and never send an
// empty list; the teammate row must survive them.
const stop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [{ id: 'tlkjjs0jv', type: 'teammate', status: 'running' }]
})
expect(stop?.payload.state).toBe('done')
expect(stop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'areviewer-6d3cb5b52120b7bf', state: 'idle' })
])
// oss-hunt finishes too — roster empties and the pane resolves done, 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('drops named workflow lanes instead of retaining them as phantom idle teammates', () => {
// Regression for the live-observed 32-row pile: workflow lanes report
// name-embedding ids (afinder-C-<hex>, agent_type = the label), which
// share the teammate id shape but are one-shots.
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'native chat review (ultracode)' })
it('reaps a named agent via its TeammateIdle fallback when SubagentStop is lost', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'orchestration (ultracode)' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'afinder-C-5d713c0781b7f8d2',
agent_type: 'finder-C'
agent_id: 'areview-standards-2750dacd',
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.
const idled = claudeEvent({
hook_event_name: 'TeammateIdle',
teammate_name: 'review-standards',
team_name: 'orchestration'
})
expect(idled?.payload.subagents).toBeUndefined()
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()
})
it('reaps a killed named agent at the lead Stop when no teammate task remains', () => {
// A named agent dies with neither SubagentStop nor TeammateIdle. Its
// teammate-shaped id never appears as a task id, so it can only be reaped
// when a complete inventory shows no teammate-typed task at all.
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'orchestration (ultracode)' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'acr-triage-1-c5a0588e7a2e4151',
agent_type: 'cr-triage-1'
})
// finder-C finishes; its SubagentStop carries the task inventory that
// lists it id-exact as a subagent — proof it is not a teammate.
const stopped = claudeEvent({
hook_event_name: 'SubagentStop',
agent_id: 'afinder-C-5d713c0781b7f8d2',
agent_type: 'finder-C',
background_tasks: [
{
id: 'afinder-C-5d713c0781b7f8d2',
type: 'subagent',
status: 'running',
agent_type: 'finder-C'
},
{
id: 'acr-triage-1-c5a0588e7a2e4151',
type: 'subagent',
status: 'running',
agent_type: 'cr-triage-1'
}
]
})
expect(stopped?.payload.subagents).toEqual([
expect.objectContaining({ id: 'acr-triage-1-c5a0588e7a2e4151', state: 'working' })
])
// cr-triage-1 is killed (SubagentStop lost). The lead Stop's complete
// inventory lists no teammate-typed task, so the leftover is removed.
const stop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [