fix(agents): hold the wave-close fallback open while a member's report is in flight - #9008
fix(agents): hold the wave-close fallback open while a member's report is in flight#9008javenciu wants to merge 2 commits into
Conversation
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of Race confirmed against base: Design-Verdict: CONCERNS Sound fix to a real double-finalize race, but it mints a convention — "every Watch
Suggestions
[DESIGN-REVIEWED] 9ab40ff |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of All base-tree evidence is in. The delegation-machinery check cleared the one zero-consumer suspect (the manager delegate is mandated at import time by First-Principles-Verdict: CONCERNS Cause-level fix that earns its registry — but one terminal done-flip ( What this change shipsIntent: stop a multi-member wave from emitting a premature and then a duplicate wave-close digest when a sibling completes while a member is done-but-unreported (issue #8554) — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 9ab40ff |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 1 of 1 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- src/kiro_crew/subagent_manager/terminal.py:127, admission.py:587 -- scheduled reports arm their holds too late [BLOCK-MERGE] 9ab40ff Adjudication (Opus 4.8) — is blocking on each finding proportionate?I have enough to rule. This is a single fenced finding (F1); my verdict set is UPHOLD-FENCED or FLAG, and FLAG requires a complete record that every condition combination reaching the defect is extreme. The finding claims the hold is armed too late for synthetic-rejection paths. I confirmed against the diff and the base tree that the synthetic-rejection callers create Harm rung: matches the fence's unbounded classification (residual/crash-data-loss-corruption — duplicated wave digest). Conditions confirmed above at their |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsOne advisory finding — the PR arms every terminal FINDING — src/kiro_crew/subagent_manager/cancellation.py:127 — the recovery-failed arm flips [OPUS-REVIEWED] 9ab40ff |
…une to operator clears The wave-close hold added for kirodotdev#8554 lived as a flag on the agent records, so `batch_reports_in_flight` derived it from `_agents` membership — but `_agents` is operator-mutable: a clear-completed (`DELETE /api/spawn`) pops every done member, and one landing inside the done-but-unreported window silently dropped the hold, reopening the exact premature-finalize hole the predicate closes (review finding on kirodotdev#9008). The hold now lives in a manager-level `_reports_in_flight` registry: armed by the report machinery in the same synchronous block that flips `info.done` (safe-announced synthetics arm immediately before their announce; flush-only records never arm), released by the completion consumer in the same synchronous block as the done-count increment. One structural `try/finally` in the report runner (and its `_safe_announce` mirror) replaces all four per-arm clears: however a report ends without reaching the consumer — injection timeout, announce failure, cancellation, early return — the hold is released and the wave keeps its degraded a-sibling-can-close liveness. The cancelled-recovery limbo arm needs no release at all: holds are armed only at a report's done-flip, which that arm's `not info.done` guard proves never ran. The reaper's digest-hold sweep is now in-flight-aware in both directions: it skips only waves whose close is genuinely in flight, and force-flushes a wave whose hold aged past the deadline with no report in flight — previously that stranded state was unsweepable and held sibling results until gateway restart.
…une to operator clears The wave-close hold added for kirodotdev#8554 lived as a flag on the agent records, so `batch_reports_in_flight` derived it from `_agents` membership — but `_agents` is operator-mutable: a clear-completed (`DELETE /api/spawn`) pops every done member, and one landing inside the done-but-unreported window silently dropped the hold, reopening the exact premature-finalize hole the predicate closes (review finding on kirodotdev#9008). The hold now lives in a manager-level `_reports_in_flight` registry: armed by the report machinery in the same synchronous block that flips `info.done` (safe-announced synthetics arm immediately before their announce; flush-only records never arm), released by the completion consumer in the same synchronous block as the done-count increment. One structural `try/finally` in the report runner (and its `_safe_announce` mirror) replaces all four per-arm clears: however a report ends without reaching the consumer — injection timeout, announce failure, cancellation, early return — the hold is released and the wave keeps its degraded a-sibling-can-close liveness. The cancelled-recovery limbo arm needs no release at all: holds are armed only at a report's done-flip, which that arm's `not info.done` guard proves never ran. The reaper's digest-hold sweep is now in-flight-aware in both directions: it skips only waves whose close is genuinely in flight, and force-flushes a wave whose hold aged past the deadline with no report in flight — previously that stranded state was unsweepable and held sibling results until gateway restart.
186ebd8 to
401196f
Compare
…t is in flight A multi-member wave could emit its final digest twice, or finalize prematurely with results missing, when one member's done flag flipped before its terminal report reached the completion consumer. batch_members_pending() stops counting a member the moment info.done flips, but that member's contribution to the consumer's done-count only lands when its (shielded, possibly slow) terminal report actually executes the consumer. In that window a sibling completion observes done < total with no pending members, so the last-member fallback finalized the wave early — and the in-flight report then re-created the batch-progress record via setdefault and finalized the same wave again. Reachable with two RUNNING members alone. The fix counts a member as outstanding from done=True until its report is consumed: a new batch_reports_in_flight() predicate (any registered member with done flipped whose _report_consumed is unset) joins batch_members_pending() in the fallback decision, and the consumer sets the flag in the same synchronous block that lands the done-count, so the flag and the count can never be observed apart. Terminal arms that end a report WITHOUT reaching the consumer clear the hold themselves, preserving the wave's degraded a-sibling-can-close liveness instead of stranding it: the report path's injection-timeout and announce-failure arms, the _safe_announce failure arm, and the cancelled-recovery limbo arm (the one terminal RECORD writer that is deliberately report-free). Part of kirodotdev#8554
…une to operator clears The wave-close hold added for kirodotdev#8554 lived as a flag on the agent records, so `batch_reports_in_flight` derived it from `_agents` membership — but `_agents` is operator-mutable: a clear-completed (`DELETE /api/spawn`) pops every done member, and one landing inside the done-but-unreported window silently dropped the hold, reopening the exact premature-finalize hole the predicate closes (review finding on kirodotdev#9008). The hold now lives in a manager-level `_reports_in_flight` registry: armed by the report machinery in the same synchronous block that flips `info.done` (safe-announced synthetics arm immediately before their announce; flush-only records never arm), released by the completion consumer in the same synchronous block as the done-count increment. One structural `try/finally` in the report runner (and its `_safe_announce` mirror) replaces all four per-arm clears: however a report ends without reaching the consumer — injection timeout, announce failure, cancellation, early return — the hold is released and the wave keeps its degraded a-sibling-can-close liveness. The cancelled-recovery limbo arm needs no release at all: holds are armed only at a report's done-flip, which that arm's `not info.done` guard proves never ran. The reaper's digest-hold sweep is now in-flight-aware in both directions: it skips only waves whose close is genuinely in flight, and force-flushes a wave whose hold aged past the deadline with no report in flight — previously that stranded state was unsweepable and held sibling results until gateway restart.
401196f to
9ab40ff
Compare
|
@javenciu Thanks for staying on this. First, nothing here has been overtaken by main. What remains before it can land:
Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
Problem / Motivation
A multi-member wave can emit its final digest twice, or emit a premature "wave finished" with results missing, when one member's
SubagentInfo.doneflips before that member's terminal report reaches the gateway completion consumer (issue #8554).batch_members_pending()stops counting a member the momentinfo.doneflips, but the member's contribution to the consumer'sbp["done"]only lands when its (shielded, possibly slow) terminal report actually executes the consumer. In that gap, ANY sibling completion that reaches the consumer seesdone < totalANDbatch_members_pending() == False, so the last-member fallback finalizes early — and the in-flight report then re-creates the batch-progress record viasetdefaultand "finalizes" the same wave again. Reachable with two RUNNING members alone: member A done-but-unreported while member B's report executes the consumer.Why it matters
The parent hears "wave finished — all results delivered" while a member's result is still in flight, then receives a second contradictory wave-close digest for the same batch. Both digests carry wrong counts (the premature one is missing the in-flight member entirely), the spawn-discipline gate releases early, and
finalize_batchprunes wave bookkeeping twice. Anything a parent agent does on the strength of the first "complete" digest — spawning follow-ups, synthesizing results — runs against a wave that was not actually finished.What changed (motivation → approach → change)
Observed symptom: double/premature wave-close digests. Root cause: the done-but-unreported window above — the wave-close fallback consults a predicate (
batch_members_pending) that stops counting a member strictly before the count it guards (bp["done"]) includes that member.The change extends the issue's suggested direction — count a member as outstanding from
done=Trueuntil its report enters the consumer:WaveDigestCoordinator.batch_reports_in_flight_impl()(new,subagent_manager/waves.py): True while any member of the batch is done-but-unreported. The consumer's last-member fallback now requiresnot batch_members_pending(id) and not batch_reports_in_flight(id)._reports_in_flightregistry (batch_id -> set of member ids), NOT on the agent records:_agentsmembership is operator-mutable (DELETE /api/spawnpops every done member), so a predicate derived from it silently drops the hold when a clear-completed lands inside the window — reopening the exact hole it closes. The report machinery arms the hold in the same synchronous block that flipsinfo.done(safe-announced synthetics arm immediately before their announce; flush-only records never arm), and the consumer (slack/gateway.py) releases it in the same synchronous block that incrementsbp["done"], so the hold and the count can never be observed apart.try/finallywrapping the entire report body past the done-flip (subagent_manager/terminal.py, mirrored for_safe_announceinsubagent_manager/admission.py— covers the registered approval-parked rejection): injection timeout, announce failure, cancellation, and early returns all funnel through it, preserving the wave's degraded a-sibling-can-close liveness instead of stranding. The cancelled-recovery limbo arm (subagent_manager/cancellation.py) needs no release at all: holds are armed only at a report's done-flip, which that arm'snot info.doneguard proves never ran.Commit 2 (review round): the first commit kept the hold as a flag on the agent records (
_report_consumed) with four per-arm clears. Review convicted the record-derived predicate — an operator clear-completed landing inside the done-but-unreported window popped the member from_agentsand the hold evaporated with it, reopening the premature-finalize hole (probe: hold established → operator clear →batch_reports_in_flightflips False at the commit-1 tree; stays True after). Commit 2 moves the hold to the manager registry, replaces the per-arm clears with the structuralfinally, and closes both design concerns raised alongside: a stranded hold is now sweepable, and a report-path failure on the final member no longer parks held siblings behind an unconditional sweep skip.Alternatives considered and rejected:
_force_reap, and cancel-recovery — a missed or doubled decrement is invisible (the count is just wrong). The registry keeps per-member identity instead: release is an idempotentdiscardkeyed by member id (double-release harmless, structurally guaranteed by the report path'sfinally), and a stranded entry names exactly which member stranded. Commit 1's per-member flag had the same idempotence but derived the predicate from_agentsmembership; the registry keeps the flag design's degrade-safety without the membership dependence._tasks[id], recovery keys, queued-stop synthetics); deriving "in flight" from task registry state couples wave accounting to task-registry lifecycle details thatcancel_all()and the reaper mutate mid-teardown._batch_unqueued_pendingbridge: the issue notes the Stop all leaves queued subagents running #8270-adjacent bridge as prior art, but it never landed on main (its PR closed unmerged), so there is nothing to generalize — this change stands alone against current main.Scope note: if the FINAL member's report terminally fails (timeout/announce-failure), no later completion re-evaluates the wave. Commit 1 left that pre-existing gap to the reaper's sweep as backstop; commit 2 makes the sweep actually able to take it — the in-flight-aware guard force-flushes an aged hold with no report in flight, where before the sweep skipped every no-pending-members wave unconditionally. Held sibling results now flush after the hold deadline instead of stranding until restart.
Tests
test/test_wave_digest_double_finalize.py(new) drives the REAL_subagent_doneclosure (captured from_init_subagents, same construction as the existing cron-injection suite) with a scripted manager, so the race window is deterministic instead of a timing lottery:test_sibling_completion_in_the_done_but_unreported_window_does_not_close_the_wave— THE race: fails before the fix withExpected 'finalize_batch' to not have been called. Called 1 time(the premature finalize), passes after; then the in-flight report lands and the wave closes exactly once.test_in_flight_report_cannot_refinalize_after_the_wave_closed— count-side no-double-fire control (passes both sides).test_spawn_failure_fallback_still_closes_the_wave— no-new-deny control: the fallback this fix constrains still closes waves whose members failed at spawn (passes both sides).test_consumer_releases_the_hold_with_the_count— pins the hold-release/count same-block contract; the release goes to the manager-level registry, so it survives the member having been popped from_agentsby an operator clear mid-flight.test/test_subagent_coverage.py(extended, sibling ofTestBatchMembersPending):TestBatchReportsInFlight— 8 predicate/registry pins: done-but-unreported → True; consumed → False and the wave's registry entry is pruned, not left empty;test_operator_clear_cannot_drop_the_hold— THE commit-2 conviction: the hold survives the member being popped from_agents(whatDELETE /api/spawndoes to done members) and the consumer still releases by id; flush-only synthetics never arm; release is idempotent and unarmed release is a no-op;finalize_batchprunes the registry; still-running member → False (that isbatch_members_pending's job — the arm happens only at the report's done-flip); other wave's member → False.TestReportsInFlightStrandBackstops— 5 pins that the structuralfinallyreleases however a report ends: injection-timeout arm and announce-failure arm (each also asserts the hold was VISIBLE mid-announce — armed at the done-flip, released only when the report ended);_safe_announcefailure arm (arms before its announce, releases on the raise); the happy path (hold visible to the consumer, released with the count, the report'sfinallyre-release an idempotent no-op); the cancelled-recovery limbo arm (driven deterministically: recovery parked on its original-task wait, then cancelled) never strands the wave — a record terminalized there never carried a hold.TestSweepDigestHolds— the sweep's two new directions:test_closing_wave_not_forced(hold armed → skip: the real wave-close flush is in flight) andtest_stranded_hold_past_deadline_is_forced(no pending members, no hold, deadline passed → force-flush fires with the wave's parent key: the previously unsweepable stranded state).test/test_subagent_scale.py(adjacent pins updated to the commit-2 contract): the settle-after-_on_donesource inspection follows the report body into_report_terminal_guarded_impl(the split that hosts the structuralfinally), andTestDigestHoldDeadline.test_closing_wave_is_not_force_flushednow arms the in-flight hold — the aged-hold state WITHOUT one is the stranded state the sweep must flush.Fails-before, commit 1 (fix stashed, committed tests kept): 12 failed / 2 passed — failures are the race conviction plus the predicate/hold absence; the 2 passes are the two both-sides controls. Fixed tree: 14/14.
Fails-before, commit 2 (at the commit-1 tree): the operator-clear probe — hold established (
in_flight=True),DELETE /api/spawn-equivalent pop,in_flightflips False (premature-finalize hole reopened); at the commit-2 tree the same probe holds True through the pop.test_stranded_hold_past_deadline_is_forcedalso fails at the commit-1 tree (force_digest_flushnever called — the sweep skipped every no-pending-members wave unconditionally). Full gate mirror green at each commit: targeted + seam-neighbor suites (subagent, batch injection, scale, reap-race, approval-parked, delivery-TTL, manager boundaries, persistence), mypy clean on all six touched source files, flake8/isort clean, black baseline gate pass, docs-lint pass.Manual verification
N/A — unit coverage sufficient: the race is convicted deterministically through the real completion-consumer closure with a scripted manager (no timing dependence), and both liveness directions (hold open / never strand) are pinned at the manager level.
Screenshots / video
No visual delta: backend-only PR — subagent wave-close logic, gateway wiring, docs, and tests; zero rendered surfaces change.
Related Issues
Part of #8554 — addresses the done-but-unreported double-finalize mechanism; the issue also discusses whether the finalize decision should move entirely onto the report path (single source of truth), which is a maintainer-owned design question this change deliberately leaves open.
Pattern harvest
Rule candidate: review-prompt — "when a completion decision combines a COUNT (incremented by consumers) with a PREDICATE (derived from state flags), verify the predicate keeps counting every entity until its count contribution lands; a predicate that releases before the count includes it opens an early-completion window between the two." The same shape is worth checking anywhere a
done-style flag feeds a pending-predicate while a separate consumer owns the tally.Adjacent audit performed (not changed here): every
done = Truewriter insubagent_manager/was audited for report routing — run-path arms report via the finalize claim, the reap path reports, admission rejections announce, queued-stop synthetics register-then-report, lost-submission/digest-flush synthetics never register (invisible to the predicate by construction). The cancelled-recovery limbo arm was the one report-free writer, and it is covered in this change.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)docs/system-specs/modules/subagent.md(the AGENTS.md-routed owning doc) gains the wave-close contract paragraph and the cancelled-recovery hold-clearing note, same commitContribution License Agreement
Per the template placeholder (CLA text pending): offered under the same terms as my prior merged contributions to this repository (#8835).