{"repo":"NousResearch/hermes-agent","public_id":"11016","display_label":"Gateway stale session lock and orphaned guard bugs","summary":"These issues and fixes all concern the gateway's session management, specifically stale or orphaned session guards and locks that can block new sessions, cause split-brain state, or leave tasks unable to stop cleanly. Multiple platforms (Feishu, Telegram) and several related edge cases are represented.","subsystem":"gateway","counts":{"members":11,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":14513},"resolution":{"state":"partially_resolved","by":"pr-14513,pr-51553"},"freshness":{"state":"verified","last_verified_at":"2026-06-23T21:51:38+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1650909090909091},"fix_edges":[{"issue_number":11016,"pr_number":14513,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":12216,"pr_number":14513,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":48300,"pr_number":51553,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":11016,"title":"bug(gateway): stale adapter busy lock can trap Telegram sessions in repeated \"Interrupting current task...\" while /stop says \"No active task to stop\"","url":null,"state":"closed"},{"kind":"issue","number":12216,"title":"[Bug]: stale Telegram topic run can reclaim session slot after /stop or /new","url":null,"state":"closed"},{"kind":"issue","number":34253,"title":"fix(gateway): Feishu session cancellation orphans session guard, permanently blocking messages","url":null,"state":"closed"},{"kind":"issue","number":48300,"title":"bug(gateway): _session_task_is_stale misses stale lock when task entry already cleaned up","url":null,"state":"closed"},{"kind":"pr","number":12219,"title":"fix(gateway): stop stale runs from reclaiming session slots","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14513","number":14513}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":14513,"title":"fix(gateway): heal split-brain session locks — salvage #8472 + #12219 (closes #11016)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/run.py","scripts/release.py","tests/gateway/test_session_split_brain_11016.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -900,10 +900,16 @@ def __init__(self, config: PlatformConfig, platform: Platform):\n         self._fatal_error_retryable = True\n         self._fatal_error_handler: Optional[Callable[[\"BasePlatformAdapter\"], Awaitable[None] | None]] = None\n         \n-        # Track active message handlers per session for interrupt support\n-        # Key: session_key (e.g., chat_id), Value: (event, asyncio.Event for interrupt)\n+        # Track active message handlers per session for interrupt support.\n+        # _active_sessions stores the per-session interrupt Event; _session_tasks\n+        # maps session → the specific Task currently processing it so that\n+        # session-terminating commands (/stop, /new, /reset) can cancel the\n+        # right task and release the adapter-level guard deterministically.\n+        # Without the owner-task map, an old task's finally block could delete\n+        # a newer task's guard, leaving stale busy state.\n         self._active_sessions: Dict[str, asyncio.Event] = {}\n         self._pending_messages: Dict[str, MessageEvent] = {}\n+        self._session_tasks: Dict[str, asyncio.Task] = {}\n         # Background message-processing tasks spawned by handle_message().\n         # Gateway shutdown cancels these so an old gateway instance doesn't keep\n         # working on a task after --replace or manual restarts.","@@ -1680,6 +1686,222 @@ def _merge_caption(existing_text: Optional[str], new_text: str) -> str:\n             return f\"{existing_text}\\n\\n{new_text}\".strip()\n         return existing_text\n \n+    # ------------------------------------------------------------------\n+    # Session task + guard ownership helpers\n+    # ------------------------------------------------------------------\n+    # These were introduced together with the _session_tasks owner map to\n+    # make session lifecycle reconciliation deterministic across (a) the\n+    # normal completion path, (b) /stop/ /new/ /reset bypass commands,\n+    # and (c) stale-lock self-heal on the next inbound message.\n+\n+    def _release_session_guard(\n+        self,\n+        session_key: str,\n+        *,\n+        guard: Optional[asyncio.Event] = None,\n+    ) -> None:\n+        \"\"\"Release the adapter-level guard for a session.\n+\n+        When ``guard`` is provided, only release the entry if it still points\n+        at that exact Event.  This lets reset-like commands swap in a temporary\n+        guard while the old processing task unwinds, without having the old\n+        task's cleanup accidentally clear the replacement guard.\n+        \"\"\"\n+        current_guard = self._active_sessions.get(session_key)\n+        if current_guard is None:\n+            return\n+        if guard is not None and current_guard is not guard:\n+            return\n+        del self._active_sessions[session_key]\n+\n+    def _session_task_is_stale(self, session_key: str) -> bool:\n+        \"\"\"Return True if the owner task for ``session_key`` is done/cancelled.\n+\n+        A lock is \"stale\" when the adapter still has ``_active_sessions[key]``\n+        AND a known owner task in ``_session_tasks`` that has already exited.\n+        When there is no owner task at all, that usually means the guard was\n+        installed by some path other than handle_message() (tests sometimes\n+        install guards directly) — don't treat that as stale.  The on-entry\n+        self-heal only needs to handle the production split-brain case where\n+        an owner task was recorded, then exited without clearing its guard.\n+        \"\"\"\n+        task = self._session_tasks.get(session_key)\n+        if task is None:\n+            return False\n+        done = getattr(task, \"done\", None)\n+        return bool(done and done())\n+\n+    def _heal_stale_session_lock(self, session_key: str) -> bool:\n+        \"\"\"Clear a stale session lock if the owner task is already gone.\n+\n+        Returns True if a stale lock was healed.  Returns False if there is\n+        no lock, or the owner task is still alive (the normal busy case).\n+\n+        This is the on-entry safety net sidbin's issue #11016 analysis calls\n+        for: without it, a split-brain — adapter still thinks the session is\n+        active, but nothing is actually processing — traps the chat in\n+        infinite \"Interrupting current task...\" until the gateway is\n+        restarted.\n+        \"\"\"\n+        if session_key not in self._active_sessions:\n+            return False\n+        if not self._session_task_is_stale(session_key):\n+            return False\n+        logger.warning(\n+            \"[%s] Healing stale session lock for %s (owner task is done/absent)\",\n+            self.name,\n+            session_key,\n+        )\n+        self._active_sessions.pop(session_key, None)\n+        self._pending_messages.pop(session_key, None)\n+        self._session_tasks.pop(session_key, None)\n+        return True\n+\n+    def _start_session_processing(\n+        self,\n+        event: MessageEvent,\n+        session_key: str,\n+        *,\n+        interrupt_event: Optional[asyncio.Event] = None,\n+    ) -> bool:\n+        \"\"\"Spawn a background processing task under the given session guard.\n+\n+        Returns True on success.  If the runtime stubs ``create_task`` with a\n+        non-Task sentinel (some tests do this), the guard is rolled back and\n+        False is returned so the caller isn't left holding a half-installed\n+        session lock.\n+        \"\"\"\n+        guard = interrupt_event or asyncio.Event()\n+        self._active_sessions[session_key] = guard\n+\n+        task = asyncio.create_task(self._process_message_background(event, session_key))\n+        self._session_tasks[session_key] = task\n+        try:\n+            self._background_tasks.add(task)\n+        except TypeError:\n+            # Tests stub create_task() with lightweight sentinels that are not\n+            # hashable and do not support lifecycle callbacks.\n+            self._session_tasks.pop(session_key, None)\n+            self._release_session_guard(session_key, guard=guard)\n+            return False\n+        if hasattr(task, \"add_done_callback\"):\n+            task.add_done_callback(self._background_tasks.discard)\n+            task.add_done_callback(self._expected_cancelled_tasks.discard)\n+        return True\n+\n+    async def cancel_session_processing(\n+        self,\n+        session_key: str,\n+        *,\n+        release_guard: bool = True,\n+        discard_pending: bool = True,\n+    ) -> None:\n+        \"\"\"Cancel in-flight processing for a single session.\n+\n+        ``release_guard=False`` keeps the adapter-level session guard in place\n+        so reset-like commands can finish atomically before follow-up messages\n+        are allowed to start a fresh background task.\n+        \"\"\"\n+        task = self._session_tasks.pop(session_key, None)\n+        if task is not None and not task.done():\n+            logger.debug(\n+                \"[%s] Cancelling active processing for session %s\",\n+                self.name,\n+                session_key,\n+            )\n+            self._expected_cancelled_tasks.add(task)\n+            task.cancel()\n+            try:\n+                await task\n+            except asyncio.CancelledError:\n+                pass\n+            except Exception:\n+                logger.debug(\n+                    \"[%s] Session cancellation raised while unwinding %s\",\n+                    self.name,\n+                    session_key,\n+                    exc_info=True,\n+                )\n+        if discard_pending:\n+            self._pending_messages.pop(session_key, None)\n+        if release_guard:\n+            self._release_session_guard(session_key)\n+\n+    async def _drain_pending_after_session_command(\n+        self,\n+        session_key: str,\n+        command_guard: asyncio.Event,\n+    ) -> None:\n+        \"\"\"Resume the latest queued follow-up once a session command completes.\n+\n+        Called at the tail of /stop, /new, and /reset dispatch.  Releases the\n+        command-scoped guard, then — if a follow-up message landed while the\n+        command was running — spawns a fresh processing task for it.\n+        \"\"\"\n+        pending_event = self._pending_messages.pop(session_key, None)\n+        self._release_session_guard(session_key, guard=command_guard)\n+        if pending_event is None:\n+            return\n+        self._start_session_processing(pending_event, session_key)\n+\n+    async def _dispatch_active_session_command(\n+        self,\n+        event: MessageEvent,\n+        session_key: str,\n+        cmd: str,\n+    ) -> None:\n+        \"\"\"Dispatch a reset-like bypass command while preserving guard ordering.\n+\n+        /stop, /new, and /reset must:\n+          1. Keep the session guard installed while the runner processes the\n+             command (so a racing follow-up message stays queued, not\n+             dispatched as a second parallel run).\n+          2. Cancel the old in-flight adapter task only AFTER the runner has\n+             finished handling the command (so the runner sees consistent\n+             state and its response is sent in order).\n+          3. Release the command-scoped guard and drain the latest queued\n+             follow-up exactly once, after 1 and 2 complete.\n+        \"\"\"\n+        logger.debug(\n+            \"[%s] Command '/%s' bypassing active-session guard for %s\",\n+            self.name,\n+            cmd,\n+            session_key,\n+        )\n+\n+        current_guard = self._active_sessions.get(session_key)\n+        command_guard = asyncio.Event()\n+        self._active_sessions[session_key] = command_guard\n+        thread_meta = {\"thread_id\": event.source.thread_id} if event.source.thread_id else None\n+\n+        try:\n+            response = await self._message_handler(event)\n+            # Old adapter task (if any) is cancelled AFTER the runner has\n+            # fully handled the command — keeps ordering deterministic.\n+            await self.cancel_session_processing(\n+                session_key,\n+                release_guard=False,\n+                discard_pending=False,\n+            )\n+            if response:\n+                await self._send_with_retry(\n+                    chat_id=event.source.chat_id,\n+                    content=response,\n+                    reply_to=event.message_id,\n+                    metadata=thread_meta,\n+                )\n+        except Exception:\n+            # On failure, restore the original guard if one still exists so\n+            # we don't leave the session in a half-reset state.\n+            if self._active_sessions.get(session_key) is command_guard:\n+                if session_key in self._session_tasks and current_guard is not None:\n+                    self._active_sessions[session_key] = current_guard\n+                else:\n+                    self._release_session_guard(session_key, guard=command_guard)\n+            raise\n+\n+        await self._drain_pending_after_session_command(session_key, command_guard)\n+\n     async def handle_message(self, event: MessageEvent) -> None:\n         \"\"\"\n         Process an incoming message.","@@ -1696,7 +1918,15 @@ async def handle_message(self, event: MessageEvent) -> None:\n             group_sessions_per_user=self.config.extra.get(\"group_sessions_per_user\", True),\n             thread_sessions_per_user=self.config.extra.get(\"thread_sessions_per_user\", False),\n         )\n-        \n+\n+        # On-entry self-heal: if the adapter still has an _active_sessions\n+        # entry for this key but the owner task has already exited (done or\n+        # cancelled), the lock is stale.  Clear it and fall through to\n+        # normal dispatch so the user isn't trapped behind a dead guard —\n+        # this is the split-brain tail described in issue #11016.\n+        if session_key in self._active_sessions:\n+            self._heal_stale_session_lock(session_key)\n+\n         # Check if there's already an active handler for this session\n         if session_key in self._active_sessions:\n             # Certain commands must bypass the active-session guard and be","@@ -1713,6 +1943,23 @@ async def handle_message(self, event: MessageEvent) -> None:\n             from hermes_cli.commands import should_bypass_active_session\n \n             if should_bypass_active_session(cmd):\n+                # /stop, /new, /reset must cancel the in-flight adapter task\n+                # and preserve ordering of queued follow-ups.  Route those\n+                # through the dedicated handoff path that serializes\n+                # cancellation + runner response + pending drain.\n+                if cmd in (\"stop\", \"new\", \"reset\"):\n+                    try:\n+                        await self._dispatch_active_session_command(event, session_key, cmd)\n+                    except Exception as e:\n+                        logger.error(\n+                            \"[%s] Command '/%s' dispatch failed: %s\",\n+                            self.name, cmd, e, exc_info=True,\n+                        )\n+                    return\n+\n+                # Other bypass commands (/approve, /deny, /status,\n+                # /background, /restart) just need direct dispatch — they\n+                # don't cancel the running task.\n                 logger.debug(\n                     \"[%s] Command '/%s' bypassing active-session guard for %s\",\n                     self.name, cmd, session_key,","@@ -1758,19 +2005,9 @@ async def handle_message(self, event: MessageEvent) -> None:\n         # starts would also pass the _active_sessions check and spawn a\n         # duplicate task.  (grammY sequentialize / aiogram EventIsolation\n         # pattern — set the guard synchronously, not inside the task.)\n-        self._active_sessions[session_key] = asyncio.Event()\n-\n-        # Spawn background task to process this message\n-        task = asyncio.create_task(self._process_message_background(event, session_key))\n-        try:\n-            self._background_tasks.add(task)\n-        except TypeError:\n-            # Some tests stub create_task() with lightweight sentinels that are not\n-            # hashable and do not support lifecycle callbacks.\n-            return\n-        if hasattr(task, \"add_done_callback\"):\n-            task.add_done_callback(self._background_tasks.discard)\n-            task.add_done_callback(self._expected_cancelled_tasks.discard)\n+        # _start_session_processing installs the guard AND the owner-task\n+        # mapping atomically so stale-lock detection works.\n+        self._start_session_processing(event, session_key)\n     \n     @staticmethod\n     def _get_human_delay() -> float:","@@ -2130,6 +2367,9 @@ def _record_delivery(result):\n                 drain_task = asyncio.create_task(\n                     self._process_message_background(late_pending, session_key)\n                 )\n+                # Hand ownership of the session to the drain task so stale-lock\n+                # detection keeps working while it runs.\n+                self._session_tasks[session_key] = drain_task\n                 try:\n                     self._background_tasks.add(drain_task)\n                     drain_task.add_done_callback(self._background_tasks.discard)","@@ -2139,9 +2379,14 @@ def _record_delivery(result):\n                 # Leave _active_sessions[session_key] populated — the drain\n                 # task's own lifecycle will clean it up.\n             else:\n-                # Clean up session tracking\n-                if session_key in self._active_sessions:\n-                    del self._active_sessions[session_key]\n+                # Clean up session tracking.  Guard-match both deletes so a\n+                # reset-like command that already swapped in its own\n+                # command_guard (and cancelled us) can't be accidentally\n+                # cleared by our unwind.  The command owns the session now.\n+                current_task = asyncio.current_task()\n+                if current_task is not None and self._session_tasks.get(session_key) is current_task:\n+                    del self._session_tasks[session_key]\n+                self._release_session_guard(session_key, guard=interrupt_event)\n     \n     async def cancel_background_tasks(self) -> None:\n         \"\"\"Cancel any in-flight background message-processing tasks.","@@ -2171,6 +2416,7 @@ async def cancel_background_tasks(self) -> None:\n             # will be in self._background_tasks now.  Re-check.\n         self._background_tasks.clear()\n         self._expected_cancelled_tasks.clear()\n+        self._session_tasks.clear()\n         self._pending_messages.clear()\n         self._active_sessions.clear()\n "]},{"file":"gateway/run.py","hunks":["@@ -8665,7 +8665,12 @@ def _is_intentional_model_switch(self, session_key: str, agent_model: str) -> bo\n         override = self._session_model_overrides.get(session_key)\n         return override is not None and override.get(\"model\") == agent_model\n \n-    def _release_running_agent_state(self, session_key: str) -> None:\n+    def _release_running_agent_state(\n+        self,\n+        session_key: str,\n+        *,\n+        run_generation: Optional[int] = None,\n+    ) -> bool:\n         \"\"\"Pop ALL per-running-agent state entries for ``session_key``.\n \n         Replaces ad-hoc ``del self._running_agents[key]`` calls scattered","@@ -8681,13 +8686,25 @@ def _release_running_agent_state(self, session_key: str) -> None:\n         across turns (``_session_model_overrides``, ``_voice_mode``,\n         ``_pending_approvals``, ``_update_prompt_pending``) is NOT\n         touched here — those have their own lifecycles.\n+\n+        When ``run_generation`` is provided, only clear the slot if that\n+        generation is still current for the session.  This prevents an\n+        older async run whose generation was bumped by /stop or /new from\n+        clobbering a newer run's state during its own unwind.  Returns\n+        True when the slot was cleared, False when an ownership guard\n+        blocked it.\n         \"\"\"\n         if not session_key:\n-            return\n+            return False\n+        if run_generation is not None and not self._is_session_run_current(\n+            session_key, run_generation\n+        ):\n+            return False\n         self._running_agents.pop(session_key, None)\n         self._running_agents_ts.pop(session_key, None)\n         if hasattr(self, \"_busy_ack_ts\"):\n             self._busy_ack_ts.pop(session_key, None)\n+        return True\n \n     def _clear_session_boundary_security_state(self, session_key: str) -> None:\n         \"\"\"Clear approval state that must not survive a real conversation switch.\"\"\"","@@ -10249,10 +10266,24 @@ async def track_agent():\n             # Wait for agent to be created\n             while agent_holder[0] is None:\n                 await asyncio.sleep(0.05)\n-            if session_key:\n-                self._running_agents[session_key] = agent_holder[0]\n-                if self._draining:\n-                    self._update_runtime_status(\"draining\")\n+            if not session_key:\n+                return\n+            # Only promote the sentinel to the real agent if this run is still\n+            # current.  If /stop or /new bumped the generation while we were\n+            # spinning up, leave the newer run's slot alone — we'll be\n+            # discarded by the stale-result check in _handle_message_with_agent.\n+            if run_generation is not None and not self._is_session_run_current(\n+                session_key, run_generation\n+            ):\n+                logger.info(\n+                    \"Skipping stale agent promotion for %s — generation %s is no longer current\",\n+                    (session_key or \"\")[:20],\n+                    run_generation,\n+                )\n+                return\n+            self._running_agents[session_key] = agent_holder[0]\n+            if self._draining:\n+                self._update_runtime_status(\"draining\")\n         \n         tracking_task = asyncio.create_task(track_agent())\n         ","@@ -10758,7 +10789,14 @@ async def _notify_long_running():\n             # Clean up tracking\n             tracking_task.cancel()\n             if session_key:\n-                self._release_running_agent_state(session_key)\n+                # Only release the slot if this run's generation still owns\n+                # it.  A /stop or /new that bumped the generation while we\n+                # were unwinding has already installed its own state; this\n+                # guard prevents an old run from clobbering it on the way\n+                # out.\n+                self._release_running_agent_state(\n+                    session_key, run_generation=run_generation\n+                )\n             if self._draining:\n                 self._update_runtime_status(\"draining\")\n             "]},{"file":"scripts/release.py","hunks":["@@ -111,6 +111,8 @@\n     \"[redacted:email]\": \"rnijhara\",\n     # contributors (manual mapping from git names)\n     \"[redacted:email]\": \"asheriif\",\n+    \"[redacted:email]\": \"dyxushuai\",\n+    \"[redacted:email]\": \"etcircle\",\n     \"[redacted:email]\": \"liujinkun2025\",\n     \"[redacted:email]\": \"dmahan93\",\n     \"[redacted:email]\": \"ifrederico\","]},{"file":"tests/gateway/test_session_split_brain_11016.py","hunks":["@@ -0,0 +1,399 @@\n+\"\"\"Regression tests for issue #11016 — Telegram sessions trapped in\n+repeated 'Interrupting current task...' while /stop reports no active task.\n+\n+Covers three layers of the fix:\n+\n+1. Adapter-side task ownership (_session_tasks map): /stop, /new, /reset\n+   actually cancel the in-flight adapter task and release the guard in\n+   order, so follow-up messages reach the new session.\n+\n+2. Adapter-side on-entry self-heal: if _active_sessions still has an\n+   entry but the recorded owner task is already done/cancelled, clear it\n+   on the next inbound message rather than trapping the user.\n+\n+3. Runner-side generation guard: a stale async run can't promote itself\n+   into _running_agents after /stop/ /new bumped the generation, and\n+   can't clear a newer run's slot on the way out.\n+\"\"\"\n+\n+import asyncio\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import GatewayConfig, Platform, PlatformConfig\n+from gateway.platforms.base import (\n+    BasePlatformAdapter,\n+    MessageEvent,\n+    MessageType,\n+)\n+from gateway.run import GatewayRunner, _AGENT_PENDING_SENTINEL\n+from gateway.session import SessionSource, build_session_key\n+\n+\n+# ---------------------------------------------------------------------------\n+# Adapter helpers\n+# ---------------------------------------------------------------------------\n+\n+\n+class _StubAdapter(BasePlatformAdapter):\n+    async def connect(self):\n+        pass\n+\n+    async def disconnect(self):\n+        pass\n+\n+    async def send(self, chat_id, text, **kwargs):\n+        pass\n+\n+    async def get_chat_info(self, chat_id):\n+        return {}\n+\n+\n+def _make_adapter():\n+    config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+    adapter = _StubAdapter(config, Platform.TELEGRAM)\n+    adapter.sent_responses = []\n+\n+    async def _mock_send_retry(chat_id, content, **kwargs):\n+        adapter.sent_responses.append(content)\n+\n+    adapter._send_with_retry = _mock_send_retry\n+    return adapter\n+\n+\n+def _make_event(text=\"hello\", chat_id=\"12345\"):\n+    source = SessionSource(\n+        platform=Platform.TELEGRAM, chat_id=chat_id, chat_type=\"dm\"\n+    )\n+    return MessageEvent(text=text, message_type=MessageType.TEXT, source=source)\n+\n+\n+def _session_key(chat_id=\"12345\"):\n+    source = SessionSource(\n+        platform=Platform.TELEGRAM, chat_id=chat_id, chat_type=\"dm\"\n+    )\n+    return build_session_key(source)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Runner helpers\n+# ---------------------------------------------------------------------------\n+\n+\n+def _make_runner():\n+    runner = object.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(\n+        platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token=\"***\")}\n+    )\n+    runner.adapters = {}\n+    runner._running_agents = {}\n+    runner._running_agents_ts = {}\n+    runner._session_run_generation = {}\n+    runner._pending_messages = {}\n+    runner._draining = False\n+    runner._update_runtime_status = MagicMock()\n+    return runner\n+\n+\n+# ===========================================================================\n+# Layer 1: Adapter-side session cancellation on /stop /new /reset\n+# ===========================================================================\n+\n+\n+class TestAdapterSessionCancellation:\n+    @pytest.mark.asyncio\n+    @pytest.mark.parametrize(\"command_text\", [\"/stop\", \"/new\", \"/reset\"])\n+    async def test_command_cancels_active_task_and_unblocks_follow_up(\n+        self, command_text\n+    ):\n+        \"\"\"/stop /new /reset must cancel the adapter task and let follow-ups through.\"\"\"\n+        adapter = _make_adapter()\n+        sk = _session_key()\n+        processing_started = asyncio.Event()\n+        processing_cancelled = asyncio.Event()\n+        blocked_first_message = True\n+\n+        async def _handler(event):\n+            nonlocal blocked_first_message\n+            cmd = event.get_command()\n+            if cmd in {\"stop\", \"new\", \"reset\", \"model\"}:\n+                return f\"handled:{cmd}\"\n+\n+            if blocked_first_message:\n+                blocked_first_message = False\n+                processing_started.set()\n+                try:\n+                    await asyncio.Event().wait()\n+                except asyncio.CancelledError:\n+                    processing_cancelled.set()\n+                    raise\n+            return f\"handled:text:{event.text}\"\n+\n+        adapter._message_handler = _handler\n+\n+        await adapter.handle_message(_make_event(\"hello world\"))\n+        await processing_started.wait()\n+        await asyncio.sleep(0)\n+\n+        assert sk in adapter._active_sessions\n+        assert sk in adapter._session_tasks\n+\n+        await adapter.handle_message(_make_event(command_text))\n+\n+        assert processing_cancelled.is_set(), (\n+            f\"{command_text} did not cancel the active processing task\"\n+        )\n+        assert sk not in adapter._active_sessions\n+        assert sk not in adapter._pending_messages\n+        assert sk not in adapter._session_tasks\n+        expected = command_text.lstrip(\"/\")\n+        assert any(f\"handled:{expected}\" in r for r in adapter.sent_responses)\n+\n+        # Follow-up must go through normally now that the session is clean.\n+        await adapter.handle_message(\n+            _make_event(\"/model xiaomi/mimo-v2-pro --provider nous\")\n+        )\n+        await asyncio.sleep(0)\n+        await asyncio.sleep(0)\n+\n+        assert any(\"handled:model\" in r for r in adapter.sent_responses), (\n+            f\"follow-up /model stayed blocked after {command_text}\"\n+        )\n+        assert sk not in adapter._pending_messages\n+\n+    @pytest.mark.asyncio\n+    async def test_new_keeps_guard_until_command_finishes_then_runs_follow_up(self):\n+        \"\"\"/new must finish runner logic before cancelling old work or releasing the guard.\"\"\"\n+        adapter = _make_adapter()\n+        sk = _session_key()\n+        processing_started = asyncio.Event()\n+        command_started = asyncio.Event()\n+        allow_command_finish = asyncio.Event()\n+        follow_up_processed = asyncio.Event()\n+        call_order = []\n+\n+        async def _handler(event):\n+            cmd = event.get_command()\n+            if cmd == \"new\":\n+                call_order.append(\"command:start\")\n+                command_started.set()\n+                await allow_command_finish.wait()\n+                call_order.append(\"command:end\")\n+                return \"handled:new\"\n+\n+            if event.text == \"hello world\":\n+                processing_started.set()\n+                try:\n+                    await asyncio.Event().wait()\n+                except asyncio.CancelledError:\n+                    call_order.append(\"original:cancelled\")\n+                    raise\n+\n+            if event.text == \"after reset\":\n+                call_order.append(\"followup:processed\")\n+                follow_up_processed.set()\n+            return f\"handled:text:{event.text}\"\n+\n+        adapter._message_handler = _handler\n+\n+        await adapter.handle_message(_make_event(\"hello world\"))\n+        await processing_started.wait()\n+\n+        command_task = asyncio.create_task(adapter.handle_message(_make_event(\"/new\")))\n+        await command_started.wait()\n+        await asyncio.sleep(0)\n+\n+        assert sk in adapter._active_sessions\n+\n+        await adapter.handle_message(_make_event(\"after reset\"))\n+        await asyncio.sleep(0)\n+        await asyncio.sleep(0)\n+\n+        assert sk in adapter._active_sessions, \"guard must stay active while /new is still running\"\n+        assert sk in adapter._pending_messages, \"follow-up should stay queued until /new finishes\"\n+        assert not follow_up_processed.is_set(), \"follow-up ran before /new completed\"\n+        assert \"original:cancelled\" not in call_order, \"old task was cancelled before runner completed /new\"\n+\n+        allow_command_finish.set()\n+        await command_task\n+        await asyncio.wait_for(follow_up_processed.wait(), timeout=1.0)\n+\n+        assert any(\"handled:new\" in r for r in adapter.sent_responses)\n+        assert call_order.index(\"command:end\") < call_order.index(\"original:cancelled\")\n+        assert call_order.index(\"original:cancelled\") < call_order.index(\"followup:processed\")\n+        assert sk not in adapter._pending_messages\n+\n+\n+# ===========================================================================\n+# Layer 2: Adapter-side on-entry self-heal for stale session locks\n+# ===========================================================================\n+\n+\n+class TestStaleSessionLockSelfHeal:\n+    @pytest.mark.asyncio\n+    async def test_stale_lock_with_done_task_is_healed_on_next_message(self):\n+        \"\"\"A split-brain guard (owner task done but entry still live) heals on next inbound.\"\"\"\n+        adapter = _make_adapter()\n+        sk = _session_key()\n+\n+        # Simulate the production split-brain: an _active_sessions entry\n+        # remains AND a recorded owner task, but that task is already done.\n+        async def _done():\n+            return None\n+\n+        done_task = asyncio.create_task(_done())\n+        await done_task\n+        assert done_task.done()\n+\n+        adapter._active_sessions[sk] = asyncio.Event()\n+        adapter._session_tasks[sk] = done_task\n+\n+        assert adapter._session_task_is_stale(sk)\n+\n+        async def _handler(event):\n+            return f\"handled:{event.get_command() or 'text'}\"\n+\n+        adapter._message_handler = _handler\n+\n+        # An ordinary message should heal the stale lock, then fall through\n+        # to normal dispatch.  User gets a reply instead of a busy ack.\n+        await adapter.handle_message(_make_event(\"hello\"))\n+        # Drain any spawned background tasks.\n+        for _ in range(5):\n+            await asyncio.sleep(0)\n+\n+        assert any(\"handled:text\" in r for r in adapter.sent_responses), (\n+            \"stale lock trapped a normal message — split-brain not healed\"\n+        )\n+\n+    def test_no_owner_task_is_not_treated_as_stale(self):\n+        \"\"\"If _session_tasks has no entry at all, the guard isn't stale.\n+\n+        Tests and rare legitimate code paths install _active_sessions\n+        entries directly.  Auto-healing those would break real fixtures.\n+        \"\"\"\n+        adapter = _make_adapter()\n+        sk = _session_key()\n+\n+        adapter._active_sessions[sk] = asyncio.Event()\n+        # No _session_tasks entry.\n+\n+        assert adapter._session_task_is_stale(sk) is False\n+        assert adapter._heal_stale_session_lock(sk) is False\n+\n+    def test_live_owner_task_is_not_stale(self):\n+        \"\"\"When the owner task is alive, do NOT heal — agent is really busy.\"\"\"\n+        adapter = _make_adapter()\n+        sk = _session_key()\n+\n+        fake_task = MagicMock()\n+        fake_task.done.return_value = False\n+        adapter._active_sessions[sk] = asyncio.Event()\n+        adapter._session_tasks[sk] = fake_task\n+\n+        assert adapter._session_task_is_stale(sk) is False\n+        assert adapter._heal_stale_session_lock(sk) is False\n+        # Lock still in place.\n+        assert sk in adapter._active_sessions\n+        assert sk in adapter._session_tasks\n+\n+\n+# ===========================================================================\n+# Layer 3: Runner-side generation guard on slot promotion + release\n+# ===========================================================================\n+\n+\n+class TestRunnerSessionGenerationGuard:\n+    def test_release_without_generation_behaves_as_before(self):\n+        runner = _make_runner()\n+        sk = \"agent:main:telegram:dm:12345\"\n+        runner._running_agents[sk] = \"agent\"\n+        runner._running_agents_ts[sk] = 1.0\n+        assert runner._release_running_agent_state(sk) is True\n+        assert sk not in runner._running_agents\n+        assert sk not in runner._running_agents_ts\n+\n+    def test_release_with_current_generation_clears_slot(self):\n+        runner = _make_runner()\n+        sk = \"agent:main:telegram:dm:12345\"\n+        gen = runner._begin_session_run_generation(sk)\n+        runner._running_agents[sk] = \"agent\"\n+        runner._running_agents_ts[sk] = 1.0\n+\n+        assert runner._release_running_agent_state(sk, run_generation=gen) is True\n+        assert sk not in runner._running_agents\n+\n+    def test_release_with_stale_generation_blocks(self):\n+        runner = _make_runner()\n+        sk = \"agent:main:telegram:dm:12345\"\n+        stale_gen = runner._begin_session_run_generation(sk)\n+        # /stop bumps the generation — stale run's generation is no longer current.\n+        runner._invalidate_session_run_generation(sk, reason=\"stop\")\n+        # The fresh run lands next; imagine it has its own state installed.\n+        runner._running_agents[sk] = \"fresh_agent\"\n+        runner._running_agents_ts[sk] = 2.0\n+\n+        # Stale run's unwind MUST NOT clobber the fresh run's state.\n+        released = runner._release_running_agent_state(sk, run_generation=stale_gen)\n+\n+        assert released is False\n+        assert runner._running_agents[sk] == \"fresh_agent\"\n+        assert runner._running_agents_ts[sk] == 2.0\n+\n+    def test_is_session_run_current_tracks_bumps(self):\n+        runner = _make_runner()\n+        sk = \"agent:main:telegram:dm:12345\"\n+        gen1 = runner._begin_session_run_generation(sk)\n+        assert runner._is_session_run_current(sk, gen1) is True\n+\n+        runner._invalidate_session_run_generation(sk, reason=\"test\")\n+        assert runner._is_session_run_current(sk, gen1) is False\n+\n+        gen2 = runner._begin_session_run_generation(sk)\n+        assert gen2 > gen1\n+        assert runner._is_session_run_current(sk, gen2) is True\n+\n+\n+# ===========================================================================\n+# Layer 1 (regression): old task's finally must NOT delete a newer guard\n+# ===========================================================================\n+\n+\n+class TestOldTaskCannotClobberNewerGuard:\n+    \"\"\"Direct regression for the unconditional-delete bug.\n+\n+    Before the guard-match fix, a task in its finally would delete\n+    ``_active_sessions[session_key]`` unconditionally — even if a\n+    /stop/ /new command had already swapped in its own command_guard\n+    (which then gets clobbered, opening a race for follow-up messages).\n+    \"\"\"\n+\n+    def test_release_session_guard_matches_on_event_identity(self):\n+        adapter = _make_adapter()\n+        sk = _session_key()\n+\n+        old_guard = asyncio.Event()\n+        new_guard = asyncio.Event()\n+        # Command swapped in a newer guard.\n+        adapter._active_sessions[sk] = new_guard\n+\n+        # Old task tries to release using its captured (stale) guard.\n+        adapter._release_session_guard(sk, guard=old_guard)\n+\n+        # The newer guard survives.\n+        assert adapter._active_sessions.get(sk) is new_guard\n+\n+        # Now the command itself releases using the matching guard.\n+        adapter._release_session_guard(sk, guard=new_guard)\n+        assert sk not in adapter._active_sessions\n+\n+    def test_release_session_guard_without_guard_releases_unconditionally(self):\n+        adapter = _make_adapter()\n+        sk = _session_key()\n+        adapter._active_sessions[sk] = asyncio.Event()\n+        # Callers that don't know the guard (e.g. cancel_session_processing's\n+        # default path) still work.\n+        adapter._release_session_guard(sk)\n+        assert sk not in adapter._active_sessions\n+\n"]}]},{"kind":"pr","number":34279,"title":"fix(gateway): clear orphaned session guard on cancel timeout (#34253)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11016","number":11016},{"kind":"pr","value":"#48300","number":48300}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":48317,"title":"fix(gateway): detect stale session lock when task cleaned up without guard release","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51553","number":51553},{"kind":"pr","value":"#48315","number":48315},{"kind":"pr","value":"#51553","number":51553},{"kind":"pr","value":"#48300","number":48300}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":48347,"title":"fix(gateway): prevent stale session lock from orphaned guard swap (#48300)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51553","number":51553},{"kind":"pr","value":"#48315","number":48315},{"kind":"pr","value":"#51553","number":51553},{"kind":"pr","value":"#48300","number":48300}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":48382,"title":"fix(gateway): keep done task in _session_tasks when guard release fails","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#48315","number":48315},{"kind":"pr","value":"#51553","number":51553},{"kind":"pr","value":"#48315","number":48315},{"kind":"pr","value":"#51553","number":51553},{"kind":"pr","value":"#48300","number":48300}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":51553,"title":"fix(gateway): preserve _session_tasks on guard mismatch to heal stale session lock (#48300)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","tests/gateway/test_session_split_brain_11016.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -4976,8 +4976,27 @@ async def _stop_typing_task() -> None:\n                 # same session.\n                 current_task = asyncio.current_task()\n                 if current_task is not None and self._session_tasks.get(session_key) is current_task:\n-                    del self._session_tasks[session_key]\n-                    self._release_session_guard(session_key, guard=interrupt_event)\n+                    self._cleanup_finished_session_task(session_key, interrupt_event)\n+    \n+    def _cleanup_finished_session_task(\n+        self, session_key: str, interrupt_event: Optional[asyncio.Event]\n+    ) -> None:\n+        \"\"\"Release the session guard for a finished owner task, then drop its\n+        ``_session_tasks`` entry ONLY if the guard was actually released.\n+\n+        Release-then-conditional-delete is the #48300 fix: when a concurrent\n+        path (reset/new command, drain handoff) swapped ``_active_sessions[key]``\n+        to a different guard, ``_release_session_guard`` skips on the guard\n+        mismatch and the lock stays installed. If we deleted ``_session_tasks``\n+        unconditionally (the old order), ``_session_task_is_stale`` would later\n+        see no owner task and report \"not stale\", so the orphaned guard would\n+        never be healed — a permanent session deadlock. Keeping the done-task\n+        entry when the guard survives lets the on-entry self-heal detect the\n+        stale lock and clear it on the next inbound message.\n+        \"\"\"\n+        self._release_session_guard(session_key, guard=interrupt_event)\n+        if session_key not in self._active_sessions:\n+            self._session_tasks.pop(session_key, None)\n     \n     async def cancel_background_tasks(self) -> None:\n         \"\"\"Cancel any in-flight background message-processing tasks."]},{"file":"tests/gateway/test_session_split_brain_11016.py","hunks":["@@ -299,6 +299,78 @@ def test_live_owner_task_is_not_stale(self):\n         assert sk in adapter._active_sessions\n         assert sk in adapter._session_tasks\n \n+    @pytest.mark.asyncio\n+    async def test_guard_mismatch_preserves_session_task_for_stale_detection(self):\n+        \"\"\"When guard mismatch skips _release_session_guard, _session_tasks is preserved.\n+\n+        This is the core of the production split-brain fix: the finally block\n+        only deletes _session_tasks[key] if _active_sessions[key] was actually\n+        released. If the guard was swapped (e.g., by a reset command), the\n+        _session_tasks entry remains so _session_task_is_stale can detect the\n+        done task and heal the lock on the next inbound message.\n+        \"\"\"\n+        adapter = _make_adapter()\n+        sk = _session_key()\n+\n+        # Simulate: task recorded with guard=event_a\n+        event_a = asyncio.Event()\n+        async def _done():\n+            return None\n+\n+        done_task = asyncio.create_task(_done())\n+        await done_task\n+\n+        adapter._active_sessions[sk] = event_a\n+        adapter._session_tasks[sk] = done_task\n+\n+        # Simulate guard swap (as reset/new command would do)\n+        event_b = asyncio.Event()\n+        adapter._active_sessions[sk] = event_b\n+\n+        # Drive the REAL finally-block cleanup helper (not a copy of its logic):\n+        # _release_session_guard sees event_b != event_a → skips releasing, so\n+        # _session_tasks must be preserved for stale detection.\n+        adapter._cleanup_finished_session_task(sk, event_a)\n+\n+        # _session_tasks preserved because guard mismatch kept _active_sessions\n+        assert sk in adapter._session_tasks, (\n+            \"_session_tasks entry must survive guard mismatch so stale detection works\"\n+        )\n+        assert adapter._session_tasks[sk] is done_task\n+\n+        # Stale detection now works: task is done, guard is stale\n+        assert adapter._session_task_is_stale(sk) is True\n+\n+        # Heal clears both\n+        assert adapter._heal_stale_session_lock(sk) is True\n+        assert sk not in adapter._active_sessions\n+        assert sk not in adapter._session_tasks\n+\n+    @pytest.mark.asyncio\n+    async def test_cleanup_releases_and_deletes_when_guard_matches(self):\n+        \"\"\"Positive path for #48300: when the guard still matches (normal\n+        completion), the helper releases the guard AND drops the task entry —\n+        the release-then-conditional-delete must not strand a healthy session.\"\"\"\n+        adapter = _make_adapter()\n+        sk = _session_key()\n+\n+        event_a = asyncio.Event()\n+\n+        async def _done():\n+            return None\n+\n+        done_task = asyncio.create_task(_done())\n+        await done_task\n+\n+        adapter._active_sessions[sk] = event_a\n+        adapter._session_tasks[sk] = done_task\n+\n+        # No guard swap → _release_session_guard matches event_a and releases.\n+        adapter._cleanup_finished_session_task(sk, event_a)\n+\n+        assert sk not in adapter._active_sessions, \"guard must be released on match\"\n+        assert sk not in adapter._session_tasks, \"task entry must be dropped after release\"\n+\n \n # ===========================================================================\n # Layer 3: Runner-side generation guard on slot promotion + release\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}