fix(autopilot): offload stage-result writes - #3771
Conversation
|
🤖 Kiro Crew [operator: bolichen97]: This PR has been inactive for 7+ days with failing CI. I've assessed the blockers and they appear resolvable — I'll push fixes directly to this branch as a co-author. Assessment: The branch conflicts with main (5 days of drift) and its last CI run failed on If you'd prefer I don't touch this PR, add the |
02ca996 to
781f24c
Compare
|
🤖 Kiro Crew [operator: bolichen97] Drive-to-green push 1 (head Conflict + resolution — Authorship: original commit by Leon preserved as git author; Local gates (all judged by exit code):
|
781f24c to
a8df893
Compare
6e6ef79 to
cf93be9
Compare
|
🤖 Kiro Crew [operator: bolichen97] Push 4 (head Note: pushes 2 and 3 were zero-diff amend refreshes recovering orphaned CI runner requests during today's runner-starvation window (same recovery pattern as PR #4570); no code changed in either. |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed BLOCKING -- src/kiro_crew/dashboard/chat_orchestrator.py:203 -- publication performs synchronous filesystem I/O on the event loop
|
First Principles Review (Fable 5, fork) — 🟡 CONCERNSPremise-level review of I have everything I need: the contract, the intent file, the patch, and the base Counts I ran on the base tree: First-Principles-Verdict: CONCERNS The stop re-check fixes a pre-existing cancel-is-ignored defect but is framed as closing a window the new await opened — and it patches one of four checkpoints. What this change shipsIntent: stop stage-result persistence from stalling every other gateway task at each stage boundary — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] 16e9d15 |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS A real event-loop stall fixed at the right boundary — immutable data crosses to the worker, publication stays atomic and on-loop — proportionate and well-pinned. Suggestions
[DESIGN-REVIEWED] 16e9d15 |
cf93be9 to
7ae4462
Compare
|
🤖 Kiro Crew [operator: bolichen97] Push 5 (head Verification of the finding: legitimate, and introduced by this PR. The user-cancel path ( Fix: after the awaited capture, re-check Test: added |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI have enough to decide. Let me verify there's no session-directory cleanup that would sweep the temp files (which would fully kill the candidate) and confirm the candidate's severity ceiling. The candidate is a minor temp-file accumulation claim, self-rated "low" confidence. Applying Step 1's bar:
Step 2: I reviewed the new No findings. [OPUS-REVIEWED] 16e9d15 |
4fd7549 to
16e9d15
Compare
|
🤖 Kiro Crew [operator: bolichen97] Push 8 (head
|
|
🤖 Kiro Crew [operator: bolichen97]
|
| Round | Finding | Disposition |
|---|---|---|
| R1 | Cancel during offloaded write advances the run | ✅ Fixed (push 5): post-await stop re-check, fail-before-verified test |
| R2 | Cancelled await abandons a live writer → orphan overwrites reused stage file | ✅ Fixed (push 6): shield + fence-wait |
| R3 | The fence itself is outlived by slot deletion + key reuse | ✅ Fixed (push 7): structural — worker writes uuid temp only; atomic os.replace publish on the loop thread after uncancelled return; orphan worker cannot reach the canonical file. GPT R4: ✅ zero blocking |
| R5 | The loop-thread os.replace publish is itself "synchronous filesystem I/O on the event loop" |
⛔ This escalation |
Why R5 cannot be fixed without resurrecting R2/R3: the publish is the commit point. Move it into the worker (or any post-cancellation context) and an abandoned worker can again publish into a reused slot's canonical path — exactly the data-corruption class R2/R3 blocked on and R4 accepted the current design for. Keep it on the loop thread after an uncancelled await — the current design — and R5 objects to it. The two demands ("no syscall on the loop" and "publication must be impossible for an orphan") are jointly unsatisfiable at the commit point; some single atomic metadata syscall must anchor it somewhere.
Proportionality: os.replace is a metadata-only rename, the same class of syscall the loop already performs elsewhere (and orders of magnitude cheaper than the mkdir + payload write_text this PR removes from the loop, which is the issue #1783 bullet being fixed). The network-FS scenario invoked by R5 applies equally to code already on main.
Human decision requested — options: (a) override R5 via the AI-review override flow and merge on the current design (recommended; R4 already accepted it); (b) direct a specific alternative commit-point design; (c) revert the offload entirely per the reviewer (abandons #1783's P2 bullet).
State: CI 9/9 green on head 16e9d15a; Design ✅ PASS, Opus ✅ (head-1), FP 🟡 advisory answered (follow-up #4783), UX legitimately SKIPPED (backend-only). Author Leon preserved on the single commit with Co-authored-by: Kiro Crew. 8/10 pushes used.
`_stage_loop` is async, but it persisted each stage's result with a synchronous `mkdir` + `write_text` on the gateway event loop. Every other task on that loop -- chat streaming, WebSocket frames, cron dispatch -- is stalled for the duration of the filesystem work, once per stage boundary. Split the filesystem half into `_write_stage_result` and hand it to `asyncio.to_thread`. The extraction stays on the loop thread: it walks `slot.messages`, which is live mutable state the loop owns, so only the finished text and the slot key cross into the worker. Path format, message ordering, redaction and failure semantics are unchanged. The publication `os.replace` moves into that worker too. An earlier revision kept it on the loop deliberately, reasoning that a rename is metadata-only and that publishing after an uncancelled await made the orphan-writer class structurally unreachable. The first half of that does not hold: the session directory can be network-backed, where a rename is a round trip like any other call, so it is the same stall the offload exists to remove and the same `no-blocking-call-on-event-loop` anchor covers it. The orphan-writer guarantee is kept by a flag rather than by placement. `asyncio.to_thread` cannot interrupt a running worker, so the worker re-reads an `abandoned` event immediately before publishing and the caller sets it the moment its await is cancelled. A capture abandoned during the payload write -- the long half, and effectively all of the window on the slow filesystem this offload targets -- unlinks its temp file and leaves `stage_N_result.md` untouched, which the existing cancellation test still pins. The residual window is the gap between that check and the rename; it is not zero, and it is stated rather than claimed away. Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
16e9d15 to
fa8938a
Compare
|
This PR materially overlaps #3803 in Please coordinate a single replay of |
Open PR relationship auditThis 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
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
|
Closing as superseded by merged #8618. The audit compared this branch's full diff against One piece #8618 did not take is worth its own small PR against current |
Pull request was closed
Problem / Motivation
_stage_loopis async and drives the whole autopilot run, but stage-result persistence is synchronous. At every stage boundary_capture_stage_resultcallssession_dir.mkdir(parents=True, exist_ok=True)andpath.write_text(...)directly on the gateway event loop.Why it matters
The gateway runs a single asyncio loop, so those syscalls block every other task on it for as long as they take — chat streaming, WebSocket frames, cron dispatch. It happens once per stage, so a run with several stages pays it repeatedly, and
mkdiron a cold or networked session directory is the slower of the two.Scope note, stated deliberately: this is a scheduling defect. I have not measured the stall duration, so the claim here is only that unrelated loop work is blocked for the duration of the write, not that the duration is large.
What changed (motivation → approach → change)
Motivation — no part of stage-result persistence belongs on the loop.
Approach — draw the boundary at the filesystem, not at "the slow-looking calls". Everything that touches disk goes to one worker; everything that touches live slot state stays on the loop.
Change — the filesystem half moves into
_write_stage_result, handed toasyncio.to_thread:slot.messagesback to the stage separator, collecting assistant content,redact_exfiltration_urls+redact_credentials, buildingresult_textslot.key,stage_num, the finished immutableresult_text, and anabandonedevent; nothing elsemkdir,write_text, and the publishingos.replaceslot.messagesis live mutable state; passing the slot itself would have turned a concurrent append into a cross-thread read for no benefit, so the worker never receives it.Unchanged: the
sessions/<slot_key>/stage_<n>_result.mdpath format, message ordering, redaction (still before any byte reaches disk), and failure semantics —to_threadre-raises, so the caller'sexcept OSErrorstill logs and continues. No retries, no dedicated executor, no filesystem abstraction.The publication moved too, and why the earlier reasoning did not hold
An earlier revision of this PR kept
os.replaceon the loop on purpose, arguing that (a) a rename is metadata-only and therefore cheap, and (b) publishing strictly after an uncancelled await made the orphan-writer class structurally unreachable.(a) is false in the case this offload exists for. The session directory can be network-backed, and there a rename is a server round trip like any other call — the same stall the rest of the change removes, and the same
no-blocking-call-on-event-loopanchor covers it. Keeping it was importing the defect back at a smaller size.(b) is real and is preserved — by a flag instead of by placement.
asyncio.to_threadcannot interrupt a running worker, so the worker re-readsabandonedimmediately before it publishes, and the caller sets that event the moment its await is cancelled (stop button, slot close, slot-key reuse). A capture abandoned during the payload write unlinks its temp file and leavesstage_N_result.mduntouched. That is the whole of the window on the slow filesystem this change targets, and it is whattest_cancelled_capture_never_publishes_the_stage_filepins — that test is unchanged and still passes.What this does not claim: the check and the rename are two operations, so a cancellation landing between them still publishes. That residual window is one already-resolved rename rather than the entire payload write. It is stated rather than claimed away, and it cannot be closed by moving the rename back to the loop — while a loop-thread rename runs, the loop is not free to observe the cancellation either.
Two production functions, one test file.
Tests
test/test_stage_result_off_loop.py, eight tests:..._write_runs_off_the_loop_thread..._mkdir_runs_off_the_loop_threadtest_stage_result_publication_runs_off_the_loop_threados.replace, pinned separately because an implementation can satisfy the write assertion while still publishing on the loop, which is exactly the state this was added fortest_no_filesystem_call_reaches_the_loop_threadmkdir,write_textoros.replacefor this stage runs on the loop thread, so a future fourth syscall cannot be added on the loop without failing somethingtest_slot_messages_are_read_on_the_loop_threadtest_capture_preserves_path_ordering_and_redactiontest_stop_landing_during_the_offloaded_write_does_not_advancetest_cancelled_capture_never_publishes_the_stage_fileThread assertions are scoped to this stage's own directory and file, so unrelated filesystem traffic from fixtures cannot decide them, and each carries a non-empty guard so it cannot pass vacuously.
Fail-before / pass-after for the two new tests, with
chat_orchestrator.pyreverted to the previous head16e9d15a6and the tests left in place:The other six pass on both trees by design — including the cancellation test, which is the point: the orphan-writer guarantee is not weakened to buy the off-loop rename.
Gates:
flake8·isort --check-onlyclean on both changed files.test_stage_result_off_loop.pyis not in.github/black-baseline.txt, so it is held toblackand is formatted;chat_orchestrator.pyis baselined andblack --diffreports no hunk overlapping any changed region, so it is left unformatted rather than graduated off the baseline.Manual verification
N/A — unit coverage sufficient: every claim here is a thread-identity or file-content property, and both are asserted directly against the real
_capture_stage_resultat the syscall boundary. Reproducing the stall by hand means running autopilot against a network-mounted session directory, which is the condition the tests encode rather than observe.Related Issues
Refs #1783 — only the P2 bullet "
_capture_stage_resultdoes blocking disk I/O on the event loop". #1783 is an umbrella tracker whose body states each remaining finding is independently shippable; every other bullet is untouched and it stays open.Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)Contribution License Agreement