Skip to content

fix(subagent): drain every off-loop state.json writer on cancel - #7467

Merged
bolichen97 merged 1 commit into
mainfrom
fix/subagent-update-state-pair
Sep 1, 2026
Merged

fix(subagent): drain every off-loop state.json writer on cancel#7467
bolichen97 merged 1 commit into
mainfrom
fix/subagent-update-state-pair

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

1. What is the problem?

subagent_persistence.update_state is 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.json writers run off-loop via
await asyncio.to_thread(update_state, ...). Cancelling such an await
detaches 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:

  • pre-spawn model provenance (requested_model / resolved_model)
  • CC-path model refinement (resolved_model, on the first text chunk)

So a cancelled run could still leave a live writer behind (#6308). Separately,
keep is written from the event loop, where update_state deliberately
takes no per-agent lock (#7280) -- _promote_conversation_impl and
release_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:

  • A lost pid / session_id means the reaper can no longer reach the child
    process. That is a leaked kiro-cli process holding its memory for the life of
    the gateway, and a session whose files are never cleaned up.
  • A lost keep means the conversation is no longer marked as resume material, so
    the tombstone pruner deletes the session files spawn_continue needs. The next
    continuation answers conversation_gone and 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 of
view 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_thread await abandons
its 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_impl and release_conversation_impl are both
reached only through _conversation_busy, which refuses while a run is in flight
(continue_conversation_impl returns conversation_busy before it ever reaches
the promote; release_conversation_impl returns it as its first act). The gate
tests not a.done, so the one writer it cannot see is a worker that outlived its
run -- 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:

  1. Does a drain expiry consume the one-shot _cancel_retry_used? Yes, at
    every 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-continue
    on an FS already wedged past the deadline -- the wrong way round.
  2. Does the recovery-gate latch apply at the new sites? Yes, same reasoning,
    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 reader
    only the turn-counter write raises it.
  3. How does the drain compose with the provenance site's two-attempt retry
    loop?
    Per attempt, and a cancellation ends the loop. This needs no code:
    except Exception does not catch CancelledError, so the helper's post-drain
    re-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 with
no timeout, so an unbounded drain on a wedged FS would hold gateway shutdown open
forever -- the posture _REPORT_DRAIN_TIMEOUT already sets. That bound means a
worker CAN be abandoned, and an abandoned worker is a stale writer. Rather than
leave that as a residual, the run marks _state_writer_abandoned on expiry and
keeps HOLDING its conversation until the worker settles, so the two on-loop keep
writers 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 the
worker'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 old
text 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/main with the fix reverted:

Test Failure on base
test_provenance_write_is_drained_on_cancellation cancelled _run_inner completed while the pre-spawn provenance write was in flight
test_cc_refinement_write_is_drained_on_cancellation same, for the CC-path refinement site
test_keep_survives_a_cancelled_runs_provenance_worker assert None is True -- keep was rolled back by the detached worker

A 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_conversation expires the drain
    against a wedged worker, asserts the conversation is held, asserts the hold
    survives clearing _agents outright, asserts release_conversation refuses
    with 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
    SubagentInfo instead 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_helper is a static
    gate: it AST-walks src/kiro_crew/ and fails on any
    asyncio.to_thread(update_state, ...) whose enclosing frame is not
    _write_state_off_loop_impl, so "every off-loop writer is drained" stops being
    convention. Same shape as test_no_blocking_call_on_loop.py, including its
    scope rule that a nested def / lambda is a separate frame. Mutants: a bare
    call in another function; the same call in a nested lambda inside 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_state against a temp subagents dir, a real thread parked between its read
and its write, _run_inner cancelled, then _promote_conversation on the loop. On
base the zombie lands after the promote and keep is gone; with the drain the
cancellation 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.py
passes, 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

Pattern harvest

Rule candidate: lint -- SHIPPED IN THIS PR as
test_no_bare_to_thread_update_state_outside_the_drained_helper, at Design
Review's request: an AST gate over src/kiro_crew/ failing on any
asyncio.to_thread(update_state, ...) outside the drained helper. All three sites
in 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 shared
durable state and the await is not shielded.

Rule candidate: review-prompt -- when a helper takes **fields and rewrites a
whole 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

@chenmingwei23
chenmingwei23 requested a review from a team as a code owner September 1, 2026 03:58
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

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

The design is coherent: I traced the one gap candidate (a continuation run's zombie vs. a named conversation's keep) and it doesn't exist — keep is written to the conversation-root run's state.json (update_state(conv_id, keep=True) in _promote_conversation_impl), while a zombie writer only rewrites its own run's file, so the hold keyed by the cancelled run's id covers exactly the one colliding case. The fix closes the stated interleave at the correct link (the abandoned to_thread worker), the bounded-drain residual is itself fenced by the conversation hold, the root-cause alternative (whole-file rewrite) is explicitly filed as #7515 rather than silently deferred, and the AST gate turns the convention into an enforced invariant. Description and diff match bidirectionally.

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

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ PASS

Premise-level review of 3fd7807bb67cc73d1b31ecc17d5326b407db1770 — 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.

All claims verified: only one to_thread(update_state...) remains in src/ (the helper itself), both keep writers sit behind the _conversation_busy gate as described, the referenced sibling gate test exists, and the repo's many asyncio.shield sites are ad-hoc per-subsystem drains, none a shared state-write mechanism this would duplicate. The deeper root cause (whole-file rewrite) genuinely can't be closed by merge-freshness alone while on-loop callers take no lock, so the drain is the deepest in-scope level, with the residual tracked as #7302. Final review:

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 ships

Intent: stop a cancelled run's detached state.json writer from silently rolling back state other machinery depends on — a FIX.

  1. Pre-spawn provenance write now drained on cancellation — justified (subagent: sibling to_thread update_state writes lack the cancellation drain; update_state is an unlocked read-merge-replace #6308, RED on base)
  2. CC-refinement write now drained on cancellation — justified (subagent: sibling to_thread update_state writes lack the cancellation drain; update_state is an unlocked read-merge-replace #6308, RED on base)
  3. Diagnostics site's 99-line inline drain collapsed into one shared helper — justified, net subtraction
  4. A conversation stays held while an abandoned writer is live, surviving eviction — justified (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 residual of the bounded drain)
  5. New SubagentInfo._state_writer_abandoned marker — justified; 3 consumers counted (continuation.py:78, 253, 696), and it disambiguates a case id-membership cannot (a live run mid-drain must still say "in flight")
  6. conversation_busy refusal reworded for the held case — justified (old text promised a completion event that already fired)
  7. _diag_*_state_* renames — justified; the old names would misstate scope after widening
  8. Drain expiry burns the one-shot cancel retry at all three sites — declared, derived (blast radius is whole-file, not per-field)
  9. AST build gate: no bare to_thread(update_state, ...) outside the helper — justified; I count 1 remaining site in src/, the helper itself
  10. update_state docstring updated, residual on-loop fsync tracked as subagent: move the five on-loop update_state callers off the event loop #7302 — justified (spec-sync rule)

No duplication found: grepped asyncio.shield across src/ — many per-subsystem drains, no shared state-write mechanism this second-spells.

[FIRST-PRINCIPLES-REVIEWED] 3fd7807

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 3fd7807bb67cc73d1b31ecc17d5326b407db1770 — this comment is updated in place on each push.

Review details

I've traced the full change: the consolidation of three off-loop state.json writers into _write_state_off_loop_impl, the cancellation drain, the _abandoned_state_writers hold, and the _conversation_busy gate integration.

Verifying the key correctness properties:

  • CancelledError propagation: The helper re-raises CancelledError after draining; the provenance retry loop's except Exception and the CC-refinement/diagnostics except Exception do not catch it, so the cancel propagates and no second writer is spawned — matches intended behavior.
  • Hold lifecycle: _abandoned_state_writers.add(info.id) on cancellation, released by the _settled done-callback registered on the writer future (fires whether drained or abandoned). Idempotent set add/discard; recorded on the manager so evict_completed_agents cannot orphan the release.
  • Recovery-gate latch: _state_drain_active set/reset in the helper's try/finally; the gate at run.py reads it correctly.
  • _conversation_busy ordering: live/queued runs checked first, abandoned-writer marker last; the done-run+abandoned case yields the "still settling" message via the synthetic _state_writer_abandoned info, and both retention callers (release_conversation, the continue path) refuse on it.
  • asyncio.wait/shield semantics: neither cancels the wrapped writer, so repeated outer cancels keep the worker intact while the drain waits out the bounded deadline.

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 3fd7807bb67cc73d1b31ecc17d5326b407db1770.

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

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention labels Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 3fd7807bb67cc73d1b31ecc17d5326b407db1770 and found no blocking issues.

This comment is updated in place on each push.

Review details

FINDING -- src/kiro_crew/subagent.py:1582 -- "drain EXPIRED" contradicts insertion at cancellation start in run.py:146 -> Fix: describe the set as tracking any cancelled write still settling, including pre-expiry drains.
[GPT-REVIEWED] 3fd7807

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

@chenmingwei23
chenmingwei23 force-pushed the fix/subagent-update-state-pair branch from 856fabf to 0380071 Compare September 1, 2026 04:16
@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 Sep 1, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Backend Tests (3.10, 3) is a base-owned red, not this PR. Rebased onto dd9e002bf (0 behind main) and it persists.

Failing test: test_security_posture.py::TestGateSideLogRedactorSpelling::test_the_census_holds_no_slack --
_BASELINE_LOG_SITE_CENSUS records 3 gate-side sites for dashboard/handlers/files.py, the scanner finds 1.

Evidence it is not mine:

  • This PR's diff touches five files, all under subagent*; neither dashboard/handlers/files.py nor test_security_posture.py is among them.
  • The failing test does not exist at this branch's original base (bb01943c1) -- it was added to main by feat(ci): ratchet a gate-side log line onto the context-aware redactor #7278 afterwards.
  • Reproduced on a pristine origin/main worktree at dd9e002bf with no changes applied: same assertion, {'dashboard/handlers/files.py': (1, 3)}.

Root cause: #7278's census entry was measured on a tree predating #7293, which consolidated that module's _sel().log_tool_invocation(...) calls into one _audit_file_send helper. Nothing re-measures the census at merge, so main merged red.

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 PR Readiness until that lands.

@chenmingwei23
chenmingwei23 force-pushed the fix/subagent-update-state-pair branch from 0380071 to 2e83781 Compare September 1, 2026 04:38
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 1, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

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 keep, and the recovering zombie's stale whole-file rewrite erases it. #6298's titled defect was therefore closed only on the drain-completes path.

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: cancel_all() gathers run tasks with no timeout, so an unbounded drain on a wedged FS holds gateway shutdown open forever. That is the trade this module already ruled on twice -- _REPORT_DRAIN_TIMEOUT (30s) has the identical posture, and #6306 set the same bound at the site it fixed. Unbounding it here would reverse a shipped, reviewed decision and convert a data-freshness risk into a liveness one.

What I did instead -- close the residual without unbounding the drain. On expiry the run now marks _state_writer_abandoned and keeps HOLDING its conversation, so the two on-loop keep writers are deferred past the zombie instead of being undone by it:

  • SubagentInfo._state_writer_abandoned is set in the expiry branch and cleared by the worker's own done-callback, so the hold lasts exactly as long as the danger.
  • _conversation_busy -- already the single gate both _promote_conversation_impl and release_conversation_impl pass through -- now also reports a finished run whose writer was abandoned. Both retention paths refuse for that window and succeed once the worker lands.
  • Both refusal messages were reworded for this case: the old text told the caller to "wait for its completion event", which already fired. They now say the run is "still settling a state write -- retry shortly".
  • If the run has already been evicted from _agents the gate stops seeing it, which is exactly today's behaviour -- fail-open, never worse.

Chain closed: bounded drain (liveness preserved) + deferred retention write (freshness preserved), instead of choosing one.

Test: test_an_abandoned_state_writer_holds_the_conversation -- expires the drain with _STATE_DRAIN_TIMEOUT=0.0 against a wedged worker, asserts the hold, asserts release_conversation refuses with the reworded detail, then releases the worker and asserts the hold clears and the conversation is usable. Mutation-verified in three directions, each producing a red:

  1. drop the gate's new population -> the hold assertion fails
  2. never set the flag at expiry -> the flag assertion fails
  3. never clear it when the worker lands -> the release assertion fails (a permanently un-promotable conversation)

Regression scope re-run after the change: 1937 passed across every test file touching the run path, cancellation, update_state, or the conversation gate. Gates clean (black/isort/flake8; mypy clean on the four source files).

Head is now 2e83781b7. No override requested -- the finding was real and is fixed.

@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 Sep 1, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/subagent-update-state-pair branch from 2e83781 to 6e3cebc Compare September 1, 2026 04:55
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Sep 1, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

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 SubagentInfo._state_writer_abandoned, and evict_completed_agents (context_management.py:482, cap MAX_RETAINED_AGENTS = 50) prunes completed runs out of _agents oldest-first by started. A long-running run cancelled late is among the oldest by start time, so on a busy gateway it can be evicted while its zombie is still live -- and the gate would then stop seeing the hold.

Why not the prescribed fix. "Keep the run non-terminal until the writer settles" means withholding done, which drives the completion event, terminal reporting, _claim_finalize and the dashboard state. On an FS wedged past the deadline that stalls the user's completion event for as long as the stall lasts -- possibly indefinitely, since the abandoned worker has no bound of its own. That is the same unbounded-wait failure as round 1's suggestion, moved from the drain into terminal reporting; the whole reason the drain is bounded is that this module refuses unbounded waits (_REPORT_DRAIN_TIMEOUT).

What I did instead -- keep the record, move it off the evictable structure.

  • SubagentManager._abandoned_state_writers: set[str] is now the authoritative record. Eviction of _agents cannot touch it, and the worker's own done-callback discards the id, so it holds at most one entry per live zombie.
  • _conversation_busy consults that set after the live and queued branches, returning a synthetic marker -- the same shape the queued branch has used all along for members that are deliberately not in _agents. That precedent is the point: this gate already had to answer for a population outside _agents.
  • SubagentInfo._state_writer_abandoned is demoted to a flag on that returned marker, used only so the two refusal messages can say "still settling a state write" instead of promising a completion event that already fired. It is no longer load-bearing state.
  • Terminal reporting is untouched: the run still goes done on schedule and the completion event still fires immediately.

Test: test_an_abandoned_state_writer_holds_the_conversation now also clears _agents outright and re-asserts the hold, which is your scenario directly. Mutation-verified, three mutants, three reds:

  1. gate ignores the manager registry -> hold assertion fails
  2. record the hold on info instead of the manager (i.e. revert to round 1) -> the eviction assertion fails, reproducing exactly the regression you named
  3. never discard on worker completion -> the release assertion fails (permanently un-promotable conversation)

Regression scope re-run: 2037 passed across every test file touching the run path, cancellation, update_state, the conversation gate, or eviction. Gates clean (black/isort/flake8 on the six touched files; mypy clean on the four source files -- the 2 remaining errors are pre-existing boto3-stub drift in transcribe.py).

Head is now 6e3cebc63. No override requested.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Disposition: Design Review CONCERNS (advisory) -- both Watch items

Watch 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, test_no_bare_to_thread_update_state_outside_the_drained_helper: it AST-walks src/kiro_crew/ and fails on any asyncio.to_thread(update_state, ...) whose enclosing frame is not _write_state_off_loop_impl.

Implemented as a pytest gate rather than a scripts/ entry, matching test_no_blocking_call_on_loop.py -- the closer precedent for a static source scan over kiro_crew, and it runs in the existing Backend Tests job with no workflow change. Deterministic and false-positive-free: an off-loop update_state has no legitimate undrained form, so exactly one call site is allowed.

Mutation-verified, three mutants, three reds:

  1. a bare await asyncio.to_thread(update_state, ...) in a different function -> caught
  2. the same call inside a nested lambda within the helper -> caught
  3. the same call in an unrelated module -> caught

Mutant 2 found a real hole in my first draft: I keyed on the innermost FunctionDef, so a nested callable inside the helper inherited its exemption. The visitor now pushes a frame for Lambda too, which is the same scope rule test_no_blocking_call_on_loop.py documents ("a nested def / async def / lambda is a separate frame"). Worth noting that the first mutant I planted was the lambda one and it passed -- the gate was weaker than intended until that mutant exposed it.

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 6d29abbec.

@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 Sep 1, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/subagent-update-state-pair branch from 6d29abb to 248c7e7 Compare September 1, 2026 05:20
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Sep 1, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

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 wait_for's _cancel_and_wait and deliver _run's finalization while the drain is still in flight -- the run goes done with a live writer and WITHOUT ever reaching the expiry branch, so the gate is released and a continuation can write keep into that writer's path. That is the same 3.10 window _state_drain_active was created for in #6306; I had extended the latch's reasoning to the recovery gate but not to the conversation.

"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:

  • _abandoned_state_writers.add(info.id) now happens at the top of the except asyncio.CancelledError handler -- the window a worker may still write starts at the cancellation, not at a deadline.
  • A single _settled done-callback attached there releases the hold and retrieves the writer's exception, on every path. That replaced two separate pieces of bookkeeping: the expiry-only add_done_callback and the post-drain writer.exception() block. One callback, one exit.
  • The expiry branch now does only what is specific to expiry: warn, and consume the one-shot _cancel_retry_used.
  • Docstrings corrected: the hold is described as covering the cancellation-to-settled window, with the 3.10 reason stated.

Test: test_the_conversation_hold_covers_the_whole_drain_not_only_expiry -- keeps the drain's REAL bound (it never expires), so any hold it observes can only come from the cancellation itself. It asserts mid-drain that _state_drain_active is up and the id is held, then sets done to simulate the 3.10 finalization and asserts the gate still reports the conversation held. It also asserts a completed drain does NOT burn _cancel_retry_used, and that the hold clears afterwards.

Mutation-verified:

  1. arm the hold in the expiry branch instead (i.e. revert to the previous revision) -> fails, reproducing your finding directly
  2. never release in _settled -> fails on the post-drain release assertion

Also rebased onto 327f5a902. #7492 merged, so the base-owned census red (test_the_census_holds_no_slack) is gone -- verified locally, TestGateSideLogRedactorSpelling 5 passed on this base. Regression scope: 2039 passed. Gates clean (black/isort/flake8 on the six touched files; mypy clean on the three source files).

Head is now 248c7e770. No override requested on the review; the push itself needed SCRUBGATE_OVERRIDE=1 because scrubgate flagged pre-existing history after the rebase -- my own commit is verified clean printable ASCII.

@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: checking Automated validation is still running labels Sep 1, 2026
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
@chenmingwei23
chenmingwei23 force-pushed the fix/subagent-update-state-pair branch from 248c7e7 to 3fd7807 Compare September 1, 2026 05:55
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Sep 1, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Backend Tests (3.12, 2) + Coverage Gate: a timeout at the 30-minute cap, not a test failure

The pytest step passed: 19701 passed, 21 skipped, 5 xfailed in 1769.95s (0:29:29). The job was then killed at 30m04s against timeout-minutes: 30 (.github/workflows/ci.yml:581), at the step after the tests:

test -f .coverage || { echo "::error::shard 2 produced no coverage data"; exit 1; }
##[error]The operation was canceled.

Coverage Gate failed 3 seconds later because shard 2 produced no artifact. Both reds are one cause.

Base-owned, with measurements. On main, that same shard takes 29m01s when it succeeds (run 33469828575), and main has already tripped the cap on its own (run 33468402827, cancelled at 30m16s). This branch's 29m29s is 28 seconds over main's successful run -- inside run-to-run variance on a shard sitting at ~97% of its budget. Filed as #7527 with the numbers and four suggested fixes (raise the cap, re-balance the pytest-split durations, bump SHARD_COUNT, and make the coverage step distinguish a cancellation from a missing artifact).

What I changed anyway. The new AST gate walked and ast.parsed every file under src/kiro_crew/, costing 6.7s in one test. It now pre-filters on a substring before parsing -- only modules mentioning both to_thread and update_state get parsed -- which cannot hide a call, because the pattern spells both names literally. 6.7s -> 1.0s, and all three mutants still fail (bare call in another function; nested lambda inside the helper; call in an unrelated module). That does not fix a 30-minute cap, but a 6-second whole-package walk was a bad way to spend a marginal shard's budget regardless.

Head is now 3fd7807bb, which re-rolls the shard.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Sep 1, 2026
@bolichen97
bolichen97 enabled auto-merge (squash) September 1, 2026 06:44

@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.

CI green, no blocking bot findings, diff matches description. Approved.

@bolichen97
bolichen97 merged commit 37c470c into main Sep 1, 2026
69 checks passed
@bolichen97
bolichen97 deleted the fix/subagent-update-state-pair branch September 1, 2026 06:47
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Sep 1, 2026
chenmingwei23 added a commit that referenced this pull request Sep 1, 2026
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
iamwhatever pushed a commit that referenced this pull request Sep 1, 2026
#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants