fix(subagent): serialize state.json rewrites for off-loop writers - #7280
Conversation
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: |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A real lost-write class closed at its root for the only callers allowed to block, with the remaining half honestly scoped, tracked, and mutation-pinned. Suggestions
[DESIGN-REVIEWED] fabe2ce |
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: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All claims verified: the three pool writers ( First-Principles-Verdict: PASS Serializing off-loop What this change shipsIntent: stop concurrent background writers on one subagent from silently rolling back each other's
Watch
[FIRST-PRINCIPLES-REVIEWED] fabe2ce |
Escalating a verified design fork -- no override takenGPT 5.6 has blocked twice on the same anchor ( GPT's premise is factually correctI went looking for evidence that the loop-side callers were already off-loop. They are not. The retention writes reach
So a bounded acquire does add a wait of up to the budget on real loop paths. GPT's round-2 sharpening is also fair: on expiry the caller pays the wait and writes unserialized, which is the worst cell of the matrix rather than a clean degradation. Why neither remedy is mine to chooseRevert leaves #6298 and #6308 open. The defect is real and unfixed on Offloading the loop-side callers is the durable answer to the anchor, and after this PR it is safer than it was -- the lock removes the whole-file rollback, so a detached There is a third shape I considered and rejected as too large to slip in unreviewed: on the loop, acquire non-blocking and on contention defer the write to Where this leaves the PREvery other lane passes on A maintainer ruling is what unblocks this. The options as I see them:
I am standing down rather than grinding a third identical round. Happy to implement whichever of the three you pick. |
UX Review (Fable 5) — ⏭️ skippedRevision |
8858197 to
d9f5713
Compare
2e5834d to
8dd02bc
Compare
`update_state` reads state.json, merges the caller's fields and rewrites the whole file, with a blocking `_atomic_write` (fsync + rename) between the read and the write. Nothing serialised two writers on the same agent_id, so the second writer's read could predate the first writer's write and its rewrite restored a stale whole-file snapshot -- losing the other writer's fields, and rolling back fields neither writer touched. A run has three thread-pool writers, all `await asyncio.to_thread(update_state, ...)`: pre-spawn model provenance, CC-path model refinement, and the per-turn diagnostics write. They overlap during a run -- refinement fires on the first text chunk and diagnostics on every tool event, while provenance's own bounded retry can still be in flight -- so whichever renames second can revert `resolved_model`, drop the turn counters, or restore a snapshot taken before an earlier writer landed. Cancellation widens the window: cancelling a to_thread await DETACHES the worker rather than stopping it, so it finishes carrying a read that is already stale. Serialise the whole read/merge/rewrite per agent id for callers that are NOT on the event loop, mirroring the per-key lock registry this repo already uses for file read-modify-write (learn._lock_for, artifacts._lock_for_root, history.ConversationLog._file_locks). In-process only, because every update_state caller lives in the gateway process that owns the run. SCOPE: off-loop writers only. Serialising on-loop callers would mean the event loop waiting on a pool thread's fsync, which is the no-blocking-call-on-event-loop anchor; bounding that wait only shrinks the violation, and on expiry the caller pays the wait AND writes unserialised. So on-loop callers keep exactly their pre-existing behaviour and the anchor is structurally unobservable here: no event-loop caller waits on this lock and none is ever a holder. That also means the acquire needs no timeout and no fail-open path, since the only threads that block are pool workers whose own read + fsync + rename already exposes them to a wedged FS. The seam is a running-loop check rather than a caller flag, because update_state takes **fields -- any keyword flag would be indistinguishable from a state field a caller means to persist. The documented "returns whether the merge was written" contract is unchanged, so the #5394 provenance retry still sees a skipped merge. The registry is SELF-CLEANING and has no explicit eviction anywhere: values are held weakly and each caller keeps a strong reference for the length of its critical section, so an entry lives exactly as long as some writer is using it. That is correctness, not tidiness. Removing an entry while another writer still holds or is queued on it SPLITS the lock's identity -- the next caller mints a fresh lock and enters state.json alongside the writer still inside it, which is the loss this lock exists to prevent. Every hook that could do that is gone, including the tombstone pruner's, whose case is not hypothetical: _atomic_write re-creates the parent directory, so a writer already inside its critical section resurrects the folder the pruner just removed. Weak values make the whole class unrepresentable, and agent ids are per-run uuids so nothing accumulates either. threading.Lock cannot be weakly referenced, hence the one-field _AgentLock wrapper the registry stores. KNOWN LIMITATION: an interleave with one on-loop participant is still open, unchanged -- the loop-side retention `keep` write against an in-flight pool write (#6298), and a detached pool worker against the PID/session-id writes a cancel-respawn recovery run makes on the loop (#6308). Those are the titled defects of both issues, so the trailers are Refs rather than Fixes; closing them means moving the loop-side callers off-loop, filed as #7302. Both drain comments in run.py say so rather than claiming a guarantee this change does not deliver. Refs #6298 Refs #6308 Refs #7302
8dd02bc to
fabe2ce
Compare
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: serializes subagent_persistence.update_state's read-merge-replace with a per-agent weakly-held threading.Lock taken by off-loop writers only, so two thread-pool writers on one agent_id can no longer interleave across the blocking atomic write and roll back state.json fields neither of them touched; on-loop callers keep their existing unserialized behaviour, so no new blocking call lands on the event loop.
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: serializes update_state's read/merge/rewrite per agent for off-loop callers so two thread-pool writers can no longer restore a pre-write state.json snapshot — clear root cause (nothing serialized two writers on one agent_id), two source files plus one test.
bolichen97
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: refactor/fix — per-agent write serialization (3 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean (Semgrep + CodeQL both success, 0 annotations, 0 PR-scoped CodeQL alerts), security checklist all-NO, AI reviewers green. Category: fix (3 files, clear root cause — two off-loop pool writers interleave update_state's unserialized read-merge-rewrite on state.json and roll back each other's fields; adds an in-process per-agent threading lock keyed by agent_id, off-loop callers only, on-loop behaviour unchanged).
1. What is the problem?
update_state(src/kiro_crew/subagent_persistence.py) readsstate.json, merges the caller's fields and rewrites the whole file. Between the read and the write sits a blocking_atomic_write(fsync + rename). Nothing serialized two writers on the sameagent_id, so the second writer's read could predate the first writer's write -- and its rewrite then restored a stale whole-file snapshot. Losing the other writer's fields is the visible half; silently rolling back fields neither writer touched is the damaging half.One run writes state from both sides at once:
keep(subagent_manager/continuation.py), PID, session id + provider, shared-session PIDawait asyncio.to_thread(update_state, ...)The three pool writers overlap each other during a run: CC-path refinement fires on the first text chunk and the diagnostics write fires on every tool event, while the provenance write's own bounded retry can still be in flight. Cancellation widens the window further -- cancelling a
to_threadawait detaches the worker rather than stopping it, so it finishes carrying a read that is already stale.This PR closes the pool-vs-pool half of that. See section 3 for the deliberate scope split and the Known limitation below it for what stays open.
2. Why this issue matters to the user
The fields that get rolled back are the ones recovery depends on.
pidandsession_idare what orphan recovery andspawn_continueread from disk to find a run again;turnsandlast_toolare the orphan-recovery diagnostics;resolved_modelis the run's served-model provenance (#5394).Concretely, within the pool-vs-pool half this PR fixes: the per-turn diagnostics write and the CC-path model refinement both fire during a normal run. Unserialized, whichever renames second restores its own earlier snapshot -- so a run can end up with diagnostics that never advanced, or with
resolved_modelreverted to the spawn-time value, or with a field written by an earlier pool writer simply absent. Nothing logs and the file stays valid JSON; the fields are just not there.3. How our fix solves it
Symptom -> cause chain:
update_staterewrites the whole file from a snapshot it read earlier, so its write carries stale values for every key it was not asked to change.agent_id-- the root.The fix closes (4) for off-loop callers, which makes (3) and (2) harmless between them: each pool writer's read now observes its predecessor's write, so a merge cannot resurrect a pre-write snapshot.
Scope split -- off-loop writers only. Serializing on-loop callers too would mean the event loop waiting on a pool thread's fsync, which is the repo's
no-blocking-call-on-event-loopanchor. An earlier revision of this PR bounded that wait; review was correct that bounding shrinks the violation without removing it, and that on expiry the caller pays the wait and writes unserialized. So the lock is now taken only by callers not on the loop, where blocking is legal, and on-loop callers keep exactly their pre-existing behaviour. The finding is structurally unobservable rather than tuned: no event-loop caller can wait on this lock, and no on-loop caller is ever a holder.Two consequences worth naming:
update_statetakes**fields: any keyword flag would be indistinguishable from a state field a caller means to persist.asyncio.to_thread/run_in_executorthreads have no running loop and so serialize; a synchronous call from a coroutine does and so does not wait. The documentedreturns whether the merge was writtencontract is untouched, so the Subagent model provenance: conservative downgrade heuristic + drop redundant spawn persistence write #5394 provenance retry still sees a skipped merge.The two open questions triage flagged are both answered in-tree, so this mirrors an established pattern rather than inventing one:
Lock nature. A per-key
threading.Lockregistry (dict+ guard lock) is what this repo already uses to serialize file read-modify-write in three places:learn._lock_for,artifacts._lock_for_root,history.ConversationLog._file_locks.learn.pydocuments the same in-process-only reasoning verbatim. A filesystem lock is the tool for cross-process contention (session_ledger._locked,history's flock layer) and there is none here -- everyupdate_statecaller lives in the gateway process that owns the run.Nobody evicts: the registry is self-cleaning. Values are held weakly and every caller keeps a strong reference for the length of its critical section, so an entry lives exactly as long as some writer is using it and then disappears on its own. Keyed per agent, so unrelated runs never queue behind one agent's fsync, and agent ids are per-run
uuid4().hex[:8], so nothing accumulates.This is a correctness property, not tidiness. Removing an entry explicitly while another writer still holds or is queued on it splits the lock's identity -- the next caller mints a fresh lock and enters
state.jsonalongside the writer still inside it, which is precisely the loss this lock exists to prevent. Earlier revisions of this PR carried three such hooks (delete_agent_folder,prune_stale_tombstones, and a read-failure path) and review found the class twice; all three are now gone. The pruner's case is not hypothetical:_atomic_writecallsmkdir(parents=True), so a writer already inside its critical section re-creates the folder the pruner just removed, and the pruner holds no lock so it cannot know. Weak values make the whole class unrepresentable rather than guarded case by case. (threading.Lockcannot be weakly referenced, hence the one-field_AgentLockwrapper the registry stores; callers must retain the wrapper, not just.lock.)A write for an id whose folder is already gone still creates an entry --
_lock_for_agentruns before the read that discovers the folder is missing -- but it evaporates when that writer returns, so no hook is needed and none exists.Known limitation
An interleave with one ON-LOOP participant is still open, unchanged from before this PR. Specifically:
keepwrite against an in-flight pool write -- the defect 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 is titled for;subagent_manager/run.py) -- the scenario subagent: sibling to_thread update_state writes lack the cancellation drain; update_state is an unlocked read-merge-replace #6308's drain defends against.Neither is closed here, because the on-loop participant does not take the lock. Both drain comments in
run.pywere corrected to say so, rather than claiming a guarantee this PR does not deliver: the whole-file rollback that the #6306 shield-and-drain exists to bound is still reachable.Closing that half means moving the loop-side callers off-loop -- #6288's defect class, which #6306 deliberately scoped to a single site and which five triage passes on #6298/#6308 parked as needing a human ruling. It is filed separately rather than bundled in here. Because of this, the trailers are
Refs, notFixes: both issues' titled defects name an interleave with an on-loop participant, so neither is closed by this PR. Noted on both issues.4. What tests we did
New file
test/test_subagent_state_write_serialization.py(8 tests). Each race test parks one writer between its read and its write and drives the other writer meanwhile, with a bounded park so the test terminates instead of deadlocking. The two split tests are a matched pair: an on-loop caller must NOT wait on a held lock, and the same call from a plain thread MUST wait -- so a change that made every caller skip the lock cannot pass either.The pool-vs-pool tests were verified RED before the lock existed, failing on the mechanism rather than a missing symbol (a rolled-back
keep, a revertedpid, andwriters lost their fields: ['field_0', 'field_1', 'field_3', ...]for 8 barrier-released writers).Mutation matrix, each reversion reddening its own axis and nothing else:
The global-lock mutation pins the lock as genuinely per-agent: one process-wide lock also passes the race tests while making every unrelated run queue behind one agent's fsync. The split-removed mutation pins the split itself -- and the on-loop test is deliberately built with a separate holder thread on a timed release, because holding the lock on the test's own thread would make that mutation deadlock instead of fail, which is not a usable signal. The strong-ref mutation pins the self-cleaning registry from one side; a matched test pins it from the other, asserting the entry SURVIVES while a queued writer still references it (including across a mid-flight folder delete), which is the property that makes explicit eviction both unnecessary and unsafe.
Targeted suites, 806 passed, 0 failed (630 + 176 across two batches) -- every test file that references
subagent_persistence, plus the adjacent subagent suites:test_subagent,test_subagent_persistence,test_subagent_state_write_serialization,test_subagent_provenance_write,test_subagent_continuable,test_continuable_followups,test_session_cleanup,test_subagent_delivery_ttl_anchor,test_subagent_reap_race,test_subagent_resolved_model,test_subagent_manager_boundaries,test_subagent_coverage,test_lazy_data_home_paths,test_members,test_subagent_context_group_plumbing,test_subagent_error_detail,test_subagent_result_path_spelling,test_subagent_scale,test_ci_surface_tests.Gates:
isort,flake8andblackclean on every changed file.mypyreports 2 errors, both intranscribe.py, a file this diff never touches (boto3-stub env drift).Pattern harvest
Rule candidate: semgrep
Pattern: whole-file read / merge / rewrite with no serialization on the record's key
The defect is not a typo and it is not the first of its kind here. The shape is a function that reads a JSON document, merges caller fields into the parsed dict, and rewrites the whole file, with a blocking fsync between the read and the write and no lock keyed on the record. Any second writer's rewrite then restores a pre-write snapshot, so the loss is not confined to the fields either caller passed. This repo has hit the family repeatedly: #6017 and #6290 on the session ledger, #5244 (
config.jsonmaterializes every key, so a stored value beats a changed default), #4955 (thekirocrew.jsonrebuild reads back its own computed values as if user-authored). Three modules already carry the per-key-lock remedy (learn._lock_for,artifacts._lock_for_root,history.ConversationLog._file_locks) and two carry the filesystem-lock variant (session_ledger._locked,history's flock layer). The remedy is settled; it is the detection that is missing.A useful rule flags a function that both reads a path (
read_text/json.loads) and writes that same path, where the enclosing module defines no lock guarding the pair. The signal to key on is the round trip through a parsed dict, not the write primitive:_atomic_writemakes each individual write atomic, which is exactly what makes the missing serialization easy to mistake for handled. Two refinements keep it honest -- exempt a writer that never reads first (create_agent_folderbuilds a fresh document and cannot lose a merge), and require the lock to be per-key rather than module-global, since one process-wide lock is a correct-but-costly answer a naive check would accept.5. Any other suggestions on the work
create_agent_folderis not locked. It writes a freshstate.jsonfor an id that has no other writer yet, so it cannot lose a merge. Worth folding in if the invariant is ever stated as "everystate.jsonwrite serializes" rather than "every read-modify-write serializes".threading.Lockdoes not span processes. No caller outside the gateway writes subagent state today, so this is a note on what the fix does not claim, not a gap --learn.pycarries the same caveat.continuation.pycannot tell that itsupdate_statecall is the unserialized one. If the loop-side offload does not land soon, an explicit named wrapper for the on-loop callers would make the asymmetry legible.delete_agent_folderhas no production callers (First Principles noted this; grep confirms tests and docs only). This PR no longer touches it at all, but the dead function itself is worth removing -- out of scope here.Refs #6298
Refs #6308