fix(subagent): report spawn-approval-parked reaps accurately, not as a missed deadline - #7325
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: |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Attribution fixed at the root — the message derives from observed state, not reason-dispatch elimination — with the wiring gap caught and test-pinned; the baseline add is a grounded migration record, not a widening. Suggestions
[DESIGN-REVIEWED] 816c38a |
First Principles Review (Fable 5) —
|
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: |
144c176 to
b8bc9d9
Compare
b8bc9d9 to
3fe0ba3
Compare
3fe0ba3 to
8715f07
Compare
…a missed deadline A default install gates every spawn_run behind an interactive spawn approval. If nobody answers, the run sits registered with turns==0, _pid None, _exec_started None; the reaper's wall clock (1800s) always fires ~5400s before the dashboard approval window (7200s) closes, so _force_reap stamped 'Reaped after 1801s (exceeded 1800s deadline) [turn 0/100]' -- blaming an execution deadline a run that never started could not have reached. - terminal.py: add a first branch in _force_reap_impl gated on info._awaiting_approval and info._exec_started is None that reports the run as parked on a never-answered spawn approval. Both conjuncts are load-bearing: run.py sets _awaiting_approval for mid-run TOOL prompts (where _exec_started is set), so _exec_started is None is what separates 'never started' from 'was running and blew the deadline'. - admission.py: wire info._awaiting_approval on the pre-execution spawn-approval gate (_spawn_with_approval_impl) using the same set-before-await / clear-in-finally lifecycle as the run.py TOOL sites, so the reap branch is actually reachable for the spawn-parked case. - baseline: record the pre-existing, coordinator-split-out ACP edge in terminal.py (ROUTE 2), matching the cron.py precedent, to unblock the scoped agent-sdk boundary gate. - tests: pin the approval-parked vs mid-run-tool-parked distinction in both directions, plus a production-wiring test that the spawn gate sets the flag with _exec_started None during the wait.
8715f07 to
816c38a
Compare
The lane's finding, quoted in full:
The premise is that this PR introduces or launders a new boundary violation. It does not.
The proposed fix fails on both halves. "Revert the Honest caveat, because the reading is textually grounded rather than noise: the baseline header does say "Do NOT add or raise a line to make a red gate green". It is nonetheless wrong here, because both alternative readings fail above and the commit message already discloses the entry as "the pre-existing, coordinator-split-out ACP edge … matching the cron.py precedent". |
The Design lane printed no The concern, quoted:
Accepted as correct, and deliberately deferred. Two reasons this PR is the wrong place to resolve it:
On the lane's suggestion to track the follow-up against #6484: that is a maintainer action on an issue rather than a code change, and I am not filing or retitling issues from this PR. Recording it against the finding here so the decision is captured rather than lost, and so the polite error message does not become the reason the timer mismatch is never revisited. |
chenmingwei23
left a comment
There was a problem hiding this comment.
Approved: PR Readiness passed, all review lanes green.
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
…al (#7299) 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 Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
Fixes #7306
Split out of #6484 / PR #7299 (which fixed the observability half). This lands the reap-message fix that could not ride along, plus the boundary-gate condition that blocked it.
Part 1 — the reaper misattributed the cause
A default install gates every
spawn_runbehind the interactive spawn approval. If nobody answers, the run sits withturns == 0,_pid is None,_exec_started is None. The subagent reaper's wall clock (1800s) always fires ~5400s before the dashboard approval window (7200s) closes, so_force_reap_implstamped:That blames an execution deadline a run which executed nothing could not have reached — exactly the misleading turn-0 timeout the startup watchdog exists to avoid.
Fix: a new first branch in
TerminalCoordinator._force_reap_impl, gated oninfo._awaiting_approval and info._exec_started is None, that reports the run as parked on a never-answered spawn approval (no "exceeded"/"deadline" wording). Both conjuncts are load-bearing:run.pysets_awaiting_approvalfor mid-run TOOL prompts (where_exec_startedis set), so_exec_started is Noneis what separates 'never started' from 'was running and blew the deadline'. The branch keys on info flags rather thanreason, because the buggy wall-clock reaper calls_force_reapwith no reason.Wiring fix (found in review): the new branch was dead code in this tree because nothing set
_awaiting_approvalon the pre-execution spawn-approval wait (the #7299 flag-setter is not an ancestor of this branch; onlyrun.py's mid-run TOOL approvals set it).admission.pynow wiresinfo._awaiting_approval = Truearound the spawn-approvalawaitin_spawn_with_approval_impl(cleared in afinally), matching the run.py lifecycle.Part 2 — unblocking the agent-sdk boundary gate
terminal.pyimports four private process-tree helpers directly fromkiro_crew.acp.client, but had no entry in.github/agent-sdk-boundary-baseline.txt. The file-scoped gate is dormant on main and fires the moment any PR touches the file, on a violation it did not introduce (the gate itself reports0 file(s) with new edges on added lines). The likely origin is the coordinator split-out: the edge moved into a new module while the baseline still tracked onlysubagent.py.Route chosen: record the migrated edge (ROUTE 2), not build a facade (ROUTE 1). Grounding:
cron.pycarries the identical four-helper import and is already baselined at count 1 — direct precedent.rfc-crew-agent-sdk-boundary.md§5.4 defers PID tracking/sweeping/reaping to a future AgentSupervisor phase,agent_sdkexposes none of these helpers today, andsession.py+cron.pyimport the same set — out of scope for a bug fix.Added
1 src/kiro_crew/subagent_manager/terminal.pyin sorted position.Changes (6 files)
.github/agent-sdk-boundary-baseline.txt— record the migrated ACP edge.src/kiro_crew/subagent_manager/terminal.py— new accurate reap branch.src/kiro_crew/subagent_manager/admission.py— set/clear_awaiting_approvalaround the spawn gate.src/kiro_crew/subagent.py— widen the_awaiting_approvalfield comment: the flag covers the pre-execution spawn gate as well as a mid-run tool prompt.docs/system-specs/modules/subagent.md— spec update for the same two facts (the widened flag meaning and the new reap message).test/test_subagent_startup_watchdog.py— three tests: both-directions distinction (approval-parked -> new message; mid-run-tool-parked -> generic message, proving the_exec_started is Noneconjunct is load-bearing) plus a production-wiring test that the spawn gate sets the flag.Testing
1 new offender(s), 0 grown count(s), 0 file(s) with new edges on added lines).py_compilepasses on all changed files.modules closest to the change (106 tests, including the 3 new ones) and 11 broader subagent modules (679 tests).
flake8andmypy --platform linuxare clean on the three touched source modules;blackreports all fourtouched Python files unchanged, and the repo's black, docs-lint, harness-parity and brand gates pass in scope.
Deliberately untouched
Item #3 in the issue (
_BACKGROUND_APPROVAL_SOURCESinslack/gateway.py) is approval-model policy, not a defect, and was left alone per the issue's explicit note.Pattern harvest
Rule candidate: review-prompt + a rule line in
docs/system-specs/common/error-handling.mdPattern: a terminal-state failure message assembled by elimination instead of from observed state.
_force_reap_implsynthesized its error with a two-arm dispatch —if reason == "startup_timeout"else"Reaped after {n}s (exceeded {default_timeout}s deadline)"— and the wall-clock sweep atmonitoring.py:489calls_force_reap(agent_id, info, elapsed)with noreasonat all. So theelsearm is the catch-all for every cause anyone ever adds, while asserting one specific limit was hit. A run withturns == 0,_pid is Noneand_exec_started is Nonetherefore got an epitaph naming an execution deadline it provably never reached. The rule that would have caught it: a synthesized failure message may name a limit (deadline, timeout, turn cap) only when the observed state proves the run reached the phase that limit measures; a default arm may describe what was observed, but must not assert which limit was hit.error-handling.mdcarries three principles today and none of them says this — even though the startup watchdog next door exists precisely to avoid "a misleading 30-minute turn-0 timeout", i.e. the same defect, caught once, at one site, without the rule ever being written down. Semgrep is the wrong instrument here (it cannot distinguish failure text from any other f-string); the checkable form is the doc rule plus a review-prompt line, with a concrete shape for a reviewer to look for: a reason-dispatch whose default arm interpolates a configured limit, reached by a caller that passes no reason.Rule candidate: ratchet tooling — conserve a baselined violation across a pure MOVE.
Pattern: this PR's second half, and the reason it cost a review round. Every baselined gate's
--update-baseline"only ever lowers counts and deletes lines, never adds or raises one", and the verdicts are diff-scoped. So when #6944 moved_sigkill_session_impland itsfrom kiro_crew.acp.client import (...)out ofsubagent.pyinto the newly createdsubagent_manager/terminal.py, #7150 dutifully recorded the departure (9 -> 8, total109 -> 108) and nothing could record the arrival. The destination file was left dirty-but-unbaselined and completely invisible — until the first unrelated PR to touch it, which then inherits a gate failure it did not cause and has to argue for a baseline ADD that the file's own header explicitly forbids. The fix for the class is mechanical, not judgment: run the ratchet family unscoped onmain(post-merge, its own non-blocking job) so a dirty-but-unbaselined file is named by the refactor that created it, and/or give--update-baselinea--migrate <old> <new>mode that moves a count between files without raising the total. Either one makes "the recorded total is conserved across a refactor" something CI checks, instead of something a later contributor has to reconstruct withgit log -Lon the baseline file. This applies identically tocheck_black_formatting.py,check_subprocess_encoding.pyandcheck_sync_io_in_async.py, which share the same shrink-only tooling and the same blind spot.