fix(acp): restore not-in-memory sessions off the event loop in every handler (#58083)

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.
This commit is contained in:
teknium1
2026-09-18 23:06:58 -07:00
committed by Teknium
parent 6a93afa0a8
commit 2cb017556d

View File

@@ -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