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