mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
fix(agent-status): close the remaining no-lead sticky/phantom paths
Review findings round 7: (1) a child permission wait that is a fresh pane's first lead record fabricated 'working' from the no-stash clear fallback when the child died — drop the record instead and let evidence rules decide. (2) A complete empty stop inventory now clears restored phantom rows (it is complete by construction; the non-empty additive fold is unchanged). (3) An uncapped raw scan sees running subagents hidden past the 32-entry parser cap, so a capped inventory with live work emits truthful working instead of retaining stale state.
This commit is contained in:
@@ -3221,9 +3221,60 @@ describe('shared agent-hook-listener', () => {
|
||||
agent_id: 'a1',
|
||||
background_tasks: cappedTasks
|
||||
})
|
||||
// Why: the uncapped raw scan still sees hidden-live running — the pane is
|
||||
// truthfully working, never done.
|
||||
expect(stopped?.payload.state).toBe('working')
|
||||
})
|
||||
|
||||
it('claims nothing from a no-lead removal when a truncated inventory shows no live work', () => {
|
||||
claudeEvent({
|
||||
hook_event_name: 'SubagentStart',
|
||||
agent_id: 'a1',
|
||||
agent_type: 'general-purpose'
|
||||
})
|
||||
const teammateOnly = Array.from({ length: 33 }, (_, i) => ({
|
||||
id: `t${i}`,
|
||||
type: 'teammate',
|
||||
status: 'running'
|
||||
}))
|
||||
const stopped = claudeEvent({
|
||||
hook_event_name: 'SubagentStop',
|
||||
agent_id: 'a1',
|
||||
background_tasks: teammateOnly
|
||||
})
|
||||
expect(stopped).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves a fresh-pane child permission wait whose child dies to done, not phantom working', () => {
|
||||
// Why: the wait is this pane's FIRST lead record; the old no-stash fallback
|
||||
// fabricated 'working' when the waiting child stopped — a stuck card.
|
||||
claudeEvent({
|
||||
hook_event_name: 'PermissionRequest',
|
||||
agent_id: 'w1',
|
||||
agent_type: 'general-purpose',
|
||||
tool_name: 'Bash'
|
||||
})
|
||||
const stopped = claudeEvent({ hook_event_name: 'SubagentStop', agent_id: 'w1' })
|
||||
expect(stopped?.payload.state).toBe('done')
|
||||
expect(stopped?.payload.subagents).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears restored phantom rows when a no-lead stop carries a complete empty inventory', () => {
|
||||
seedClaudeSubagentRosterFromSnapshots(state, PANE_KEY, [
|
||||
{ id: 'a1', state: 'working', startedAt: 1, agentType: 'general-purpose' },
|
||||
{ id: 'a2', state: 'working', startedAt: 1, agentType: 'general-purpose' }
|
||||
])
|
||||
// Why: a2's finish hook was lost while Orca was down; a1's stop reports an
|
||||
// authoritative empty inventory, which must not leave a2 spinning forever.
|
||||
const stopped = claudeEvent({
|
||||
hook_event_name: 'SubagentStop',
|
||||
agent_id: 'a1',
|
||||
background_tasks: []
|
||||
})
|
||||
expect(stopped?.payload.state).toBe('done')
|
||||
expect(stopped?.payload.subagents).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps working when a no-lead drain leaves a live shell in the inventory', () => {
|
||||
claudeEvent({
|
||||
hook_event_name: 'SubagentStart',
|
||||
|
||||
@@ -2478,7 +2478,11 @@ function normalizeClaudeSubagentLifecycleEvent(
|
||||
getOrCreateClaudeSubagentRoster(state, paneKey),
|
||||
inventory.tasks,
|
||||
Date.now(),
|
||||
{ inventoryComplete: false }
|
||||
// Why: an empty inventory is complete by construction and proves nothing is left
|
||||
// alive (lead-path rule), clearing restored phantoms; a non-empty one folds
|
||||
// additively — its reap would kill a just-parked teammate row before its
|
||||
// TeammateIdle confirmation arrives.
|
||||
{ inventoryComplete: inventory.tasks.length === 0 }
|
||||
)
|
||||
}
|
||||
// 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.
|
||||
@@ -2552,7 +2556,14 @@ function clearClaudePendingWaitForAgent(
|
||||
if (lead?.state !== 'waiting' || !lead.waitingAgentId || !ownsWait(lead.waitingAgentId)) {
|
||||
return
|
||||
}
|
||||
state.claudeLeadStateByPaneKey.set(paneKey, lead.stateBeforeWait ?? { state: 'working' })
|
||||
if (lead.stateBeforeWait) {
|
||||
state.claudeLeadStateByPaneKey.set(paneKey, lead.stateBeforeWait)
|
||||
return
|
||||
}
|
||||
// Why: no stash means the wait was this pane's FIRST lead record (child wait on a fresh
|
||||
// process); fabricating 'working' here becomes a stuck card when the waiting child dies —
|
||||
// drop the record and let the evidence rules decide downstream (STA-2915 review).
|
||||
state.claudeLeadStateByPaneKey.delete(paneKey)
|
||||
}
|
||||
|
||||
/** Clear an AskUserQuestion wait after the answer is typed (answering emits no hook event; the caller infers it from the submit keystroke). Restores the stashed pre-wait lead state or 'working', drops the cached card, and returns the pane state to emit (gated up to 'working' while children run). */
|
||||
@@ -2580,6 +2591,22 @@ export function clearClaudeAnsweredQuestionWait(
|
||||
}
|
||||
|
||||
/** Re-emit the lead's cached state on child activity — gated up to 'working' while a child works — without touching the lead's tool/prompt caches, so a live card or permission wait survives child churn. */
|
||||
// Why: the roster parser bounds TRACKED rows at the wire cap; this boolean liveness scan of the
|
||||
// raw array must still see a running subagent-typed entry hidden past it.
|
||||
function rawInventoryHasRunningSubagentTask(hookPayload: Record<string, unknown>): boolean {
|
||||
const raw = hookPayload['background_tasks']
|
||||
return (
|
||||
Array.isArray(raw) &&
|
||||
raw.some((item) => {
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
return false
|
||||
}
|
||||
const task = item as Record<string, unknown>
|
||||
return task.type === 'subagent' && task.status === 'running'
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function buildClaudeChildDrivenStatusPayload(
|
||||
state: HookListenerState,
|
||||
eventName: unknown,
|
||||
@@ -2610,16 +2637,18 @@ function buildClaudeChildDrivenStatusPayload(
|
||||
})
|
||||
}
|
||||
if (evidence.removedRow) {
|
||||
// Why: live shells/crons veto done (the raw-array check is uncapped). A truncated agent-task
|
||||
// inventory is incomplete evidence — a running child past the parser cap would be invisible —
|
||||
// so it claims neither done nor working. Running non-teammate tasks were already folded into
|
||||
// the roster above and surface through the working branch.
|
||||
if (hasActiveClaudeNonAgentBackgroundWork(hookPayload)) {
|
||||
// Why: live shells/crons veto done, and the uncapped raw scan sees a running subagent hidden
|
||||
// past the parser cap (tracked folding surfaces the listed ones through the working branch).
|
||||
if (
|
||||
hasActiveClaudeNonAgentBackgroundWork(hookPayload) ||
|
||||
rawInventoryHasRunningSubagentTask(hookPayload)
|
||||
) {
|
||||
return buildClaudeStatusPayload(state, eventName, '', paneKey, hookPayload, {
|
||||
stateName: 'working',
|
||||
updateToolSnapshot: false
|
||||
})
|
||||
}
|
||||
// Why: a truncated inventory with no visible live work is incomplete evidence — claim nothing.
|
||||
const inventory = readClaudeBackgroundAgentTasks(hookPayload)
|
||||
if (inventory.present && inventory.truncated) {
|
||||
return null
|
||||
|
||||
Reference in New Issue
Block a user