fix(subagent): bound the spawn queue depth with a typed refusal - #6272
fix(subagent): bound the spawn queue depth with a typed refusal#6272DeryFerd wants to merge 1 commit into
Conversation
f8b8ef8 to
785d4ae
Compare
785d4ae to
787f0f0
Compare
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed BLOCKING -- src/kiro_crew/subagent.py:3576 -- Queue overflow creates an unbounded callback backlog |
First Principles Review (Fable 5, fork) — 🔴 BLOCKPremise-level review of All evidence gathered. Composing the review. First-Principles-Verdict: BLOCK The cap is earned; the hand-rolled announce bypass patches around a nameable in-scope leak in What this change shipsIntent: stop a spawn flood from growing the gateway heap without bound — a FIX.
BlockersThe announce bypass duplicates
WatchDescription says "Two tests, written first… these are the two assertions a reviewer would write"; the diff ships three — the third exists to pin the undeclared bypass in blocker 1. [FIRST-PRINCIPLES-REVIEWED] fed1a60 |
Design Review (Fable 5, fork) — 🔴 BLOCK (blocking)Design-level review of I have everything I need. The design picture is now complete:
Design-Verdict: BLOCK The cap refuses members of legitimate documented waves — spawn_run promises "pass ALL tasks, overflow queues automatically," and this diff silently breaks that at 32. Blockers
Watch
[DESIGN-REVIEWED] fed1a60 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
787f0f0 to
fed1a60
Compare
fed1a60 to
c86503f
Compare
c86503f to
c5f868c
Compare
The SubagentManager docstring promises that a max concurrent limit prevents resource exhaustion. The promise is half-kept: the running side is bounded by _max_concurrent (default 3), but _queue itself has no upper bound — a sustained flood of spawn() calls with the pool saturated accumulates in _queue linearly with the spawn rate while the drain is bounded by the same _max_concurrent. Add _MAX_QUEUED = 32 (mirroring _MAX_CONCURRENT = 3) as a constructor kwarg so callers can tune it without editing the module. Refusals mirror the existing refusal shape: done=True SubagentInfo with a retry-later error string and a typed refused_queue_full SEL outcome carrying max_queued + queue_depth in metadata, alongside the existing rejected_empty_task, refused_memory_critical, and rejected_invalid_cwd outcomes. Pinned by test/test_subagent_queue_cap.py (queue-routing + flood-overflow). The test file is pinned via healthy_host_memory at module scope so the spawn-host-memory ratchet goes green; inner patches in each test land on top of the fixture to vary the verdict the test is interested in. A pre-existing 'Sanity' prefix in the first test's docstring tripped the inclusive-language gate; dropped, the docstring now opens on the test's actual subject.
c5f868c to
7803c80
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Blocking: several concrete description claims disagree with this head. The body says the change is two files and adds a max_queued constructor kwarg with two new tests; the diff touches six files, does not expose that constructor kwarg, exempts batched spawns from the cap, adds a separate _announce_rejection task-leak fix, and contains four new tests plus a coverage-test change. Please update the implementation or the PR description so the public contract, scope, and test inventory exactly describe the code under review.
|
Addressed in 7803c80. The PR body is now updated to match the head: it describes the 6-file change (no more The implementation has not changed since the body you reviewed — only the description has. Could you re-check on this head and let me know if the contract / scope / test inventory now matches the code under review? |
|
@bolichen97 gentle nudge — could you re-review on head 7803c80? The PR body now matches the implementation (description is the only thing that changed; no new commits). If the GitHub UI does not surface a Re-request review button, dismissing your previous CHANGES_REQUESTED review from the PR page (or the Files Changed tab) and submitting a fresh one is the cleanest path. Thanks. |
|
Integration requirement after rereading current #5280 ( This PR's Please make queue-full a durable, replayable terminal admission result after command identity/claim ownership is known: settle the command, commit the terminal/outbox event exactly once, and let the existing wave consumer close accounting. Avoid leaving a |
|
Agreed on the design. The durable terminal/outbox path is the right home for queue-full — the current SEL + _safe_fire on_done is a stopgap that loses replayability. Concrete observations from reading the stack on my end:
Two concrete paths, and I'd like your read before I start moving code: Path A (this PR, scoped): route the queue-full refusal through Path B (rebased stack): close #6272 and open a new PR rebased onto Which path would you prefer? Happy to go either way; I want to confirm before I start a 200-file rebase. If A, I'll have a draft on the existing |
Pull request was closed
Problem / Motivation
SubagentManager.spawn()bounds the running pool with_max_concurrent(default 3), so a sustained flood cannot start more than three sub-agents in parallel. The module docstring opens with the contract this enforces — "Max concurrent limit prevents resource exhaustion." The contract is half-kept: the running side is bounded, but the queue that backs the running gate has no upper bound of its own.self._queue.append(...)adds entries whenever the pool is saturated, and the only path that removes them is_drain_queue()running on a one-per-_max_concurrentcadence. A sustained flood of non-batchedspawn()calls accumulates in_queuelinearly with the spawn rate while the drain stays bounded. A related leak lives in_announce_rejection: the pre-existing helper inserted areject-<id>key intoself._tasksfor every batched rejection so the wave accounting could release the digest, but no pop ever matched the prefix — only unprefixedagent_idkeys are popped, leavingreject-×2,lost-, andflush-keys in place forever. A reviewer also caught that a global cap would refuse members of legitimate documented waves.spawn_run tasks=[...]advertises "still pass ALL of them in one call — any beyond the cap are queued and drained automatically," and a wave's worst case is already bounded bybatch_total(transport-clamped at 1000). Refusing a wave member mid-fan-out would strand the digest because the same spawn discipline forbids the parent from retrying mid-wave.Why it matters
The queue growth surfaces under exactly the conditions a production install hits when a wave runs hot or a sub-agent path thrashes: more arrivals than the pool can finish. Today the failure mode is silent — no SEL audit, no log warning, no error returned. The LLM keeps getting
queued=Trueback, the sidebar keeps showing "N waiting", and a long enough flood produces a process that has to be killed. The_tasksleak is the same shape on the bookkeeping side: a sustained rejection storm from a transport that misses releases accumulates rejection keys until the gateway restarts. The wave-fan-out concern matters because the first cut of this PR was too broad: it would have broken the explicit "pass ALL of them" contract that the tool description and the spec both document. Scoping the cap to non-batched calls preserves the wave contract while still bounding the genuine flood shape (a single caller's tightspawn_runloop, or a retry storm from a transport missing releases).What changed (motivation → approach → change)
Symptom:
SubagentManagerholds_running_countunder_max_concurrentbut lets_queuegrow without bound, contradicting the docstring's "max concurrent limit prevents resource exhaustion" promise. A related leak lives in_announce_rejection's use ofself._tasks[f"reject-{info.id}"] = ...— no pop matches the prefix. Fix: introduce_MAX_QUEUED = 32as a module constant; refuse the non-batched spawn past the cap with a typedrefused_queue_fullSEL outcome; route_announce_rejectionthrough the module-level_safe_firehelper (the same fire-and-forget shape every other terminal completion uses, with its own_background_taskscleanup); scope the cap to non-batched spawns so a wave's documented fan-out is unaffected. The pre-existingtry/except RuntimeError: passguard in_announce_rejectionis no longer needed because_safe_firealready handles the no-running-loop case.Two alternatives I considered, and why I did not take them:
batch_idsplit already gives the wave-vs-flood distinction for free.cached_admission_checkwould centralise the verdict. The existing admission check answers a different question (host memory posture, with a 2 GB / 1 GB floor that fails open on an unknown posture), and a queue-depth cap is a process-local concern that does not need cross-process posture staleness in the loop. Keeping it inline keeps the refusal path synchronous and the SEL emit a single call.The change is six files.
src/kiro_crew/subagent.py_MAX_QUEUED = 32after_MAX_CONCURRENT = 3, with the rationale in the comment: the cap is flood-scoped, batched calls bypass it, and the refusal mirrors the existingrefused_memory_criticalshape.self._queue.append(...), guarded bynot batch_idso wave members skip it. The refusal shape is identical to the existing ones: adone=TrueSubagentInfo, an error string that names the cap and asks the caller to retry when the pool drains, and a typedrefused_queue_fullSEL outcome carryingmax_queuedandqueue_depthin the metadata. The SEL emit is wrapped intry/exceptand only logs a debug message on failure, mirroringrejected_empty_taskandrefused_memory_critical: a SEL outage is not a credential outage._announce_rejectionnow routes the batched on_done through the module-level_safe_firehelper instead of inserting intoself._tasks. The pre-existing helper usedself._tasks[f"reject-{info.id}"] = asyncio.ensure_future(self._safe_announce(info))— a key that no pop ever matched. The new path closes the leak for every sibling rejection that goes through this helper, not just the queue-full one.src/kiro_crew/mcp_tools/spawn.pyspawn_runtool description gains a one-sentence note that the queue cap that bounds a non-wave flood does NOT bound wave fan-out, so the LLM caller is not misled into splitting a wave to avoid a refusal it will never see.docs/system-specs/modules/subagent.md_MAX_QUEUED = 32and the flood-scope rule, in the same shape as the existing_MAX_CONCURRENTrow. This is the spec update the same-commit rule requires when a documented behavior changes.test/test_subagent_queue_cap.py(new)Four tests, each pinning a different surface of the contract:
test_spawn_is_queued_when_pool_is_saturatedpins the existing half of the contract — the running-side gate queues a non-batched second member.test_non_batched_flood_past_queue_cap_is_refusedpins the new half — a non-batched flood past the cap is refused with a typed SEL outcome, the queue stays at the cap, and the error string names both numbers.test_batched_wave_member_bypasses_queue_cappins the wave-bypass — ten batched members queue past a 1-element cap, none refused, and a non-batched call at the same depth is the one refused. This is the regression lock for the wave contract.test_batched_governance_rejection_does_not_grow_taskspins the_announce_rejectionself-reap contract — a batched governance denial routes through_safe_fireand_tasksdoes not gain areject-*entry. This is the regression lock for the bookkeeping leak, on a path that already exists in the file so a future refactor cannot silently re-introduce it.test/test_subagent_coverage.pytest_batch_rejection_reaches_the_wavepreviously awaitedmgr._tasks.values()to prove the batched announce fired. After the self-reap refactor, the announce lives in_background_tasks, notmgr._tasks. The test now snapshots_background_tasksbefore the helper discards itself, then awaits the snapshot — same behavior contract, new bookkeeping location..github/black-baseline.txtsrc/kiro_crew/mcp_tools/spawn.py(this PR formatted the file) andsrc/kiro_crew/mcp_tools/workflows.py(upstream PR feat: unify task runner and workflow management #5951 added the line but the file was already black-clean). One-for-one prune, run viascripts/check_black_formatting.py --update-baseline. The ratchet's whole point is that pruning is a zero-cost routine operation, not an unrelated change.Tests
The new file is pinned via
healthy_host_memoryat module scope so a memory-pressured CI runner cannot produce the misleadingKeyErrorthe host-memory ratchet exists to prevent; inner patches in each test land on top of the fixture and vary the verdict to whatever that test needs.I followed the red-green sequence in order. Wrote each test first, watched it fail for the right reason, then made the smallest change that turned it green. The wave-bypass test, in particular, was written against the first cut of the cap, watched the non-batched branch of the gate fire and refuse every batched member, then was fixed by the
not batch_idguard at the same call site. The bookkeeping-leak test was written against the oldself._tasks[f"reject-…"]path, watched the rejection still complete but never enter_tasks, then was fixed by the_safe_fireswap in_announce_rejection.Manual verification
The local gates prove the cap fires on non-batched floods, that batched waves bypass the cap, that the queue does not grow past the cap for non-batched spawns, that the SEL event is typed, and that
_announce_rejectiondoes not insert into_tasksfor batched rejections. They do not prove the wave-fan-out shape under a realspawn_run tasks=[…]call. A reviewer who wants to confirm the in-process path can run the gateway, push a non-batchedmgr.spawn(task=…)tight loop with the pool saturated, and inspect the SEL log:The line proves the cap fires with the right shape, no plaintext task in the metadata (only the redacted prefix), and a
queue_depthofmax_queuedat the moment of refusal. Then send a singlespawn_run tasks=[10 members]call withbatch_idset: every member is queued regardless of depth, norefused_queue_fullevents, and the wave's digest is delivered as the members complete. Both observations are out of scope for the local pre-push gate; the test suite covers the in-process assertions.Related Issues
There is no dedicated issue for this surface. The module docstring (
subagent.py:5) is the contract the PR is closing; the existing refusal shapes atsubagent.py:3306–3370(rejected_empty_task,refused_memory_critical) are the pattern the newrefused_queue_fulloutcome follows. The wave-digest contract atdocs/system-specs/modules/subagent.mddocuments the fan-out shape the cap respects. The recent run-coordinator recovery series (PRs #5277–#5283) is the neighborhood this fix lives in: a recovery loop that misses a release, or a cron job that wakes faster than it drains, no longer has an unbounded memory surface to push into on either side — the queue or the bookkeeping map.Checklist
_announce_rejectionself-reap contract. The pre-existingtest_subagent_coverage.py::TestAnnounceRejection::test_batch_rejection_reaches_the_wavewas updated to await_background_tasksinstead ofmgr._tasks(the behavior contract is unchanged; only the bookkeeping location moved)_emit_seland_safe_firefail-soft patterns used elsewhere in the module_MAX_QUEUEDrow in the spec's Constants table, tool description forspawn_run