From 756f7d3e34c6591607c80dcb75c26a6fcc45cd01 Mon Sep 17 00:00:00 2001 From: Ray Tien Date: Mon, 14 Sep 2026 15:13:19 +0800 Subject: [PATCH] fix(apodex): offload per-turn session persist off the event loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _on_turn fires after every agent turn and called _persist() inline — a synchronous open()+json.dump() over the full history/display_history/ workflow_turns. As a session grows this write grows with it, and being awaited directly in run_agent_loop it stalls the event loop on every single turn (TUI freezes, no other coroutine gets to run). Move it to asyncio.to_thread, awaited so writes stay ordered turn to turn and the resume checkpoint can't be overwritten out of order. Benchmarked with a 2000-message history + a concurrent heartbeat coroutine: sync persist blocks the loop for ~139ms with 0 heartbeat ticks; to_thread lets ~4558 ticks through with a 0.85ms max stall. --- apodex/session.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apodex/session.py b/apodex/session.py index d58fcf0..31a34ac 100644 --- a/apodex/session.py +++ b/apodex/session.py @@ -476,7 +476,10 @@ async def _on_turn(self, turn: int, messages: list, metadata: dict) -> None: after each completed turn — keep history current and persist.""" self.history = list(messages) self.display_history = list(messages) - self._persist() + # _persist() does synchronous file I/O over the full history; run it + # off the event loop so long sessions don't stall on every turn. + # Awaited (not fire-and-forget) so writes stay ordered turn-to-turn. + await asyncio.to_thread(self._persist) # ── persistence (interrupt-safe resume) ─────────────────────────────── def _enrich_task(self, task: str) -> str: