fix: offload remaining transcript reads off the gateway loop - #7464
fix: offload remaining transcript reads off the gateway loop#7464bolichen97 wants to merge 1 commit into
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound offload routed through the pre-existing async rehydrate, but the cron prefetch reads the whole transcript every run to cover a rare hydration case. Watch
Suggestions
[DESIGN-REVIEWED] c7c1302 |
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) — 🟡 CONCERNSPremise-level review of All verification is done. Summary of what I confirmed: the fix reuses the pre-existing First-Principles-Verdict: CONCERNS Every item is the declared fix on an existing seam — but "closes the remaining blocking reads" is contradicted by two counted on-loop reads the audit missed. What this change shipsIntent: stop transcript disk reads from stalling the gateway event loop (issue #7408) — a FIX.
No new config key, flag, signature, or public surface; items 2–5 route through mechanisms that pre-exist the diff ( Watch
[FIRST-PRINCIPLES-REVIEWED] c7c1302 |
GPT 5.6 Review — 🔴 changes requested (blocking)GPT 5.6 found at least one blocking issue that must be resolved before merging This comment is updated in place on each push. BLOCKING -- src/kiro_crew/slack/gateway.py:4584 -- Suppression paths can auto-pause successful cron jobs
|
|
Closing as superseded by #7469, which is merged ( Both PRs close #7408 and offload the same three reads. #7469 was opened 12 minutes after this one and landed first. This is not a judgement call — it is what the rebase says. Resolving this branch onto the current main tip, hunk by hunk in the "main's version plus this PR's delta" direction, the delta turned out to be a strict subset of main's at every site, so every one of the seven conflicts resolved to main's text and Site by site:
#7469 is a strict superset, not merely an equivalent. It also deleted the synchronous fallback read inside That difference is also what makes the one remaining file unsalvageable as it stands. So the only two ways to "finish" this PR would be to push an effectively empty commit carrying a duplicate test file, or to delete a red test to go green. Neither is worth doing, so the branch is left unpushed at One thing worth keeping, and it is small. Process note for whoever dispatches these. #7469 carries |
Summary
Closes the remaining blocking
ConversationLogreads that ran on the gateway event loop (issue #7408). The re-triage comment's correction was confirmed: after auditing bothasyncio.to_threadandloop.run_in_executorspellings, the real work list was 3 sites, not 6 — three of the six named sites were already offloaded and are left unchanged.The signature-change judgment the issue flagged was answered by routing to the pre-existing
rehydrate_slot_from_history_asyncrather than mutating the sync helper's signature, so its sync callers are untouched.Per-site analysis (why each is correct)
chat_runner.py:5877async def _run_chatawait asyncio.to_thread(read_messages, history_key).history_keyis an immutable local snapshot; only the disk read + JSON parse cross the hop.len(), themem_countscan, and_build_history_prefixstay loop-affine. Theif state.conversation_log:guard is preserved.session_transfer.py:173_read_chained_historyasyncio.to_thread(_read_and_assemble)chat_persistence.py:1009_rehydrate_slot_from_historyslack/gateway.py:3239_deliver_script_result,handlers/messaging.py:2135api_send_message)await rehydrate_slot_from_history_async(...)(same None-for-closed/gone contract; loop-affine slot build stays on the loop). Sync signature unchanged. Gateway's now-unused sync import removed; messaging's import swapped (no F401).channel_slots.py:751_load_messagesloop.run_in_executor(None, _load_messages)to_thread-only audit misses this (~447run_in_executorvs ~2321to_threadon main) — exactly the re-triage's point.handlers/artifacts.py:755_collect_session_docs_run_off_loopthrough both callerscron_inject.py:98inject_cron_result_to_dashboardhistory)history = await asyncio.to_thread(read_messages, "cron:{job.id}") if conversation_log else []and passhistory=, sohistory is Noneis False and the inline read is skipped.cron_inject.pyis unchanged and keeps its inline read as the fallback for a synchronous caller. Signature unchanged.Changed files
Production:
dashboard/chat_runner.py,dashboard/handlers/messaging.py,slack/gateway.py.Tests:
test/test_transcript_reads_off_loop.py(new) plus mechanical renamed-symbol updates totest_dashboard_file_io.py,test_cron_origin_injection.py,test_send_message_targeted.py.Confirmed not in the diff:
session_transfer.py,channel_slots.py,handlers/artifacts.py,cron_inject.py. No docs/CHANGELOG touched.Testing
New
test/test_transcript_reads_off_loop.pyuses a thread recorder that wraps the real reads and asserts the loop thread's ident never appears, plus a behavior-preservation assertion per site (would fail if any offload were reverted).Not runnable in this sandbox (network mode
INTEGRATIONS_ONLY: PyPI blocked,aiohttp/pytest/pytest-asyncionot installed). Achievable gate passed on the pyenv 3.11.15 interpreter:py_compileexit 0 on all 7 changed files; ruff clean on the new test file and all changed regions;black --checkclean on the new test file and changed production files. The new test and the 3 updated tests must run in CI before merge.Reviewer note (non-blocking)
_run_chathas a benign post-hop staleness window:disk_countis a pre-awaitsnapshot whilemem_countis read after the hop. Worst case on a brand-new session's first turn is a redundant or skipped history prefix, never corruption.Follow-up (not in this PR)
read_messages_chainedfull-reads a transcript then keeps onlymessages[-500:](~254 ms on a 33.6 MB session, ~99% discarded). A tail-read would fix it but must recompute_disk_older_count, which drives the frozen-prefix save model, so it carries data-loss risk and needs its own tests. Tracked separately, intentionally out of scope here.This PR references #7408 but does not close it via keyword, so the issue stays open for the follow-up.
Pattern harvest
Rule candidate: repo gate (AST walker under
scripts/, in the shape ofcheck_harness_parity.py/check_subprocess_encoding.py) — "a blockingConversationLogread reached directly from anasync def".Pattern to flag: a call to
ConversationLog.read_messages,read_messages_chained, orget_metadata(each a whole-file open plus a JSON parse per line — 100–300 ms on a large store) whose nearest enclosing function is anasync def, and which does not sit in the callable-argument position ofasyncio.to_thread(...)/loop.run_in_executor(...). That is a purely syntactic test — no dataflow needed, because the offload idiom is itself syntactic — and it is exactly what the manual audit behind this PR was doing by hand.Why the class is real rather than a one-off: the three sites fixed here match that shape, and so do two more that a reviewer found afterwards by grepping
\.read_messages(_chained)?\(and reading each enclosing scope —dashboard/handlers/sessions.py(api_session_detail, whose own file-neighboursapi_sessions_searchandapi_session_deleteboth offload) andsync_bridge.py(handoff_to_slack, plus a bareget_metadatatwo lines later). Five instances, and a careful hand audit still missed two. Nothing in CI notices: the code type-checks, every test passes, and the only symptom is a latency spike plus — because the loop-stall watchdog is itself a coroutine — a process exit under load. A defect class that is invisible to every existing gate and invisible in review regrows on the next handler someone writes, which is why this one wants a gate rather than another audit. The two sites named above are the gate's first two hits and are listed in this PR's follow-up.Secondary, review-prompt line: "when you hoist IO across an
awaitto get it off the loop, check both what consumes it and what catches it." The first version of this fix prefetched the cron transcript unconditionally at both suppress sites, althoughinject_cron_result_to_dashboardreadshistoryonly underif not slot.linked_session_key— so it paid, on every suppressed run, the exact cost the offload exists to remove. Worse, the whole of_cron_callbackis inside one broadexcept Exceptionthat ends atrecord_failure(): hoisting the read up into that scope meant a transcriptOSErroron a run that had already produced its result would be counted as a cron failure, marching a healthy job toward auto-pause. Generalized as a review question with two halves: "on which branch does the callee actually consume this argument?", and "which handler now catches this exception, and what does it conclude from it?"