mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Drop named workflow lanes that share the teammate id shape
Workflow/named one-shot agents report name-embedding lifecycle ids (afinder-C-<hex>, agent_type = the label), indistinguishable by shape from resumable teammates - so the roster retained them as idle rows (observed live: a 32-row 12h-old pile). Unlike teammates they ARE listed id-exact as subagent-typed background tasks, including inside their own SubagentStop payload. Use that corroboration to remove them on stop, reclassify task-listed teammate-shaped entries as one-shots, and reap teammate-shaped leftovers when a complete inventory lists no teammate-typed task at all (a teams session always lists its teammates, even idle ones).
This commit is contained in:
@@ -2412,7 +2412,14 @@ function normalizeClaudeSubagentLifecycleEvent(
|
||||
Date.now()
|
||||
)
|
||||
} else {
|
||||
finishClaudeSubagent(roster, agentId)
|
||||
// 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 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.
|
||||
|
||||
@@ -32,6 +32,83 @@ describe('claude-subagent-roster', () => {
|
||||
expect(roster.get('aprobe1-6d3cb5b5')).toMatchObject({ state: 'idle', teammate: true })
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const roster: ClaudeSubagentRoster = new Map()
|
||||
upsertWorkingClaudeSubagent(roster, 'aprobe1-6d3cb5b5', { agentType: 'probe1' }, 100)
|
||||
|
||||
@@ -105,13 +105,24 @@ function evictOldestIdleClaudeSubagent(roster: ClaudeSubagentRoster): boolean {
|
||||
/** 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. */
|
||||
export function finishClaudeSubagent(roster: ClaudeSubagentRoster, id: string): void {
|
||||
* 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) {
|
||||
if (existing.teammate && options?.listedAsSubagentTask !== true) {
|
||||
existing.state = 'idle'
|
||||
return
|
||||
}
|
||||
@@ -178,7 +189,12 @@ export function readClaudeBackgroundAgentTasks(hookPayload: Record<string, unkno
|
||||
* - 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. */
|
||||
* 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. */
|
||||
export function foldClaudeBackgroundTasksIntoRoster(
|
||||
roster: ClaudeSubagentRoster,
|
||||
tasks: ClaudeBackgroundAgentTask[],
|
||||
@@ -192,6 +208,7 @@ export function foldClaudeBackgroundTasksIntoRoster(
|
||||
return
|
||||
}
|
||||
const listedIds = new Set<string>()
|
||||
const hasTeammateTypedTask = tasks.some((task) => task.teammate)
|
||||
for (const task of tasks) {
|
||||
listedIds.add(task.id)
|
||||
const existing = roster.get(task.id)
|
||||
@@ -203,6 +220,12 @@ export function foldClaudeBackgroundTasksIntoRoster(
|
||||
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
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (task.teammate || !task.running) {
|
||||
@@ -227,6 +250,14 @@ export function foldClaudeBackgroundTasksIntoRoster(
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -149,6 +149,65 @@ describe('claude subagent sidebar row lifecycle', () => {
|
||||
])
|
||||
})
|
||||
|
||||
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)' })
|
||||
claudeEvent({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'afinder-C-5d713c0781b7f8d2',
|
||||
agent_type: 'finder-C'
|
||||
})
|
||||
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: [
|
||||
{
|
||||
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({
|
||||
|
||||
Reference in New Issue
Block a user