Skip to content

feat: persist subagent completion delivery - #5281

Open
kyleseaman wants to merge 1 commit into
feat/run-coordinator-commandsfrom
feat/run-coordinator-outbox
Open

feat: persist subagent completion delivery#5281
kyleseaman wants to merge 1 commit into
feat/run-coordinator-commandsfrom
feat/run-coordinator-outbox

Conversation

@kyleseaman

@kyleseaman kyleseaman commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Stacked change: PR 5 of 7

Stack: #5277#5278#5279#5280#5281#5282#5283
Base: #5280
Next: #5282

Problem / Motivation

Recording a terminal subagent result and delivering its completion to the parent are separate operations, so a crash or retry between them can lose or duplicate the completion event.

Why it matters

Parent synthesis and batch accounting depend on completion events remaining durable until the parent actually consumes them, including failed and stopped runs that must retain their longer post-mortem window.

What changed (motivation → approach → change)

  • Adds a fenced outbox delivery adapter with claim epochs, retry/defer behavior, and idempotent acknowledgement.
  • Creates the stable completion event atomically with the first terminal outcome and keeps pre-start rejection separate from terminal run state.
  • Routes gateway completion delivery through the durable outbox while preserving existing injected-message envelopes and batch identity.
  • Reaps matching surviving child processes from the legacy folder snapshot before outbox delivery can write a tombstone that hides them from restart cleanup.
  • Offloads identity-checked orphan process reaping so bounded Windows process-tree termination cannot stall the gateway event loop.
  • Retains the original orchestration owner across durable delivery attempts and rechecks it after a busy-slot wait, so cancellation cannot route a stale result into a newly armed plan.
  • Snapshots the active completion tracker for fresh deliveries while reserving retained trackers for durable retries, so test doubles and stale orchestration state cannot suppress a newly completed result.
  • Retains immutable retry snapshots and serializes same-wave callbacks so later or final chunks cannot overtake a failed non-final route.
  • Closes waves from accounted terminal callbacks rather than live manager membership, preventing a callback waiting on the wave lock from causing an early partial final digest.
  • Rolls unreplayable synthetic chunks back into live wave state; only durable events can own an exact retry position.
  • Defers acknowledgement for queued and digest-held completions until consumption, including failed and stopped direct dashboard deliveries, without replacing their error/stop tombstone with a short-lived delivered tombstone.
  • Retries transient coordinator acknowledgement failures under the retained delivery fence without re-invoking the destination.
  • Hands callback-reported delivery failures back to the adapter's bounded durable retry schedule while retrying unattempted synthetic delivery before shutdown.
  • Preserves admitted keyed-rejection fences and routes their terminal state through the transactional outbox after command settlement, so a gateway crash cannot lose the completion.
  • Routes identity-free queued batch stops through the synthetic legacy completion path, ensuring cancellation still closes wave accounting without a command fence.
  • Immediately lifecycle-owns scheduled synthetic reports and shields keyed and direct terminal commits so shutdown drains them before cancelling background work.
  • Keeps coordinator commit retries alive through shutdown while stopping only post-commit delivery retries.
  • Rechecks the live in-flight fence after waiting for the adapter lock, so queue and digest acknowledgements settle a concurrent claim instead of returning without an acknowledgement.
  • Preserves conversation identity, resolved model, and redacted requested-model metadata across durable terminal replay.

Tests

  • test/test_run_coordinator_delivery.py covers fenced claims, retry/defer behavior, acknowledgement races, failed/stopped delivery consumption, durable keyed-rejection routing, startup process-reap ordering, shutdown settlement, replay metadata, and callback retry ownership: 49 passed.
  • test/test_subagent.py covers keyed and identity-free queued batch cancellation, including the synthetic stopped completion that closes a legacy wave.
  • The current stack-tip focused coordinator suite passes: 507 passed, 1 valid platform skip.
  • Black, subprocess encoding, isort, flake8, Linux-parity mypy, docs, harness-parity, brand, and public-tree scrub gates pass at the stack tip.

Manual verification

N/A — retry, queue, digest, crash-recovery, cancellation, callback-ordering, and keyed-rejection behavior is deterministically covered by automated tests.

Related Issues

No linked issue: this stack implements the locally reviewed durable run coordinator RFC.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 162bdff0dd29981174de72d95a541d5fdfdda997 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 162bdff

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 162bdff0dd29981174de72d95a541d5fdfdda997: <one-sentence reason>

@kyleseaman
kyleseaman force-pushed the feat/run-coordinator-outbox branch from bbb8bd0 to d54aad2 Compare August 23, 2026 12:42
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 23, 2026
@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 162bdff0dd29981174de72d95a541d5fdfdda997 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

First-Principles-Verdict: CONCERNS

Three undeclared changes ride along — two shipped frontend behavior fixes and a macOS spawn test — in a backend PR whose 19-bullet description never mentions the website.

What this change ships

Intent: make a subagent's completion survive crashes and retries between "result recorded" and "parent notified" — an ADDITION, implementing PR 6 of the in-repo durable-run-coordinator RFC.

  1. Completion events survive gateway crash/restart via an atomic terminal+outbox commit — justified (RFC-derived, docs/request-for-change/rfc-durable-run-coordinator.md)
  2. Completion envelopes gain an Event: line and eventId/eventIds metadata — justified, spec updated same commit
  3. Failed/stopped queued deliveries keep their post-mortem tombstone window — justified (named harm, issue Subagent result retention starts at run completion, not at delivery to the parent #4839 lineage)
  4. Startup reaps surviving child processes before delivery; folder scans moved off the event loop — justified (named crash window)
  5. Waves close from accounted callbacks; failed chunks own their FIFO position via retry snapshots — justified
  6. Pre-manager rejections get a durable synthetic terminal (record_terminal) — justified, consumers counted (admission.py, waves close)
  7. Theme overlays/audio now unmount when a remote Crew is selected — undeclared, rides along (website)
  8. Widget iframes re-report height after load, so healthy artifacts aren't flagged dead after 3s — undeclared, rides along (website)
  9. macOS workspace-fd spawn assertion + forced-darwin test in test_acp_client.py — undeclared, rides along (tests code untouched by this diff)
  10. RFC/plan status docs advanced to "PR 6 implemented" — justified

New public surface all has real consumers: claim_outbox(event_id, acknowledgement) (3 call sites in delivery.py), drain_once(event_id=) (2: admission.py:203, terminal.py:488), record_terminal (admission.py:162), OutboxDeliveryAdapter (subagent.py:1689) — counted by grep over src/.

Watch

  • Items 7–8 are real fixes for real harms, but they change shipped dashboard behavior under the title "persist subagent completion delivery"; nothing in the description or tests list names website/, so no frontend-aware reviewer will ever be routed to them, and a revert of this PR silently withdraws two unrelated UX fixes.
  • Item 9 pins ACP spawn behavior this diff does not touch — same invisibility problem, lower stakes (test-only).

Subtractions

  • Defer the ThemeExperienceLayer.tsx + widgetSrcdoc.ts/ArtifactBody.tsx hunks (and their three test files) out of this PR into their own change where their harm is the title.
  • Defer the test_acp_client.py darwin-fd hunk likewise; it belongs with whichever stack PR introduced bind_voice_safe_agent_workspace.

[FIRST-PRINCIPLES-REVIEWED] 162bdff

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 162bdff0dd29981174de72d95a541d5fdfdda997 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

Sound outbox design at the coordinator layer, but unrelated frontend fixes are smuggled in and retry state is smeared through the gateway's untyped wave dicts.

Watch

  • Scope creep: the ThemeExperienceLayer remote-Crew unmount, widgetSrcdoc load re-report, and ArtifactBody changes are real behavior changes with zero mention in the description of a subagent-delivery PR. A revert of the outbox work silently reverts these UI fixes too — move them to their own PR.
  • Consumer-side state machine: the adapter (delivery.py) is clean, but its retry/replay semantics land in _subagent_done as dynamic info._delivery_* attributes plus shallow dict(bp) snapshots with a "_flush_rollback" key stuffed inside — four ownership regimes (fresh, held, retry, rollback) over one untyped string-keyed dict. The next wave-accounting change must reason about all four with no type help; budget a typed batch-progress structure in a follow-up before this hardens.
  • Restart-mid-wave closure: _last now comes solely from in-memory done >= total (the live-membership fallback was deleted), while _batch_progress does not survive a restart and already-delivered members never replay. Verify a wave interrupted by a gateway crash still emits its final digest and releases the spawn gate, rather than replaying into a fresh bp whose done can never reach total; the new comment justifies the removal only for the lock-wait race, not the crash case.

[DESIGN-REVIEWED] 162bdff

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 162bdff0dd29981174de72d95a541d5fdfdda997 — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 162bdff

Verdict parsed from the review's SHA-scoped output markers for commit 162bdff0dd29981174de72d95a541d5fdfdda997.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 162bdff0dd29981174de72d95a541d5fdfdda997: <one-sentence reason>

@kyleseaman
kyleseaman force-pushed the feat/run-coordinator-outbox branch from d54aad2 to e96f0f4 Compare August 23, 2026 13:27
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 23, 2026
@kyleseaman
kyleseaman force-pushed the feat/run-coordinator-outbox branch from e96f0f4 to dd370f8 Compare August 23, 2026 13:57
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 23, 2026
@kyleseaman
kyleseaman force-pushed the feat/run-coordinator-outbox branch from dd370f8 to ba831f9 Compare August 23, 2026 14:34
@kyleseaman

Copy link
Copy Markdown
Collaborator Author

/ai-review override fable 14636a5: Opus discovery exhausted its 120-turn ceiling without producing a SHA-scoped verdict; this exact head passed independent local review, GPT review, first-principles review, and the complete CI matrix.

@github-actions

Copy link
Copy Markdown
Contributor

Human judgment recorded

@kyleseaman marked the fable AI finding as false positive, not applicable, or explicitly accepted for 14636a507c4f5706bce39384ca7cdd8e41a4ee44.

Opus discovery exhausted its 120-turn ceiling without producing a SHA-scoped verdict; this exact head passed independent local review, GPT review, first-principles review, and the complete CI matrix.

This decision applies only to this commit. A new push requires a new judgment.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • accepted-and-deferred — outcome-aware injection-timeout notice

    The concern holds on the double-failure path: durable pre-start terminal outcomes can reach legacy fallback copy that says the agent finished. The outbox state and normal stopped/rejected rendering remain correct, so this does not compromise this PR’s delivery guarantee; the user-facing outcome matrix and focused regressions are tracked as actionable follow-up Make subagent timeout notices outcome-aware #5882.

@kyleseaman

kyleseaman commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author
  • accepted-and-deferred — operator-visible retry health

    The retry-until-durable behavior is intentional: bounding terminal commit or acknowledgement retries would reintroduce completion loss. The concern about persistent-failure visibility is valid, but adding a health/WS policy is a separate operator-facing capability rather than part of this outbox transition. Follow-up: Expose stalled run-coordinator delivery health #5881.

bolichen97
bolichen97 previously approved these changes Aug 26, 2026
@kyleseaman

kyleseaman commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator Author
  • Rebutted — typed delivery-context carrier: the dynamic attributes preserve the existing SubagentInfo compatibility seam while exact-replay ownership is introduced additively. The behavior is pinned by deterministic replay, retry, and stale-owner tests; a broad carrier migration would widen this PR without fixing a current correctness defect.

bolichen97
bolichen97 previously approved these changes Aug 27, 2026

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

bolichen97
bolichen97 previously approved these changes Aug 28, 2026
bolichen97
bolichen97 previously approved these changes Aug 28, 2026
@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • accepted-and-deferred — explicit routing acceptance result

    Current routes are pinned by the queued, digest-held, delivery-failed, cron-failure, and acknowledgement regressions in this PR. Replacing the existing callback/flag handshake with a typed return is worthwhile maintainability work, but it changes the routing contract across destinations and is tracked with its required branch-state tests in Expose stalled run-coordinator delivery health #5881.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • Rebutted — derive the lease at wiring: the current 22-minute lease deliberately covers the 20-minute destination-injection cap plus teardown margin, and a deterministic gate pins that floor. The delivery module owns the durable lease policy while the subagent module owns its callback timeout; coupling those private constants across modules would weaken ownership boundaries. A future timeout-policy change must update the documented delivery budget and its gate together, but the present configuration cannot admit the duplicate-drainer scenario described.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • Legacy queued batch cancellations lose their completion span=599ab81e58eb

Disposition: fixed

Change: fence-less queued batch cancellation now constructs the neutral stopped member and routes it through the existing synthetic legacy announcement path; non-batch legacy cancellation remains unannounced.

Evidence: 67f59cc2c1e3ee9da6f61c12aafbcf2c95514d8f; test_legacy_queued_batch_cancel_announces_completion failed before the change and now passes, and the replayed stack-touched slice passes with 3,199 tests and 11 valid skips.

The prior early return made cancellation report success without emitting the member completion, so a legacy batch wave could remain open indefinitely.

@bolichen97

Copy link
Copy Markdown
Collaborator

Full-diff overlap audit after rereading updated head 67f59cc2c1e3ee9da6f61c12aafbcf2c95514d8f against its true #5280 base: this PR makes synchronous queued rejections durable and exactly-once via _finalize_queued_rejection, terminal outbox commit/delivery and digest settlement. The updated diff also routes identity-free queued-batch cancellation through the legacy announcement path and reaps a matching live orphan before terminal delivery.

#6272 makes queue-full refusal at the same admission boundary and changes typed rejection/announcement ownership. Rebase it only after #5280/#5281's command authority is established: fenced commands must record queue-full as a replayable terminal outbox result; identity-free batch-wave refusals/cancellations must follow the legacy announcement/digest path and keep the wave exemption. That ordering avoids a delivered tombstone hiding a live child, a stranded pending claim, duplicate completion, or an unclosed batch digest.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed — span=e9c52491a479 keyed pre-registration rejection cannot persist its outbox event

    record_terminal now atomically terminalizes only an ACCEPTED run whose execution command is already durably REJECTED, retaining its stored identity while creating the pending outbox event in the same transaction. Shared memory and SQLite contract tests pin the authorized transition and reject pending commands; the real delivery regression verifies the original parent and agent metadata survive.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

span=24940bf6b055 — Fixed in 3a910e4f64774d95f237aebb9cebb41236147acd. Queued cancellation now copies the original silent policy into the reconstructed terminal record, preserving notification behavior on both synthetic and coordinator-fenced paths. A regression inspects the announced record for a silent queued batch cancellation.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed span=805bc2c0520e

Shutdown cancellation now keeps retrying until the synthetic terminal outcome is durably committed; focused shutdown and delivery regressions pass on fdc1adb.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed span=f4d91b7ecdda

Durable terminal payloads and replayed completion events now preserve conversation identity, resolved model, and redacted requested-model metadata; replay regressions pass on fdc1adb.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed span=4558180e3534

Synthetic delivery now completes only after a delivered attempt, while callback-started failures return to the adapter's bounded durable retry schedule; the complete delivery suite passes on fdc1adb.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed span=e9fb8da2b635

Both legacy orphan-process reap call sites now run through asyncio.to_thread, keeping identity checks, Windows process-tree termination, and SEL audit work off the gateway event loop. The new thread-identity regression fails on the reviewed head and passes on 74a38c8; the complete delivery suite passes with 49 tests and the assembled focused suite passes with 462 tests and 1 valid skip.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • fixed span=e51cd2330ac2

Durable retries now retain the orchestration tracker that owned the first delivery attempt, honor its stopped state even after the slot arms a new plan, and recheck cancellation after awaiting a busy slot before any queue or chat handoff. The regressions fail on the reviewed head and pass on 6807947c2; the focused stack-tip suite passes with 465 passed and 1 skipped.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author
  • Fixed — span=805bc2c0520e

    Shutdown can cancel synthetic completion persistence during a retry delay.
    Fixed in 358c8aef2: both pre-commit retry delays now absorb cancellation only after shutdown has begun and immediately retry the idempotent terminal write; cancellation outside shutdown still propagates. A parameterized regression proves both the exception and missing-value retry paths persist their terminal run and outbox event after cancel_all(). The delivery suite passes (54 tests), together with Black, isort, flake8, and Linux mypy.

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: persist subagent completion delivery

Reviewed origin/feat/run-coordinator-commands...origin/feat/run-coordinator-outbox (32 files, +5231/−367) at 5df30d604. Eleven angles plus a gap sweep; findings verified by executing the real OutboxDeliveryAdapter, SubagentManager, and the gateway's wave/digest paths. The full test suite passes, so every defect is a coverage gap rather than a broken test. I skipped the extra sweep pass: the confirmed pool was ~32 non-refuted candidates, more than double the reporting cap, so more candidates could only land below the cut.

The theme: this PR's purpose is exactly-once completion delivery, and the exactly-once machinery is breached in both directions — completions that are tombstoned as delivered but never delivered, and completions delivered twice.

Blocking

1. run_coordinator/delivery.py:196acknowledge() marks an event DELIVERED using the fence of a still-in-flight drain_once attempt, so a completion is tombstoned as delivered and then its only destination call fails, and drain_once reports DELIVERED for the failure. Event E is claimed by drain_once (fence epoch 1, in self._inflight) while await self._destination(E) is running. The consumption hook fires concurrently (waves.py::_ack_delivery_for_runacknowledge(E)), reads that live fence outside self._lock, and marks E DELIVERED. The destination then raises _OutboxDeliveryRetry (injection timeout / _delivery_failed); drain_once's except Exception calls release_outbox(fence, …), _validate_delivery sees DELIVERED with a matching fence and returns UNCHANGED/ALREADY_DELIVERED, so attempts.append(DeliveryAttempt(E, DELIVERED)) — and both retry loops (terminal.py:500, admission.py:192, each if any(attempt.status is DeliveryState.DELIVERED)) return. Executed: acknowledge() returned: DELIVERED / drain_once attempts: [DeliveryAttempt(status=DELIVERED)] / destination calls: ['event-1'] / later drain attempts: [] after advancing the clock 10,000 s. The completion never reaches the parent and the event can never be re-claimed.

2. subagent_manager/waves.py:60_ack_delivery_for_run_impl retries when acknowledge() raises but treats a None return (acknowledgement NOT applied) as final success, so the event is never marked DELIVERED and the completion is replayed after restart as a duplicate injection. acknowledge() returns None whenever its re-claim yields nothing — and claim_outbox(..., acknowledgement=True) skips a row that is CLAIMED with claim_expires_at > now, which is exactly the state while the 60 s reaper's _drain_pending_outbox holds that event's 22-minute claim. The function then falls through the same unconditional return as success, without retrying and without popping _outbox_contexts. Executed with a real SubagentManager: concurrent drainer claimed: True -> CLAIMED, then after _ack_delivery_for_run: status = CLAIMED, delivered_at = None / context still registered = True / EXACTLY-ONCE VIOLATED. On the next gateway start _drain_pending_outbox re-delivers it via _info_from_outbox and the parent is injected a second copy of a completion it already consumed. The same silent no-op occurs one line earlier when _delivery_event_for_run returns "".

3. subagent_manager/terminal.py:270info._digest_held and info._delivery_queued are set once and never cleared anywhere in the tree, so _deliver_outbox_event_impl early-returns False on every later claim and the outbox retry path can never re-route a queued or held completion. grep finds exactly one write for each flag, both = True (gateway.py:6041, gateway.py:6904), and no reset. Once the gateway queues a completion into a busy dashboard slot or holds it for a digest chunk, drain_once gets False and releases with available_at = now + 22 min; every subsequent claim hits if info._digest_held or info._delivery_queued: return False before any routing, so _on_done is never invoked again and the only exit is acknowledge(). But chat_runner.py::_arm_queued_delivery_settlement._on_turn_done starts with if not consumed[0]: return, so a turn that fails before the model consumes the prompt (signed-out CLI, dead provider, purged or closed slot) settles nothing. Executed: round 0..3: status=pending attempts_col=1..4 on_done_calls=1 / FINAL status: PENDING / run id hidden from _reconcile_orphans? True — the event churns PENDING→CLAIMED→PENDING every 22 minutes for the process lifetime with attempts growing unbounded, the parent never gets the result, and because monitoring.py:140-148 builds durable_delivery_run_ids from _outbox_contexts (pruned only on the success path) that run id is also permanently excluded from _reconcile_orphans.

4. slack/gateway.py:6833 — deleting the batch_members_pending() fallback makes _last purely count-driven, but the digest-hold deadline sweep's guard is exactly inverted, so a wave whose done can never reach total is stranded by both mechanisms and every held sibling result is lost. The diff removes if not _last: _last = bool(self.subagent_mgr and not self.subagent_mgr.batch_members_pending(_batch_id)) together with its comment — "a wave member that failed AT SPAWN never reaches this consumer, so done can never hit total". Take a 5-member wave where member 5 never reaches _subagent_done (its durable commit hits _TerminalCommitRejected, which _report_terminal_impl handles with clear_tombstone + return and no _on_done; or its synthetic-batch report is stuck in the record_terminal retry loop). bp['done'] = 4 < bp['total'] = 5, so _last is False and members 1–4 stay _digest_held. The surviving backstop at waves.py:227 is if not self._manager.batch_members_pending(batch_id): continue — so once _batch_submitted is satisfied with no undone member in _agents and nothing queued, it declines to force a flush ("the real wave-close flush is already in flight"). Neither path closes the wave: four finished results are never injected, no delivered tombstone is written, _batch_progress leaks, and the parent waits forever. batch_members_pending now has that one inverted caller as its only production reader.

5. slack/gateway.py:7812 — the new per-batch retry-owner gate returns before the wave counting block and also swallows the two synthetic liveness backstops, so one failed non-final chunk freezes bp['done'] and permanently wedges the wave with no escape hatch. Wave of 12, chunk size 10: member w9 flushes chunk 1, its route raises, _retain_failed_position sets _batch_delivery_retry_owner['wv'] = 'w9'. Every later member hits if retry_owner and retry_owner != info.id: info._delivery_failed = True; return — which is before bp['done'] += 1 at :6777 — so done freezes at 10/12 and _last can never fire. Both unwedging backstops are swallowed too: force_digest_flush mints a fresh uuid.uuid4().hex[:8] on every sweep (waves.py:267-275) so it can never match retry_owner, and _announce_digest_flush_impl then hits the new if info._delivery_failed: return (waves.py:309-311) and settles nothing; record_lost_submission_spawn_synthetic_batch_terminal_report hits the same gate. Nothing ages the owner out — it is popped only by that member's own success (:7837) — and the finally at :7843-7845 keeps _batch_delivery_locks[batch_id] whenever an owner is registered, so both dicts leak per wedged batch for the gateway's lifetime. Executed: after 10 members: done = 10 chunks = 1 / member w10 _delivery_failed: True | w11: True / closure dicts: [{}, {'wv': <Lock>}, {'wv': 'w9'}].

6. subagent_command_authority.py:956reject_waiting_execution now RAISES when no waiting claim exists (it used to be a silent no-op), and the only wrapping caller retries that permanently-unclearable condition forever at 1 Hz, so cancel() and queue settlement never return. The base had finish_error = "" and fell through to stop_execution_heartbeat. Now any second settlement of the same run raises: either the waiting entry is gone ("waiting execution claim not found") or it is present and finish_command returns outcome_conflict because the rejection_reason differs. terminal.py:357-373 catches exactly that exception inside while True with await asyncio.sleep(_TERMINAL_RETRY_SECONDS), no cap and no raise path, so _finalize_queued_cancel_impl/_finalize_queued_rejection_impl never reach _run_terminal_report: the user's Stop hangs permanently, the run gets no terminal record or outbox event, the queue entry is retained forever, stop_execution_heartbeat is never reached so the lease-renew task renews forever and blocks recovery takeover, and the batch member never reaches _subagent_done — compounding finding 4. Executed three ways: 1st reject ok; waiting entry retained: True / 2nd reject RAISES … outcome_conflict / 3rd reject (no waiting entry) RAISES … waiting execution claim not found, and _reject_waiting_before_terminal_impl was still spinning when an external 3.2 s timeout fired.

7. subagent_manager/cancellation.py:309cancel_impl still calls reject_waiting_execution with no try/except, but that call is no longer a safe no-op, so a user Stop landing in a narrow race window fails with an authority error and the subagent keeps running. A coordinator-admitted run starts; run.py awaits execution_started(info.id), whose last statement is await self.stop_execution_heartbeat(run_id) — that pops _waiting_executions[run_id] and then awaits asyncio.gather(lease_task, …), while info._coordinator_waiting = False is only executed after that await returns (run.py:354). A Stop arriving inside that window sees info.done False and _coordinator_waiting True, so cancel_impl calls reject_waiting_execution with the claim already popped → AuthorityOutcomeUncertain propagates out of cancel(). _force_reap at :326 is never reached: the subagent keeps running, no stopped card and no subagent_done event are emitted, and the HTTP stop returns 500 (messaging.py:1064 does not wrap it) or an authority-failure response. Executed: the same call is a no-op on the base branch; on HEAD it raises AuthorityOutcomeUncertain: … waiting execution claim not found.

8. subagent_manager/terminal.py:557 — the gateway's new batch gate sets info._delivery_failed and returns NORMALLY, but only the durable branch reads that flag; the legacy branch treats the swallowed callback as successful delivery and writes the delivered tombstone, permanently losing the completion. messaging.py:341 spawns non-keyed batches whenever command_identity is None, so those members have _coordinator_fence is None and take the legacy branch. One member of such a wave is refused at spawn (so it does get a durable outbox event via _spawn_synthetic_batch_terminal_report), its non-final chunk fails to route, and _batch_delivery_retry_owner[batch_id] is set. Legacy member B then finishes successfully; _subagent_done hits the retry-owner gate, sets _delivery_failed, and returns normally. Back in the legacy branch _on_done did not raise, so info._reported_to_parent = True is set and the mark_delivered_on_success and not info.error and not _digest_held and not _delivery_queued condition here is satisfied → await asyncio.to_thread(mark_delivered, B.id). B's folder is tombstoned, excluded from orphan reconciliation, and pruned after subagent_result_ttl_secs. The parent never receives B's result and nothing retries it — B has no outbox event. grep confirms _delivery_failed is read only at terminal.py:320 (durable path), waves.py:309, and gateway.py:7834 — never in the legacy branch.

Should fix

9. subagent_manager/admission.py:157_report_synthetic_batch_terminal_impl swallows asyncio.CancelledError and continues its unbounded commit loop when _shutting_down, so cancel_all's bounded straggler gather can never complete and gateway shutdown wedges permanently. cancel_all_impl sets _shutting_down = True, deliberately skips report tasks in the _tasks cancel loop (the new if task in report_tasks: continue at cancellation.py:386), waits _REPORT_DRAIN_TIMEOUT (30 s), times out, then does report_task.cancel() followed by await asyncio.gather(*stragglers, return_exceptions=True). The CancelledError lands inside await record_terminal(request); because _shutting_down is True the handler logs "retrying" and continues. Only one cancellation is ever delivered, and the retry hits a coordinator being torn down, so the loop takes except Exception: await asyncio.sleep(1.0); continue forever. gather() awaits a task that can never finish, so the lease-task cleanup (cancellation.py:465-471) and command_authority.close() never run and the process hangs — defeating the bounded drain whose own comment says stragglers must "gather to completion". Note only the inner delivery loop is guarded by while not self._manager._shutting_down (:188); the commit loop is not. Executed: RESULT: HANG — task still running after cancel(); record_terminal calls = 4; task.cancelling() = 2.

10. subagent_manager/terminal.py:323_delivery_batch_progress/_delivery_batch_final are cleared and _reported_to_parent set BEFORE the deferred check three lines later, so a merely-queued digest chunk loses its replay snapshot and a redrive double-counts the member, closing the wave early with wrong totals. Dashboard parent, wave of 12, chunk size 10: w9 flushes chunk 1; the dashboard route always calls _defer_queued_delivery, setting _delivery_queued = True. Back in _deliver_outbox_event_impl, :320 sees no failure, so :322-325 wipe the snapshot and set _reported_to_parent = True; only then does :326 return False and drain_once release the claim, leaving the event PENDING. On any later redrive the snapshot is gone, so _batch_retry is False and the member is counted a second time. Executed: chunk1 fired: 0 | done: 10 | flushed: 10 | _delivery_queued: True, snapshot after terminal.py:323 = None, after redrive: done = 11, then after w10: wave popped (_last fired) = True with batch_finished {'total': 12, 'ok': 12, 'err': 0, 'stopped': 0} broadcast while w11 had never completed — the wave closed early and over-reported successes.

11. subagent_manager/terminal.py:157_completion_payload_impl truncates result_summary BEFORE redacting it, so a credential straddling the 4000-char boundary leaks its raw prefix into the durable outbox payload_json. summary, _ = redact_exfiltration_urls(_done_result(info.result)[:_OUTBOX_RESULT_SUMMARY_LEN]) slices first, so a secret spanning that boundary is cut in half, the prefix no longer matches the credential regex, and the raw fragment is persisted into the SQLite outbox row and replayed to the parent on every redelivery and restart. task[:1000] and error[:2000] on the surrounding lines correctly redact first, so only result_summary is wrong. subagent.py:407 already provides _redact_and_truncate, whose docstring says verbatim: "Redact over the FULL text, then truncate (never _redact(x[:n])). Truncating first can cut a credential in half at the boundary, leaving a fragment the redaction regexes no longer match" — and it is already reachable from component modules (monitoring.py:29 imports and uses it). Executed with a 40-char cap: this order yields 'xxx…AKIAIOSFOD' (raw key prefix) while redact_and_truncate yields 'xxx…[REDACTED:'.

12. run_coordinator/delivery.py:27 — the 22-minute outbox claim is never renewed while the destination runs, and the destination's own budget is within 60 s of it, so a lease expiry mid-delivery lets a second drainer re-invoke the destination and inject the completion twice. drain_once claims E with claim_expires_at = t0 + 1320 s and there is no heartbeat for outbox claims (unlike run leases, which got _start_coordinator_heartbeat in this same PR). The destination path budget is _ON_DONE_TIMEOUT (1200 s) + teardown_done.wait(_RESET_TIMEOUT + 30) (60 s) = 1260 s, leaving 60 s for _fire_event, the to_thread mark_delivered calls, and — for a wave flusher — one _settle_digest_holds iteration per held member, each a to_thread plus a full acknowledge() round trip (and SQLiteRunCoordinator rewrites every row per call). Because the lease is wall-clock, a host suspend or NTP step of >60 s during delivery also expires it. Past expiry another drainer claims E and re-invokes the destination → the parent gets the completion twice; owner A then gets mark_delivered → STALE_FENCE, its claim_outbox(..., acknowledgement=True) returns [] because B holds a live claim, so _acknowledge_accepted returns REJECTED, settled stays False, and E leaks in both self._accepted and self._inflight forever. Executed: owner B destination calls while A in flight: ['event-1'] / owner A destination calls: ['event-1'] / => destination invoked 2 times for one completion. Nothing in code or CI pins _DELIVERY_LEASE_SECONDS > _ON_DONE_TIMEOUT + _RESET_TIMEOUT + 30; the derivation lives only in a comment.

13. subagent_manager/monitoring.py:455now = time.time() is sampled BEFORE the newly inserted and unbounded await _drain_pending_outbox(), so every deadline in the reaper sweep is evaluated against a clock that can be many minutes stale — and the sweep does not run at all during the drain. _drain_pending_outbox_impl loops while True over drain_once(limit=16), and each of those 16 destination calls is asyncio.wait_for(self._manager._on_done(info), timeout=_ON_DONE_TIMEOUT) with _ON_DONE_TIMEOUT = 1200.0 plus teardown_done.wait(_RESET_TIMEOUT + 30). One wedged parent slot therefore blocks the 60-second reaper iteration for up to 16 × ~21 min per pass while now still holds the pre-drain timestamp. Everything downstream under-reports by the drain duration: elapsed = now - info.started (so a run past _default_timeout is not force-reaped), _is_startup_stalled, _maybe_flag_stall, _sweep_stuck_waves, _sweep_conversations, and _sweep_digest_holds — the only latency trigger that force-flushes held wave results at DIGEST_HOLD_SECS=120. This is self-reinforcing: the deadline flush that would unblock held members is queued behind the blocked delivery. Executed with _REAPER_INTERVAL=0 and a 3 s stand-in drain: staleness: 3.7 s / agent real age: 6.7 s vs _default_timeout 5.0 / force_reap calls: [] — the overdue agent was skipped. The same inline await also sits at :125 ahead of tombstone writing.

14. subagent_manager/monitoring.py:63 — the new reap-before-tombstone pre-pass is a no-op on macOS AND Windows, so the PR's headline ordering invariant does not hold on either non-Linux platform. subagent.py:1935 does proc_stat = os.stat(f"/proc/{pid}") inside except (FileNotFoundError, OSError): return False. On macOS/Windows that always raises, so _is_orphan_process returns False, this guard returns False, and _reap_orphan_process never reaches _kill_orphan_pid or the orphan_reconcile_kill SEL audit — even though platform_compat.pid_exists and kill_pid both have real cross-platform implementations, so only the identity check is Linux-only. Concretely: the gateway crashes on macOS between the durable terminal commit and child teardown. On restart the pre-delivery snapshot loop visits the orphan, kills nothing, reaped_orphan_ids stays empty, then _drain_pending_outbox() delivers the event and _deliver_outbox_event_impl writes mark_delivered, removing the folder from the second list_orphans() — so the surviving child is never killed and is now invisible to every future reconcile. The second loop repeats the same no-op, and the whole pre-pass costs an extra full list_orphans() directory scan plus one thread hop per orphan for nothing. Executed on this host: platform: Darwin / own pid alive: True / _is_orphan_process(own pid, now): False / /proc exists: False. The repo ships install.ps1 and make.ps1, so Windows is supported.

15. slack/gateway.py:6960_delivery_progress = dict(bp) is a shallow copy that shares the held_infos LIST with the live wave dict, so a durable retry of the flushing member zeroes _digest_held_at for members of the NEXT chunk and permanently disables the reaper's hold-deadline sweep for them. Wave of 25, chunk size 10, notification-only parent: w9 flushes chunk 1; :6939 rebinds bp['held_infos'] = [] to a new list, and this line's dict(bp) captures that same list into w9._delivery_batch_progress. Members w10–w14 are then held and appended into the shared list. w9's injection exhausts its retries → notify_injection_failed sets _delivery_failedterminal.py:321 raises _OutboxDeliveryRetry → the outbox redrives w9 → bp = dict(_retry_progress) and :6937 loops over the leaked held_infos setting _held._digest_held_at = 0.0 for w10–w14. waves.py:216 skips any info with _digest_held_at <= 0.0, so oldest gets no entry for the wave and force_digest_flush never fires — with a hanging straggler, five finished results are withheld for the entire _TIMEOUT_SECS window, regressing the #2215 latency trigger. Lines 7055-7059 deliberately rebind fail_lines/ok_lines/guard_msgs/held_delivery_ids/delivery_event_ids (safe) but nothing rebinds held_infos. Executed: ALIAS held_infos snapshot IS live: True / snapshot leaked next-chunk members: ['w10'..'w14'] / after retry, held clocks: [0.0, 0.0, 0.0, 0.0, 0.0] / => sweep guard skips them.

Cut by the reporting cap (all real, lower severity)

ShadowRunCoordinator outbox parity is 100% broken (shadow.py:46): event_id is in _GENERATED_IDENTIFIER_FIELDS so each store mints its own, but the adapter then replays release_outbox/mark_delivered with the primary's id — executed, MISMATCH boundary=mark_delivered on every completion, shadow outbox stuck at claimed, and no shadow test exercises a fenced outbox boundary · context.callback_started = True is set even when _on_done did nothing (terminal.py:294), so the first real _subagent_done_impl run is mislabelled _delivery_retry=True, permanently skipping the done WS broadcast and all orchestration-tracker accounting for that member (executed) · _settle_digest_holds_impl awaits an unbounded _ack_delivery_for_run per id (waves.py:388) after detaching the id list, so one bad id strands ids 3..9 — violating that function's own unchanged docstring — blocks inside drain_once holding a 22-min fence, and a shutdown cancellation loses the rest of the chunk's tombstones and acks · notify_injection_failed now sets _delivery_failed for every caller including legacy notification-only paths, and waves.py:309 newly honours it, so a legacy wave's deadline flush skips _settle_digest_holds and released members are never tombstoned and are re-announced after restart · _report_terminal's drain loop lacks the sibling's if context.callback_started: return and while not _shutting_down (terminal.py:502) → 1 Hz polling where each poll is a full SQLite DELETE+re-INSERT of all three tables plus PRAGMA quick_check · failed/stopped runs now enter owed/held_delivery_ids and the protecting guard if context is None or outcome == "completed" fails open (and scans only _outbox_contexts), so a failed run gets a delivered tombstone, collapsing its 7-day post-mortem window to delivered_ttl_secs (1 h) — the removed comment stated that invariant explicitly · unguarded json.loads in _info_from_outbox_impl (a poison-pill row is retried forever, and DeliveryState has no terminal-failure member) · except BaseException without re-raise in _drain_queue_impl, swallowing Ctrl-C into a fabricated spawn rejection · an approval-rejection lease leak from stop_heartbeat=False plus an early return · the dropped reject_waiting_execution in _finalize_queued_cancel_impl's fence-None branch, whose covering test test_queued_cancel_stops_the_authority_lease was deleted · _arm_queued_delivery_settlement registered twice on the same task · a second lease-renewal loop at terminal.py:101 duplicating SubagentCommandAuthority._start_execution_heartbeat line-for-line, so one run lease now has two owners.

Conventions: subagent.py:60 adds a module-level from kiro_crew.dashboard.chat_utils import dashboard_slot_key to a core module, contradicting two still-standing comments in terminal.py that lazily import the same symbol "because the dashboard layer must not be imported by a core module at import time" — measured: 19 dashboard modules now on the import path, was 3. And cancellation.py:344 introduces the comment marker "(GPT review)", which AGENTS.md forbids verbatim ("no PR/CR numbers, review-round markers…") and which regresses a previously clean comment.


Execution-verified AI-assisted review (Claude Code) across 11 angles plus a gap sweep, run against a local checkout at 5df30d604; nothing modified, nothing posted elsewhere. Findings name the input that reproduces them — please push back where one misreads intent.

@kyleseaman

Copy link
Copy Markdown
Collaborator Author

Bolin review disposition for the current stack

All fifteen numbered items from the September 2 review were rechecked.

  1. Fixed.
  2. Fixed.
  3. Fixed.
  4. Fixed.
  5. Fixed.
  6. Fixed.
  7. Fixed.
  8. Fixed.
  9. Fixed.
  10. Fixed.
  11. Fixed.
  12. Rebutted with the timing contract: the 22-minute claim deliberately exceeds the 20-minute destination budget, leaving settlement margin instead of expiring while a valid delivery callback is still running.
  13. Fixed.
  14. Fixed.
  15. Fixed.

The resulting outbox keeps terminal events durable through callback, digest, cancellation, shutdown, and retry races; acknowledgements remain fence-checked and delivery metadata round-trips conversation and model identity.

Current submitted head: 162bdff.

@bolichen97

Copy link
Copy Markdown
Collaborator

Open PR relationship audit

This is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion.

Relationship findings

  • PR #5277 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #5277: MERGE_DISCUSSION. Consumes this PR's delivery contract; complementary, not duplicative. Files: src/kiro_crew/run_coordinator/models.py.

No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

readiness: passed Eligible automated validation passed for the current revision

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants