fix(subagent): drain every off-loop state.json writer on cancel - #7467
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of The design is coherent: I traced the one gap candidate (a continuation run's zombie vs. a named conversation's Design-Verdict: PASS Fixes the failure class at its last common link, fences its own residual (drain expiry) instead of leaving it, and files the root cause separately. [DESIGN-REVIEWED] 3fd7807 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified: only one First-Principles-Verdict: PASS Every item traces to a reported defect (#6298/#6306/#6308) with a RED-on-base test, and the change deletes more per-site machinery than it adds. What this change shipsIntent: stop a cancelled run's detached state.json writer from silently rolling back state other machinery depends on — a FIX.
No duplication found: grepped [FIRST-PRINCIPLES-REVIEWED] 3fd7807 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've traced the full change: the consolidation of three off-loop Verifying the key correctness properties:
No path in the changed lines produces a concrete wrong outcome from a real input; the diff adds a guard and removes none. The discovery pass's "No candidates" holds under independent falsification, and Step 2 surfaced nothing groundable to the 80+ bar. No findings. [OPUS-REVIEWED] 3fd7807 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- src/kiro_crew/subagent.py:1582 -- False positive or not applicable? A repository writer can comment: |
856fabf to
0380071
Compare
|
Failing test: Evidence it is not mine:
Root cause: #7278's census entry was measured on a tree predating #7293, which consolidated that module's Fix is a one-line ratchet lowering, kept out of this PR and filed separately as #7492 so this one stays single-purpose. This PR cannot show a green |
0380071 to
2e83781
Compare
Disposition: GPT 5.6 BLOCKING -- "Drain timeout still permits state corruption"Concern: ACCEPTED and now FIXED. Prescribed remedy: DECLINED, with reason. The scenario is right and I had disclosed it as a residual rather than closing it: the drain is bounded at 5s, so on an FS that stalls past the deadline the worker is abandoned, the cancellation completes, a promote writes Why not the prescribed fix. "Keep cancellation pending until the writer finishes; do not abandon it at the timeout" trades a rare stale write for an unbounded one: What I did instead -- close the residual without unbounding the drain. On expiry the run now marks
Chain closed: bounded drain (liveness preserved) + deferred retention write (freshness preserved), instead of choosing one. Test:
Regression scope re-run after the change: 1937 passed across every test file touching the run path, cancellation, Head is now |
2e83781 to
6e3cebc
Compare
Disposition: GPT 5.6 round 2 -- "Eviction bypasses the abandoned-writer hold"Concern: ACCEPTED and FIXED. Prescribed remedy: DECLINED, with reason. Correct, and it lands on the one fail-open I had written into the round-1 docstring rather than closed: the hold lived on Why not the prescribed fix. "Keep the run non-terminal until the writer settles" means withholding What I did instead -- keep the record, move it off the evictable structure.
Test:
Regression scope re-run: 2037 passed across every test file touching the run path, cancellation, Head is now |
6e3cebc to
6d29abb
Compare
Disposition: Design Review CONCERNS (advisory) -- both Watch itemsWatch 2, "the invariant is convention-only": FIXED, and the suggestion taken as written. You are right that nothing in the diff enforced it, and that this is the same divergence the PR exists to repair -- three structurally identical sites, one drained, two not. Added a static gate, Implemented as a pytest gate rather than a Mutation-verified, three mutants, three reds:
Mutant 2 found a real hole in my first draft: I keyed on the innermost Watch 1, "the class stays open / force the decision now": AGREED, and filed rather than absorbed. The count is right: #6306, #7280 and this PR have each fenced the whole-file rewrite and each explicitly declined to remove it, and the accumulated machinery is real. I am not folding a persistence-format or CAS change into a PR that has converged on two named defects -- that is a different blast radius and needs a maintainer ruling, not a fixer's judgment. Filed as #7515, which states the root cause, lists what all three PRs added, and puts the three options (versioned CAS / per-field write / keep fencing) with their costs, noting #7302 should be decided with it. That turns "the predictable fourth interleave PR" into a decision someone has to answer, which was your point. Head is now |
6d29abb to
248c7e7
Compare
Disposition: GPT 5.6 round 3 -- "Conversation hold starts only after drain expiry"FIXED, and this time the prescribed remedy was the right one -- taken as written. Correct: the hold was armed in the expiry branch, so it covered only the window past the deadline. On Python 3.10 a second outer cancel can interrupt "Add the hold when cancellation draining begins and clear it when the writer completes" is exactly right, and it makes the code smaller rather than larger:
Test: Mutation-verified:
Also rebased onto Head is now |
update_state rewrites state.json whole-file from the snapshot it read, so a writer that outlives its await rolls back every field written after that read -- not only the fields it names. Cancelling an `await asyncio.to_thread(update_state, ...)` detaches the worker without stopping it, which is how a zombie erases the pid / session_id a cancel-respawn recovery run writes on the loop, or the retention `keep` that promote / release write on the loop. PR #6306 fixed this for one of the three off-loop writers (per-turn diagnostics) with an inline shield-and-drain. Extract that into _write_state_off_loop and route the other two through it (pre-spawn model provenance, CC-path model refinement), so no pool writer can outlive the run it belongs to. That also closes the on-loop half: both `keep` writes are reached only through a _conversation_busy gate that refuses while a run is in flight, so the only writer that could still interleave with them was a detached one -- the population this removes. Closes #6298 Closes #6308
248c7e7 to
3fd7807
Compare
|
bolichen97
left a comment
There was a problem hiding this comment.
CI green, no blocking bot findings, diff matches description. Approved.
update_state reads state.json, merges and rewrites the whole file with a blocking fsync between. Three of a run's writers called it directly from an async def body -- the PID record and the session record in _run_inner, and the session-sharing PID record in _create_shared_session -- so each fsync ran on the gateway's only event loop, the no-blocking-call-on-event-loop class #6288 names. PR #7467 had already moved every other writer inside a run through _write_state_off_loop; these three were left behind. All three now go through that same helper, which runs the write in a pool thread and drains the worker on cancellation. Off-loop also means the write TAKES update_state's per-agent lock, which on-loop callers skip, so it can no longer interleave with another pool writer's read-merge-rewrite. Refs #7302
#7557) update_state reads state.json, merges and rewrites the whole file with a blocking fsync between. Three of a run's writers called it directly from an async def body -- the PID record and the session record in _run_inner, and the session-sharing PID record in _create_shared_session -- so each fsync ran on the gateway's only event loop, the no-blocking-call-on-event-loop class #6288 names. PR #7467 had already moved every other writer inside a run through _write_state_off_loop; these three were left behind. All three now go through that same helper, which runs the write in a pool thread and drains the worker on cancellation. Off-loop also means the write TAKES update_state's per-agent lock, which on-loop callers skip, so it can no longer interleave with another pool writer's read-merge-rewrite. Refs #7302 Co-authored-by: gh-autofix#2887 <chenmingwei23@users.noreply.github.com>
1. What is the problem?
subagent_persistence.update_stateis a read / merge / whole-file rewrite.It writes back the snapshot it read, so any writer that runs late rolls back
every field landed after its read -- not just the fields it names.
Three of a run's
state.jsonwriters run off-loop viaawait asyncio.to_thread(update_state, ...). Cancelling such an awaitdetaches the worker without stopping it. PR #6306 closed that for one of the
three (the per-turn diagnostics write) with an inline shield-and-drain; the other
two were left as bare awaits:
requested_model/resolved_model)resolved_model, on the first text chunk)So a cancelled run could still leave a live writer behind (#6308). Separately,
keepis written from the event loop, whereupdate_statedeliberatelytakes no per-agent lock (#7280) --
_promote_conversation_implandrelease_conversation_impl-- and a detached worker's stale rewrite erases it(#6298).
2. Why this issue matters to the user
Both rollbacks destroy state that other machinery acts on:
pid/session_idmeans the reaper can no longer reach the childprocess. That is a leaked
kiro-cliprocess holding its memory for the life ofthe gateway, and a session whose files are never cleaned up.
keepmeans the conversation is no longer marked as resume material, sothe tombstone pruner deletes the session files
spawn_continueneeds. The nextcontinuation answers
conversation_goneand the accumulated context is gone --from the user's side, the agent forgot a conversation it promised to keep.
Both are silent. Nothing logs a rollback, because from
update_state's point ofview every write succeeded.
3. How our fix solves it
The chain is: whole-file rewrite -> a detached worker is a stale writer -> a
detached worker is only possible because a cancelled
to_threadawait abandonsits worker. So the fix is at the last link, for every off-loop writer rather
than one of them.
_write_state_off_loop(info, what, **fields)is #6306's shield-and-drain,extracted verbatim and parameterised by a label. All three off-loop writers now
go through it, and the inline 99-line block at the diagnostics site collapses to
a 5-line call. The helper is the single place that answers "what happens to a
state write when the run is cancelled".
The on-loop half closes as a consequence, without moving those callers
off-loop.
_promote_conversation_implandrelease_conversation_implare bothreached only through
_conversation_busy, which refuses while a run is in flight(
continue_conversation_implreturnsconversation_busybefore it ever reachesthe promote;
release_conversation_implreturns it as its first act). The gatetests
not a.done, so the one writer it cannot see is a worker that outlived itsrun -- exactly the population this PR removes. No triage pass on #6298 had
noticed the gate, which is why the issue reads as needing the loop-side offload.
Three per-site rulings the triage passes parked as human decisions collapse once
the whole-file rewrite is taken seriously, so the helper needs no per-site flags:
_cancel_retry_used? Yes, atevery site, as fix(subagent): bound cancellation drain for diagnostics writes #6306 already did at its own. The reason is not which fields a
caller named: an abandoned worker's rewrite is whole-file, so its blast radius
is identical everywhere. Letting a recovery run respawn while a zombie is live
trades one lost
pid(an unreapable orphan) for one best-effort auto-continueon an FS already wedged past the deadline -- the wrong way round.
which is why it is set inside the helper. It is renamed
_diag_drain_active->_state_drain_active(and_DIAG_DRAIN_TIMEOUT->_STATE_DRAIN_TIMEOUT): leaving "diag" in the name would tell the next readeronly the turn-counter write raises it.
loop? Per attempt, and a cancellation ends the loop. This needs no code:
except Exceptiondoes not catchCancelledError, so the helper's post-drainre-raise propagates out of the loop instead of starting a second writer for
the same fields. A test pins it.
The drain is deliberately bounded (5s):
cancel_all()gathers run tasks withno timeout, so an unbounded drain on a wedged FS would hold gateway shutdown open
forever -- the posture
_REPORT_DRAIN_TIMEOUTalready sets. That bound means aworker CAN be abandoned, and an abandoned worker is a stale writer. Rather than
leave that as a residual, the run marks
_state_writer_abandonedon expiry andkeeps HOLDING its conversation until the worker settles, so the two on-loop
keepwriters are deferred past the zombie instead of being undone by it. The hold rides
the gate that already guards both of them (
_conversation_busy), is cleared by theworker's own done-callback, and fails open to today's behaviour once a run is
evicted from
_agents. Both refusal messages were reworded for the case: the oldtext told the caller to "wait for its completion event", which has already fired.
Still true and stated in
update_state's docstring rather than left implied:on-loop callers pay this function's fsync on the loop.
4. What tests we did
Three new tests, each verified RED on
origin/mainwith the fix reverted:test_provenance_write_is_drained_on_cancellationcancelled _run_inner completed while the pre-spawn provenance write was in flighttest_cc_refinement_write_is_drained_on_cancellationtest_keep_survives_a_cancelled_runs_provenance_workerassert None is True--keepwas rolled back by the detached workerA fourth pins the drain-EXPIRY path and a fifth enforces the invariant. Neither
has a base equivalent (both mechanisms are new), so both are mutation-verified
instead -- three mutants each, three reds each:
test_an_abandoned_state_writer_holds_the_conversationexpires the drainagainst a wedged worker, asserts the conversation is held, asserts the hold
survives clearing
_agentsoutright, assertsrelease_conversationrefuseswith the reworded detail, then releases the worker and asserts the hold clears.
Mutants: gate ignores the manager registry; keep the record on the run's
SubagentInfoinstead of the manager (an eviction then releases the hold);never discard on completion (a permanently un-promotable conversation).
test_no_bare_to_thread_update_state_outside_the_drained_helperis a staticgate: it AST-walks
src/kiro_crew/and fails on anyasyncio.to_thread(update_state, ...)whose enclosing frame is not_write_state_off_loop_impl, so "every off-loop writer is drained" stops beingconvention. Same shape as
test_no_blocking_call_on_loop.py, including itsscope rule that a nested
def/lambdais a separate frame. Mutants: a barecall in another function; the same call in a nested
lambdainside the helper;the same call in an unrelated module.
The third table row is the end-to-end for this PR's on-loop claim: real
update_stateagainst a temp subagents dir, a real thread parked between its readand its write,
_run_innercancelled, then_promote_conversationon the loop. Onbase the zombie lands after the promote and
keepis gone; with the drain thecancellation cannot settle until the worker has landed, so the promote is strictly
after it.
Regression scope: every test file that touches the run path, cancellation,
update_state, the conversation gate, or agent eviction -- 2038 passed,including all of #6306's hardened drain tests unchanged (proof the extraction is
behaviour-preserving) and
test_no_blocking_call_on_loop.py. Gates:scripts/check_black_formatting.pypasses, isort and flake8 clean on the five touched files, mypy clean on the three
source files (the 2 remaining errors are pre-existing boto3-stub drift in
transcribe.py, untouched here).5. Any other suggestions on the work
loop, and its motivation is the
no-blocking-call-on-event-loopanchor: thosecallers still perform a synchronous fsync on the loop, which this PR does not
change. What subagent: retention keep-write at subagent.py:3825 runs update_state on the event loop unguarded, can clobber an in-flight to_thread write #6298 / subagent: sibling to_thread update_state writes lack the cancellation drain; update_state is an unlocked read-merge-replace #6308 asked for was the interleave, and that is what
closes here. subagent: move the five on-loop update_state callers off the event loop #7302 should stay open on its own (weaker) merits -- a
loop-blocking fsync -- and it no longer has to carry two named defects, nor the
three rulings above.
at Design Review's request:
update_statewriting whole-file is what makes anylate writer a stale writer, and three PRs (fix(subagent): bound cancellation drain for diagnostics writes #6306, fix(subagent): serialize state.json rewrites for off-loop writers #7280, this one) have each
fenced it and each explicitly declined to remove it. subagent: decide whether update_state stops rewriting state.json whole-file #7515 states the root cause,
lists what all three added, and puts the options (versioned CAS / per-field write
/ keep fencing) with their costs, noting subagent: move the five on-loop update_state callers off the event loop #7302 belongs to the same decision. This
PR fences the last reachable path; it does not remove the class, and folding a
persistence-format change in here would be a different blast radius.
Pattern harvest
Rule candidate: lint -- SHIPPED IN THIS PR as
test_no_bare_to_thread_update_state_outside_the_drained_helper, at DesignReview's request: an AST gate over
src/kiro_crew/failing on anyasyncio.to_thread(update_state, ...)outside the drained helper. All three sitesin this PR would have matched it; one had already been fixed by hand (#6306) and
the other two had not, which is exactly the shape a gate catches and a code review
does not -- the fixed site and the bare sites sit 300 lines apart in one file and
look identical at the call. Generalisable beyond this call: flag
await asyncio.to_thread(<f>, ...)where<f>is a read-modify-write on shareddurable state and the await is not shielded.
Rule candidate: review-prompt -- when a helper takes
**fieldsand rewrites awhole file, ask whether it has one caller-facing wrapper per concurrency context.
Three call-shapes for one primitive (bare on-loop, bare off-loop, drained
off-loop) is what let the divergence exist and persist across two PRs. The
generalisation is the deeper lesson: for a whole-file read-modify-write, "which
fields did this writer name" is never the right question -- the blast radius is
every field, so per-site rulings about which fields are safe to lose are
answering a question that does not apply.
Closes #6298
Closes #6308