From 2cb017556dd75ba4ba5f22fd81890db7ae47a5de Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:06:58 -0700 Subject: [PATCH] fix(acp): restore not-in-memory sessions off the event loop in every handler (#58083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prompt / cancel / set_session_model / set_session_mode / set_config_option still called session_manager.get_session inline. For an id not in memory that runs _restore -> _make_agent (config, memory-provider import, SessionDB) on the loop — the hang class session/new just left — and, since restores are single-flight, it also parks the loop on _restore_lock while an off-loop session/load is in flight. Route the five sites through asyncio.to_thread like new/load/resume/fork. Test: a parametrized invariant over the five handlers with a slow DB restore; ticks=0 on the previous head, green now. --- acp_adapter/server.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 9c908061d5..f2a0ed4731 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -628,7 +628,9 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent): return ResumeSessionResponse(**await self._session_response_fields(state, "resume")) async def cancel(self, session_id: str, **kwargs: Any) -> None: - state = self.session_manager.get_session(session_id) + # get_session restores a not-in-memory id from the DB (full AIAgent build) and waits + # on the restore lock — off the loop, like new/load/resume/fork (#58083). + state = await asyncio.to_thread(self.session_manager.get_session, session_id) if not (state and state.cancel_event): return with state.runtime_lock: @@ -801,7 +803,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent): async def prompt(self, prompt: list[PromptBlock], session_id: str, **kwargs: Any) -> PromptResponse: """Run Hermes on the user's prompt and stream events back to the editor.""" - state = self.session_manager.get_session(session_id) + state = await asyncio.to_thread(self.session_manager.get_session, session_id) if state is None: logger.error("prompt: session %s not found", session_id) return PromptResponse(stop_reason="refusal") @@ -996,7 +998,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent): async def set_session_model(self, model_id: str, session_id: str, **kwargs: Any) -> SetSessionModelResponse | None: """Switch the model for a session (called by ACP protocol).""" - state = self.session_manager.get_session(session_id) + state = await asyncio.to_thread(self.session_manager.get_session, session_id) if state: # switch_model() does synchronous network I/O (models.dev, custom-endpoint probes, # ~10 s cold) — off the loop, like the gateway, so other ACP sessions keep flowing. @@ -1011,7 +1013,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent): async def set_session_mode(self, mode_id: str, session_id: str, **kwargs: Any) -> SetSessionModeResponse | None: """Persist the editor-requested mode so ACP clients do not fail on mode switches.""" - state = self.session_manager.get_session(session_id) + state = await asyncio.to_thread(self.session_manager.get_session, session_id) if state is None: logger.warning("Session %s: mode switch requested for missing session", session_id) return None @@ -1027,7 +1029,7 @@ class HermesACPAgent(SlashCommandsMixin, acp.Agent): self, config_id: str, session_id: str, value: str, **kwargs: Any ) -> SetSessionConfigOptionResponse | None: """Accept ACP config option updates even when Hermes has no typed ACP config surface yet.""" - state = self.session_manager.get_session(session_id) + state = await asyncio.to_thread(self.session_manager.get_session, session_id) if state is None: logger.warning("Session %s: config update requested for missing session", session_id) return None