fix(autopilot): offload previous-result reads - #3772
Conversation
`_stage_loop` rebuilds a context message before every stage, and `_build_stage_context` inlines each earlier stage's result by reading it off disk synchronously -- `exists`, `stat`, then `read_bytes` or an `open`/`read`/`seek`/`read` for the truncated branch. All of it runs on the gateway event loop, and the read count grows with the plan: stage N re-reads all N-1 earlier files, so a longer plan pays more at each boundary. Move the reads into `_read_previous_results` and hand it to `asyncio.to_thread`. The path list is snapshotted on the loop first, because `tracker._stage_results` is mutated there by `record_stage_result` as stages finish, so only immutable `(stage_num, path)` pairs cross into the worker. A first stage has nothing recorded and skips the hop entirely. Truncation policy, sensitive-path refusal, OSError/ValueError handling and the emitted text are unchanged.
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Design Review (Fable 5, fork) — ✅ PASSAdvisory design-level review of Design-Verdict: PASS Correct minimal shape for the named defect: snapshot mutable state on the loop, hand only immutable data to the worker, behavior contracts pinned unchanged. [DESIGN-REVIEWED] 1250db7 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsThe refactor is a clean async conversion. I've verified:
No defect grounds out to (a)/(b)/(c). Nothing survives falsification and nothing new is groundable. No findings. [OPUS-REVIEWED] 1250db7 |
First Principles Review (Fable 5, fork) — 🟡 CONCERNSAdvisory premise-level review of All evidence gathered — contract read, intent and patch read, base file inspected, siblings counted. Final review: First-Principles-Verdict: CONCERNS The boundary reads go off-loop, but What this change shipsIntent: stop per-stage rebuilds of the context message from blocking the gateway event loop with disk reads — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] 1250db7 |
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: perf (3 files). Criteria: no conflict, no requested changes, no security surface, AI reviewers green. Category: performance — moves previous-result file reads off the main autopilot event loop into a thread pool.
`_stage_loop` rebuilds a context message before every stage, and `_build_stage_context` inlines each earlier stage's result by reading it off disk synchronously -- `exists`, `stat`, then `read_bytes` or an `open`/`read`/`seek`/`read` for the truncated branch. All of it runs on the gateway event loop, and the read count grows with the plan: stage N re-reads all N-1 earlier files, so a longer plan pays more at each boundary. Move the reads into `_read_previous_results` and hand it to `asyncio.to_thread`. The path list is snapshotted on the loop first, because `tracker._stage_results` is mutated there by `record_stage_result` as stages finish, so only immutable `(stage_num, path)` pairs cross into the worker. A first stage has nothing recorded and skips the hop entirely. Truncation policy, sensitive-path refusal, OSError/ValueError handling and the emitted text are unchanged.
Problem / Motivation
_stage_loopbuilds a fresh context message before every autopilot stage, and_build_stage_contextinlines each earlier stage's result by reading it off disk — synchronously, on the gateway event loop:_build_stage_contexthas exactly one production caller and it is inside the async loop, so every one of those syscalls lands on it.Why it matters
The read count grows with the plan rather than staying flat: stage N re-reads all N-1 earlier result files, so a five-stage run performs ten file reads spread across its boundaries, each up to four syscalls. Every one of them blocks the single gateway loop — chat streaming, WebSocket frames, cron dispatch — for its duration.
Stated precisely: this is a scheduling defect. I have not measured the stall, so the claim is that unrelated loop work is blocked for the duration of the reads, not that the duration is large.
What changed
The reads move into
_read_previous_results, handed toasyncio.to_thread. The boundary keeps live state on the loop:range(1, current_idx + 1), pull each recorded path out oftracker._stage_results, build an immutable(stage_num, path)listexists,stat,read_bytes/open+seek+read, and the compactiontracker._stage_resultsis mutated on the loop byrecord_stage_resultas each stage finishes, so passing the tracker into a worker would have made a concurrent record a cross-thread read for no benefit. A first stage has nothing recorded and returns early without a worker hop at all._build_stage_contextbecomesasyncand its single caller awaits it.chat.pyre-exports both symbols undernoqa: F401and calls neither, so no other production code is affected.Unchanged: the 2000-byte budget and its 30/70 head/tail split, the sensitive-path refusal,
except (OSError, ValueError), which stages are injected, and the emitted text.Tests
New
test/test_previous_result_read_off_loop.py, six tests:..._read_runs_off_the_loop_threadread_byteslands off-loop..._stat_runs_off_the_loop_threadtest_truncated_read_runs_off_the_loop_threadopen/seek/readtest_tracker_state_is_read_on_the_loop_threadtest_context_preserves_content_and_missing_file_semanticstest_sensitive_path_is_not_readThread assertions are scoped to this stage's own result file so unrelated filesystem traffic cannot decide them, and each carries a non-empty guard against a vacuous pass. The driver tolerates a sync or async
_build_stage_contextdeliberately: a bareawaitagainst the old signature would raise "can't be used in 'await' expression", which proves the symbol changed rather than that a read was mis-scheduled.The truncation test instruments the builtin
open, notPath.open, because that is what the production branch calls — patchingPath.openrecords nothing and reports "never opened" instead of the thread.85cf65b22: 3 failed, 3 passed. All three failures are the real thread assertions; the three preservation tests pass on both sides.test_dashboard_chat.pyupdated toawaitthe real functions; no assertion weakened.test_dashboard_chat.py, which drives_stage_loopend to end: 563 passed, 1 skipped.chat_orchestrator.py, no issues), docs lint (207 files), brand gate, harness-parity gate,git diff --check: all clean.Screenshots / video
Backend scheduling only. No component, markup, copy, style or catalog string changed.
Related Issues
None. Self-discovered while scoping #3771 (the sibling stage-result write), and deliberately not filed against #1783 — that umbrella tracker does not list previous-result reads as one of its bullets, so claiming it would misattribute the finding. The reproduction above stands on its own.