fix(subagent): say when a run is parked on an unanswered spawn approval - #7299
Conversation
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All verification done — flag-set at First-Principles-Verdict: PASS Every added surface traces to one reported harm — a run invisible while parked on its spawn prompt — with consumers counted and the UI half explicitly deferred, not faked. What this change shipsIntent: let a user see that a "waiting" subagent is parked on an unanswered spawn approval, not executing — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 10afdf7 |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A silent indefinite wait becomes observable on every read surface, additively and reversibly — the right diagnostic shape, with policy questions correctly deferred and filed. Suggestions
[DESIGN-REVIEWED] 10afdf7 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
69b6fba to
6fd7798
Compare
|
The finding was correct, and it is already fixed - it read the description as it stood when the run was triggered, not the current one. Sequence: I force-pushed Nothing to rebut - a description claiming a diagnostic that does not exist is exactly the thing worth blocking on, and "a human merging on this description believes that diagnostic now exists" is the right framing of the harm. The remediation is already in place rather than promised: the current description contains zero matches for I have re-run this workflow so it re-reads the current description. No code changed for this finding; Two of the review's own observations are worth keeping on the record, since they verify the shipped surface rather than just the prose: the flag has a real pre-existing consumer at |
6fd7798 to
73abfb7
Compare
|
Verified and taken as a declared deferral, with the overclaim fixed. The claim checks out. The real defect here was the description, not the diff: it said the fix speaks "at each place the user actually looked" while quoting a sentence that names the dashboard. That framing is gone -- the section now enumerates the surfaces that actually got it (run state, log, both Filed as #7318, so it has the same standing as the reap-message deferral rather than "getting nothing". Not folded into this PR because it is a different kind of change, not a hidden cost:
The backend field is the prerequisite and it has landed, so #7318 can proceed independently without re-establishing the mechanism. #7318 also carries two things worth deciding there rather than here: a click-through to the approvals surface (which matters most for an unowned spawn, whose prompt carries Also noted: item 6, the one-shot |
73abfb7 to
943d15a
Compare
|
Real finding, fixed in Verified at the call sites first: Both remedies were on offer; I took the gating one rather than softening the strings, because the strings are not the only wrong thing - the lock glyph and the whole "waiting to start" framing are wrong for a mid-run tool wait, and a run at turn 5 should not appear in that state at all.
Both read paths now go through one predicate, Mutation-verified both ways: dropping the Worth recording, because it is the more interesting half of this finding: the deferred |
943d15a to
c6ea4b0
Compare
|
Real finding, fixed in Verified both halves of the claim at the source. Now reports On the accounting, which is the part of this finding worth keeping: all four read surfaces rendering per-run status off I have also recorded the process failure honestly in the harvest section: this fix found those four consumers one at a time across three review rounds, shipping each time believing it was complete. Grepping for an existing optional field on the same payload ( |
c6ea4b0 to
01f375b
Compare
buluoray
left a comment
There was a problem hiding this comment.
Reviewed at 01f375b51. 0 blocking / 1 yellow / 1 blue — approving.
Confirmed the gap is real: _exec_started is stamped at run.py:442, and on origin/main _awaiting_approval is set only at the three in-run tool sites (1276/1367/1391), never at the spawn gate — so a run parked on an unanswered spawn approval was registered and counted exactly like an executing agent with no child process. That is #6484's silence, and this addresses the cause rather than the symptom.
The _exec_started is None conjunct is genuinely load-bearing — it is the only thing separating a spawn-gate park from a mid-run tool approval, which reuses the same flag. Checked for both failure directions and found neither: the finally clears the flag on answer/deny/cancel, every consumer checks done first, and the stall watchdog's first guard (if not (info.turns>0 or info._pid is not None): return) early-returns for a parked run, so setting the flag at the spawn gate does not perturb reaping, the timeout window, or orphan recovery. MCP statelessness holds — no module global, no per-caller state, just a read branch on the /api/spawn response. Mutations reproduce your table exactly.
Yellow, and the one thing I would like fixed: the PR adds a new wire field awaiting_approval to the /api/spawn and /api/spawn/<id> payloads without updating docs/system-specs/modules/subagent.md in the same commit. That spec already documents the sibling stalled surface signal, so it now under-describes the payload and the next editor has no record of the field. AGENTS.md requires the spec move with the schema. Non-blocking, but no bot can catch this class — code-review.yml does not read docs and the line reviewers' FIX BAR excludes untouched files.
Blue: test_both_endpoints_use_the_shared_predicate does a literal .replace() of the exact 2-line predicate, so a future black rewrap breaks the ratchet on unchanged behavior.
|
Rebased onto current main after the 13-PR merge batch put this branch in conflict; the conflict is resolved and the PR is MERGEABLE again. The remaining reds on this head are main-owned and reproduce on PRs with disjoint diffs, so they are not actionable here:
Per house rule the main-owned fixes are not being folded in here. Once main heals I will rebase to cut a fresh merge ref and re-run, rather than re-triggering against a stale one. |
A default install has no YOLO override, no auto_approve_subagent_spawn and no session trust, so every spawn_run is gated behind the interactive spawn approval. While that prompt is unanswered the run is registered in _agents and counted by the manager's running count, so every reader that goes through /api/spawn reports it exactly like an agent that is executing: no child ACP process, subagents_spawned still 0, and nothing in the payload, the CLI spawn list, the MCP roster or the log naming the gate. An unowned spawn (the CLI posts no parent_session) raises its prompt with slot="", so it is surfaced only on the global approvals feed and appears in no chat tab either. Two adjacent halves of #6484 have already landed and are not redone here. #7325 stopped the reap of such a run blaming a deadline it never reached, and in doing so put info._awaiting_approval on the spawn gate. #7477 stopped a chat tab rendering an owned parked run as executing, deriving its cue from the WS approval event (status 'pending' + approval_id), not from this payload -- so it is scoped to a slot, and the unowned spawn still reaches no tab. What is left is the wait's NAME, on every path that reads a run: * _spawn_with_approval logs at INFO under the run id, with the parent (or "<unowned>"). #7325 marked the wait in machine state for the reaper; a mark is not a message, and nothing was written at all -- which is exactly how #6484 was reported, the reporter's only lead being that no log record mentioned the affected run id. * BOTH /api/spawn read paths carry awaiting_approval while parked, through ONE shared predicate _awaiting_spawn_approval(), present only then so the default payload is unchanged. The list endpoint feeds `kirocrew spawn list`; the single-run status endpoint is what a BLOCKING `kirocrew spawn run` polls every 2s, so reporting it on the list alone would have left the CLI reproduction exactly as silent as before. * That predicate requires _exec_started is None as well as the flag, because the flag is SHARED: run.py sets it at three in-run tool-approval sites, so a bare read would render a run at turn 5 waiting on a tool prompt as "waiting for spawn approval" and tell a still-polling caller to approve it "to start this run" that already started. _exec_started is stamped once when execution begins (_run_inner_impl), so None means the run never entered execution. terminal.py picks the reap message off the same pair, arrived at independently; the predicate is not extracted onto SubagentInfo because this read must survive the info doubles the handlers are tested with, and unifying would mean editing a reap path this change does not touch. One predicate rather than two inlined conditions: the handlers build their payloads independently, and a drift between them is invisible to a behavioural test, so a source ratchet pins both call sites. * MCP `spawn_list` reports [awaiting-approval] rather than [running] -- the surface an LLM reads, and the one spawn.py itself points a failing caller at ("Check spawn_list"). * `kirocrew spawn list` renders the wait instead of the bare hourglass it shared with a running agent, and the blocking poll announces it once rather than on every poll. * Prunes src/kiro_crew/mcp_tools/spawn.py from .github/black-baseline.txt: the file was listed as known-unformatted and this change makes it black-clean, and that baseline is shrink-only, so the gate requires the graduated entry be removed. Fixes #6484
e4074e3 to
10afdf7
Compare
Disposition: GPT 5.6 advisory on
|
buluoray
left a comment
There was a problem hiding this comment.
Verdict: 0 blocking, 2 non-blocking. Approving.
This is an additive, backend-only observability fix: a subagent parked on an unanswered spawn-approval prompt is now distinguishable from one that is executing, on every reader that goes through /api/spawn (both HTTP shapes, spawn list, the blocking spawn run poll, and MCP spawn_list), plus one INFO log line keyed to the run id.
What I verified (against GitHub main at head 10afdf7, not the local mirror)
- Real approval state, not a timeout/slowness heuristic. The wire predicate
_awaiting_spawn_approvalisgetattr(_awaiting_approval) is True and getattr(_exec_started) is None(dashboard/handlers/messaging.py:672). The flag is set synchronously at the spawn gate (admission.py:731, pre-existing from #7325) and_exec_startedis stamped once when execution begins (run.py:569). A slow-but-executing run has_exec_startedset, so the predicate returns False for it — a merely-slow run is never mislabeled "parked on approval." Confirmed the three mid-run TOOL-approval sites (run.py:1330,1421,1445) all run after_exec_startedis stamped, so_exec_started is Nonecorrectly separates the spawn gate from a mid-run tool wait. - No credential/argument leak on the reporting path. The new log line (
admission.py:741) emits onlyinfo.id,request_id(=spawn:{id}), andparent_session_key— no tool arguments and no task text (the redactedtask_previewis pre-existing and used only in the approval description). The new payload additions are a bare booleanawaiting_approval. Pre-existing_redact(info.last_tool)is unchanged. - State is cleared on every terminal branch. Verified on GitHub main that
admission.pysets the flag inside atrywithfinally: info._awaiting_approval = False(main:737, PR:752), so it clears on approval, on denial (returns after the finally), and on crash (except runs after the finally). It cannot wedge as permanently parked. - Previously-working output is unchanged for non-parked runs. All new fields and labels are present-only-while-awaiting (same convention as the existing
context_withheld); a live run's payload and glyph are byte-identical to before. - No frontend surface touched. The diff is Python-only (
admission.py,messaging.py,cli_commands.py,mcp_tools/spawn.py) plus theblack-baseline.txtprune and one new test file — nowebsite/or docs files — sowebsite/AUTOSDE.yaml's six blocking rules and the i18n catalog do not apply. - Spec obligation (AGENTS.md).
docs/system-specs/modules/subagent.mdalready documents the_awaiting_approval/_exec_startedmechanism this builds on (lines 158, 222, 264, including the spawn gate resetting the flag in afinally), and that mechanism is unchanged here. The PR only surfaces existing state, so no mandatory same-commit spec update is triggered. - All four AI review lanes PASS on this exact SHA (GPT 5.6, Opus 4.8, First Principles, Design), and the
mcp_tools/spawn.pyreformat hunks correspond 1:1 to its removal from.github/black-baseline.txt, which AGENTS.md sanctions for touched files.
Non-blocking findings
-
docs/system-specs/modules/subagent.md— the spec's/api/spawndescription does not mention the newawaiting_approvalwire field. Consequence: a future reader enumerating the payload from the spec alone would miss it. This is not a rule violation (the spec does not enumerate wire fields liketurns/last_tool/elapsedeither) and no gate flagged it — purely optional. Suggestion: a one-line mention next to the existingoutcome/stoppedwire notes, or leave it for #7318 which will touch this surface. -
dashboard/handlers/messaging.py:672andsubagent_manager/terminal.py:418— the_awaiting_approval and _exec_started is Nonepair is spelled in two places, held together by cross-referencing comments rather than one owner onSubagentInfo. The Design and First Principles bots both raised this; the author accepted-and-deferred it to #7318 with a rationale (the handler read must survive test info-doubles, the terminal read is a plain attribute read on a live run, and unifying would edit a reap path this PR doesn't touch). I agree the deferral is reasonable — a source ratchet in the new test pins both handler call sites in the meantime. Recording it so a third spelling appearing is the trigger to hoist it.
What I could not verify
- I did not independently re-run the test suite (read-only review in a checkout shared by 19 agents; no git writes). I relied on the author's documented A/B (6 of 9 tests fail against pristine main + the new file) and three mutation probes, plus the green Backend Tests lanes on this SHA. Two of the nine tests are precondition pins that pass on main by design, which the author discloses honestly.
- I judged review-lane freshness by comment bodies citing the full current SHA; I did not separately audit the earlier main-owned CI reds (#7499/#7504/#7295) the author diagnosed as unrelated to this diff.
Problem / Motivation
On a default install a
spawn_runcan sit forever showing "waiting" while no child ACP process is ever created, and nothing that reads the run through/api/spawnsays why. The reporter's status API showed the contradiction exactly:Registered and counted as running, but never spawned: no error, no failure event, no log line keyed by the run id, and
kirocrew spawn listshowing the same hourglass it shows for a healthy agent. The only way out was restarting the gateway.The cause is not a queueing race. A default install has no YOLO override,
auto_approve_subagent_spawndefaults toFalse, and a fresh session has no trust, so everyspawn_runfalls through the approval ladder to the interactive spawn-approval prompt. Until that prompt is answered the run parks in_spawn_with_approval: registered in_agents, counted by the manager's running count,turns == 0,_pid is None,_exec_started is None.countfilters_agentsonnot done and not queued, so the reporter's own numbers pin the location:subagents: 1proves the run passed registration, andsubagents_spawned: 0proves it never reached_log_spawned. Between those two points there is only the approval wait.Both reported reproductions collapse to this one mechanism. The CLI half is worse:
kirocrew spawn runposts{"task": ...}with noparent_session, so the prompt resolves toslot=""and is surfaced only on the global approvals feed. It appears in no chat tab, while the CLI prints "waiting for result..." and polls.Why it matters
The failure is silent, so a two-second click turns into a full investigation.
kirocrew logscontained no error or warning keyed by the affected run ID." That is still true on main today.GET /api/spawn,GET /api/spawn/<id>,kirocrew spawn list, and MCPspawn_list. Each reports it byte-identically to an executing agent, so none can answer "is this working, or waiting for me?"kirocrew spawn runpolls the single-run endpoint every 2s and prints "waiting for result..." while the prompt it needs is on a surface it never mentions.What changed (motivation -> approach -> change)
Symptom to cause: no child process -> the run never reached
_run-> it never reached_log_spawned(hencesubagents_spawned: 0) -> it is parked in_spawn_with_approval-> and that wait is marked in memory but is not named in the log and not reported on the wire. So the change names the wait and reports it, on every path that reads a run.Two adjacent halves of #6484 already landed on main and are deliberately not redone here. #7325 stopped the reap of such a run blaming a deadline it never reached, and in doing so put
info._awaiting_approval = Trueon the spawn gate. #7477 stopped a chat tab rendering an owned parked run as executing; it is frontend-only and derives its cue from the WSapprovalevent (status === 'pending' && approval_id), not from this payload, so it is scoped to a slot and the unowned CLI spawn still reaches no tab. What is left is every reader that goes through/api/spawn.subagent_manager/admission.py_spawn_with_approvallogs at INFO under the run id, with the parent or<unowned>. The flag beside it is #7325's; an earlier revision of this branch set it too, and that duplication was removed on rebase.dashboard/handlers/messaging.py/api/spawnread paths carryawaiting_approvalwhile parked, via one shared predicate_awaiting_spawn_approval(info). Present only while true, so the default payload is unchanged (same convention as the existingcontext_withheld).cli_commands.pykirocrew spawn listrenders a lock glyph and the wait instead of the bare hourglass it shared with a running agent; the blockingspawn runpoll announces the pending approval once, not on every 2s poll.mcp_tools/spawn.pyspawn_listreports[awaiting-approval]rather than[running]. This is the surface an LLM reads, andspawn.pyitself tells a caller whose spawn POST failed to "Check spawn_list"..github/black-baseline.txtsrc/kiro_crew/mcp_tools/spawn.py: touching the file made it black-clean, and the baseline is shrink-only, so the gate requires the graduated entry be removed.Two details in the predicate are load-bearing:
_exec_started is Noneas well as the flag._awaiting_approvalis shared:run.pysets it at three in-run TOOL-approval sites, so a bare read would render a run at turn 5 waiting on a tool prompt as "waiting for spawn approval" and tell a still-polling caller to approve it "to start this run" that already started._exec_startedis stamped once when execution begins (_run_inner_impl), soNonemeans the run never entered execution.terminal.pypicks its reap message off the same pair, arrived at independently in fix(subagent): report spawn-approval-parked reaps accurately, not as a missed deadline #7325.Not extracted onto
SubagentInfoto share withterminal.py: that read is a plain attribute read on a live run inside the manager package, while this one must survive theSimpleNamespace/MagicMockinfo doubles the handlers are tested with, and unifying them would mean editing a reap path this change does not otherwise touch. Two lines, and both sites name each other.Covering both endpoints matters:
api_spawn_listfeedskirocrew spawn list, butapi_spawn_statusis what a blockingkirocrew spawn runpolls every 2s, so reporting the wait on the list alone would have left the CLI reproduction exactly as silent as before.Deliberately not changed: the approval requirement itself, the approval window values, and the decision to leave a human prompt without a deadline of its own. Those are policy.
Tests
New file
test/test_subagent_spawn_approval_parked_6484.py, 9 tests._awaiting_approval, with the reported state (done is False,turns == 0,_pid is None,_exec_started is None,count == 1) asserted as preconditions so the test cannot drift onto a different state.admission.py; everything else covers a reader of the state._exec_startedset)./api/spawnhandlers gate on the shared predicate, and no bare flag read may creep back into either.awaiting_approvaland announces it once.spawn_listreportsawaiting-approvalfor a parked run and still reportsrunningfor a live one.Two of the nine are deliberately precondition pins, not new behaviour. The flag set/clear pair now passes on main, because #7325 put the flag on the spawn gate. They are kept because every payload test feeds the predicate a hand-built
SubagentInfo: if the gate stopped setting the flag, those tests would still pass while the field went dark in production, and #7325's own tests assert on the reap message rather than on the flag. Their docstrings say so rather than claiming credit.Scoped suites, all green: 9/9 in the new file; 724 passed / 2 skipped across
test_subagent_startup_watchdog(#7325's own),test_handlers_messaging_coverage,test_spawn_list_redaction,test_mcp_core_spawn_sub_agents,test_spawn_agent_roster,test_agent_roster_shared,test_subagent_reap_race,test_subagent_stall,test_cli; 493 passed acrosstest_api_server,test_subagent_coverage,test_subagent_persistence,test_subagent_context_group_plumbing,test_messaging_commands.Manual verification
Rebased first, then re-verified rather than trusting the automerge. The branch was 164 commits behind main and the rebase reported zero conflicts, but main had landed overlapping work in the same function, so a clean merge was not evidence of a correct result. Reading the rebased file found this PR's
_awaiting_approvalset/clear pair sitting next to #7325's, plus prose claims main had made false. Both were fixed before pushing.A/B against pristine main, measured not asserted. A detached worktree at main tip with only the new test file copied in: 6 failed, 3 passed. The 3 that pass are the flag set/clear pair and the "a live run still says
[running]" control. The 6 that fail are the log line, the four predicate/wire assertions, and the CLI poll, i.e. exactly this diff.Mutation-verified, three probes, because a test that passes both ways proves nothing:
logger.infofromadmission.py_awaiting_approvalreadapi_spawn_statuscall siteGates, all green: flake8, isort, mypy (1259 files), the black gate (6 files in scope, baseline honoured), agent-sdk-boundary, loop-bound-locks, sync-io-in-async, subprocess-encoding, lockdown-before-publish, brand-name, focus-cue, builtin-skill-scope, testpaths-coverage.
One CI red on this head was diagnosed and is not this diff's.
Backend Tests (3.12, 3)failed ontest/test_security.py::TestKeystoneVariableLeafNativeSpellings::test_an_absolute_home_spelled_with_backslashes_is_refused. The diff touches neithersecurity.pynortest_security.py;git log HEAD..mainon those two paths is empty, so the file is identical in this branch and in main; the test class predates this base; it passes locally in isolation; and only the 3.12 shard failed while every 3.10 shard was green on the same head. A rerun went green, consistent with order dependence (the test builds its input fromos.path.expanduser("~"), whilesecurity.pyanchors home viaPath.home()behind a cache keyed on resolved roots). Nothing was folded into this diff for it.No UI change, so no screenshots, no i18n key, and no eslint or bundle-size exposure.
Related Issues
_awaiting_approvalon the spawn gate.Pattern harvest
Rule candidate: review-prompt
Pattern: a status field added to one serializer of a run/job object but not to its siblings
Only the second of this defect's two layers generalizes.
The surface layer is close to a one-off: one particular wait, the spawn-approval gate, was marked in memory but never named in the log.
The layer underneath is a class.
/api/spawnhas two read paths that build their payloads independently, so a field a caller acts on can land on one and be silently absent from the other, and the gap is invisible to a behavioural test because "field absent" and "the run is not in that state" are the same bytes on the wire. An earlier revision of this very PR shipped exactly that bug: the field was on the list endpoint only, which left the blockingkirocrew spawn runreproduction as silent as it was before the fix, because that path polls the single-run endpoint. The only guard available was a hand-written per-file source ratchet pinning both call sites, and needing to hand-write one is itself the argument for a shared rule.Suggested prompt line: when a PR adds a status field to a run/job payload, name every serializer of that object and confirm the field reaches all of them, or say why it deliberately does not.
Deliberately not proposed as semgrep or lint: "sibling serializer" is not structurally detectable here. The two handlers share no type and no base class, so any pattern broad enough to catch them would fire on every optional key in the codebase.
Checklist
Fixes #6484