orca/src/shared/claude-subagent-row-lifecyc...

306 lines
13 KiB
TypeScript

/**
* 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: 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
* `working` forever and pinned the pane working. Fixed: a lead Stop's
* 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.
*
* 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.
*/
import { beforeEach, describe, expect, it } from 'vitest'
import {
createHookListenerState,
normalizeHookPayload,
type HookListenerState
} from './agent-hook-listener'
import { makePaneKey } from './stable-pane-id'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey('tab-1', LEAF_ID)
describe('claude subagent sidebar row lifecycle', () => {
let state: HookListenerState
beforeEach(() => {
state = createHookListenerState()
})
const claudeEvent = (payload: Record<string, unknown>): ReturnType<typeof normalizeHookPayload> =>
normalizeHookPayload(state, 'claude', { paneKey: PANE_KEY, payload }, 'production')
it('drops each finished workflow subagent instead of accumulating idle rows', () => {
claudeEvent({
hook_event_name: 'UserPromptSubmit',
prompt: 'Help me research Vercel sandbox usage (ultracode)'
})
// A Workflow run spawns 21 one-shot agents over a long turn; each stops
// shortly after starting. Pre-fix this accumulated 21 idle rows.
let last: ReturnType<typeof claudeEvent>
for (let i = 0; i < 21; i++) {
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: `awf0000000000000${String(i).padStart(2, '0')}`,
agent_type: 'general-purpose'
})
last = claudeEvent({
hook_event_name: 'SubagentStop',
agent_id: `awf0000000000000${String(i).padStart(2, '0')}`
})
expect(last?.payload.subagents).toBeUndefined()
}
// Concurrent agents still show while working.
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aworking0000000001',
agent_type: 'general-purpose'
})
const working = claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aworking0000000002',
agent_type: 'general-purpose'
})
expect(working?.payload.subagents).toHaveLength(2)
expect(working?.payload.state).toBe('working')
})
it('removes a killed subagent whose SubagentStop was never delivered at the next lead Stop', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'research task' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'akilled0000000001',
agent_type: 'general-purpose'
})
// The child is killed; no SubagentStop ever arrives. The next lead Stop
// lists everything still alive — the killed child is not in it.
const stop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [
{
id: 'aother00000000001',
type: 'subagent',
status: 'running',
agent_type: 'general-purpose'
}
]
})
expect(stop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'aother00000000001', state: 'working' })
])
// The pane stays working only for the child that is genuinely alive.
expect(stop?.payload.state).toBe('working')
claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'aother00000000001' })
const finalStop = claudeEvent({ hook_event_name: 'Stop', background_tasks: [] })
expect(finalStop?.payload.state).toBe('done')
expect(finalStop?.payload.subagents).toBeUndefined()
})
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-#8825 these squatted as
// permanent idle rows (the 11-row gar "Orchestration Messages" pile).
claudeEvent({
hook_event_name: 'UserPromptSubmit',
prompt: '--- Orchestration Messages (1) --- (ultracode)'
})
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aweb-research-8a76b7d7595ce04e',
agent_type: 'web-research'
})
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aoss-hunt-95a28c160dc99e5e',
agent_type: 'oss-hunt'
})
// 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 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).toHaveLength(2)
expect(afterFirst?.payload.subagents).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'aoss-hunt-95a28c160dc99e5e', state: 'working' }),
expect.objectContaining({ id: 'aweb-research-8a76b7d7595ce04e', state: 'idle' })
])
)
// 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('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',
agent_id: 'areview-standards-2750dacd',
agent_type: 'review-standards'
})
// 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).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).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', () => {
// 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'
})
const stop = claudeEvent({
hook_event_name: 'Stop',
background_tasks: [
{
id: 'awf0000000000000zz',
type: 'subagent',
status: 'running',
agent_type: 'general-purpose'
}
]
})
expect(stop?.payload.subagents).toEqual([
expect.objectContaining({ id: 'awf0000000000000zz', state: 'working' })
])
})
it('removes aborted subagents on the interrupt Stop so the pane can resolve', () => {
claudeEvent({ hook_event_name: 'UserPromptSubmit', prompt: 'long batch' })
claudeEvent({
hook_event_name: 'SubagentStart',
agent_id: 'aaborted000000001',
agent_type: 'general-purpose'
})
// Esc/Ctrl+C: claude emits SubagentStop for aborted children (verified
// live), then Stop with is_interrupt. Both paths clean the roster.
claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'aaborted000000001' })
const stop = claudeEvent({
hook_event_name: 'Stop',
is_interrupt: true,
background_tasks: []
})
expect(stop?.payload.state).toBe('done')
expect(stop?.payload.interrupted).toBe(true)
expect(stop?.payload.subagents).toBeUndefined()
})
})