{"repo":"NousResearch/hermes-agent","public_id":"53297","display_label":"SessionStore blocking event loop causing activation delays","summary":"A bug where synchronous SessionStore calls block the asyncio event loop, causing existing Telegram sessions to take 15–30 seconds to activate. Fixes involve enforcing an async boundary and offloading SessionStore calls via asyncio.to_thread.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":61905,"merged_at":"2026-07-10T07:08:49+00:00"},"resolution":{"state":"resolved","by":"pr-61905"},"freshness":{"state":"verified","last_verified_at":"2026-07-10T07:08:49+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":53297,"pr_number":61905,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-10T07:08:49+00:00"}],"members":[{"kind":"issue","number":53297,"title":"[Bug]: Telegram existing sessions can take 15-30s to activate while new chats respond immediately","url":null,"state":"closed"},{"kind":"pr","number":61842,"title":"fix(gateway): offload SessionStore calls off the event loop via asyncio.to_thread","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61905","number":61905},{"kind":"pr","value":"#61847","number":61847}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":61905,"title":"fix(gateway): enforce async SessionStore boundary","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","gateway/session.py","gateway/slash_commands.py","tests/gateway/test_35809_auto_reset_clean_context.py","tests/gateway/test_async_session_store.py","tests/gateway/test_compression_in_flight_check.py","tests/gateway/test_compression_interrupt_demotion_56391.py","tests/gateway/test_priority_path_compression_demotion_56391.py","tests/gateway/test_session_store_lock_io.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -1744,6 +1744,7 @@ def _profile_runtime_scope(profile_home: \"Path\"):\n     load_gateway_config,\n )\n from gateway.session import (\n+    AsyncSessionStore,\n     SessionStore,\n     SessionSource,\n     SessionContext,","@@ -2853,6 +2854,10 @@ def __init__(self, config: Optional[GatewayConfig] = None):\n                 key, max_active_age=_bg_max_age_seconds,\n             ),\n         )\n+        # One enforced loop-side boundary for the synchronous SessionStore.\n+        # Sync helpers keep using ``session_store`` directly; async gateway\n+        # handlers call this facade and await every operation.\n+        self._async_session_store = AsyncSessionStore(self.session_store)\n         self.delivery_router = DeliveryRouter(self.config)\n         self._running = False\n         self._gateway_loop: Optional[asyncio.AbstractEventLoop] = None","@@ -5174,7 +5179,7 @@ def _agent_has_active_subagents(running_agent: Any) -> bool:\n         except Exception:\n             return False\n \n-    def _session_has_compression_in_flight(self, session_key: str) -> bool:\n+    async def _session_has_compression_in_flight(self, session_key: str) -> bool:\n         \"\"\"Return True when a compression lock is held for this session's id.\n \n         Context compression is interrupt-protected (#23975) but gateway","@@ -5182,28 +5187,43 @@ def _session_has_compression_in_flight(self, session_key: str) -> bool:\n         the pre-rotation parent while compression is mid-flight, producing\n         orphaned compression siblings (#56391). Callers demote interrupt to\n         queue when this returns True.\n+\n+        Both blocking sources — the ``session_store`` lock + JSON load, and the\n+        SQLite ``get_compression_lock_holder`` SELECT — are offloaded to a\n+        worker thread so a large state.db never freezes the event loop (#5).\n         \"\"\"\n         session_store = getattr(self, \"session_store\", None)\n         if not session_key or session_store is None:\n             return False\n         try:\n-            with session_store._lock:  # noqa: SLF001 — snapshot entry under lock\n-                session_store._ensure_loaded_locked()  # noqa: SLF001\n-                entry = session_store._entries.get(session_key)  # noqa: SLF001\n-            session_id = getattr(entry, \"session_id\", None) if entry is not None else None\n-            if not session_id:\n-                return False\n+            session_id = await asyncio.to_thread(\n+                self._lookup_session_id_under_store_lock, session_store, session_key\n+            )\n         except Exception:\n             return False\n+        if not session_id:\n+            return False\n         session_db = getattr(self, \"_session_db\", None)\n         if session_db is None:\n             return False\n-        db = getattr(session_db, \"_db\", session_db)\n+        raw_db = getattr(session_db, \"_db\", session_db)\n         try:\n-            return bool(db.get_compression_lock_holder(str(session_id)))\n+            holder = await asyncio.to_thread(\n+                raw_db.get_compression_lock_holder, str(session_id)\n+            )\n+            return bool(holder)\n         except Exception:\n             return False\n \n+    @staticmethod\n+    def _lookup_session_id_under_store_lock(session_store, session_key: str):\n+        \"\"\"Sync helper run in the thread pool: read session_id under the store lock.\"\"\"\n+        # noqa: SLF001 — intentional private access; runs off the event loop.\n+        with session_store._lock:  # noqa: SLF001\n+            session_store._ensure_loaded_locked()  # noqa: SLF001\n+            entry = session_store._entries.get(session_key)  # noqa: SLF001\n+        return getattr(entry, \"session_id\", None) if entry is not None else None\n+\n     # Hard cap on per-session pending follow-ups for busy_input_mode=queue\n     # (and the draining/steer-fallback/subagent-demotion paths that share\n     # this entry point).  Without a cap, a stuck agent + a rapid-fire user","@@ -5426,7 +5446,7 @@ async def _handle_active_session_busy_message(self, event: MessageEvent, session\n             effective_mode = \"queue\"\n         demoted_for_compression = (\n             effective_mode == \"interrupt\"\n-            and self._session_has_compression_in_flight(session_key)\n+            and await self._session_has_compression_in_flight(session_key)\n         )\n         if demoted_for_compression:\n             logger.info(","@@ -5715,7 +5735,7 @@ async def _notify_active_sessions_of_shutdown(self) -> None:\n             source = None\n             try:\n                 if getattr(self, \"session_store\", None) is not None:\n-                    self.session_store._ensure_loaded()\n+                    await self.async_session_store._ensure_loaded()\n                     entry = self.session_store._entries.get(session_key)\n                     source = getattr(entry, \"origin\", None) if entry else None\n             except Exception as e:","@@ -6985,7 +7005,7 @@ async def start(self) -> bool:\n                 pass\n         else:\n             try:\n-                suspended = self.session_store.suspend_recently_active()\n+                suspended = await self.async_session_store.suspend_recently_active()\n                 if suspended:\n                     logger.info(\"Marked %d in-flight session(s) as resumable from previous run\", suspended)\n             except Exception as e:","@@ -7546,13 +7566,13 @@ async def _process_handoff(self, row: Dict[str, Any]) -> None:\n         # Make sure there's an entry in the session_store for this key. If\n         # the home channel has never been used, get_or_create_session\n         # creates one; switch_session then re-points it.\n-        self.session_store.get_or_create_session(dest_source)\n+        await self.async_session_store.get_or_create_session(dest_source)\n \n         # Re-bind the destination key to the CLI session_id. switch_session\n         # ends the prior session in SQLite and reopens the CLI session under\n         # the new key. The CLI's transcript becomes the active one for the\n         # gateway from this moment on.\n-        switched = self.session_store.switch_session(session_key, cli_session_id)\n+        switched = await self.async_session_store.switch_session(session_key, cli_session_id)\n         if switched is None:\n             raise RuntimeError(\n                 f\"could not switch session key {session_key} → {cli_session_id}\"","@@ -7629,13 +7649,13 @@ async def _session_expiry_watcher(self, interval: int = 300):\n         _MAX_FINALIZE_RETRIES = 3\n         while self._running:\n             try:\n-                self.session_store._ensure_loaded()\n+                await self.async_session_store._ensure_loaded()\n                 # Collect expired sessions first, then log a single summary.\n                 _expired_entries = []\n                 for key, entry in list(self.session_store._entries.items()):\n                     if entry.expiry_finalized:\n                         continue\n-                    if not self.session_store._is_session_expired(entry):\n+                    if not await self.async_session_store._is_session_expired(entry):\n                         continue\n                     _expired_entries.append((key, entry))\n ","@@ -7719,7 +7739,7 @@ async def _session_expiry_watcher(self, interval: int = 300):\n                         # state.db (single write-path, #9006) — also drops\n                         # the persisted /model override, since finalization\n                         # is a conversation boundary.\n-                        self.session_store.set_expiry_finalized(entry)\n+                        await self.async_session_store.set_expiry_finalized(entry)\n                         logger.debug(\n                             \"Session expiry finalized for %s\",\n                             entry.session_id,","@@ -7734,7 +7754,7 @@ async def _session_expiry_watcher(self, interval: int = 300):\n                                 \"Marking as finalized to prevent infinite retry loop.\",\n                                 failures, entry.session_id, e,\n                             )\n-                            self.session_store.set_expiry_finalized(\n+                            await self.async_session_store.set_expiry_finalized(\n                                 entry, clear_model_override=False\n                             )\n                             _finalize_failures.pop(entry.session_id, None)","@@ -7787,7 +7807,7 @@ async def _session_expiry_watcher(self, interval: int = 300):\n                             getattr(self.config, \"session_store_max_age_days\", 0) or 0\n                         )\n                         if _max_age > 0:\n-                            _pruned = self.session_store.prune_old_entries(_max_age)\n+                            _pruned = await self.async_session_store.prune_old_entries(_max_age)\n                             if _pruned:\n                                 logger.info(\n                                     \"SessionStore prune: dropped %d stale entries\",","@@ -8121,7 +8141,7 @@ def _phase_elapsed() -> float:\n                 if _agent is _AGENT_PENDING_SENTINEL:\n                     continue\n                 try:\n-                    self.session_store.mark_resume_pending(\n+                    await self.async_session_store.mark_resume_pending(\n                         _sk,\n                         \"restart_timeout\" if self._restart_requested else \"shutdown_timeout\",\n                     )","@@ -8152,7 +8172,7 @@ def _phase_elapsed() -> float:\n                 for _sk in _pre_drain_keys:\n                     if _sk not in self._running_agents:\n                         try:\n-                            self.session_store.clear_resume_pending(_sk)\n+                            await self.async_session_store.clear_resume_pending(_sk)\n                         except Exception as _e:\n                             logger.debug(\n                                 \"clear_resume_pending after drain failed for %s: %s\",","@@ -8195,7 +8215,7 @@ def _phase_elapsed() -> float:\n                     if _agent is _AGENT_PENDING_SENTINEL:\n                         continue\n                     try:\n-                        self.session_store.mark_resume_pending(_sk, _resume_reason)\n+                        await self.async_session_store.mark_resume_pending(_sk, _resume_reason)\n                     except Exception as _e:\n                         logger.debug(\n                             \"mark_resume_pending failed for %s: %s\",","@@ -9568,7 +9588,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n             # the id out from under it, forking orphaned compression\n             # siblings. Demote to queue semantics so the follow-up waits\n             # for the in-flight compression + rotation to land.\n-            if self._session_has_compression_in_flight(_quick_key):\n+            if await self._session_has_compression_in_flight(_quick_key):\n                 logger.info(\n                     \"PRIORITY interrupt demoted to queue for session %s \"\n                     \"because context compression is in flight (#56391)\",","@@ -10275,7 +10295,7 @@ async def _do_undo():\n                 # on error. Let the user drive the next turn.\n                 if _final_text.strip():\n                     try:\n-                        session_entry = self.session_store.get_or_create_session(source)\n+                        session_entry = await self.async_session_store.get_or_create_session(source)\n                     except Exception:\n                         session_entry = None\n                     if session_entry is not None:","@@ -10656,6 +10676,15 @@ def _cache_session_source(self, session_key: str, source) -> None:\n         except Exception:\n             pass\n \n+    @property\n+    def async_session_store(self) -> AsyncSessionStore:\n+        \"\"\"Return the single async facade for this runner's SessionStore.\"\"\"\n+        facade = getattr(self, \"_async_session_store\", None)\n+        if facade is None or facade._store is not self.session_store:\n+            facade = AsyncSessionStore(self.session_store)\n+            self._async_session_store = facade\n+        return facade\n+\n     def _get_cached_session_source(self, session_key: str):\n         if not session_key:\n             return None","@@ -10699,7 +10728,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n             except Exception:\n                 pass\n \n-        session_entry = self.session_store.get_or_create_session(source)\n+        session_entry = await self.async_session_store.get_or_create_session(source)\n         session_key = session_entry.session_key\n         pinned_session_id = str(\n             (getattr(event, \"metadata\", None) or {}).get(\"gateway_session_id\") or \"\"","@@ -10731,7 +10760,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 )\n                 return\n             prior_session_id = session_entry.session_id\n-            switched = self.session_store.switch_session(session_key, pinned_session_id)\n+            switched = await self.async_session_store.switch_session(session_key, pinned_session_id)\n             if switched is not None:\n                 session_entry = switched\n                 logger.info(","@@ -10781,7 +10810,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                     # lane session is ended cleanly. Mutating session_entry in\n                     # place here created a split-brain state where the JSON\n                     # index pointed at one id but code downstream used another.\n-                    switched = self.session_store.switch_session(session_key, bound_session_id)\n+                    switched = await self.async_session_store.switch_session(session_key, bound_session_id)\n                     if switched is not None:\n                         session_entry = switched\n                 # If the stored binding pointed at a parent, rewrite it to the","@@ -10969,7 +10998,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 logger.warning(\"[Gateway] Failed to auto-load skill(s) %s: %s\", _skill_names, e)\n \n         # Load conversation history from transcript\n-        history = self.session_store.load_transcript(session_entry.session_id)\n+        history = await self.async_session_store.load_transcript(session_entry.session_id)\n         \n         # -----------------------------------------------------------------\n         # Session hygiene: auto-compress pathologically large transcripts","@@ -11232,7 +11261,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                                     )\n                                     if _hyg_rotated:\n                                         session_entry.session_id = _hyg_new_sid\n-                                        self.session_store._save()\n+                                        await self.async_session_store._save()\n                                         await asyncio.to_thread(\n                                             self._sync_telegram_topic_binding,\n                                             source, session_entry,","@@ -11260,7 +11289,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                                     # messages and replace them with only the compressed\n                                     # summary (permanent data loss, #21301).\n                                     if _hyg_rotated:\n-                                        self.session_store.rewrite_transcript(\n+                                        await self.async_session_store.rewrite_transcript(\n                                             session_entry.session_id, _compressed\n                                         )\n                                         # Reset stored token count — transcript rewritten","@@ -11377,7 +11406,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                         )\n \n         # First-message onboarding -- only on the very first interaction ever\n-        if not history and not self.session_store.has_any_sessions():\n+        if not history and not await self.async_session_store.has_any_sessions():\n             # Default first-contact note: a brief self-introduction.\n             _intro_note = (\n                 \"\\n\\n[System note: This is the user's very first message ever. \"","@@ -11621,7 +11650,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n             if session_key and _should_clear_resume_pending_after_turn(agent_result):\n                 self._clear_restart_failure_count(session_key)\n                 try:\n-                    self.session_store.clear_resume_pending(session_key)\n+                    await self.async_session_store.clear_resume_pending(session_key)\n                 except Exception as _e:\n                     logger.debug(\n                         \"clear_resume_pending failed for %s: %s\",","@@ -11643,8 +11672,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n             if agent_result.get(\"session_id\") and agent_result[\"session_id\"] != session_entry.session_id:\n                 if session_entry.session_id == _run_start_session_id:\n                     session_entry.session_id = agent_result[\"session_id\"]\n-                    self.session_store._save()\n-                    self.session_store._record_gateway_session_peer(\n+                    await self.async_session_store._save()\n+                    await self.async_session_store._record_gateway_session_peer(\n                         session_entry.session_id,\n                         session_key,\n                         source,","@@ -11842,7 +11871,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                     \"Auto-resetting session %s after compression exhaustion.\",\n                     session_entry.session_id,\n                 )\n-                new_entry = self.session_store.reset_session(session_key)\n+                new_entry = await self.async_session_store.reset_session(session_key)\n                 self._evict_cached_agent(session_key)\n                 self._session_model_overrides.pop(session_key, None)\n                 self._set_session_reasoning_override(session_key, None)","@@ -11886,7 +11915,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 pass  # Skip all transcript writes — don't grow a broken session\n             elif not history:\n                 tool_defs = agent_result.get(\"tools\", [])\n-                self.session_store.append_to_transcript(\n+                await self.async_session_store.append_to_transcript(\n                     session_entry.session_id,\n                     {\n                         \"role\": \"session_meta\",","@@ -11940,7 +11969,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 # after transient failures). #47237\n                 _skip_persist = (\n                     event.message_id\n-                    and self.session_store.has_platform_message_id(\n+                    and await self.async_session_store.has_platform_message_id(\n                         session_entry.session_id, str(event.message_id)\n                     )\n                 )","@@ -11951,7 +11980,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                         event.message_id, session_entry.session_id,\n                     )\n                 else:\n-                    self.session_store.append_to_transcript(\n+                    await self.async_session_store.append_to_transcript(\n                         session_entry.session_id,\n                         _user_entry,\n                         skip_db=agent_persisted,","@@ -11977,13 +12006,13 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                     }\n                     if event.message_id:\n                         _user_entry[\"message_id\"] = str(event.message_id)\n-                    self.session_store.append_to_transcript(\n+                    await self.async_session_store.append_to_transcript(\n                         session_entry.session_id,\n                         _user_entry,\n                         skip_db=agent_persisted,\n                     )\n                     if response:\n-                        self.session_store.append_to_transcript(\n+                        await self.async_session_store.append_to_transcript(\n                             session_entry.session_id,\n                             {\"role\": \"assistant\", \"content\": response, \"timestamp\": ts},\n                             skip_db=agent_persisted,","@@ -12008,7 +12037,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                         ):\n                             entry[\"message_id\"] = str(event.message_id)\n                             _user_msg_id_attached = True\n-                        self.session_store.append_to_transcript(\n+                        await self.async_session_store.append_to_transcript(\n                             session_entry.session_id, entry,\n                             skip_db=agent_persisted,\n                         )","@@ -12016,7 +12045,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n             # Token counts and model are now persisted by the agent directly.\n             # Keep only last_prompt_tokens here for context-window tracking and\n             # compression decisions.\n-            self.session_store.update_session(\n+            await self.async_session_store.update_session(\n                 session_entry.session_key,\n                 last_prompt_tokens=agent_result.get(\"last_prompt_tokens\", 0),\n             )","@@ -12116,7 +12145,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 if 'message_text' in locals() and message_text is not None and session_entry is not None:\n                     _already_persisted = False\n                     try:\n-                        _recent_transcript = self.session_store.load_transcript(session_entry.session_id)\n+                        _recent_transcript = await self.async_session_store.load_transcript(session_entry.session_id)\n                     except Exception:\n                         _recent_transcript = []\n                     for _msg in reversed(_recent_transcript[-10:]):","@@ -12144,7 +12173,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                         }\n                         if getattr(event, \"message_id\", None):\n                             _user_entry[\"message_id\"] = str(event.message_id)\n-                        self.session_store.append_to_transcript(\n+                        await self.async_session_store.append_to_transcript(\n                             session_entry.session_id,\n                             _user_entry,\n                         )","@@ -12599,7 +12628,7 @@ def _goal_max_turns_from_config(self) -> int:\n         except Exception:\n             return 20\n \n-    def _get_goal_manager_for_event(self, event: \"MessageEvent\"):\n+    async def _get_goal_manager_for_event(self, event: \"MessageEvent\"):\n         \"\"\"Return a GoalManager bound to the session for this gateway event.\n \n         Returns ``(manager, session_entry)`` or ``(None, None)`` if the","@@ -12611,7 +12640,7 @@ def _get_goal_manager_for_event(self, event: \"MessageEvent\"):\n             logger.debug(\"goal manager unavailable: %s\", exc)\n             return None, None\n         try:\n-            session_entry = self.session_store.get_or_create_session(event.source)\n+            session_entry = await self.async_session_store.get_or_create_session(event.source)\n         except Exception as exc:\n             logger.debug(\"goal manager: session lookup failed: %s\", exc)\n             return None, None","@@ -14054,8 +14083,8 @@ async def _execute_mcp_reload(self, event: MessageEvent) -> str:\n                 \"content\": f\"[IMPORTANT: MCP servers have been reloaded. {change_detail}{tool_summary}. The tool list for this conversation has been updated accordingly.]\",\n             }\n             try:\n-                session_entry = self.session_store.get_or_create_session(event.source)\n-                self.session_store.append_to_transcript(\n+                session_entry = await self.async_session_store.get_or_create_session(event.source)\n+                await self.async_session_store.append_to_transcript(\n                     session_entry.session_id, reload_msg\n                 )\n             except Exception:"]},{"file":"gateway/session.py","hunks":["@@ -8,6 +8,7 @@\n - Dynamic system prompt injection (agent knows its context)\n \"\"\"\n \n+import asyncio\n import hashlib\n import logging\n import os","@@ -958,6 +959,30 @@ def build_session_key(\n     return \":\".join(key_parts)\n \n \n+class _SessionFlight:\n+    def __init__(self) -> None:\n+        self.event = threading.Event()\n+        self.result: Optional[\"SessionEntry\"] = None\n+        self.error: Optional[BaseException] = None\n+\n+\n+class AsyncSessionStore:\n+    \"\"\"Async boundary for the synchronous, thread-safe SessionStore.\"\"\"\n+\n+    def __init__(self, store: \"SessionStore\") -> None:\n+        self._store = store\n+\n+    def __getattr__(self, name: str):\n+        attr = getattr(self._store, name)\n+        if not callable(attr):\n+            return attr\n+\n+        async def _offloaded(*args, **kwargs) -> Any:\n+            return await asyncio.to_thread(attr, *args, **kwargs)\n+\n+        return _offloaded\n+\n+\n class SessionStore:\n     \"\"\"\n     Manages session storage and retrieval.","@@ -973,6 +998,14 @@ def __init__(self, sessions_dir: Path, config: GatewayConfig,\n         self._entries: Dict[str, SessionEntry] = {}\n         self._loaded = False\n         self._lock = threading.Lock()\n+        # Serialize whole-index persistence without holding ``_lock`` across\n+        # SQLite / fsync. Each writer snapshots the latest state only after\n+        # acquiring this lock, preventing stale delayed writes.\n+        self._save_lock = threading.Lock()\n+        self._routing_generation = 0\n+        self._persisted_routing_generation = 0\n+        self._inflight_lock = threading.Lock()\n+        self._inflight_sessions: Dict[str, _SessionFlight] = {}\n         self._has_active_processes_fn = has_active_processes_fn\n         # Whether to keep writing the legacy sessions.json mirror alongside\n         # the primary gateway_routing table in state.db. Default True for","@@ -1179,40 +1212,45 @@ def _prune_stale_sessions_locked(self) -> None:\n             self._save()\n \n     def _save(self) -> None:\n-        \"\"\"Persist the routing index (session key -> ID mapping).\n-\n-        state.db's ``gateway_routing`` table is the primary store (#9006\n-        follow-up): the whole index is replaced atomically in one SQLite\n-        transaction, mirroring the previous full-file JSON rewrite semantics.\n-\n-        sessions.json is additionally written for backward compatibility\n-        (external tooling, downgrade safety) unless the user disables it via\n-        ``gateway.write_sessions_json: false`` in config.yaml.\n-        \"\"\"\n-        data = {key: entry.to_dict() for key, entry in self._entries.items()}\n-\n-        # Primary: durable SQLite routing table.\n-        db_saved = False\n-        _db = getattr(self, \"_db\", None)\n-        if _db:\n-            replacer = getattr(_db, \"replace_gateway_routing_entries\", None)\n-            if callable(replacer):\n-                try:\n-                    replacer(\n-                        {k: json.dumps(v) for k, v in data.items()},\n-                        scope=self._routing_scope(),\n-                    )\n-                    db_saved = True\n-                except Exception as exc:\n-                    logger.warning(\n-                        \"gateway.session: state.db routing save failed: %s\", exc\n-                    )\n+        \"\"\"Persist the routing index while the caller holds ``_lock``.\"\"\"\n+        data, generation = self._snapshot_routing_locked()\n+        self._persist_routing_data(data, generation)\n+\n+    def _snapshot_routing_locked(self) -> tuple[Dict[str, Any], int]:\n+        \"\"\"Capture immutable routing data and a monotonic generation.\"\"\"\n+        self._routing_generation = getattr(self, \"_routing_generation\", 0) + 1\n+        return (\n+            {key: entry.to_dict() for key, entry in self._entries.items()},\n+            self._routing_generation,\n+        )\n \n-        # Legacy mirror: sessions.json. Kept on by default for compat; when\n-        # disabled we still fall back to it if the DB write failed, so the\n-        # index is never lost entirely.\n-        if getattr(self, \"_write_sessions_json\", True) or not db_saved:\n-            self._save_sessions_json(data)\n+    def _persist_routing_data(self, data: Dict[str, Any], generation: int) -> None:\n+        \"\"\"Serialize all whole-index writers through one durable write lock.\"\"\"\n+        save_lock = getattr(self, \"_save_lock\", None)\n+        if save_lock is None:\n+            save_lock = threading.Lock()\n+            self._save_lock = save_lock\n+        with save_lock:\n+            if generation <= getattr(self, \"_persisted_routing_generation\", 0):\n+                return\n+            db_saved = False\n+            _db = getattr(self, \"_db\", None)\n+            if _db:\n+                replacer = getattr(_db, \"replace_gateway_routing_entries\", None)\n+                if callable(replacer):\n+                    try:\n+                        replacer(\n+                            {k: json.dumps(v) for k, v in data.items()},\n+                            scope=self._routing_scope(),\n+                        )\n+                        db_saved = True\n+                    except Exception as exc:\n+                        logger.warning(\n+                            \"gateway.session: state.db routing save failed: %s\", exc\n+                        )\n+            if getattr(self, \"_write_sessions_json\", True) or not db_saved:\n+                self._save_sessions_json(data)\n+            self._persisted_routing_generation = generation\n \n     def _save_sessions_json(self, data: Dict[str, Any]) -> None:\n         \"\"\"Write the legacy sessions.json mirror of the routing index.\"\"\"","@@ -1254,6 +1292,11 @@ def _save_sessions_json(self, data: Dict[str, Any]) -> None:\n                 logger.debug(\"Could not remove temp file %s: %s\", tmp_path, e)\n             raise\n     \n+    def _save_entries(self) -> None:\n+        \"\"\"Snapshot latest state under ``_lock`` and persist after releasing it.\"\"\"\n+        with self._lock:\n+            data, generation = self._snapshot_routing_locked()\n+        self._persist_routing_data(data, generation)\n     def _resolve_profile_for_key(self, source: Optional[SessionSource] = None) -> Optional[str]:\n         \"\"\"Return the profile namespace for session keys, or None when off.\n ","@@ -1395,6 +1438,51 @@ def _recover_session_from_db(\n             now=now,\n         )\n \n+    def _query_recoverable_session(self, *, session_key, source, now):\n+        \"\"\"DB-only half of _recover_session_from_db (no lock needed).\n+\n+        Returns a SessionEntry or None.  Caller assigns _entries[key] under lock.\n+        \"\"\"\n+        if not self._db:\n+            return None\n+        finder = getattr(self._db, \"find_latest_gateway_session_for_peer\", None)\n+        if not callable(finder):\n+            return None\n+        try:\n+            recovered = finder(\n+                source=source.platform.value,\n+                user_id=source.user_id,\n+                session_key=session_key,\n+                chat_id=source.chat_id,\n+                chat_type=source.chat_type,\n+                thread_id=source.thread_id,\n+            )\n+        except Exception as exc:\n+            logger.debug(\"Gateway session DB recovery failed for %s: %s\",\n+                         session_key, exc)\n+            return None\n+        if not isinstance(recovered, dict):\n+            return None\n+        if not self._recovered_row_allowed_for_active_profile(\n+            requested_session_key=session_key,\n+            recovered=recovered,\n+        ):\n+            logger.warning(\n+                \"Gateway session DB recovery ignored %s for %s because \"\n+                \"multiplex_profiles is disabled and the row belongs to a \"\n+                \"different profile\",\n+                recovered.get(\"session_key\"),\n+                session_key,\n+            )\n+            return None\n+        try:\n+            self._db.reopen_session(str(recovered[\"id\"]))\n+        except Exception as exc:\n+            logger.debug(\"Gateway session DB reopen failed for %s: %s\",\n+                         session_key, exc)\n+        return self._create_entry_from_recovered_row(\n+            row=recovered, session_key=session_key, source=source, now=now,\n+        )\n     def _record_gateway_session_peer(\n         self,\n         session_id: str,","@@ -1687,23 +1775,69 @@ def has_any_sessions(self) -> bool:\n     def get_or_create_session(\n         self,\n         source: SessionSource,\n-        force_new: bool = False\n+        force_new: bool = False,\n     ) -> SessionEntry:\n+        \"\"\"Single-flight session lookup/create per routing key.\n+\n+        Calls for different keys remain concurrent. Overlapping calls for the\n+        same key share the owner's result, including concurrent ``force_new``\n+        deliveries, so only one routing transition and SQLite row is created.\n         \"\"\"\n-        Get an existing session or create a new one.\n+        session_key = self._generate_session_key(source)\n+        inflight_lock = getattr(self, \"_inflight_lock\", None)\n+        if inflight_lock is None:\n+            inflight_lock = threading.Lock()\n+            self._inflight_lock = inflight_lock\n+            self._inflight_sessions = {}\n+\n+        with inflight_lock:\n+            slot = self._inflight_sessions.get(session_key)\n+            if slot is None:\n+                slot = _SessionFlight()\n+                self._inflight_sessions[session_key] = slot\n+                owner = True\n+            else:\n+                owner = False\n+\n+        if not owner:\n+            slot.event.wait()\n+            if slot.error is not None:\n+                raise slot.error\n+            assert slot.result is not None\n+            return slot.result\n+\n+        try:\n+            result = self._get_or_create_session_impl(source, force_new=force_new)\n+            slot.result = result\n+            return result\n+        except BaseException as exc:\n+            slot.error = exc\n+            raise\n+        finally:\n+            slot.event.set()\n+            with inflight_lock:\n+                self._inflight_sessions.pop(session_key, None)\n \n-        Evaluates reset policy to determine if the existing session is stale.\n-        Creates a session record in SQLite when a new session starts.\n+    def _get_or_create_session_impl(\n+        self,\n+        source: SessionSource,\n+        force_new: bool = False,\n+    ) -> SessionEntry:\n+        \"\"\"Perform one session routing transition for the single-flight owner.\n+\n+        All blocking I/O (SQLite SELECTs, routing-index rewrite + ``os.fsync``,\n+        recovery DB queries) is performed *outside* ``self._lock``. The lock\n+        protects only ``_entries`` / ``_loaded`` mutations.\n         \"\"\"\n         session_key = self._generate_session_key(source)\n         now = _now()\n \n-        # SQLite calls are made outside the lock to avoid holding it during I/O.\n-        # All _entries / _loaded mutations are protected by self._lock.\n         db_end_session_id = None\n         db_create_kwargs = None\n         existing_session_id = None\n+        force_new_observed_entry = None\n \n+        # ---- Phase 0: lock read -- existing session_id for compression tip ----\n         if not force_new:\n             with self._lock:\n                 self._ensure_loaded_locked()","@@ -1711,13 +1845,52 @@ def get_or_create_session(\n                 if entry is not None:\n                     existing_session_id = entry.session_id\n \n-        # Look up the compression continuation outside the lock (DB I/O).\n+        # Compression tip lookup outside the lock (DB I/O).\n         canonical_existing_session_id = (\n             self._compression_tip_for_session_id(existing_session_id)\n             if existing_session_id\n             else None\n         )\n \n+        # ---- Phase 1: lock read -- get entry snapshot for stale/reset checks ----\n+        _stale_session_id = None\n+        _entry_for_checks = None\n+        with self._lock:\n+            self._ensure_loaded_locked()\n+            if force_new:\n+                force_new_observed_entry = self._entries.get(session_key)\n+            if session_key in self._entries and not force_new:\n+                _entry_for_checks = self._entries[session_key]\n+                _stale_session_id = _entry_for_checks.session_id\n+\n+        # ---- Phase 1b: no-lock I/O -- stale check + reset policy ----\n+        _is_stale = False\n+        _reset_reason = None\n+        if _entry_for_checks is not None and _stale_session_id is not None:\n+            _is_stale = self._is_session_ended_in_db(_stale_session_id)\n+            if _entry_for_checks.suspended:\n+                _reset_reason = \"suspended\"\n+            elif _entry_for_checks.resume_pending:\n+                _reset_reason = self._should_reset(_entry_for_checks, source)\n+                if not _reset_reason:\n+                    _fw = auto_continue_freshness_window()\n+                    _ref_time = (\n+                        _entry_for_checks.last_resume_marked_at\n+                        or _entry_for_checks.updated_at\n+                    )\n+                    if _fw > 0 and (now - _ref_time).total_seconds() > _fw:\n+                        _reset_reason = \"resume_pending_expired\"\n+            else:\n+                _reset_reason = self._should_reset(_entry_for_checks, source)\n+\n+        # ---- Phase 2: lock write -- apply decisions to _entries ----\n+        _needs_save = False\n+        _needs_recover = False\n+        entry: Optional[SessionEntry] = None\n+        was_auto_reset = False\n+        auto_reset_reason = None\n+        reset_had_activity = False\n+\n         with self._lock:\n             self._ensure_loaded_locked()\n ","@@ -1727,25 +1900,13 @@ def get_or_create_session(\n                     entry, existing_session_id, canonical_existing_session_id\n                 )\n \n-                # Self-heal stale routing: if this session_key still points at\n-                # a session that has ALREADY been ended in state.db (end_reason\n-                # set), the in-memory sessions.json entry is stale.  Reusing it\n-                # would route every incoming message into a closed session and\n-                # silently drop it — with no log, no error, no response — until\n-                # the gateway restarts and _prune_stale_sessions_locked() clears\n-                # it (#54878 — the live-gateway variant of #52804/FM9, which\n-                # only the startup prune previously caught).\n-                #\n-                # Drop the stale entry and fall through to the recovery path\n-                # below.  Leaving db_end_session_id None routes us into\n-                # _recover_session_from_db, whose finder\n-                # (hermes_state.find_latest_gateway_session_for_peer) selects\n-                # rows WHERE `ended_at IS NULL OR end_reason = 'agent_close'`\n-                # — so it REOPENS gateway-cleanup-ended ('agent_close') rows and\n-                # resumes the SAME session_id (transcript preserved), but returns\n-                # None for any other end_reason (e.g. /new), which then correctly\n-                # starts a fresh session.\n-                if self._is_session_ended_in_db(entry.session_id):\n+                if _is_stale and entry.session_id == _stale_session_id:\n+                    # Stale routing self-heal (#54878): the in-memory entry\n+                    # points at a session that has ALREADY been ended in\n+                    # state.db.  Drop it and fall through to recovery/create.\n+                    # Recovery finder reopens ``agent_close`` rows (preserving\n+                    # the transcript) but returns None for other end_reasons\n+                    # (e.g. /new), starting a fresh session.\n                     logger.warning(\n                         \"gateway.session: routing key %r -> %s is ended in \"\n                         \"state.db but still live in sessions.json; dropping \"","@@ -1754,82 +1915,49 @@ def get_or_create_session(\n                         session_key, entry.session_id,\n                     )\n                     self._entries.pop(session_key, None)\n-                    was_auto_reset = False\n-                    auto_reset_reason = None\n-                    reset_had_activity = False\n-                    # Fall through to the recovery/create path below; the\n-                    # stale entry is gone so we must NOT consult its\n-                    # suspended/resume/reset state.\n+                    entry = None\n+                    _needs_recover = True\n+                elif entry.session_id != _stale_session_id:\n+                    # Another thread handled this entry during our lock-free\n+                    # window.  Treat as healthy -- bump updated_at and save.\n+                    entry.updated_at = now\n+                    _needs_save = True\n                 else:\n-                    # Auto-reset sessions marked as suspended (e.g. after /stop\n-                    # broke a stuck loop — #7536).  ``suspended`` is the hard\n-                    # forced-wipe signal and always wins over ``resume_pending``,\n-                    # so repeated interrupted restarts that escalate via the\n-                    # existing ``.restart_failure_counts`` stuck-loop counter\n-                    # still converge to a clean slate.\n-                    if entry.suspended:\n-                        reset_reason = \"suspended\"\n-                    elif entry.resume_pending:\n-                        # Restart-interrupted session: preserve the session_id\n-                        # and return the existing entry so the transcript reloads\n-                        # intact, but still honour normal daily/idle reset policy.\n-                        #\n-                        # Freshness gate (#46934): the idle/daily policy checks\n-                        # ``updated_at``, which is bumped to ``now`` on every\n-                        # message — so a zombie session that keeps receiving\n-                        # messages never trips it and would resume stale context\n-                        # forever.  ``last_resume_marked_at`` is set once when\n-                        # resume was marked and never bumped per-message, so it\n-                        # correctly measures how long resume has been pending.\n-                        # If that exceeds the auto-continue freshness window, the\n-                        # recovery turn either never ran or failed — treat the\n-                        # session as a zombie and fall through to auto-reset.\n-                        reset_reason = self._should_reset(entry, source)\n-                        if not reset_reason:\n-                            _fw = auto_continue_freshness_window()\n-                            _ref_time = entry.last_resume_marked_at or entry.updated_at\n-                            if _fw > 0 and (now - _ref_time).total_seconds() > _fw:\n-                                reset_reason = \"resume_pending_expired\"\n-                            else:\n-                                entry.updated_at = now\n-                                self._save()\n-                                return entry\n-                    else:\n-                        reset_reason = self._should_reset(entry, source)\n-                    if not reset_reason:\n-                        entry.updated_at = now\n-                        self._save()\n-                        return entry\n-                    else:\n-                        # Session is being auto-reset.\n+                    # Stale check clean.  Apply reset decision.\n+                    if _reset_reason:\n                         was_auto_reset = True\n-                        auto_reset_reason = reset_reason\n-                        # Track whether the expired session had any real\n-                        # conversation.  total_tokens is never written (token\n-                        # counts migrated to agent-direct persistence) so it is\n-                        # always 0 — use last_prompt_tokens, updated every turn.\n+                        auto_reset_reason = _reset_reason\n                         reset_had_activity = entry.last_prompt_tokens > 0\n                         db_end_session_id = entry.session_id\n+                        self._entries.pop(session_key, None)\n+                        entry = None\n+                        _needs_recover = True\n+                    else:\n+                        entry.updated_at = now\n+                        _needs_save = True\n             else:\n-                was_auto_reset = False\n-                auto_reset_reason = None\n-                reset_had_activity = False\n-\n-            if not force_new and not db_end_session_id:\n-                recovered_entry = self._recover_session_from_db(\n-                    session_key=session_key,\n-                    source=source,\n-                    now=now,\n-                )\n-                if recovered_entry is not None:\n-                    self._entries[session_key] = recovered_entry\n-                    self._save()\n-                    return recovered_entry\n+                if not force_new:\n+                    _needs_recover = True\n \n-            # Create new session\n+        # ---- Phase 3: no-lock I/O -- recovery + create + save + DB ops ----\n+        if _needs_recover and db_end_session_id is None:\n+            recovered = self._query_recoverable_session(\n+                session_key=session_key, source=source, now=now,\n+            )\n+            if recovered is not None:\n+                with self._lock:\n+                    published = self._entries.get(session_key)\n+                    if published is None:\n+                        self._entries[session_key] = recovered\n+                        published = recovered\n+                entry = published\n+                _needs_save = True\n+\n+        if entry is None:\n+            # Create a candidate outside the lock, then publish only if another\n+            # worker has not already populated this routing key.\n             session_id = f\"{now.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}\"\n-\n-            entry = SessionEntry(\n+            candidate = SessionEntry(\n                 session_key=session_key,\n                 session_id=session_id,\n                 created_at=now,","@@ -1842,20 +1970,34 @@ def get_or_create_session(\n                 auto_reset_reason=auto_reset_reason,\n                 reset_had_activity=reset_had_activity,\n             )\n-\n-            self._entries[session_key] = entry\n-            self._save()\n-            db_create_kwargs = {\n-                \"session_id\": session_id,\n-                \"source\": source.platform.value,\n-                \"user_id\": source.user_id,\n-                \"session_key\": session_key,\n-                \"chat_id\": source.chat_id,\n-                \"chat_type\": source.chat_type,\n-                \"thread_id\": source.thread_id,\n-            }\n-\n-        # SQLite operations outside the lock\n+            with self._lock:\n+                current = self._entries.get(session_key)\n+                may_publish = current is None or (\n+                    force_new and current is force_new_observed_entry\n+                )\n+                if may_publish:\n+                    self._entries[session_key] = candidate\n+                    published = candidate\n+                else:\n+                    published = current\n+            assert published is not None\n+            entry = published\n+            _needs_save = True\n+            if entry is candidate:\n+                db_create_kwargs = {\n+                    \"session_id\": session_id,\n+                    \"source\": source.platform.value,\n+                    \"user_id\": source.user_id,\n+                    \"session_key\": session_key,\n+                    \"chat_id\": source.chat_id,\n+                    \"chat_type\": source.chat_type,\n+                    \"thread_id\": source.thread_id,\n+                }\n+\n+        if _needs_save:\n+            self._save_entries()\n+\n+        # SQLite operations outside the lock (unchanged).\n         if self._db and db_end_session_id:\n             try:\n                 self._db.end_session(db_end_session_id, \"session_reset\")"]},{"file":"gateway/slash_commands.py","hunks":["@@ -34,6 +34,7 @@\n from gateway.config import HomeChannel, Platform, PlatformConfig\n from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType\n from gateway.session import (\n+    AsyncSessionStore,\n     SessionSource,\n     build_session_key,\n     is_shared_multi_user_session,","@@ -86,6 +87,8 @@ def _model_switch_skew_guard() -> Optional[str]:\n class GatewaySlashCommandsMixin:\n     \"\"\"In-session slash-command handlers for GatewayRunner.\"\"\"\n \n+    async_session_store: AsyncSessionStore\n+\n     def _typed_command_prefix_for(self, platform) -> str:\n         \"\"\"Return the prefix users can always type to reach Hermes commands.\n ","@@ -198,7 +201,7 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer\n             pass\n \n         # Reset the session\n-        new_entry = self.session_store.reset_session(session_key)\n+        new_entry = await self.async_session_store.reset_session(session_key)\n \n         # Clear any session-scoped model/reasoning overrides so the next agent\n         # picks up configured defaults instead of previous session switches.","@@ -263,7 +266,7 @@ async def _handle_reset_command(self, event: MessageEvent) -> Union[str, Ephemer\n             header = await asyncio.to_thread(self._telegram_topic_new_header, source) or t(\"gateway.reset.header_default\")\n         else:\n             # No existing session, just create one\n-            new_entry = self.session_store.get_or_create_session(source, force_new=True)\n+            new_entry = await self.async_session_store.get_or_create_session(source, force_new=True)\n             header = await asyncio.to_thread(self._telegram_topic_new_header, source) or t(\"gateway.reset.header_new\")\n \n         # Set session title if provided with /new <title>","@@ -495,7 +498,7 @@ async def _handle_status_command(self, event: MessageEvent) -> str:\n         from gateway.run import _AGENT_PENDING_SENTINEL, _load_gateway_config, _resolve_gateway_model\n \n         source = event.source\n-        session_entry = self.session_store.get_or_create_session(source)\n+        session_entry = await self.async_session_store.get_or_create_session(source)\n \n         connected_platforms = [p.value for p in self.adapters.keys()]\n ","@@ -1061,7 +1064,7 @@ async def _handle_stop_command(self, event: MessageEvent) -> Union[str, Ephemera\n         \"\"\"\n         from gateway.run import _AGENT_PENDING_SENTINEL, _INTERRUPT_REASON_STOP\n         source = event.source\n-        session_entry = self.session_store.get_or_create_session(source)\n+        session_entry = await self.async_session_store.get_or_create_session(source)\n         session_key = session_entry.session_key\n \n         agent = self._running_agents.get(session_key)","@@ -1598,7 +1601,7 @@ async def _on_model_selected(\n                         _sess_db = getattr(_self, \"_session_db\", None)\n                         if _sess_db is not None:\n                             try:\n-                                _sess_entry = _self.session_store.get_or_create_session(\n+                                _sess_entry = await _self.async_session_store.get_or_create_session(\n                                     event.source\n                                 )\n                                 await _sess_db.update_session_model(","@@ -1629,7 +1632,7 @@ async def _on_model_selected(\n                         # store so the picked model survives a gateway restart\n                         # (api_key is never persisted).\n                         try:\n-                            _self.session_store.set_model_override(\n+                            await _self.async_session_store.set_model_override(\n                                 _session_key,\n                                 _self._session_model_overrides[_session_key],\n                             )","@@ -1840,7 +1843,7 @@ async def _finish_switch() -> str:\n             _sess_db = getattr(self, \"_session_db\", None)\n             if _sess_db is not None:\n                 try:\n-                    _sess_entry = self.session_store.get_or_create_session(source)\n+                    _sess_entry = await self.async_session_store.get_or_create_session(source)\n                     # If this session was auto-reset, consume the flag so the\n                     # next regular message's cleanup does not wipe the model\n                     # override just stored below (Closes #48031).","@@ -1878,8 +1881,9 @@ async def _finish_switch() -> str:\n             # api_key/api_mode are never persisted — they are re-resolved via\n             # runtime provider resolution on rehydration.\n             try:\n-                self.session_store.set_model_override(\n-                    session_key, self._session_model_overrides[session_key]\n+                await self.async_session_store.set_model_override(\n+                    session_key,\n+                    self._session_model_overrides[session_key],\n                 )\n             except Exception:\n                 logger.debug(","@@ -2142,8 +2146,8 @@ def _resolve_prompt(value):\n     async def _handle_retry_command(self, event: MessageEvent) -> str:\n         \"\"\"Handle /retry command - re-send the last user message.\"\"\"\n         source = event.source\n-        session_entry = self.session_store.get_or_create_session(source)\n-        history = self.session_store.load_transcript(session_entry.session_id)\n+        session_entry = await self.async_session_store.get_or_create_session(source)\n+        history = await self.async_session_store.load_transcript(session_entry.session_id)\n         \n         # Find the last user message\n         last_user_msg = None","@@ -2159,10 +2163,10 @@ async def _handle_retry_command(self, event: MessageEvent) -> str:\n         \n         # Truncate history to before the last user message and persist\n         truncated = history[:last_user_idx]\n-        self.session_store.rewrite_transcript(session_entry.session_id, truncated)\n+        await self.async_session_store.rewrite_transcript(session_entry.session_id, truncated)\n         # Reset stored token count — transcript was truncated\n         session_entry.last_prompt_tokens = 0\n-        \n+\n         # Re-send by creating a fake text event with the old message\n         retry_event = MessageEvent(\n             text=last_user_msg,","@@ -2189,7 +2193,7 @@ async def _handle_goal_command(self, event: \"MessageEvent\") -> str:\n         args = (event.get_command_args() or \"\").strip()\n         lower = args.lower()\n \n-        mgr, session_entry = self._get_goal_manager_for_event(event)\n+        mgr, session_entry = await self._get_goal_manager_for_event(event)\n         if mgr is None:\n             return t(\"gateway.goal.unavailable\")\n ","@@ -2323,7 +2327,7 @@ async def _handle_subgoal_command(self, event: \"MessageEvent\") -> str:\n         to invoke while the agent is running.\n         \"\"\"\n         args = (event.get_command_args() or \"\").strip()\n-        mgr, _session_entry = self._get_goal_manager_for_event(event)\n+        mgr, _session_entry = await self._get_goal_manager_for_event(event)\n         if mgr is None:\n             return t(\"gateway.goal.unavailable\")\n         if not mgr.has_goal():","@@ -2390,8 +2394,8 @@ async def _handle_undo_command(self, event: MessageEvent) -> str:\n             if n < 1:\n                 n = 1\n \n-        session_entry = self.session_store.get_or_create_session(source)\n-        result = self.session_store.rewind_session(session_entry.session_id, n)\n+        session_entry = await self.async_session_store.get_or_create_session(source)\n+        result = await self.async_session_store.rewind_session(session_entry.session_id, n)\n \n         if result is None:\n             return t(\"gateway.undo.nothing\")","@@ -3090,8 +3094,8 @@ async def _handle_compress_command(self, event: MessageEvent) -> str:\n         https://code.claude.com/docs/en/whats-new/2026-w20).\n         \"\"\"\n         source = event.source\n-        session_entry = self.session_store.get_or_create_session(source)\n-        history = self.session_store.load_transcript(session_entry.session_id)\n+        session_entry = await self.async_session_store.get_or_create_session(source)\n+        history = await self.async_session_store.load_transcript(session_entry.session_id)\n \n         if not history or len(history) < 4:\n             return t(\"gateway.compress.not_enough\")","@@ -3279,7 +3283,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str:\n                 # original messages and replace them with only the compressed\n                 # summary (permanent data loss #44794, #39704).\n                 if rotated:\n-                    if not self.session_store.rewrite_transcript(\n+                    if not await self.async_session_store.rewrite_transcript(\n                         new_session_id, compressed\n                     ):\n                         raise RuntimeError(","@@ -3287,7 +3291,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str:\n                             f\"session {new_session_id}\"\n                         )\n                     session_entry.session_id = new_session_id\n-                    self.session_store._save()\n+                    await self.async_session_store._save()\n                     await asyncio.to_thread(\n                         self._sync_telegram_topic_binding,\n                         source, session_entry, reason=\"compress-command\",","@@ -3304,7 +3308,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str:\n                         \"it (#44794).\"\n                     )\n                 # Reset stored token count — transcript changed, old value is stale\n-                self.session_store.update_session(\n+                await self.async_session_store.update_session(\n                     session_entry.session_key, last_prompt_tokens=0\n                 )\n                 new_tokens = estimate_request_tokens_rough(","@@ -3453,7 +3457,7 @@ async def _handle_topic_command(self, event: MessageEvent, args: str = \"\") -> st\n     async def _handle_title_command(self, event: MessageEvent) -> str:\n         \"\"\"Handle /title command — set or show the current session's title.\"\"\"\n         source = event.source\n-        session_entry = self.session_store.get_or_create_session(source)\n+        session_entry = await self.async_session_store.get_or_create_session(source)\n         session_id = session_entry.session_id\n \n         if not self._session_db:","@@ -3636,7 +3640,7 @@ async def _list_titled_sessions() -> list[dict]:\n             return t(\"gateway.resume.blocked_not_owner\", name=name)\n \n         # Check if already on that session\n-        current_entry = self.session_store.get_or_create_session(source)\n+        current_entry = await self.async_session_store.get_or_create_session(source)\n         if current_entry.session_id == target_id:\n             return t(\"gateway.resume.already_on\", name=name)\n ","@@ -3644,7 +3648,7 @@ async def _list_titled_sessions() -> list[dict]:\n         self._release_running_agent_state(session_key)\n \n         # Switch the session entry to point at the old session\n-        new_entry = self.session_store.switch_session(session_key, target_id)\n+        new_entry = await self.async_session_store.switch_session(session_key, target_id)\n         if not new_entry:\n             return t(\"gateway.resume.switch_failed\")\n         self._clear_session_boundary_security_state(session_key)","@@ -3681,7 +3685,7 @@ async def _list_titled_sessions() -> list[dict]:\n         title = await self._session_db.get_session_title(target_id) or name\n \n         # Count messages for context\n-        history = self.session_store.load_transcript(target_id)\n+        history = await self.async_session_store.load_transcript(target_id)\n         msg_count = len([m for m in history if m.get(\"role\") == \"user\"]) if history else 0\n         msg_part = f\" ({msg_count} message{'s' if msg_count != 1 else ''})\" if msg_count else \"\"\n ","@@ -3732,7 +3736,7 @@ async def _handle_sessions_command(self, event: MessageEvent) -> str:\n         # `/sessions all` and enumerate other origins' session ids / titles /\n         # previews / sources — the enumeration half of the /resume IDOR.\n         cross_origin = include_all and self._resume_caller_is_admin(source)\n-        current_entry = self.session_store.get_or_create_session(source)\n+        current_entry = await self.async_session_store.get_or_create_session(source)\n         rows = await asyncio.to_thread(\n             query_session_listing,\n             getattr(self._session_db, \"_db\", self._session_db),","@@ -3781,8 +3785,8 @@ async def _handle_branch_command(self, event: MessageEvent) -> str:\n         session_key = self._session_key_for_source(source)\n \n         # Load the current session and its transcript\n-        current_entry = self.session_store.get_or_create_session(source)\n-        history = self.session_store.load_transcript(current_entry.session_id)\n+        current_entry = await self.async_session_store.get_or_create_session(source)\n+        history = await self.async_session_store.load_transcript(current_entry.session_id)\n         if not history:\n             return t(\"gateway.branch.no_conversation\")\n ","@@ -3849,7 +3853,7 @@ async def _handle_branch_command(self, event: MessageEvent) -> str:\n             pass\n \n         # Switch the session store entry to the new session\n-        new_entry = self.session_store.switch_session(session_key, new_session_id)\n+        new_entry = await self.async_session_store.switch_session(session_key, new_session_id)\n         if not new_entry:\n             return t(\"gateway.branch.switch_failed\")\n         self._clear_session_boundary_security_state(session_key)","@@ -3967,7 +3971,7 @@ async def _handle_usage_command(self, event: MessageEvent) -> str:\n         api_key = [redacted:secret-assignment] \"api_key\", None) if agent and agent is not _AGENT_PENDING_SENTINEL else None\n         if not provider and getattr(self, \"_session_db\", None) is not None:\n             try:\n-                _entry_for_billing = self.session_store.get_or_create_session(source)\n+                _entry_for_billing = await self.async_session_store.get_or_create_session(source)\n                 persisted = await self._session_db.get_session(_entry_for_billing.session_id) or {}\n             except Exception:\n                 persisted = {}","@@ -4040,7 +4044,9 @@ async def _handle_usage_command(self, event: MessageEvent) -> str:\n             # Same engine the desktop popover uses (PR #54907). The system\n             # prompt / tools / skills / memory slices read off the live agent;\n             # the conversation slice is estimated from the session transcript.\n-            breakdown_lines = self._context_breakdown_lines(agent, source)\n+            breakdown_lines = await asyncio.to_thread(\n+                self._context_breakdown_lines, agent, source\n+            )\n             if breakdown_lines:\n                 lines.append(\"\")\n                 lines.extend(breakdown_lines)","@@ -4055,8 +4061,8 @@ async def _handle_usage_command(self, event: MessageEvent) -> str:\n             return \"\\n\".join(lines)\n \n         # No agent at all -- check session history for a rough count\n-        session_entry = self.session_store.get_or_create_session(source)\n-        history = self.session_store.load_transcript(session_entry.session_id)\n+        session_entry = await self.async_session_store.get_or_create_session(source)\n+        history = await self.async_session_store.load_transcript(session_entry.session_id)\n         if history:\n             from agent.model_metadata import estimate_messages_tokens_rough\n             msgs = [m for m in history if m.get(\"role\") in {\"user\", \"assistant\"} and m.get(\"content\")]"]},{"file":"tests/gateway/test_35809_auto_reset_clean_context.py","hunks":["@@ -85,6 +85,10 @@ def test_reset_session_return_is_captured(self):\n         for stmt in ast.walk(block):\n             if isinstance(stmt, ast.Assign):\n                 val = stmt.value\n+                # reset_session is async at the gateway boundary, so the\n+                # assignment value is Await(Call(...)), not a bare Call.\n+                if isinstance(val, ast.Await):\n+                    val = val.value\n                 if (\n                     isinstance(val, ast.Call)\n                     and isinstance(val.func, ast.Attribute)"]},{"file":"tests/gateway/test_async_session_store.py","hunks":["@@ -0,0 +1,152 @@\n+\"\"\"Async SessionStore boundary for gateway event-loop safety.\"\"\"\n+\n+import ast\n+import asyncio\n+import threading\n+from pathlib import Path\n+\n+import pytest\n+\n+from gateway.session import AsyncSessionStore\n+\n+\n+class _SpyStore:\n+    def __init__(self) -> None:\n+        self.calls: list[tuple[str, int]] = []\n+        self.label = \"store\"\n+\n+    def read(self, value: str) -> str:\n+        self.calls.append((value, threading.get_ident()))\n+        return value\n+\n+\n[redacted:email]\n+async def test_async_session_store_offloads_calls() -> None:\n+    store = _SpyStore()\n+    facade = AsyncSessionStore(store)  # type: ignore[arg-type]\n+    loop_thread = threading.get_ident()\n+\n+    assert await facade.read(\"ok\") == \"ok\"\n+    assert store.calls == [(\"ok\", store.calls[0][1])]\n+    assert store.calls[0][1] != loop_thread\n+    assert facade.label == \"store\"\n+    assert facade._store is store\n+\n+\n+def _nearest_function(node: ast.AST, parents: dict[ast.AST, ast.AST]):\n+    current = node\n+    while current in parents:\n+        current = parents[current]\n+        if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)):\n+            return current\n+    return None\n+\n+\n+def _is_awaited(node: ast.AST, parents: dict[ast.AST, ast.AST]) -> bool:\n+    current = node\n+    while current in parents:\n+        current = parents[current]\n+        if isinstance(current, ast.Await):\n+            return True\n+        if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)):\n+            return False\n+    return False\n+\n+\n+def test_gateway_async_code_uses_one_awaited_session_store_boundary() -> None:\n+    \"\"\"Loop-side store calls must use the facade; raw store remains sync-only.\"\"\"\n+    root = Path(__file__).resolve().parents[2]\n+    violations: list[str] = []\n+    for rel in (\"gateway/run.py\", \"gateway/slash_commands.py\"):\n+        tree = ast.parse((root / rel).read_text(encoding=\"utf-8\"))\n+        parents = {\n+            child: parent\n+            for parent in ast.walk(tree)\n+            for child in ast.iter_child_nodes(parent)\n+        }\n+        for owner in (node for node in ast.walk(tree) if isinstance(node, ast.AsyncFunctionDef)):\n+            raw_aliases = {\n+                target.id\n+                for node in ast.walk(owner)\n+                if isinstance(node, ast.Assign)\n+                and isinstance(node.value, ast.Attribute)\n+                and isinstance(node.value.value, ast.Name)\n+                and node.value.value.id in {\"self\", \"_self\"}\n+                and node.value.attr == \"session_store\"\n+                for target in node.targets\n+                if isinstance(target, ast.Name)\n+            }\n+            for node in ast.walk(owner):\n+                if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):\n+                    continue\n+                if _nearest_function(node, parents) is not owner:\n+                    # A nested sync helper (for example run_sync) executes off-loop.\n+                    continue\n+                receiver = node.func.value\n+                if isinstance(receiver, ast.Name) and receiver.id in raw_aliases:\n+                    violations.append(\n+                        f\"{rel}:{node.lineno} raw alias {receiver.id}.{node.func.attr}() in async {owner.name}\"\n+                    )\n+                    continue\n+                if not (\n+                    isinstance(receiver, ast.Attribute)\n+                    and isinstance(receiver.value, ast.Name)\n+                    and receiver.value.id in {\"self\", \"_self\"}\n+                ):\n+                    continue\n+                if receiver.attr == \"session_store\":\n+                    violations.append(\n+                        f\"{rel}:{node.lineno} raw session_store.{node.func.attr}() in async {owner.name}\"\n+                    )\n+                elif receiver.attr == \"async_session_store\" and not _is_awaited(\n+                    node, parents\n+                ):\n+                    violations.append(\n+                        f\"{rel}:{node.lineno} unawaited async_session_store.{node.func.attr}()\"\n+                    )\n+    assert not violations, \"\\n\".join(violations)\n+\n+\n+def test_every_async_compression_check_is_awaited() -> None:\n+    root = Path(__file__).resolve().parents[2]\n+    tree = ast.parse((root / \"gateway/run.py\").read_text(encoding=\"utf-8\"))\n+    parents = {\n+        child: parent\n+        for parent in ast.walk(tree)\n+        for child in ast.iter_child_nodes(parent)\n+    }\n+    violations = []\n+    for node in ast.walk(tree):\n+        if (\n+            isinstance(node, ast.Call)\n+            and isinstance(node.func, ast.Attribute)\n+            and node.func.attr == \"_session_has_compression_in_flight\"\n+            and not _is_awaited(node, parents)\n+        ):\n+            violations.append(node.lineno)\n+    assert not violations, f\"compression check must be awaited at lines {violations}\"\n+\n+\n+def test_gateway_initializes_async_session_store_facade() -> None:\n+    source = (Path(__file__).resolve().parents[2] / \"gateway/run.py\").read_text(\n+        encoding=\"utf-8\"\n+    )\n+    tree = ast.parse(source)\n+    assignments = [\n+        node\n+        for node in ast.walk(tree)\n+        if isinstance(node, ast.Assign)\n+        and any(\n+            isinstance(target, ast.Attribute)\n+            and isinstance(target.value, ast.Name)\n+            and target.value.id == \"self\"\n+            and target.attr == \"_async_session_store\"\n+            for target in node.targets\n+        )\n+    ]\n+    assert assignments, \"GatewayRunner must initialize one AsyncSessionStore facade\"\n+\n+\n+def test_no_repository_local_claude_permissions_file() -> None:\n+    root = Path(__file__).resolve().parents[2]\n+    assert not (root / \".claude\" / \"settings.json\").exists()"]},{"file":"tests/gateway/test_compression_in_flight_check.py","hunks":["@@ -0,0 +1,73 @@\n+\"\"\"#5 regression: _session_has_compression_in_flight must offload both blocking sources to thread pool.\"\"\"\n+import inspect\n+import threading\n+from unittest.mock import MagicMock\n+\n+import pytest\n+\n+\n+def _make_runner(holder_value=None, record_thread=False, thread_sink=None):\n+    from gateway.run import GatewayRunner\n+    runner = GatewayRunner.__new__(GatewayRunner)\n+\n+    store = MagicMock()\n+    store._lock = threading.Lock()\n+    store._loaded = True\n+    store._entries = {\"k\": MagicMock(session_id=\"sess-123\")}\n+    store._ensure_loaded_locked = lambda: None\n+    runner.session_store = store\n+\n+    raw_db = MagicMock()\n+    if record_thread and thread_sink is not None:\n+        def _holder(sid):\n+            thread_sink[\"thread\"] = threading.get_ident()\n+            return holder_value\n+        raw_db.get_compression_lock_holder = _holder\n+    else:\n+        raw_db.get_compression_lock_holder = MagicMock(return_value=holder_value)\n+\n+    session_db = MagicMock()\n+    session_db._db = raw_db\n+    runner._session_db = session_db\n+    return runner\n+\n+\n+def test_method_is_coroutine():\n+    from gateway.run import GatewayRunner\n+    assert inspect.iscoroutinefunction(\n+        GatewayRunner._session_has_compression_in_flight\n+    ), \"#5: method must be async, blocking calls offloaded\"\n+\n+\n[redacted:email]\n+async def test_returns_true_when_lock_held():\n+    runner = _make_runner(holder_value=\"agent-1\")\n+    assert await runner._session_has_compression_in_flight(\"k\") is True\n+\n+\n[redacted:email]\n+async def test_returns_false_when_no_lock():\n+    runner = _make_runner(holder_value=None)\n+    assert await runner._session_has_compression_in_flight(\"k\") is False\n+\n+\n[redacted:email]\n+async def test_returns_false_when_no_session_store():\n+    from gateway.run import GatewayRunner\n+    runner = GatewayRunner.__new__(GatewayRunner)\n+    runner.session_store = None\n+    runner._session_db = MagicMock()\n+    assert await runner._session_has_compression_in_flight(\"k\") is False\n+\n+\n[redacted:email]\n+async def test_db_call_runs_off_event_loop():\n+    \"\"\"Regression core: get_compression_lock_holder MUST execute in non-event-loop thread.\"\"\"\n+    sink = {}\n+    runner = _make_runner(holder_value=\"agent-1\", record_thread=True, thread_sink=sink)\n+    loop_thread = threading.get_ident()\n+    await runner._session_has_compression_in_flight(\"k\")\n+    assert \"thread\" in sink, \"underlying db.get_compression_lock_holder was not called\"\n+    assert sink[\"thread\"] != loop_thread, (\n+        \"DB call still on event loop thread — #5 NOT fixed (to_thread not applied)\"\n+    )"]},{"file":"tests/gateway/test_compression_interrupt_demotion_56391.py","hunks":["@@ -105,22 +105,25 @@ def _make_parent_no_subagents() -> MagicMock:\n \n \n class TestSessionHasCompressionInFlight:\n-    def test_returns_false_without_session_store(self) -> None:\n+    @pytest.mark.asyncio\n+    async def test_returns_false_without_session_store(self) -> None:\n         runner = _make_runner()\n         runner.session_store = None\n-        assert runner._session_has_compression_in_flight(\"sk\") is False\n+        assert await runner._session_has_compression_in_flight(\"sk\") is False\n \n-    def test_returns_true_when_lock_held(self) -> None:\n+    @pytest.mark.asyncio\n+    async def test_returns_true_when_lock_held(self) -> None:\n         runner = _make_runner()\n         sk = build_session_key(_make_event().source)\n         runner._session_db._db.get_compression_lock_holder.return_value = \"holder-1\"\n-        assert runner._session_has_compression_in_flight(sk) is True\n+        assert await runner._session_has_compression_in_flight(sk) is True\n \n-    def test_returns_false_when_lock_free(self) -> None:\n+    @pytest.mark.asyncio\n+    async def test_returns_false_when_lock_free(self) -> None:\n         runner = _make_runner()\n         sk = build_session_key(_make_event().source)\n         runner._session_db._db.get_compression_lock_holder.return_value = None\n-        assert runner._session_has_compression_in_flight(sk) is False\n+        assert await runner._session_has_compression_in_flight(sk) is False\n \n \n class TestBusyHandlerDemotesInterruptForCompression:"]},{"file":"tests/gateway/test_priority_path_compression_demotion_56391.py","hunks":["@@ -112,7 +112,9 @@ def _make_runner(*, compression_in_flight: bool):\n     # No subagents active — isolates the compression-demotion behavior from\n     # the (already-correct) subagent-demotion branch.\n     runner._agent_has_active_subagents = lambda _agent: False\n-    runner._session_has_compression_in_flight = lambda _sk: compression_in_flight\n+    runner._session_has_compression_in_flight = AsyncMock(\n+        return_value=compression_in_flight\n+    )\n \n     import time\n     agent_mock = MagicMock()"]},{"file":"tests/gateway/test_session_store_lock_io.py","hunks":["@@ -0,0 +1,383 @@\n+\"\"\"Regression: blocking I/O must not run while session_store._lock is held.\n+\n+``get_or_create_session`` previously held the store lock during SQLite\n+SELECTs (``_is_session_ended_in_db``), a full routing-index rewrite +\n+``os.fsync`` (``_save``), and a recovery DB query\n+(``_recover_session_from_db``) -- all on every inbound message.\n+\n+These tests assert those three I/O calls are invoked *outside* the lock.\n+They follow the mock-DB idiom from ``test_session_store_runtime_stale_guard``.\n+\"\"\"\n+import json\n+import threading\n+from concurrent.futures import ThreadPoolExecutor\n+from datetime import datetime, timedelta\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import GatewayConfig, Platform, SessionResetPolicy\n+from gateway.session import SessionEntry, SessionSource, SessionStore\n+\n+\n+# ---------------------------------------------------------------------------\n+# Test helpers\n+# ---------------------------------------------------------------------------\n+\n+class _TrackedLock:\n+    \"\"\"Drop-in replacement for ``threading.Lock`` that tracks hold state.\n+\n+    Used to assert that blocking I/O runs only when the lock is released.\n+    \"\"\"\n+\n+    def __init__(self):\n+        self._lock = threading.Lock()\n+        self._held = False\n+\n+    def acquire(self, *a, **kw):\n+        r = self._lock.acquire(*a, **kw)\n+        if r:\n+            self._held = True\n+        return r\n+\n+    def release(self):\n+        self._held = False\n+        self._lock.release()\n+\n+    def __enter__(self):\n+        self.acquire()\n+        return self\n+\n+    def __exit__(self, *a):\n+        self.release()\n+\n+    @property\n+    def held(self) -> bool:\n+        return self._held\n+\n+\n+def _db_with_rows(rows: dict) -> MagicMock:\n+    \"\"\"Mock SessionDB where ``get_session`` maps session_id -> row dict.\"\"\"\n+    db = MagicMock()\n+    db.get_session.side_effect = lambda sid: rows.get(sid)\n+    db.find_latest_gateway_session_for_peer.return_value = None\n+    db.reopen_session.return_value = None\n+    db.create_session.return_value = None\n+    # Identity compression tip (no child session).\n+    db.get_compression_tip.side_effect = lambda sid: sid\n+    return db\n+\n+\n+def _make_store(tmp_path, db_mock=None) -> SessionStore:\n+    \"\"\"Build a SessionStore with a ``_TrackedLock``, bypassing disk load.\"\"\"\n+    config = GatewayConfig(default_reset_policy=SessionResetPolicy(mode=\"none\"))\n+    with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+        store = SessionStore(sessions_dir=tmp_path, config=config)\n+    if db_mock is not None:\n+        store._db = db_mock\n+    store._loaded = True\n+    store._lock = _TrackedLock()\n+    return store\n+\n+\n+def _source() -> SessionSource:\n+    return SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"12345\",\n+        chat_type=\"dm\",\n+        user_id=\"12345\",\n+    )\n+\n+\n+def _seed_entry(store, key, session_id) -> SessionEntry:\n+    now = datetime.now()\n+    entry = SessionEntry(\n+        session_key=key,\n+        session_id=session_id,\n+        created_at=now - timedelta(hours=2),\n+        updated_at=now - timedelta(hours=1),\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"dm\",\n+    )\n+    store._entries[key] = entry\n+    return entry\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests\n+# ---------------------------------------------------------------------------\n+\n+class TestStaleCheckOutsideLock:\n+    def test_is_session_ended_not_holding_lock(self, tmp_path):\n+        \"\"\"``_is_session_ended_in_db`` must run with the lock released.\"\"\"\n+        source = _source()\n+        db = _db_with_rows({\n+            \"sid_alive\": {\"end_reason\": None, \"id\": \"sid_alive\"},\n+        })\n+        store = _make_store(tmp_path, db)\n+        key = store._generate_session_key(source)\n+        _seed_entry(store, key, \"sid_alive\")\n+\n+        lock = store._lock\n+        calls_under_lock = []\n+\n+        orig = store._is_session_ended_in_db\n+\n+        def tracking(sid):\n+            if lock.held:\n+                calls_under_lock.append(sid)\n+            return orig(sid)\n+\n+        store._is_session_ended_in_db = tracking  # type: ignore[method-assign]\n+\n+        store.get_or_create_session(source)\n+\n+        assert not calls_under_lock, (\n+            f\"_is_session_ended_in_db called {len(calls_under_lock)} \"\n+            f\"time(s) while lock was held\"\n+        )\n+\n+\n+class TestSaveOutsideLock:\n+    def test_save_not_holding_lock(self, tmp_path):\n+        \"\"\"``_save`` must run with the lock released.\"\"\"\n+        source = _source()\n+        db = _db_with_rows({})\n+        store = _make_store(tmp_path, db)\n+\n+        lock = store._lock\n+        save_calls_under_lock = []\n+\n+        orig_save = store._save_entries\n+\n+        def tracking_save():\n+            if lock.held:\n+                save_calls_under_lock.append(True)\n+            orig_save()\n+\n+        store._save_entries = tracking_save  # type: ignore[method-assign]\n+\n+        # force_new bypasses the existing-entry path, goes straight to create.\n+        store.get_or_create_session(source, force_new=True)\n+\n+        assert not save_calls_under_lock, (\n+            f\"_save called {len(save_calls_under_lock)} time(s) \"\n+            f\"while lock was held\"\n+        )\n+\n+\n+class TestRecoverOutsideLock:\n+    def test_recover_not_holding_lock(self, tmp_path):\n+        \"\"\"``_recover_session_from_db`` must run with the lock released.\"\"\"\n+        source = _source()\n+        db = _db_with_rows({})\n+        db.find_latest_gateway_session_for_peer.return_value = {\n+            \"id\": \"sid_recovered\",\n+            \"started_at\": datetime.now().timestamp(),\n+        }\n+        store = _make_store(tmp_path, db)\n+        # No entry seeded -- forces the recovery path.\n+\n+        lock = store._lock\n+        recover_calls_under_lock = []\n+\n+        orig = store._query_recoverable_session\n+\n+        def tracking(**kw):\n+            if getattr(lock, \"held\", False):\n+                recover_calls_under_lock.append(True)\n+            return orig(**kw)\n+\n+        store._query_recoverable_session = tracking  # type: ignore[method-assign]\n+\n+        store.get_or_create_session(source)\n+\n+        assert not recover_calls_under_lock, (\n+            f\"_recover_session_from_db called \"\n+            f\"{len(recover_calls_under_lock)} time(s) while lock was held\"\n+        )\n+\n+\n+def test_concurrent_same_key_returns_one_published_session(tmp_path):\n+    \"\"\"Concurrent first messages for one routing key must converge on one ID.\"\"\"\n+    source = _source()\n+    db = _db_with_rows({})\n+    store = _make_store(tmp_path, db)\n+    owner_started = threading.Event()\n+    release_owner = threading.Event()\n+    original_query = store._query_recoverable_session\n+\n+    def synchronized_query(**kwargs):\n+        owner_started.set()\n+        assert release_owner.wait(timeout=2)\n+        return original_query(**kwargs)\n+\n+    store._query_recoverable_session = synchronized_query  # type: ignore[method-assign]\n+    with ThreadPoolExecutor(max_workers=2) as pool:\n+        owner = pool.submit(store.get_or_create_session, source)\n+        assert owner_started.wait(timeout=2)\n+        follower = pool.submit(store.get_or_create_session, source)\n+        release_owner.set()\n+        entries = [owner.result(timeout=2), follower.result(timeout=2)]\n+\n+    key = store._generate_session_key(source)\n+    assert entries[0] is entries[1]\n+    assert entries[0].session_id == store._entries[key].session_id\n+    created_ids = {call.kwargs[\"session_id\"] for call in db.create_session.call_args_list}\n+    assert created_ids == {entries[0].session_id}\n+\n+\n+def test_concurrent_force_new_returns_one_published_session(tmp_path):\n+    \"\"\"Concurrent /new delivery must not create orphan SQLite sessions.\"\"\"\n+    source = _source()\n+    db = _db_with_rows({})\n+    store = _make_store(tmp_path, db)\n+    owner_started = threading.Event()\n+    release_owner = threading.Event()\n+    original_impl = store._get_or_create_session_impl\n+\n+    def synchronized_impl(*args, **kwargs):\n+        owner_started.set()\n+        assert release_owner.wait(timeout=2)\n+        return original_impl(*args, **kwargs)\n+\n+    store._get_or_create_session_impl = synchronized_impl  # type: ignore[method-assign]\n+    with ThreadPoolExecutor(max_workers=2) as pool:\n+        owner = pool.submit(store.get_or_create_session, source, True)\n+        assert owner_started.wait(timeout=2)\n+        follower = pool.submit(store.get_or_create_session, source, True)\n+        release_owner.set()\n+        entries = [owner.result(timeout=2), follower.result(timeout=2)]\n+\n+    assert entries[0] is entries[1]\n+    created_ids = {call.kwargs[\"session_id\"] for call in db.create_session.call_args_list}\n+    assert created_ids == {entries[0].session_id}\n+\n+\n+def test_auto_reset_does_not_recover_session_being_ended(tmp_path):\n+    source = _source()\n+    db = _db_with_rows({})\n+    store = _make_store(tmp_path, db)\n+    key = store._generate_session_key(source)\n+    old = _seed_entry(store, key, \"old-session\")\n+    old.suspended = True\n+    db.find_latest_gateway_session_for_peer.return_value = {\n+        \"id\": old.session_id,\n+        \"session_key\": key,\n+        \"started_at\": old.created_at.timestamp(),\n+    }\n+\n+    entry = store.get_or_create_session(source)\n+\n+    assert entry.session_id != old.session_id\n+    assert entry.was_auto_reset is True\n+    db.reopen_session.assert_not_called()\n+    db.end_session.assert_called_once_with(old.session_id, \"session_reset\")\n+\n+\n+def test_legacy_and_off_lock_saves_share_one_serialization_lock(tmp_path):\n+    db = _db_with_rows({})\n+    persisted: dict[str, str] = {}\n+    first_write_started = threading.Event()\n+    release_first_write = threading.Event()\n+    write_count = 0\n+    count_lock = threading.Lock()\n+\n+    def replace(entries, *, scope):\n+        nonlocal write_count, persisted\n+        with count_lock:\n+            write_count += 1\n+            call_number = write_count\n+        if call_number == 1:\n+            first_write_started.set()\n+            assert release_first_write.wait(timeout=2)\n+        persisted = dict(entries)\n+\n+    db.replace_gateway_routing_entries.side_effect = replace\n+    store = _make_store(tmp_path, db)\n+    source_a = _source()\n+    source_b = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"67890\",\n+        chat_type=\"dm\",\n+        user_id=\"67890\",\n+    )\n+    key_a = store._generate_session_key(source_a)\n+    key_b = store._generate_session_key(source_b)\n+    _seed_entry(store, key_a, \"sid-a\")\n+\n+    with ThreadPoolExecutor(max_workers=2) as pool:\n+        future_a = pool.submit(store._save_entries)\n+        assert first_write_started.wait(timeout=2)\n+        _seed_entry(store, key_b, \"sid-b\")\n+        future_b = pool.submit(store._save)\n+        release_first_write.set()\n+        future_a.result(timeout=2)\n+        future_b.result(timeout=2)\n+\n+    assert set(persisted) == {key_a, key_b}\n+\n+\n+def test_save_serialization_snapshots_latest_routing_index(tmp_path):\n+    \"\"\"A delayed earlier writer must snapshot the state visible when it writes.\"\"\"\n+    db = _db_with_rows({})\n+    persisted: dict[str, str] = {}\n+    first_write_started = threading.Event()\n+    release_first_write = threading.Event()\n+    write_count = 0\n+    count_lock = threading.Lock()\n+\n+    def replace(entries, *, scope):\n+        nonlocal write_count, persisted\n+        with count_lock:\n+            write_count += 1\n+            call_number = write_count\n+        if call_number == 1:\n+            first_write_started.set()\n+            assert release_first_write.wait(timeout=2)\n+        persisted = dict(entries)\n+\n+    db.replace_gateway_routing_entries.side_effect = replace\n+    store = _make_store(tmp_path, db)\n+    source_a = _source()\n+    source_b = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"67890\",\n+        chat_type=\"dm\",\n+        user_id=\"67890\",\n+    )\n+    key_a = store._generate_session_key(source_a)\n+    key_b = store._generate_session_key(source_b)\n+    entry_a = _seed_entry(store, key_a, \"sid-a\")\n+\n+    with ThreadPoolExecutor(max_workers=2) as pool:\n+        future_a = pool.submit(store._save_entries)\n+        assert first_write_started.wait(timeout=2)\n+        entry_b = _seed_entry(store, key_b, \"sid-b\")\n+        future_b = pool.submit(store._save_entries)\n+        release_first_write.set()\n+        future_a.result(timeout=2)\n+        future_b.result(timeout=2)\n+\n+    assert set(store._entries) == {key_a, key_b}\n+    assert set(persisted) == {key_a, key_b}\n+    assert json.loads(persisted[key_a])[\"session_id\"] == entry_a.session_id\n+    assert json.loads(persisted[key_b])[\"session_id\"] == entry_b.session_id\n+\n+\n+def test_recovery_rejects_other_profile_row(tmp_path, monkeypatch):\n+    \"\"\"The lock-free recovery path must retain the canonical profile guard.\"\"\"\n+    source = _source()\n+    db = _db_with_rows({})\n+    db.find_latest_gateway_session_for_peer.return_value = {\n+        \"id\": \"foreign-session\",\n+        \"session_key\": \"agent:other:telegram:dm:12345\",\n+        \"started_at\": datetime.now().timestamp(),\n+    }\n+    store = _make_store(tmp_path, db)\n+    monkeypatch.setattr(store, \"_active_profile_name\", lambda: \"default\")\n+\n+    entry = store.get_or_create_session(source)\n+\n+    assert entry.session_id != \"foreign-session\"\n+    db.reopen_session.assert_not_called()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}