fix(subagent): take a run's three state.json writes off the event loop - #7557
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of The change is a targeted application of an existing, well-documented pattern ( Design-Verdict: PASS Three missed writers moved onto an existing, drain-safe helper; both the blocking-loop and lock-ordering harms close with no new mechanism. [DESIGN-REVIEWED] eed515f |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All verification done. The claims in the description check out against the repository: the helper pre-exists, the two remaining callers are synchronous retention writers as stated, and the new AST gate covers a distinct offense class from the existing #7467 gate. First-Principles-Verdict: PASS Three on-loop fsyncs move onto the one existing drained helper; every item is the fix or its pin, siblings counted and declared. What this change shipsIntent: stop a subagent spawn from stalling the gateway's only event loop (and losing state to unordered whole-file rewrites) three times per run — a FIX.
Verified counts: direct [FIRST-PRINCIPLES-REVIEWED] eed515f |
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
91e704f to
eed515f
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: moves the subagent run's PID-record and session-record update_state writes off the event loop via _write_state_off_loop (fsync no longer runs on the gateway's only loop; #7302), with a bounded regression gate.
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: the subagent run's PID and session state.json writes were calling update_state (which ends in a synchronous fsync) directly from an async body on the shared gateway loop; they now go through the existing drained _write_state_off_loop helper, which also makes them take the per-agent lock on-loop callers skip.
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: three update_state writes inside a subagent run were fsyncing on the gateway's only event loop (the #6288 class); each is routed through the existing _write_state_off_loop helper, which also means the write now takes update_state's per-agent lock that on-loop callers skip, so it can no longer interleave with another pool writer's read-merge-rewrite.
dwu96
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: fix (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: clear root cause — three of a run's state.json writers called update_state directly from async def bodies, so its synchronous fsync ran on the gateway's only event loop and the write skipped update_state's per-agent lock; all three now route through the existing #7467 _write_state_off_loop helper, inheriting its bounded cancellation drain. No new mechanism.
1. What is the problem?
subagent_persistence.update_statereadsstate.json, merges the caller'sfields and rewrites the whole file, with a blocking
_atomic_write(fsync +rename) between the read and the write.
Three of a run's writers called it directly from an
async defbody, so thatfsync ran on the gateway's only event loop:
subagent_manager/run.py_run_inner(PID record)pid,pid_recorded_atsubagent_manager/run.py_run_inner(session record)session_id,provider,keep,conversation_key,cwdsubagent_manager/run.py_create_shared_sessionpid,pid_recorded_atThat is the
no-blocking-call-on-event-loopclass #6288 names. The divergence iswhat makes it easy to miss: PR #7467 moved every OTHER state writer inside a run
through
_write_state_off_loop, and the PID record sits about thirty lines belowone of them, reading almost identically at the call.
2. Why this issue matters to the user
The gateway runs chat turns, the reaper and the heartbeat on that one loop. A
state.jsonwrite is small, but its cost is not bounded by its size: it is aread plus an
fsyncplus arenameagainst whatever filesystem the configdirectory lives on. On a network mount, a snapshotting volume, or a disk under
load, every one of those calls stalls the loop for as long as the FS takes --
which means an unrelated chat turn stops mid-stream and the heartbeat misses its
window, three times per subagent spawn.
It also left an ordering hole.
update_stateserializes its read-merge-rewriteper agent for off-loop callers only (#7280); an on-loop caller deliberately takes
no lock, because waiting on a pool thread's fsync from the loop is the very thing
the anchor forbids. So these three writes were unordered against any pool writer:
because the rewrite is whole-file, a writer that read first and wrote last rolls
back every field the other landed, not just the fields it names. A lost
pidmeans the reaper can no longer reach the child -- a leaked
kiro-cliprocessholding its memory for the life of the gateway.
3. How our fix solves it
The chain is: on-loop write -> fsync on the loop, and no per-agent lock -> an
unordered whole-file rewrite. Moving the caller off-loop closes both links at
once, because
update_statetakes the lock precisely when the caller is not onthe loop.
All three sites now
await self._manager._write_state_off_loop(info, "<label>", **fields)-- #7467's helper, unchanged. No new mechanism, no per-site flags:the loop.
trade: cancelling a
to_threadawait detaches the worker without stopping it,and a detached worker is exactly the stale whole-file writer described above.
The helper holds cancellation open until the worker lands (bounded at 5s), and
on expiry marks the run so its conversation stays held.
pool writer.
Why the two remaining on-loop callers are safe against these three. The other
update_statecallers on the loop are the retention writers,_promote_conversation(keep=True) andrelease_conversation(keep=False).The session record carries
keeptoo, so the interleave question is real -- andanswered by the gate #7467 already leaned on: both retention writers are reached
only through
_conversation_busy, which refuses while a run is notdone. Allthree sites here run inside
_run_inner/_create_shared_session, i.e. whilethe run is live, so a promote or release cannot execute concurrently with them.
info.conversation_keyis assigned at construction (continuation.py:328for acontinuation,
admission.py:448for a spawn), so the gate cannot miss a livecontinuation run either.
One behaviour is deliberately preserved rather than tidied: the
_create_shared_sessionsite has noexcept Exceptionaround it, and still doesnot. That method has no best-effort contract, so an
_atomic_writefailure keepspropagating to the caller instead of yielding a session with no recorded pid. At
the two
_run_innersites the existingexcept Exceptionstays correct becauseit does not catch
CancelledError, so the helper's post-drain re-raisepropagates instead of being swallowed.
This also makes an existing docstring true:
_write_state_off_loopalreadyclaimed "every
state.jsonwriter inside a run goes through here", which thesethree sites falsified.
Scope. #7302 lists five on-loop callers. This PR moves the three in
run.py-- the self-contained half, and the issue's own suggested first step -- so it is
Refs, notCloses. The twocontinuation.pyretention writers are left, withthe reasons recorded on the issue: they are synchronous functions whose callers
would all have to become awaitable;
release_conversation's other work includesa
_cleanup_session_files_syncunlink loop that is blocking too, so offloadingonly its 1 ms write would be a half-measure; and its
SessionMapmutation isrequired to stay on the loop by the Arbiter contract in
continuation.py. Thoseare design decisions, not substitutions.
4. What tests we did
Five new tests in
test/test_subagent_provenance_write.py, all mutation-verified-- reverting only the two
src/files to base makes all five fail in 7s, whilethe three pre-existing #7467 drain tests stay green:
test_pid_and_session_records_are_written_off_loop-- drives_run_innerwiththe REAL
asyncio.to_threadand asserts neither write ran on the loop thread.On base:
state.json write(s) ran on the event loop: PID record, session record.mid-flight and asserting the task does not complete, the recovery-gate latch is
raised, the write still lands, and the latch does not leak.
test_no_on_loop_update_state_inside_a_coroutine-- a static AST gate, siblingto fix(subagent): drain every off-loop state.json writer on cancel #7467's
test_no_bare_to_thread_update_state_outside_the_drained_helper.That one pins HOW an off-loop write is performed; this one pins WHERE a write
may happen at all. Scope-aware (innermost frame only), so a synchronous helper
nested inside a coroutine is correctly not an offender.
The three drain tests bound their gate wait at 5s rather than awaiting it
unbounded. Unbounded, a regression turned each one into a 120s pytest-timeout
with no diagnosis -- six minutes of a shared runner for one mistake. Bounded,
base fails in 7s total with the cause in the message.
Gates on the touched files: 338 tests green across the affected families
(
test_subagent,test_session_sharing,test_subagent_persistence,test_subagent_state_write_serialization,test_subagent_provenance_write,test_subagent_continuable,test_subagent_manager_boundaries,test_subagent_reap_race,test_stop_kill_cancel,test_subagent_stall_activity_scope); flake8, isort and the baseline black gateclean. Two
test_acp_backend_kas.py::TestNoImportCyclefailures areworktree-environment, not the diff: they spawn a bare
python -c "import kiro_crew"subprocess and this worktree has no editable install(
ModuleNotFoundError: No module named 'kiro_crew'); both pass withPYTHONPATH=src. The twomypyerrors reported are insrc/kiro_crew/transcribe.py, which this commit does not touch.5. Any other suggestions on the work
release_conversationruns_cleanup_session_files_syncon the event loop, an unlink loop over providersession files. It is a strictly larger blocking call than the
update_statesubagent: move the five on-loop update_state callers off the event loop #7302 was filed about, and subagent: move the five on-loop update_state callers off the event loop #7302 does not mention it. Worth folding into the
continuation half rather than discovering it there.
_write_state_off_loopis keyed on a liveSubagentInfo(
_state_drain_active,_cancel_retry_used,_abandoned_state_writersbyinfo.id). The two retention writers only hold aconv_idand may have nolive run, so they cannot reuse the helper as-is; whoever does that half needs a
hold/latch decision of their own. Noting it so it is not mistaken for a
mechanical swap.
Pattern harvest
Rule candidate: an AST/semgrep gate on call POSITION, not just call form -- a
blocking call (one ending in a synchronous
fsync,unlink, orrename) mustnot be invoked directly from an
async defbody.It is already in this PR as a test. The defect class is "a
blocking call reached from a coroutine body", and the reason five triage passes
missed it is that the offending line is textually identical to a legitimate one
in the same file -- convention cannot separate them, position can.
test_no_on_loop_update_state_inside_a_coroutinewalks the AST ofsrc/kiro_crewand fails on any directupdate_state(...)whose innermostenclosing frame is an
async def. It is deterministic and false-positive-freefor this symbol, because an off-loop write has exactly one legitimate form (the
helper's own
to_thread, which is an argument and not a call).The generalization worth taking: the repo already has
test_no_blocking_call_on_loop.pyand #7467 added a HOW gate for this symbol.The missing shape was a WHERE gate. Any function that ends in a synchronous
fsyncorunlinkdeserves one at the point it becomes reachable from acoroutine -- which would have caught these three sites the day they were written,
and would catch
_cleanup_session_files_synctoday.Refs #7302
Refs #6288
Refs #7467