{"repo":"NousResearch/hermes-agent","public_id":"5225","display_label":"Gateway session resumption and Slack thread context fixes","summary":"These items cover two adjacent themes: resuming interrupted gateway/desktop sessions after restarts, and fixing Slack thread context handling (missing parent messages, stale sessions, attachment fallbacks, and mid-thread bot mentions). Discord thread history fetching and approval buttons for Slack/Telegram are also included.","subsystem":"agent","counts":{"members":7,"open_issues":1,"open_issues_distinct":1,"open_prs":0,"closed_issues":4,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":12301,"merged_at":"2026-04-19T00:32:17+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-19T00:32:17+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.18095238095238092},"fix_edges":[{"issue_number":5225,"pr_number":12301,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-19T00:32:17+00:00"}],"members":[{"kind":"issue","number":5225,"title":"Resume interrupted gateway sessions after graceful restart","url":null,"state":"closed"},{"kind":"issue","number":7181,"title":"Gateway should persist task state and resume after self-restart","url":null,"state":"closed"},{"kind":"issue","number":7304,"title":"Gateway restart should resume the originating Slack thread conversation","url":null,"state":"closed"},{"kind":"issue","number":9673,"title":"Feature request: resume interrupted tasks on restart","url":null,"state":"open"},{"kind":"issue","number":62455,"title":"[Feature]: Auto-resume Desktop/TUI WebSocket-origin sessions after gateway restart","url":null,"state":"closed"},{"kind":"pr","number":5226,"title":"feat(gateway): resume interrupted sessions after restart","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"cb4addaca","number":0},{"kind":"pr","value":"#11852","number":11852},{"kind":"pr","value":"#12301","number":12301},{"kind":"pr","value":"#5284","number":5284},{"kind":"pr","value":"#5287","number":5287},{"kind":"pr","value":"#5321","number":5321},{"kind":"pr","value":"#5329","number":5329}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":12301,"title":"fix(gateway): auto-resume sessions after drain-timeout restart (#11852)","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","tests/gateway/test_restart_resume_pending.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -1539,7 +1539,7 @@ async def _notify_active_sessions_of_shutdown(self) -> None:\n         action = \"restarting\" if self._restart_requested else \"shutting down\"\n         hint = (\n             \"Your current task will be interrupted. \"\n-            \"Send any message after restart to resume where it left off.\"\n+            \"Send any message after restart and I'll try to resume where you left off.\"\n             if self._restart_requested\n             else \"Your current task will be interrupted.\"\n         )","@@ -2373,6 +2373,27 @@ async def _stop_impl() -> None:\n                     timeout,\n                     self._running_agent_count(),\n                 )\n+                # Mark forcibly-interrupted sessions as resume_pending BEFORE\n+                # interrupting the agents.  This preserves each session's\n+                # session_id + transcript so the next message on the same\n+                # session_key auto-resumes from the existing conversation\n+                # instead of getting routed through suspend_recently_active()\n+                # and converted into a fresh session.  Terminal escalation\n+                # for genuinely stuck sessions still flows through the\n+                # existing ``.restart_failure_counts`` stuck-loop counter\n+                # (incremented below, threshold 3), which sets\n+                # ``suspended=True`` and overrides resume_pending.\n+                _resume_reason = (\n+                    \"restart_timeout\" if self._restart_requested else \"shutdown_timeout\"\n+                )\n+                for _sk in list(active_agents.keys()):\n+                    try:\n+                        self.session_store.mark_resume_pending(_sk, _resume_reason)\n+                    except Exception as _e:\n+                        logger.debug(\n+                            \"mark_resume_pending failed for %s: %s\",\n+                            _sk[:20], _e,\n+                        )\n                 self._interrupt_running_agents(\n                     \"Gateway restarting\" if self._restart_requested else \"Gateway shutting down\"\n                 )","@@ -4152,8 +4173,20 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n             # Successful turn — clear any stuck-loop counter for this session.\n             # This ensures the counter only accumulates across CONSECUTIVE\n             # restarts where the session was active (never completed).\n+            #\n+            # Also clear the resume_pending flag (set by drain-timeout\n+            # shutdown) — the turn ran to completion, so recovery\n+            # succeeded and subsequent messages should no longer receive\n+            # the restart-interruption system note.\n             if session_key:\n                 self._clear_restart_failure_count(session_key)\n+                try:\n+                    self.session_store.clear_resume_pending(session_key)\n+                except Exception as _e:\n+                    logger.debug(\n+                        \"clear_resume_pending failed for %s: %s\",\n+                        session_key[:20], _e,\n+                    )\n \n             # Surface error details when the agent failed silently (final_response=None)\n             if not response and agent_result.get(\"failed\"):","@@ -9427,7 +9460,40 @@ def _approval_notify_sync(approval_data: dict) -> None:\n             # restart, crash, SIGTERM).  Prepend a system note so the model\n             # finishes processing the pending tool results before addressing\n             # the user's new message.  (#4493)\n-            if agent_history and agent_history[-1].get(\"role\") == \"tool\":\n+            #\n+            # Session-level resume_pending (set on drain-timeout shutdown)\n+            # escalates the wording — the transcript's last role may be\n+            # anything (tool, assistant with unfinished work, etc.), so we\n+            # give a stronger, reason-aware instruction that subsumes the\n+            # tool-tail case.\n+            _resume_entry = None\n+            if session_key:\n+                try:\n+                    _resume_entry = self.session_store._entries.get(session_key)\n+                except Exception:\n+                    _resume_entry = None\n+            _is_resume_pending = bool(\n+                _resume_entry is not None and getattr(_resume_entry, \"resume_pending\", False)\n+            )\n+\n+            if _is_resume_pending:\n+                _reason = getattr(_resume_entry, \"resume_reason\", None) or \"restart_timeout\"\n+                _reason_phrase = (\n+                    \"a gateway restart\"\n+                    if _reason == \"restart_timeout\"\n+                    else \"a gateway shutdown\"\n+                    if _reason == \"shutdown_timeout\"\n+                    else \"a gateway interruption\"\n+                )\n+                message = (\n+                    f\"[System note: Your previous turn in this session was interrupted \"\n+                    f\"by {_reason_phrase}. The conversation history below is intact. \"\n+                    f\"If it contains unfinished tool result(s), process them first and \"\n+                    f\"summarize what was accomplished, then address the user's new \"\n+                    f\"message below.]\\n\\n\"\n+                    + message\n+                )\n+            elif agent_history and agent_history[-1].get(\"role\") == \"tool\":\n                 message = (\n                     \"[System note: Your previous turn was interrupted before you could \"\n                     \"process the last tool result(s). The conversation history contains \""]},{"file":"gateway/session.py","hunks":["@@ -377,7 +377,19 @@ class SessionEntry:\n     # this session (create a new session_id) so the user starts fresh.\n     # Set by /stop to break stuck-resume loops (#7536).\n     suspended: bool = False\n-    \n+\n+    # When True the session was interrupted by a gateway restart/shutdown\n+    # drain timeout, but recovery is still expected.  Unlike ``suspended``,\n+    # ``resume_pending`` preserves the existing session_id on next access —\n+    # the user stays on the same transcript and the agent auto-continues\n+    # from where it left off.  Cleared after the next successful turn.\n+    # Escalation to ``suspended`` is handled by the existing\n+    # ``.restart_failure_counts`` stuck-loop counter (#7536), not by a\n+    # parallel counter on this entry.\n+    resume_pending: bool = False\n+    resume_reason: Optional[str] = None  # e.g. \"restart_timeout\"\n+    last_resume_marked_at: Optional[datetime] = None\n+\n     def to_dict(self) -> Dict[str, Any]:\n         result = {\n             \"session_key\": self.session_key,","@@ -397,6 +409,13 @@ def to_dict(self) -> Dict[str, Any]:\n             \"cost_status\": self.cost_status,\n             \"memory_flushed\": self.memory_flushed,\n             \"suspended\": self.suspended,\n+            \"resume_pending\": self.resume_pending,\n+            \"resume_reason\": self.resume_reason,\n+            \"last_resume_marked_at\": (\n+                self.last_resume_marked_at.isoformat()\n+                if self.last_resume_marked_at\n+                else None\n+            ),\n         }\n         if self.origin:\n             result[\"origin\"] = self.origin.to_dict()","@@ -414,7 +433,15 @@ def from_dict(cls, data: Dict[str, Any]) -> \"SessionEntry\":\n                 platform = Platform(data[\"platform\"])\n             except ValueError as e:\n                 logger.debug(\"Unknown platform value %r: %s\", data[\"platform\"], e)\n-        \n+\n+        last_resume_marked_at = None\n+        _lrma = data.get(\"last_resume_marked_at\")\n+        if _lrma:\n+            try:\n+                last_resume_marked_at = datetime.fromisoformat(_lrma)\n+            except (TypeError, ValueError):\n+                last_resume_marked_at = None\n+\n         return cls(\n             session_key=data[\"session_key\"],\n             session_id=data[\"session_id\"],","@@ -434,6 +461,9 @@ def from_dict(cls, data: Dict[str, Any]) -> \"SessionEntry\":\n             cost_status=data.get(\"cost_status\", \"unknown\"),\n             memory_flushed=data.get(\"memory_flushed\", False),\n             suspended=data.get(\"suspended\", False),\n+            resume_pending=data.get(\"resume_pending\", False),\n+            resume_reason=data.get(\"resume_reason\"),\n+            last_resume_marked_at=last_resume_marked_at,\n         )\n \n ","@@ -710,9 +740,23 @@ def get_or_create_session(\n                 entry = self._entries[session_key]\n \n                 # Auto-reset sessions marked as suspended (e.g. after /stop\n-                # broke a stuck loop — #7536).\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\n+                    # reloads intact.  ``resume_pending`` is cleared after\n+                    # the NEXT successful turn completes (not here), which\n+                    # means a re-interrupted retry keeps trying — the\n+                    # stuck-loop counter handles terminal escalation.\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:","@@ -802,6 +846,55 @@ def suspend_session(self, session_key: str) -> bool:\n                 return True\n         return False\n \n+    def mark_resume_pending(\n+        self,\n+        session_key: str,\n+        reason: str = \"restart_timeout\",\n+    ) -> bool:\n+        \"\"\"Mark a session as resumable after a restart interruption.\n+\n+        Unlike ``suspend_session()``, this preserves the existing\n+        ``session_id`` and the transcript.  The next call to\n+        ``get_or_create_session()`` for this key returns the same entry\n+        so the user auto-resumes on the same conversation lane.\n+\n+        Returns True if the session existed and was marked.\n+        \"\"\"\n+        with self._lock:\n+            self._ensure_loaded_locked()\n+            if session_key in self._entries:\n+                entry = self._entries[session_key]\n+                # Never override an explicit ``suspended`` — that is a hard\n+                # forced-wipe signal (from /stop or stuck-loop escalation).\n+                if entry.suspended:\n+                    return False\n+                entry.resume_pending = True\n+                entry.resume_reason = reason\n+                entry.last_resume_marked_at = _now()\n+                self._save()\n+                return True\n+        return False\n+\n+    def clear_resume_pending(self, session_key: str) -> bool:\n+        \"\"\"Clear the resume-pending flag after a successful resumed turn.\n+\n+        Called from the gateway after ``run_conversation()`` returns a\n+        final response for a session that had ``resume_pending=True``,\n+        signalling that recovery succeeded.\n+\n+        Returns True if a flag was cleared.\n+        \"\"\"\n+        with self._lock:\n+            self._ensure_loaded_locked()\n+            entry = self._entries.get(session_key)\n+            if entry is None or not entry.resume_pending:\n+                return False\n+            entry.resume_pending = False\n+            entry.resume_reason = None\n+            entry.last_resume_marked_at = None\n+            self._save()\n+            return True\n+\n     def prune_old_entries(self, max_age_days: int) -> int:\n         \"\"\"Drop SessionEntry records older than max_age_days.\n ","@@ -861,6 +954,12 @@ def suspend_recently_active(self, max_age_seconds: int = 120) -> int:\n         (#7536).  Only suspends sessions updated within *max_age_seconds*\n         to avoid resetting long-idle sessions that are harmless to resume.\n         Returns the number of sessions that were suspended.\n+\n+        Entries flagged ``resume_pending=True`` are skipped — those were\n+        marked intentionally by the drain-timeout path as recoverable.\n+        Terminal escalation for genuinely stuck ``resume_pending`` sessions\n+        is handled by the existing ``.restart_failure_counts`` stuck-loop\n+        counter, which runs after this method on startup.\n         \"\"\"\n         from datetime import timedelta\n ","@@ -869,6 +968,8 @@ def suspend_recently_active(self, max_age_seconds: int = 120) -> int:\n         with self._lock:\n             self._ensure_loaded_locked()\n             for entry in self._entries.values():\n+                if entry.resume_pending:\n+                    continue\n                 if not entry.suspended and entry.updated_at >= cutoff:\n                     entry.suspended = True\n                     count += 1"]},{"file":"tests/gateway/test_restart_resume_pending.py","hunks":["@@ -0,0 +1,610 @@\n+\"\"\"Tests for the resume_pending session continuity path.\n+\n+Covers the behaviour introduced to fix the ``Gateway shutting down ...\n+task will be interrupted`` follow-up bug (spec: PR #11852, builds on\n+PRs #9850, #9934, #7536):\n+\n+1. When a gateway restart drain times out and agents are force-interrupted,\n+   the affected sessions are flagged ``resume_pending=True`` — not\n+   ``suspended`` — so the next user message on the same session_key\n+   auto-resumes from the existing transcript instead of getting routed\n+   through ``suspend_recently_active()`` and converted into a fresh\n+   session.\n+\n+2. ``suspended=True`` (from ``/stop`` or stuck-loop escalation) still\n+   wins over ``resume_pending`` — the forced-wipe path is preserved.\n+\n+3. The restart-resume system note injected into the next user message is\n+   a superset of the existing tool-tail auto-continue note (from\n+   PR #9934), using session-entry metadata rather than just transcript\n+   shape so it fires even when the interrupted transcript does NOT end\n+   with a ``tool`` role.\n+\n+4. The existing ``.restart_failure_counts`` stuck-loop counter from\n+   PR #7536 remains the single source of escalation — no parallel\n+   counter is added on ``SessionEntry``.\n+\"\"\"\n+\n+import asyncio\n+from datetime import datetime, timedelta\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+from gateway.config import GatewayConfig, Platform, PlatformConfig\n+from gateway.session import SessionEntry, SessionSource, SessionStore\n+from tests.gateway.restart_test_helpers import (\n+    make_restart_runner,\n+    make_restart_source,\n+)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+\n+def _make_source(platform=Platform.TELEGRAM, chat_id=\"123\", user_id=\"u1\"):\n+    return SessionSource(platform=platform, chat_id=chat_id, user_id=user_id)\n+\n+\n+def _make_store(tmp_path):\n+    return SessionStore(sessions_dir=tmp_path, config=GatewayConfig())\n+\n+\n+def _simulate_note_injection(\n+    agent_history: list,\n+    user_message: str,\n+    resume_entry: SessionEntry | None,\n+) -> str:\n+    \"\"\"Mirror the note-injection logic in gateway/run.py _run_agent().\n+\n+    Matches the production code in the ``run_sync`` closure so we can\n+    test the decision tree without a full gateway runner.\n+    \"\"\"\n+    message = user_message\n+    is_resume_pending = bool(\n+        resume_entry is not None and getattr(resume_entry, \"resume_pending\", False)\n+    )\n+\n+    if is_resume_pending:\n+        reason = getattr(resume_entry, \"resume_reason\", None) or \"restart_timeout\"\n+        reason_phrase = (\n+            \"a gateway restart\"\n+            if reason == \"restart_timeout\"\n+            else \"a gateway shutdown\"\n+            if reason == \"shutdown_timeout\"\n+            else \"a gateway interruption\"\n+        )\n+        message = (\n+            f\"[System note: Your previous turn in this session was interrupted \"\n+            f\"by {reason_phrase}. The conversation history below is intact. \"\n+            f\"If it contains unfinished tool result(s), process them first and \"\n+            f\"summarize what was accomplished, then address the user's new \"\n+            f\"message below.]\\n\\n\"\n+            + message\n+        )\n+    elif agent_history and agent_history[-1].get(\"role\") == \"tool\":\n+        message = (\n+            \"[System note: Your previous turn was interrupted before you could \"\n+            \"process the last tool result(s). The conversation history contains \"\n+            \"tool outputs you haven't responded to yet. Please finish processing \"\n+            \"those results and summarize what was accomplished, then address the \"\n+            \"user's new message below.]\\n\\n\"\n+            + message\n+        )\n+    return message\n+\n+\n+# ---------------------------------------------------------------------------\n+# SessionEntry field + serialization\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestSessionEntryResumeFields:\n+    def test_defaults(self):\n+        now = datetime.now()\n+        entry = SessionEntry(\n+            session_key=\"agent:main:telegram:dm:1\",\n+            session_id=\"sid\",\n+            created_at=now,\n+            updated_at=now,\n+        )\n+        assert entry.resume_pending is False\n+        assert entry.resume_reason is None\n+        assert entry.last_resume_marked_at is None\n+\n+    def test_roundtrip_with_resume_fields(self):\n+        now = datetime(2026, 4, 18, 12, 0, 0)\n+        entry = SessionEntry(\n+            session_key=\"agent:main:telegram:dm:1\",\n+            session_id=\"sid\",\n+            created_at=now,\n+            updated_at=now,\n+            resume_pending=True,\n+            resume_reason=\"restart_timeout\",\n+            last_resume_marked_at=now,\n+        )\n+        restored = SessionEntry.from_dict(entry.to_dict())\n+        assert restored.resume_pending is True\n+        assert restored.resume_reason == \"restart_timeout\"\n+        assert restored.last_resume_marked_at == now\n+\n+    def test_from_dict_legacy_without_resume_fields(self):\n+        \"\"\"Old sessions.json without the new fields deserialize cleanly.\"\"\"\n+        now = datetime.now()\n+        legacy = {\n+            \"session_key\": \"agent:main:telegram:dm:1\",\n+            \"session_id\": \"sid\",\n+            \"created_at\": now.isoformat(),\n+            \"updated_at\": now.isoformat(),\n+            \"chat_type\": \"dm\",\n+        }\n+        restored = SessionEntry.from_dict(legacy)\n+        assert restored.resume_pending is False\n+        assert restored.resume_reason is None\n+        assert restored.last_resume_marked_at is None\n+\n+    def test_malformed_timestamp_is_tolerated(self):\n+        now = datetime.now()\n+        data = {\n+            \"session_key\": \"k\",\n+            \"session_id\": \"sid\",\n+            \"created_at\": now.isoformat(),\n+            \"updated_at\": now.isoformat(),\n+            \"resume_pending\": True,\n+            \"resume_reason\": \"restart_timeout\",\n+            \"last_resume_marked_at\": \"not-a-timestamp\",\n+        }\n+        restored = SessionEntry.from_dict(data)\n+        # resume_pending still honoured, only the broken timestamp drops\n+        assert restored.resume_pending is True\n+        assert restored.resume_reason == \"restart_timeout\"\n+        assert restored.last_resume_marked_at is None\n+\n+\n+# ---------------------------------------------------------------------------\n+# SessionStore.mark_resume_pending / clear_resume_pending\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestMarkResumePending:\n+    def test_marks_existing_session(self, tmp_path):\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        entry = store.get_or_create_session(source)\n+\n+        assert store.mark_resume_pending(entry.session_key) is True\n+        refreshed = store._entries[entry.session_key]\n+        assert refreshed.resume_pending is True\n+        assert refreshed.resume_reason == \"restart_timeout\"\n+        assert refreshed.last_resume_marked_at is not None\n+\n+    def test_custom_reason_persists(self, tmp_path):\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        entry = store.get_or_create_session(source)\n+\n+        store.mark_resume_pending(entry.session_key, reason=\"shutdown_timeout\")\n+        assert store._entries[entry.session_key].resume_reason == \"shutdown_timeout\"\n+\n+    def test_returns_false_for_unknown_key(self, tmp_path):\n+        store = _make_store(tmp_path)\n+        assert store.mark_resume_pending(\"no-such-key\") is False\n+\n+    def test_does_not_override_suspended(self, tmp_path):\n+        \"\"\"suspended wins — mark_resume_pending is a no-op on a suspended entry.\"\"\"\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        entry = store.get_or_create_session(source)\n+        store.suspend_session(entry.session_key)\n+\n+        assert store.mark_resume_pending(entry.session_key) is False\n+        e = store._entries[entry.session_key]\n+        assert e.suspended is True\n+        assert e.resume_pending is False\n+\n+    def test_survives_roundtrip_through_json(self, tmp_path):\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        entry = store.get_or_create_session(source)\n+        store.mark_resume_pending(entry.session_key, reason=\"restart_timeout\")\n+\n+        # Reload from disk\n+        store2 = _make_store(tmp_path)\n+        store2._ensure_loaded()\n+        reloaded = store2._entries[entry.session_key]\n+        assert reloaded.resume_pending is True\n+        assert reloaded.resume_reason == \"restart_timeout\"\n+\n+\n+class TestClearResumePending:\n+    def test_clears_flag(self, tmp_path):\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        entry = store.get_or_create_session(source)\n+        store.mark_resume_pending(entry.session_key)\n+\n+        assert store.clear_resume_pending(entry.session_key) is True\n+        e = store._entries[entry.session_key]\n+        assert e.resume_pending is False\n+        assert e.resume_reason is None\n+        assert e.last_resume_marked_at is None\n+\n+    def test_returns_false_when_not_pending(self, tmp_path):\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        entry = store.get_or_create_session(source)\n+        # Not marked\n+        assert store.clear_resume_pending(entry.session_key) is False\n+\n+    def test_returns_false_for_unknown_key(self, tmp_path):\n+        store = _make_store(tmp_path)\n+        assert store.clear_resume_pending(\"no-such-key\") is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# SessionStore.get_or_create_session resume_pending behaviour\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestGetOrCreateResumePending:\n+    def test_resume_pending_preserves_session_id(self, tmp_path):\n+        \"\"\"This is THE core behavioural fix — resume_pending ≠ new session.\"\"\"\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        first = store.get_or_create_session(source)\n+        original_sid = first.session_id\n+        store.mark_resume_pending(first.session_key)\n+\n+        second = store.get_or_create_session(source)\n+        assert second.session_id == original_sid\n+        assert second.was_auto_reset is False\n+        assert second.auto_reset_reason is None\n+        # Flag is NOT cleared on read — only on successful turn completion.\n+        assert second.resume_pending is True\n+\n+    def test_suspended_still_creates_new_session(self, tmp_path):\n+        \"\"\"Regression guard — suspended must still force a clean slate.\"\"\"\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        first = store.get_or_create_session(source)\n+        original_sid = first.session_id\n+        store.suspend_session(first.session_key)\n+\n+        second = store.get_or_create_session(source)\n+        assert second.session_id != original_sid\n+        assert second.was_auto_reset is True\n+        assert second.auto_reset_reason == \"suspended\"\n+\n+    def test_suspended_overrides_resume_pending(self, tmp_path):\n+        \"\"\"Terminal escalation: a session that somehow has BOTH flags must\n+        behave like ``suspended`` — forced wipe + auto_reset_reason.\"\"\"\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        first = store.get_or_create_session(source)\n+        original_sid = first.session_id\n+\n+        # Force the pathological state directly (normally mark_resume_pending\n+        # refuses to run when suspended=True, but a stuck-loop escalation\n+        # can set suspended=True AFTER resume_pending is set).\n+        with store._lock:\n+            e = store._entries[first.session_key]\n+            e.resume_pending = True\n+            e.resume_reason = \"restart_timeout\"\n+            e.suspended = True\n+            store._save()\n+\n+        second = store.get_or_create_session(source)\n+        assert second.session_id != original_sid\n+        assert second.was_auto_reset is True\n+        assert second.auto_reset_reason == \"suspended\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# SessionStore.suspend_recently_active skip behaviour\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestSuspendRecentlyActiveSkipsResumePending:\n+    def test_resume_pending_entries_not_suspended(self, tmp_path):\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        entry = store.get_or_create_session(source)\n+        store.mark_resume_pending(entry.session_key)\n+\n+        count = store.suspend_recently_active()\n+        assert count == 0\n+        e = store._entries[entry.session_key]\n+        assert e.suspended is False\n+        assert e.resume_pending is True\n+\n+    def test_non_resume_pending_still_suspended(self, tmp_path):\n+        \"\"\"Non-resume sessions still get the old crash-recovery suspension.\"\"\"\n+        store = _make_store(tmp_path)\n+        source_a = _make_source(chat_id=\"a\")\n+        source_b = _make_source(chat_id=\"b\")\n+        entry_a = store.get_or_create_session(source_a)\n+        entry_b = store.get_or_create_session(source_b)\n+        store.mark_resume_pending(entry_a.session_key)\n+\n+        count = store.suspend_recently_active()\n+        assert count == 1\n+        assert store._entries[entry_a.session_key].suspended is False\n+        assert store._entries[entry_b.session_key].suspended is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# Restart-resume system-note injection\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestResumePendingSystemNote:\n+    def _pending_entry(self, reason=\"restart_timeout\") -> SessionEntry:\n+        now = datetime.now()\n+        return SessionEntry(\n+            session_key=\"agent:main:telegram:dm:1\",\n+            session_id=\"sid\",\n+            created_at=now,\n+            updated_at=now,\n+            resume_pending=True,\n+            resume_reason=reason,\n+            last_resume_marked_at=now,\n+        )\n+\n+    def test_resume_pending_restart_note_mentions_restart(self):\n+        entry = self._pending_entry(reason=\"restart_timeout\")\n+        result = _simulate_note_injection(\n+            agent_history=[{\"role\": \"assistant\", \"content\": \"in progress\"}],\n+            user_message=\"what happened?\",\n+            resume_entry=entry,\n+        )\n+        assert \"[System note:\" in result\n+        assert \"gateway restart\" in result\n+        assert \"what happened?\" in result\n+\n+    def test_resume_pending_shutdown_note_mentions_shutdown(self):\n+        entry = self._pending_entry(reason=\"shutdown_timeout\")\n+        result = _simulate_note_injection(\n+            agent_history=[{\"role\": \"assistant\", \"content\": \"in progress\"}],\n+            user_message=\"ping\",\n+            resume_entry=entry,\n+        )\n+        assert \"gateway shutdown\" in result\n+\n+    def test_resume_pending_fires_without_tool_tail(self):\n+        \"\"\"Key improvement over PR #9934: the restart-resume note fires\n+        even when the transcript's last role is NOT ``tool``.\"\"\"\n+        entry = self._pending_entry()\n+        history = [\n+            {\"role\": \"user\", \"content\": \"run a long thing\"},\n+            {\"role\": \"assistant\", \"content\": \"ok, starting...\"},\n+        ]\n+        result = _simulate_note_injection(history, \"ping\", resume_entry=entry)\n+        assert \"[System note:\" in result\n+        assert \"gateway restart\" in result\n+\n+    def test_resume_pending_subsumes_tool_tail_note(self):\n+        \"\"\"When BOTH conditions are true, the restart-resume note wins —\n+        no duplicate notes.\"\"\"\n+        entry = self._pending_entry()\n+        history = [\n+            {\"role\": \"assistant\", \"content\": None, \"tool_calls\": [\n+                {\"id\": \"c1\", \"function\": {\"name\": \"x\", \"arguments\": \"{}\"}},\n+            ]},\n+            {\"role\": \"tool\", \"tool_call_id\": \"c1\", \"content\": \"result\"},\n+        ]\n+        result = _simulate_note_injection(history, \"ping\", resume_entry=entry)\n+        assert result.count(\"[System note:\") == 1\n+        assert \"gateway restart\" in result\n+        # Old tool-tail wording absent\n+        assert \"haven't responded to yet\" not in result\n+\n+    def test_no_resume_pending_preserves_tool_tail_note(self):\n+        \"\"\"Regression: the old PR #9934 tool-tail behaviour is unchanged.\"\"\"\n+        history = [\n+            {\"role\": \"assistant\", \"content\": None, \"tool_calls\": [\n+                {\"id\": \"c1\", \"function\": {\"name\": \"x\", \"arguments\": \"{}\"}},\n+            ]},\n+            {\"role\": \"tool\", \"tool_call_id\": \"c1\", \"content\": \"result\"},\n+        ]\n+        result = _simulate_note_injection(history, \"ping\", resume_entry=None)\n+        assert \"[System note:\" in result\n+        assert \"tool result\" in result\n+\n+    def test_no_note_when_nothing_to_resume(self):\n+        history = [\n+            {\"role\": \"user\", \"content\": \"hello\"},\n+            {\"role\": \"assistant\", \"content\": \"hi\"},\n+        ]\n+        result = _simulate_note_injection(history, \"ping\", resume_entry=None)\n+        assert result == \"ping\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Drain-timeout path marks sessions resume_pending\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_drain_timeout_marks_resume_pending():\n+    \"\"\"End-to-end: a drain timeout during gateway stop should flag every\n+    active session as resume_pending BEFORE the interrupt fires, so the\n+    next startup's suspend_recently_active() does not destroy them.\"\"\"\n+    runner, adapter = make_restart_runner()\n+    adapter.disconnect = AsyncMock()\n+    runner._restart_drain_timeout = 0.05\n+\n+    running_agent = MagicMock()\n+    session_key_one = \"agent:main:telegram:dm:A\"\n+    session_key_two = \"agent:main:telegram:dm:B\"\n+    runner._running_agents = {\n+        session_key_one: running_agent,\n+        session_key_two: MagicMock(),\n+    }\n+\n+    # Plug a mock session_store that records marks.\n+    session_store = MagicMock()\n+    session_store.mark_resume_pending = MagicMock(return_value=True)\n+    runner.session_store = session_store\n+\n+    with patch(\"gateway.status.remove_pid_file\"), patch(\n+        \"gateway.status.write_runtime_status\"\n+    ):\n+        await runner.stop()\n+\n+    # Both active sessions were marked with the shutdown_timeout reason.\n+    calls = session_store.mark_resume_pending.call_args_list\n+    marked = {args[0][0] for args in calls}\n+    assert marked == {session_key_one, session_key_two}\n+    for args in calls:\n+        assert args[0][1] == \"shutdown_timeout\"\n+\n+\n[redacted:email]\n+async def test_drain_timeout_uses_restart_reason_when_restarting():\n+    runner, adapter = make_restart_runner()\n+    adapter.disconnect = AsyncMock()\n+    runner._restart_drain_timeout = 0.05\n+    runner._restart_requested = True\n+\n+    running_agent = MagicMock()\n+    runner._running_agents = {\"agent:main:telegram:dm:A\": running_agent}\n+\n+    session_store = MagicMock()\n+    session_store.mark_resume_pending = MagicMock(return_value=True)\n+    runner.session_store = session_store\n+\n+    with patch(\"gateway.status.remove_pid_file\"), patch(\n+        \"gateway.status.write_runtime_status\"\n+    ):\n+        await runner.stop(restart=True, detached_restart=False, service_restart=True)\n+\n+    calls = session_store.mark_resume_pending.call_args_list\n+    assert calls, \"expected at least one mark_resume_pending call\"\n+    for args in calls:\n+        assert args[0][1] == \"restart_timeout\"\n+\n+\n[redacted:email]\n+async def test_clean_drain_does_not_mark_resume_pending():\n+    \"\"\"If the drain completes within timeout (no force-interrupt), no\n+    sessions should be flagged — the normal shutdown path is unchanged.\"\"\"\n+    runner, adapter = make_restart_runner()\n+    adapter.disconnect = AsyncMock()\n+\n+    running_agent = MagicMock()\n+    runner._running_agents = {\"agent:main:telegram:dm:A\": running_agent}\n+\n+    # Finish the agent before the (generous) drain deadline\n+    async def finish_agent():\n+        await asyncio.sleep(0.05)\n+        runner._running_agents.clear()\n+\n+    asyncio.create_task(finish_agent())\n+\n+    session_store = MagicMock()\n+    session_store.mark_resume_pending = MagicMock(return_value=True)\n+    runner.session_store = session_store\n+\n+    with patch(\"gateway.status.remove_pid_file\"), patch(\n+        \"gateway.status.write_runtime_status\"\n+    ):\n+        await runner.stop()\n+\n+    session_store.mark_resume_pending.assert_not_called()\n+    running_agent.interrupt.assert_not_called()\n+\n+\n+# ---------------------------------------------------------------------------\n+# Shutdown banner wording\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_restart_banner_uses_try_to_resume_wording():\n+    \"\"\"The notification sent before drain should hedge the resume promise\n+    — the session-continuity fix is best-effort (stuck-loop counter can\n+    still escalate to suspended).\"\"\"\n+    runner, adapter = make_restart_runner()\n+    runner._restart_requested = True\n+    runner._running_agents[\"agent:main:telegram:dm:999\"] = MagicMock()\n+\n+    await runner._notify_active_sessions_of_shutdown()\n+\n+    assert len(adapter.sent) == 1\n+    msg = adapter.sent[0]\n+    assert \"restarting\" in msg\n+    assert \"try to resume\" in msg\n+\n+\n+# ---------------------------------------------------------------------------\n+# Stuck-loop escalation integration\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestStuckLoopEscalation:\n+    \"\"\"The existing .restart_failure_counts counter (PR #7536) remains the\n+    single source of terminal escalation — no parallel counter on\n+    SessionEntry was added.  After the configured threshold, the startup\n+    path flips suspended=True which overrides resume_pending.\"\"\"\n+\n+    def test_escalation_via_stuck_loop_counter_overrides_resume_pending(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"Simulate a session that keeps getting restart-interrupted and\n+        hits the stuck-loop threshold: next startup should force it to\n+        fresh-session despite resume_pending being set.\"\"\"\n+        import json\n+\n+        from gateway.run import GatewayRunner\n+\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        entry = store.get_or_create_session(source)\n+        store.mark_resume_pending(entry.session_key, reason=\"restart_timeout\")\n+\n+        # Simulate counter already at threshold (3 consecutive interrupted\n+        # restarts).  _suspend_stuck_loop_sessions will flip suspended=True.\n+        counts_file = tmp_path / \".restart_failure_counts\"\n+        counts_file.write_text(json.dumps({entry.session_key: 3}))\n+\n+        monkeypatch.setattr(\"gateway.run._hermes_home\", tmp_path)\n+        runner = object.__new__(GatewayRunner)\n+        runner.session_store = store\n+\n+        suspended_count = GatewayRunner._suspend_stuck_loop_sessions(runner)\n+        assert suspended_count == 1\n+        assert store._entries[entry.session_key].suspended is True\n+        # resume_pending is still set on the entry, but suspended wins in\n+        # get_or_create_session so the next message still gets a new sid.\n+        second = store.get_or_create_session(source)\n+        assert second.session_id != entry.session_id\n+        assert second.auto_reset_reason == \"suspended\"\n+\n+    def test_successful_turn_flow_clears_both_counter_and_resume_pending(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"The gateway's post-turn cleanup should clear both signals so a\n+        future restart-interrupt starts with a fresh counter.\"\"\"\n+        import json\n+\n+        from gateway.run import GatewayRunner\n+\n+        store = _make_store(tmp_path)\n+        source = _make_source()\n+        entry = store.get_or_create_session(source)\n+        store.mark_resume_pending(entry.session_key, reason=\"restart_timeout\")\n+\n+        counts_file = tmp_path / \".restart_failure_counts\"\n+        counts_file.write_text(json.dumps({entry.session_key: 2}))\n+\n+        monkeypatch.setattr(\"gateway.run._hermes_home\", tmp_path)\n+        runner = object.__new__(GatewayRunner)\n+        runner.session_store = store\n+\n+        GatewayRunner._clear_restart_failure_count(runner, entry.session_key)\n+        store.clear_resume_pending(entry.session_key)\n+\n+        assert store._entries[entry.session_key].resume_pending is False\n+        assert not counts_file.exists()\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}