Repository navigation
Tuval's running-subagent list is empty for every background Agent spawn #9506
Description
Activity
- addedstatus:needs-triageFiled, awaiting triage classificationFiled, awaiting triage classification
on Sep 20, 2026 - No description provided.
- addedp1Medium priorityMedium priorityready-for:agentAn execution engine may pick this up.An execution engine may pick this up.status:triagedTriage signed off; ready for write-code to pickTriage signed off; ready for write-code to picktype:bugBehavior diverges from intentBehavior diverges from intentand removedstatus:needs-triageFiled, awaiting triage classificationFiled, awaiting triage classification
on Sep 20, 2026 PR #9524.
Look first at
apps/tuval/src/claude/history/map.ts: two small arms.userEventsskips the finish
loop when the frame'stool_use_resultsays the Agent call answered a launch;taskNoticeEvents
now finishes the slot and emits its event.The read is frame-level, not block-level —
SDKUserMessage.tool_use_resultis one structured output
per frame and names no call of its own. Every capture in the corpus answers exactly one call per
user frame, and a new case inboundary.unit.test.tsholds it to that, so the read is exact for
every shape captured. A frame settling two Agent calls at once, one foreground and one background,
is the shape it could not tell apart; none is captured and the harness backgrounds by default.The fixture is an excerpt from a real background spawn, not a
query()capture —PROVENANCE.md's
new section says what is verbatim and what was re-keyed, including why the notification's envelope
comes offtwo-subagent-turn.jsonrather than off the type declaration.Criterion 7's evidence is in the PR body under "Hand verification", and the Deviations entry says
what it substituted: no browser in this lane, so the rendered check runs through the shipped
ChatWindowin jsdom over the real spawn's own frames.Lane transcript — lane 9506, terminal
completeThe merge queue landed PR #9524 at 2026-09-20 15:02 PDT (22:02:26Z), merge commit
eb977e996f6116b58e73fe7b65adedb8e27ef18f, which closed this issue. This pass re-read the queue withship reconcile 9524 --polls 1(answer:landed) and recordedISSUE.DONE(tokenLANDED). The fold now readscomplete.lane print
{ "phases": [ { "name": "pipeline", "tasks": [ "issue" ] } ], "terminals": { "complete": "complete", "tripped": "tripped" }, "tasks": { "issue": { "initial": "queued", "maxRetries": 3, "maxWaits": 3, "maxLaps": 16, "states": { "queued": [ "WIP", "BLOCKED", "CLEARED", "CANCELLED", "LANDED" ], "build": [ "DONE", "BLOCKED", "LAP", "CLEARED", "CANCELLED", "LANDED" ], "build:ui": [ "DONE", "BLOCKED", "LAP", "CLEARED", "CANCELLED", "LANDED" ], "review": [ "PASS", "BLOCKED", "LAP", "FAIL", "CLEARED", "CANCELLED", "LANDED" ], "review:ui": [ "PASS", "BLOCKED", "LAP", "FAIL", "CLEARED", "CANCELLED", "LANDED" ], "ship": [ "LAP", "DONE", "WIP", "BLOCKED", "FAIL", "CLEARED", "CANCELLED", "LANDED" ], "ship:queued": [ "LAP", "DONE", "BLOCKED", "WIP", "FAIL", "CLEARED", "CANCELLED", "LANDED" ], "blocked": [ "UNBLOCKED", "CLEARED", "CANCELLED", "LANDED" ], "human:cp-approval": [ "UNBLOCKED", "CLEARED", "CANCELLED", "LANDED" ], "human:queue-stall": [ "UNBLOCKED", "CLEARED", "CANCELLED", "LANDED" ], "human:machinery-stall": [ "UNBLOCKED", "CLEARED", "CANCELLED", "LANDED" ], "human:budget-spent": [ "UNBLOCKED", "CLEARED", "CANCELLED", "LANDED" ], "shipped": [ "CLEARED", "CANCELLED", "LANDED" ], "diagnosed": [ "CLEARED", "CANCELLED", "LANDED" ], "board:cancelled": [ "CLEARED" ], "board:landed": [ "CLEARED" ] } } } }lane history
[ { "task": "issue", "event": "ISSUE.WIP", "at": "2026-09-20T20:03:46.941Z" }, { "task": "issue", "event": "ISSUE.DONE", "at": "2026-09-20T20:24:00.861Z", "pr": "https://github.com/kamp-us/phoenix/pull/9524" }, { "task": "issue", "event": "ISSUE.FAIL", "at": "2026-09-20T20:36:24.494Z", "pr": "https://github.com/kamp-us/phoenix/pull/9524", "classes": [ "code", "doc", "ui" ] }, { "task": "issue", "event": "ISSUE.DONE", "at": "2026-09-20T20:44:56.158Z", "pr": "https://github.com/kamp-us/phoenix/pull/9524" }, { "task": "issue", "event": "ISSUE.FAIL", "at": "2026-09-20T20:59:42.666Z", "pr": "https://github.com/kamp-us/phoenix/pull/9524", "classes": [ "code", "doc", "ui" ] }, { "task": "issue", "event": "ISSUE.DONE", "at": "2026-09-20T21:10:21.870Z", "pr": "https://github.com/kamp-us/phoenix/pull/9524" }, { "task": "issue", "event": "ISSUE.PASS", "at": "2026-09-20T21:32:38.068Z", "pr": "https://github.com/kamp-us/phoenix/pull/9524", "classes": [ "code", "doc", "ui" ], "deferred": [ "review-ui" ] }, { "task": "issue", "event": "ISSUE.PASS", "at": "2026-09-20T21:38:16.756Z", "pr": "https://github.com/kamp-us/phoenix/pull/9524", "routed": [ "review-ui" ] }, { "task": "issue", "event": "ISSUE.WIP", "at": "2026-09-20T21:48:56.762Z", "pr": "https://github.com/kamp-us/phoenix/pull/9524" }, { "task": "issue", "event": "ISSUE.WIP", "at": "2026-09-20T22:02:14.304Z" }, { "task": "issue", "event": "ISSUE.DONE", "at": "2026-09-20T22:14:29.189Z", "pr": "https://github.com/kamp-us/phoenix/pull/9524", "partial": false, "landed": [ 9524 ] } ]
Tuval's running-subagent list draws nothing while a background
Agentworker runs. The slot is opened at thetool_use, then finished one frame later by the async-launchtool_result— sorunningSubagents, which keeps onlystatus === "running", has nothing left to show.Where it breaks
apps/tuval/src/claude/history/map.ts— the tool-result arm. AfterfoldSlots({...mapping, toolCalls}, settled, framedParentId, 0)a loop walks every settled row and flips its slot tofinished, under the comment "A settling call is the one thing that ends its worker's slot". That premise held when everyAgentcall was foreground. A background spawn answers its owntool_resultwithin a second (Async agent launched successfully. (This tool result is internal metadata …)), so the loop ends a worker that has not started work.apps/tuval/src/shell/chat/subagents.ts—runningSubagentsfilters onstatus === "running", so the empty list is the downstream symptom, not a second bug.apps/tuval/src/claude/history/map.ts—taskNoticeEventsis where the real end arrives: thetask_notificationframe for thattool_use_idis raised only when the worker settles, andtaskOutcomeOfalready reads itscompleted | failed | stoppedstatus.Triage note (read at
origin/main, not the filer's checkout): the gap is live, and the original body overstates one thing.taskNoticeEventslooks the slot up bycallIdfor the name only — it emits a system item and returnsmappingunchanged. It does not resolve the slot. So the fix is two halves, not one: stop finishing on the async-launch result, and make the notice arm actually flip the slot.Shape of the fix (non-binding)
Read the async-launch answer off the settled
Agentrow and skip it in the finish loop; foreground spawns keep today's behaviour, which the existingsubagent-turn.json/two-subagent-turn.jsonfixtures cover. Then havetaskNoticeEventsreturn asubagentsmap with that slot finished, plus itsendedevent, so the slot still cannot survive the turn (the#8401invariant: a slot left running is a state no checkpoint can be taken on).The
kernelSpawnOfloop below the finish loop is the existing precedent for a spawn whose slot must not be ended by its own settling call — worth reading before changing the ordering.Pointers
apps/tuval/src/claude/history/map.ts— finish loop afterfoldSlots;spawnTypeOf;kernelSpawnOfopener;taskNoticeEvents;taskOutcomeOfapps/tuval/src/shell/chat/subagents.ts—runningSubagentsapps/tuval/src/claude/agent/sidechain-store.ts— the per-agent sidechain and its metaapps/tuval/src/claude/history/fixtures/— where the new capture goes, with its row inPROVENANCE.mdWhy now
Background is the harness default for
Agent, so every fabrika driver spawn — builder, reviewer, shipper, operator — lands in this path. The list exists to keep the live-vs-finished picture honest, and today it is empty exactly when a lane is running.Why it is standalone
Searched the board on this surface. #8795 is the mirror-image bug (a kernel-child slot that never leaves running) and #9504 is child-process lifetime, not slot state; every other subagent-list ticket (#8384, #8401, #8405, #8475, #8664) is closed. Nothing open owns this surface, so it is minted on its own.
Acceptance criteria
Agenttool_use, its async-launchtool_result, and the latertask_notificationfor the sametool_use_id— lives underapps/tuval/src/claude/history/fixtures/with a row inPROVENANCE.md.runningafter thetool_resultframe andfinishedonly after thetask_notificationframe, asserted in a unit test.runningSubagentsreturns that worker's row for every frame between the two, asserted in a unit test.Agentspawn still finishes on its owntool_result, asserted against the existingsubagent-turn.jsonandtwo-subagent-turn.jsonfixtures.taskNoticeEventsemits the slot'sendedevent alongside its notice item, so no slot outlives the turn (TuvalAiAgent port + core: a per-subagent slot on the agent state, coalesced into the checkpoint #8401).task_notificationcarryingfailedorstoppedends the slot too, not justcompleted.Agentspawn. [evidence: hand-verification section on the PR, naming the spawn and what the list showed]Original report (verbatim)
Summary
The running-subagent list at the top of the Tuval agent window shows nothing while a background
Agentworker runs. The spawning call's tool result is what ends a slot, and a background spawn answers that result within a second of launching, so the slot is marked finished while the worker keeps going for minutes.What I was doing
Driving PR #8063 to merge from the
tuval-fedesk session. I spawned afabrika:reviewersubagent (opus,isolation: worktree, background by default). The harness's own agent list showed it running; the desk's subagent list was empty. The founder asked why.What I observed
In
apps/tuval/src/claude/history/map.ts, the tool-result arm runsfoldSlotsand then walks the settled calls: any slot whose spawning call just settled is set tostatus: "finished"(the loop right afterfoldSlots({...mapping, toolCalls}, settled, framedParentId, 0), with the comment "A settling call is the one thing that ends its worker's slot").A background
Agentcall returns itstool_resultimmediately. This session's main transcript carries, for the spawningtool_use_id, the textAsync agent launched successfully. (This tool result is internal metadata …)as the result, seconds after the call. So the slot opened byspawnTypeOfat thetool_usewas finished on the next frame.runningSubagentsfilters tostatus === "running", so the list draws nothing.The worker is real and readable: its sidechain lives under the CLI project store at
<session>/subagents/agent-<id>.jsonlwith ameta.jsonnamingagentType,spawnedWithWorktreeand the spawningtoolUseId. Thetask_notificationframe for thattool_use_idarrives only when the worker actually ends;taskNoticeEventsalready receives it and already resolves the slot bycallId, but by then the slot has been finished for the whole run.Feature flag is not the cause:
featuresDefault.subagentLististrue.Why it matters
Background is now the harness default for
Agent, and every fabrika driver spawn (reviewer, shipper, builder, operator) goes that way. The list the founder relies on to see what is live is empty exactly when it matters, and a worker's nested rows have no live slot to fold into. The founder's standing ask is to keep the live-vs-finished picture accurate because a wrong list costs him attention.Pointers
apps/tuval/src/claude/history/map.ts— the tool-result arm's finish loop afterfoldSlots;spawnTypeOf;taskNoticeEvents;taskOutcomeOfapps/tuval/src/shell/chat/subagents.ts—runningSubagentsfilters onstatus === "running"apps/tuval/src/claude/agent/sidechain-store.ts— the per-agent sidechain read and its metaSuggested next step (non-binding)
Do not finish a slot on an
Agenttool result that is the async-launch answer; end it on thetask_notificationframe for thattool_use_idinstead, which is the real end of a background worker. Foreground spawns keep today's behaviour. Add a fixture from this session's frames (thetool_use, the async-launchtool_result, and the latertask_notification).Filed by an agent · session
2af7c8e9-8304-4933-9a86-72bfc3293915· 2026-09-20T19:29:05Z