{"repo":"NousResearch/hermes-agent","public_id":"52804","display_label":"Gateway stale session routing and expiry finalization bugs","summary":"These issues and fixes center on stale sessions.json entries causing silent message loss, and on expired or reset sessions being incorrectly resurrected during recovery due to missing or incorrect end_reason, ended_at, and reset boundary handling.","subsystem":"gateway","counts":{"members":22,"open_issues":3,"open_issues_distinct":3,"open_prs":4,"closed_issues":3,"merged_prs":5,"attempt_prs":11},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54138},"resolution":{"state":"partially_resolved","by":"pr-55485,pr-65783"},"freshness":{"state":"verified","last_verified_at":"2026-08-09T22:02:28+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.07705627705627704},"fix_edges":[{"issue_number":12857,"pr_number":82633,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":28746,"pr_number":65783,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":52804,"pr_number":54138,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":54878,"pr_number":55485,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":61220,"pr_number":65783,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":68539,"pr_number":82633,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":68539,"pr_number":82743,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":72680,"pr_number":82633,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":82616,"pr_number":82633,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":52804,"title":"fix(gateway): sessions.json stale entries after crash silently drop all messages until manual intervention","url":null,"state":"closed"},{"kind":"issue","number":54878,"title":"[Bug]: idle agent cache eviction ends session in DB but leaves sessions.json stale → messages silently dropped until restart","url":null,"state":"closed"},{"kind":"issue","number":61220,"title":"Bug: Session expiry finalization doesn't set end_reason='session_reset' — stale recovery reopens expired sessions with full history (#54878)","url":null,"state":"closed"},{"kind":"issue","number":61993,"title":"Gateway resurrects ended session from stale sessions.json routing key — session reset silently undone","url":null,"state":"open"},{"kind":"issue","number":67781,"title":"Bug: Daily 4AM session_reset resurrected by stale-route recovery — session ran 23h, ~2 cost from context that should have been cleared","url":null,"state":"open"},{"kind":"issue","number":68539,"title":"Gateway session recovery resurrects ancient never-ended sessions after session_reset — nightly reset silently undone, stale context resurfaces","url":null,"state":"open"},{"kind":"pr","number":52808,"title":"fix(gateway): prune stale sessions.json entries on startup (FM9)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#54138","number":54138}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":54138,"title":"fix(gateway): prune stale sessions.json entries on startup","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/session.py","tests/gateway/test_session_store_stale_prune.py"],"fix_hunks":[{"file":"gateway/session.py","hunks":["@@ -847,7 +847,58 @@ def _ensure_loaded_locked(self) -> None:\n                 print(f\"[gateway] Warning: Failed to load sessions: {e}\")\n \n         self._loaded = True\n-    \n+\n+        # Prune any sessions.json entries that point to sessions already ended\n+        # in state.db. A hard gateway crash (exit code 1) skips the graceful\n+        # shutdown path, so sessions.json is never cleared and is left pointing\n+        # at ended sessions. On the next startup those stale entries act as live\n+        # routing keys, but get_or_create_session() reuses them as long as the\n+        # time/policy reset checks pass — it never consults end_reason — so every\n+        # incoming message is silently routed into a closed session. Pruning here\n+        # (lock already held) is cheap: one lookup per routing key, once at\n+        # startup, and self-heals into a fresh session on the next message.\n+        self._prune_stale_sessions_locked()\n+\n+    def _prune_stale_sessions_locked(self) -> None:\n+        \"\"\"Remove sessions.json entries whose session has ended in state.db.\n+\n+        Called once during startup (from ``_ensure_loaded_locked``, lock held).\n+        A ``session_id`` is stale when state.db reports ``end_reason IS NOT\n+        NULL`` for it. Sessions absent from the DB (never persisted / pre-SQLite\n+        legacy) are left alone, and a ``None`` DB handle (SQLite unavailable) is\n+        a no-op. DB errors are non-fatal — startup must never fail here.\n+        \"\"\"\n+        db = getattr(self, \"_db\", None)\n+        if not db or not self._entries:\n+            return\n+\n+        stale_keys: list = []\n+        try:\n+            for key, entry in self._entries.items():\n+                row = db.get_session(entry.session_id)\n+                # row is None        -> not in DB (legacy / pre-SQLite) — keep\n+                # end_reason is None  -> session alive — keep\n+                # end_reason not None -> session ended — prune\n+                if row is not None and row.get(\"end_reason\") is not None:\n+                    logger.warning(\n+                        \"gateway.session: pruning stale sessions.json entry \"\n+                        \"%r -> %s (end_reason=%r); left by a crashed gateway\",\n+                        key, entry.session_id, row[\"end_reason\"],\n+                    )\n+                    stale_keys.append(key)\n+        except Exception as exc:\n+            logger.warning(\n+                \"gateway.session: stale-entry pruning skipped due to DB error: %s\",\n+                exc,\n+            )\n+            return\n+\n+        for key in stale_keys:\n+            del self._entries[key]\n+\n+        if stale_keys:\n+            self._save()\n+\n     def _save(self) -> None:\n         \"\"\"Save sessions index to disk (kept for session key -> ID mapping).\"\"\"\n         import tempfile"]},{"file":"tests/gateway/test_session_store_stale_prune.py","hunks":["@@ -0,0 +1,181 @@\n+\"\"\"Tests for SessionStore._prune_stale_sessions_locked — crash self-healing.\n+\n+When a gateway crashes (exit code 1) the graceful shutdown path is skipped and\n+sessions.json is left pointing at sessions already ended in state.db. On the\n+next startup _ensure_loaded_locked calls _prune_stale_sessions_locked to detect\n+and remove those stale routing entries before get_or_create_session() can reuse\n+them and silently route incoming messages into a closed session (#52804).\n+\"\"\"\n+\n+import json\n+from datetime import datetime, timedelta\n+from unittest.mock import MagicMock, patch\n+\n+from gateway.config import GatewayConfig, Platform, SessionResetPolicy\n+from gateway.session import SessionEntry, SessionStore\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+def _make_entry(key: str, session_id: str) -> SessionEntry:\n+    now = datetime.now()\n+    return SessionEntry(\n+        session_key=key,\n+        session_id=session_id,\n+        created_at=now - timedelta(hours=2),\n+        updated_at=now - timedelta(hours=1),\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"dm\",\n+    )\n+\n+\n+def _make_store_with_db(tmp_path, db_mock) -> SessionStore:\n+    \"\"\"Build a SessionStore with a mock SessionDB, bypassing disk load.\"\"\"\n+    config = GatewayConfig(default_reset_policy=SessionResetPolicy(mode=\"none\"))\n+    with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+        store = SessionStore(sessions_dir=tmp_path, config=config)\n+    store._db = db_mock\n+    store._loaded = True\n+    return store\n+\n+\n+def _db_returning(rows: dict) -> MagicMock:\n+    \"\"\"SessionDB mock where get_session maps session_id -> row dict.\"\"\"\n+    db = MagicMock()\n+    db.get_session.side_effect = lambda sid: rows.get(sid)\n+    return db\n+\n+\n+# ---------------------------------------------------------------------------\n+# Core behaviour\n+# ---------------------------------------------------------------------------\n+\n+class TestPruneStaleSessionsLocked:\n+    def test_prunes_ended_session(self, tmp_path):\n+        db = _db_returning({\"sid_dm\": {\"end_reason\": \"agent_close\", \"id\": \"sid_dm\"}})\n+        store = _make_store_with_db(tmp_path, db)\n+        store._entries[\"dm_key\"] = _make_entry(\"dm_key\", \"sid_dm\")\n+\n+        store._prune_stale_sessions_locked()\n+\n+        assert \"dm_key\" not in store._entries\n+\n+    def test_keeps_live_session(self, tmp_path):\n+        db = _db_returning({\"sid_live\": {\"end_reason\": None, \"id\": \"sid_live\"}})\n+        store = _make_store_with_db(tmp_path, db)\n+        store._entries[\"live_key\"] = _make_entry(\"live_key\", \"sid_live\")\n+\n+        store._prune_stale_sessions_locked()\n+\n+        assert \"live_key\" in store._entries\n+\n+    def test_keeps_session_absent_from_db(self, tmp_path):\n+        \"\"\"Entry for a session_id not in state.db (legacy) is left alone.\"\"\"\n+        db = _db_returning({})\n+        store = _make_store_with_db(tmp_path, db)\n+        store._entries[\"legacy_key\"] = _make_entry(\"legacy_key\", \"sid_legacy\")\n+\n+        store._prune_stale_sessions_locked()\n+\n+        assert \"legacy_key\" in store._entries\n+\n+    def test_prunes_multiple_stale_entries(self, tmp_path):\n+        db = _db_returning({\n+            \"sid_a\": {\"end_reason\": \"agent_close\", \"id\": \"sid_a\"},\n+            \"sid_b\": {\"end_reason\": \"session_reset\", \"id\": \"sid_b\"},\n+            \"sid_c\": {\"end_reason\": None, \"id\": \"sid_c\"},  # alive — keep\n+        })\n+        store = _make_store_with_db(tmp_path, db)\n+        store._entries[\"key_a\"] = _make_entry(\"key_a\", \"sid_a\")\n+        store._entries[\"key_b\"] = _make_entry(\"key_b\", \"sid_b\")\n+        store._entries[\"key_c\"] = _make_entry(\"key_c\", \"sid_c\")\n+\n+        store._prune_stale_sessions_locked()\n+\n+        assert \"key_a\" not in store._entries\n+        assert \"key_b\" not in store._entries\n+        assert \"key_c\" in store._entries\n+\n+    def test_noop_when_db_is_none(self, tmp_path):\n+        config = GatewayConfig(default_reset_policy=SessionResetPolicy(mode=\"none\"))\n+        with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+            store = SessionStore(sessions_dir=tmp_path, config=config)\n+        store._db = None\n+        store._loaded = True\n+        store._entries[\"key\"] = _make_entry(\"key\", \"sid_x\")\n+\n+        store._prune_stale_sessions_locked()  # must not raise\n+\n+        assert \"key\" in store._entries\n+\n+    def test_noop_when_no_entries(self, tmp_path):\n+        db = MagicMock()\n+        store = _make_store_with_db(tmp_path, db)\n+\n+        store._prune_stale_sessions_locked()\n+\n+        db.get_session.assert_not_called()\n+\n+    def test_db_error_is_non_fatal(self, tmp_path):\n+        db = MagicMock()\n+        db.get_session.side_effect = Exception(\"DB locked\")\n+        store = _make_store_with_db(tmp_path, db)\n+        store._entries[\"key\"] = _make_entry(\"key\", \"sid_x\")\n+\n+        store._prune_stale_sessions_locked()  # must not raise\n+\n+        assert \"key\" in store._entries  # safe fallback — keep on error\n+\n+    def test_sessions_json_rewritten_after_pruning(self, tmp_path):\n+        db = _db_returning({\"sid_stale\": {\"end_reason\": \"agent_close\", \"id\": \"sid_stale\"}})\n+        store = _make_store_with_db(tmp_path, db)\n+        store._entries[\"stale_key\"] = _make_entry(\"stale_key\", \"sid_stale\")\n+\n+        with patch.object(store, \"_save\") as mock_save:\n+            store._prune_stale_sessions_locked()\n+            mock_save.assert_called_once()\n+\n+    def test_sessions_json_not_rewritten_when_nothing_pruned(self, tmp_path):\n+        db = _db_returning({\"sid_live\": {\"end_reason\": None, \"id\": \"sid_live\"}})\n+        store = _make_store_with_db(tmp_path, db)\n+        store._entries[\"live_key\"] = _make_entry(\"live_key\", \"sid_live\")\n+\n+        with patch.object(store, \"_save\") as mock_save:\n+            store._prune_stale_sessions_locked()\n+            mock_save.assert_not_called()\n+\n+\n+# ---------------------------------------------------------------------------\n+# Integration: _ensure_loaded_locked calls _prune_stale_sessions_locked\n+# ---------------------------------------------------------------------------\n+\n+class TestEnsureLoadedCallsPrune:\n+    def test_stale_entry_pruned_during_load(self, tmp_path):\n+        entry = _make_entry(\"dm_key\", \"sid_stale\")\n+        (tmp_path / \"sessions.json\").write_text(\n+            json.dumps({\"dm_key\": entry.to_dict()}, indent=2), encoding=\"utf-8\"\n+        )\n+        db = _db_returning({\"sid_stale\": {\"end_reason\": \"agent_close\", \"id\": \"sid_stale\"}})\n+        config = GatewayConfig(default_reset_policy=SessionResetPolicy(mode=\"none\"))\n+        store = SessionStore(sessions_dir=tmp_path, config=config)\n+        store._db = db\n+\n+        store._ensure_loaded()\n+\n+        assert \"dm_key\" not in store._entries\n+\n+    def test_live_entry_survives_load(self, tmp_path):\n+        entry = _make_entry(\"active_key\", \"sid_live\")\n+        (tmp_path / \"sessions.json\").write_text(\n+            json.dumps({\"active_key\": entry.to_dict()}, indent=2), encoding=\"utf-8\"\n+        )\n+        db = _db_returning({\"sid_live\": {\"end_reason\": None, \"id\": \"sid_live\"}})\n+        config = GatewayConfig(default_reset_policy=SessionResetPolicy(mode=\"none\"))\n+        store = SessionStore(sessions_dir=tmp_path, config=config)\n+        store._db = db\n+\n+        store._ensure_loaded()\n+\n+        assert \"active_key\" in store._entries\n"]}]},{"kind":"pr","number":54916,"title":"Fix #54878: Prevent silent message loss due to stale sessions.json entries","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3a83b6bc5","number":0},{"kind":"pr","value":"#55485","number":55485}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":55232,"title":"fix: remove stale session entry from sessions.json on idle-TTL eviction","url":null,"state":"closed","close_reason":{"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":55485,"title":"fix(gateway): self-heal stale sessions.json routing at message time (#54878)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/session.py","tests/gateway/test_session_store_runtime_stale_guard.py"],"fix_hunks":[{"file":"gateway/session.py","hunks":["@@ -905,11 +905,11 @@ def _ensure_loaded_locked(self) -> None:\n         # in state.db. A hard gateway crash (exit code 1) skips the graceful\n         # shutdown path, so sessions.json is never cleared and is left pointing\n         # at ended sessions. On the next startup those stale entries act as live\n-        # routing keys, but get_or_create_session() reuses them as long as the\n-        # time/policy reset checks pass — it never consults end_reason — so every\n-        # incoming message is silently routed into a closed session. Pruning here\n-        # (lock already held) is cheap: one lookup per routing key, once at\n-        # startup, and self-heals into a fresh session on the next message.\n+        # routing keys. get_or_create_session() only consulted end_reason at\n+        # startup (here) until #54878 added a routing-time guard for the\n+        # live-gateway case; this startup prune still self-heals crash-left\n+        # entries before the first message arrives. Pruning here (lock already\n+        # held) is cheap: one lookup per routing key, once at startup.\n         self._prune_stale_sessions_locked()\n \n     def _prune_stale_sessions_locked(self) -> None:","@@ -1148,6 +1148,32 @@ def _is_session_expired(self, entry: SessionEntry) -> bool:\n \n         return False\n \n+    def _is_session_ended_in_db(self, session_id: str) -> bool:\n+        \"\"\"Return True iff state.db has this session with a non-null end_reason.\n+\n+        Mirrors the staleness test in ``_prune_stale_sessions_locked``:\n+          - no DB handle / no session_id -> False (can't tell — keep)\n+          - row absent (legacy / not yet persisted) -> False (keep)\n+          - end_reason is None -> False (alive — keep)\n+          - end_reason not None -> True (ended — stale)\n+\n+        Used by ``get_or_create_session`` to self-heal at routing time:\n+        ``_prune_stale_sessions_locked`` only runs at startup, so a session\n+        ended in the DB while the gateway stays alive (any path that finalizes\n+        the row without clearing sessions.json) would otherwise be reused as a\n+        live routing key and silently swallow every subsequent message until\n+        the next restart (#54878 — the live-gateway variant of #52804/FM9).\n+        DB errors are non-fatal — never block routing on a failed lookup.\n+        \"\"\"\n+        db = getattr(self, \"_db\", None)\n+        if not db or not session_id:\n+            return False\n+        try:\n+            row = db.get_session(session_id)\n+        except Exception:\n+            return False\n+        return bool(row is not None and row.get(\"end_reason\") is not None)\n+\n     def _should_reset(self, entry: SessionEntry, source: SessionSource) -> Optional[str]:\n         \"\"\"\n         Check if a session should be reset based on policy.","@@ -1243,39 +1269,73 @@ def get_or_create_session(\n             if session_key in self._entries and not force_new:\n                 entry = self._entries[session_key]\n \n-                # Auto-reset sessions marked as suspended (e.g. after /stop\n-                # broke a stuck loop — #7536).  ``suspended`` is the hard\n-                # forced-wipe signal and always wins over ``resume_pending``,\n-                # so repeated interrupted restarts that escalate via the\n-                # existing ``.restart_failure_counts`` stuck-loop counter\n-                # still converge to a clean slate.\n-                if entry.suspended:\n-                    reset_reason = \"suspended\"\n-                elif entry.resume_pending:\n-                    # Restart-interrupted session: preserve the session_id\n-                    # and return the existing entry so the transcript reloads\n-                    # intact, but still honour normal daily/idle reset policy.\n-                    reset_reason = self._should_reset(entry, source)\n+                # Self-heal stale routing: if this session_key still points at\n+                # a session that has ALREADY been ended in state.db (end_reason\n+                # set), the in-memory sessions.json entry is stale.  Reusing it\n+                # would route every incoming message into a closed session and\n+                # silently drop it — with no log, no error, no response — until\n+                # the gateway restarts and _prune_stale_sessions_locked() clears\n+                # it (#54878 — the live-gateway variant of #52804/FM9, which\n+                # only the startup prune previously caught).\n+                #\n+                # Drop the stale entry and fall through to the recovery path\n+                # below.  Leaving db_end_session_id None routes us into\n+                # _recover_session_from_db, whose finder\n+                # (hermes_state.find_latest_gateway_session_for_peer) selects\n+                # rows WHERE `ended_at IS NULL OR end_reason = 'agent_close'`\n+                # — so it REOPENS gateway-cleanup-ended ('agent_close') rows and\n+                # resumes the SAME session_id (transcript preserved), but returns\n+                # None for any other end_reason (e.g. /new), which then correctly\n+                # starts a fresh session.\n+                if self._is_session_ended_in_db(entry.session_id):\n+                    logger.warning(\n+                        \"gateway.session: routing key %r -> %s is ended in \"\n+                        \"state.db but still live in sessions.json; dropping \"\n+                        \"stale entry and recovering/recreating the session \"\n+                        \"(#54878)\",\n+                        session_key, entry.session_id,\n+                    )\n+                    self._entries.pop(session_key, None)\n+                    was_auto_reset = False\n+                    auto_reset_reason = None\n+                    reset_had_activity = False\n+                    # Fall through to the recovery/create path below; the\n+                    # stale entry is gone so we must NOT consult its\n+                    # suspended/resume/reset state.\n+                else:\n+                    # Auto-reset sessions marked as suspended (e.g. after /stop\n+                    # broke a stuck loop — #7536).  ``suspended`` is the hard\n+                    # forced-wipe signal and always wins over ``resume_pending``,\n+                    # so repeated interrupted restarts that escalate via the\n+                    # existing ``.restart_failure_counts`` stuck-loop counter\n+                    # still converge to a clean slate.\n+                    if entry.suspended:\n+                        reset_reason = \"suspended\"\n+                    elif entry.resume_pending:\n+                        # Restart-interrupted session: preserve the session_id\n+                        # and return the existing entry so the transcript reloads\n+                        # intact, but still honour normal daily/idle reset policy.\n+                        reset_reason = self._should_reset(entry, source)\n+                        if not reset_reason:\n+                            entry.updated_at = now\n+                            self._save()\n+                            return entry\n+                    else:\n+                        reset_reason = self._should_reset(entry, source)\n                     if not reset_reason:\n                         entry.updated_at = now\n                         self._save()\n                         return entry\n-                else:\n-                    reset_reason = self._should_reset(entry, source)\n-                if not reset_reason:\n-                    entry.updated_at = now\n-                    self._save()\n-                    return entry\n-                else:\n-                    # Session is being auto-reset.\n-                    was_auto_reset = True\n-                    auto_reset_reason = reset_reason\n-                    # Track whether the expired session had any real conversation.\n-                    # total_tokens is never written (token counts migrated to\n-                    # agent-direct persistence) so it is always 0 — use\n-                    # last_prompt_tokens, which is updated on every turn.\n-                    reset_had_activity = entry.last_prompt_tokens > 0\n-                    db_end_session_id = entry.session_id\n+                    else:\n+                        # Session is being auto-reset.\n+                        was_auto_reset = True\n+                        auto_reset_reason = reset_reason\n+                        # Track whether the expired session had any real\n+                        # conversation.  total_tokens is never written (token\n+                        # counts migrated to agent-direct persistence) so it is\n+                        # always 0 — use last_prompt_tokens, updated every turn.\n+                        reset_had_activity = entry.last_prompt_tokens > 0\n+                        db_end_session_id = entry.session_id\n             else:\n                 was_auto_reset = False\n                 auto_reset_reason = None"]},{"file":"tests/gateway/test_session_store_runtime_stale_guard.py","hunks":["@@ -0,0 +1,200 @@\n+\"\"\"Runtime self-heal for stale sessions.json routing entries (#54878).\n+\n+`_prune_stale_sessions_locked` only runs at gateway startup. A session ended\n+in state.db while the gateway stays alive (e.g. any path that finalizes the\n+row without clearing sessions.json) leaves a stale `session_key -> session_id`\n+mapping whose session has `end_reason` set. Before this fix,\n+`get_or_create_session` returned that stale entry as a live routing key (it\n+never consulted end_reason), so every subsequent message was silently routed\n+into a closed session and dropped — no log, no error, no response — until the\n+next restart pruned it.\n+\n+This is the live-gateway variant of #52804/FM9 (#52808/#54138 startup prune),\n+which required an actual gateway *crash*. Here the guard inside\n+`get_or_create_session` detects the ended row at routing time and drops the\n+stale entry, falling through to `_recover_session_from_db` (which reopens\n+`agent_close`-ended rows and resumes the SAME session_id, preserving the\n+transcript) or, failing recovery, to a fresh session.\n+\"\"\"\n+\n+from datetime import datetime, timedelta\n+from unittest.mock import MagicMock, patch\n+\n+from gateway.config import GatewayConfig, Platform, SessionResetPolicy\n+from gateway.session import SessionEntry, SessionSource, SessionStore\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+def _make_entry(key: str, session_id: str, **kw) -> SessionEntry:\n+    now = datetime.now()\n+    return SessionEntry(\n+        session_key=key,\n+        session_id=session_id,\n+        created_at=now - timedelta(hours=2),\n+        updated_at=now - timedelta(hours=1),\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"dm\",\n+        **kw,\n+    )\n+\n+\n+def _db_returning(rows: dict) -> MagicMock:\n+    \"\"\"SessionDB mock where get_session maps session_id -> row dict.\"\"\"\n+    db = MagicMock()\n+    db.get_session.side_effect = lambda sid: rows.get(sid)\n+    # By default recovery finds nothing (forces a fresh session).\n+    db.find_latest_gateway_session_for_peer.return_value = None\n+    db.reopen_session.return_value = None\n+    db.create_session.return_value = None\n+    return db\n+\n+\n+def _make_store_with_db(tmp_path, db_mock) -> SessionStore:\n+    \"\"\"Build a SessionStore with a mock SessionDB, bypassing disk load.\"\"\"\n+    config = GatewayConfig(default_reset_policy=SessionResetPolicy(mode=\"none\"))\n+    with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+        store = SessionStore(sessions_dir=tmp_path, config=config)\n+    store._db = db_mock\n+    store._loaded = True\n+    return store\n+\n+\n+def _source() -> SessionSource:\n+    # session_key for this peer is deterministic; matches the entry key we seed.\n+    return SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"8494508720\",\n+        chat_type=\"dm\",\n+        user_id=\"8494508720\",\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# _is_session_ended_in_db helper\n+# ---------------------------------------------------------------------------\n+\n+class TestIsSessionEndedInDb:\n+    def test_ended_row_is_stale(self, tmp_path):\n+        db = _db_returning({\"sid\": {\"end_reason\": \"agent_close\", \"id\": \"sid\"}})\n+        store = _make_store_with_db(tmp_path, db)\n+        assert store._is_session_ended_in_db(\"sid\") is True\n+\n+    def test_alive_row_not_stale(self, tmp_path):\n+        db = _db_returning({\"sid\": {\"end_reason\": None, \"id\": \"sid\"}})\n+        store = _make_store_with_db(tmp_path, db)\n+        assert store._is_session_ended_in_db(\"sid\") is False\n+\n+    def test_absent_row_not_stale(self, tmp_path):\n+        # Not yet persisted / legacy — must NOT be treated as ended, else a\n+        # freshly-created in-memory session would be wrongly discarded.\n+        db = _db_returning({})\n+        store = _make_store_with_db(tmp_path, db)\n+        assert store._is_session_ended_in_db(\"sid_absent\") is False\n+\n+    def test_no_db_not_stale(self, tmp_path):\n+        store = _make_store_with_db(tmp_path, _db_returning({}))\n+        store._db = None\n+        assert store._is_session_ended_in_db(\"sid\") is False\n+\n+    def test_empty_session_id_not_stale(self, tmp_path):\n+        store = _make_store_with_db(tmp_path, _db_returning({}))\n+        assert store._is_session_ended_in_db(\"\") is False\n+\n+    def test_db_error_not_stale(self, tmp_path):\n+        db = MagicMock()\n+        db.get_session.side_effect = Exception(\"DB locked\")\n+        store = _make_store_with_db(tmp_path, db)\n+        # On error, never block routing — treat as not-stale (keep).\n+        assert store._is_session_ended_in_db(\"sid\") is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# get_or_create_session — runtime self-heal\n+# ---------------------------------------------------------------------------\n+\n+class TestRuntimeStaleGuard:\n+    def test_stale_agent_close_entry_recovered_preserving_session_id(self, tmp_path):\n+        \"\"\"Stale `agent_close` entry → recovery reopens the SAME session_id.\"\"\"\n+        source = _source()\n+        db = _db_returning({\"sid_stale\": {\"end_reason\": \"agent_close\", \"id\": \"sid_stale\"}})\n+        # Recovery finds the agent_close row and reopens it (transcript-preserving).\n+        db.find_latest_gateway_session_for_peer.return_value = {\n+            \"id\": \"sid_stale\",\n+            \"started_at\": (datetime.now() - timedelta(hours=2)).timestamp(),\n+        }\n+        store = _make_store_with_db(tmp_path, db)\n+        key = store._generate_session_key(source)\n+        store._entries[key] = _make_entry(key, \"sid_stale\")\n+\n+        result = store.get_or_create_session(source)\n+\n+        # SAME session_id (resumed), not a brand-new one, and not silently\n+        # routed into the closed entry.\n+        assert result.session_id == \"sid_stale\"\n+        db.reopen_session.assert_called_once_with(\"sid_stale\")\n+        # A brand-new session row must NOT have been created.\n+        db.create_session.assert_not_called()\n+\n+    def test_stale_entry_creates_fresh_when_recovery_returns_none(self, tmp_path):\n+        \"\"\"Stale entry, no recoverable row → brand-new session (no silent drop).\"\"\"\n+        source = _source()\n+        # Ended with a non-recoverable reason (e.g. /new) → finder returns None.\n+        db = _db_returning({\"sid_stale\": {\"end_reason\": \"new_command\", \"id\": \"sid_stale\"}})\n+        db.find_latest_gateway_session_for_peer.return_value = None\n+        store = _make_store_with_db(tmp_path, db)\n+        key = store._generate_session_key(source)\n+        store._entries[key] = _make_entry(key, \"sid_stale\")\n+\n+        result = store.get_or_create_session(source)\n+\n+        assert result.session_id != \"sid_stale\"\n+        # A fresh session row was created for the new session_id.\n+        db.create_session.assert_called_once()\n+        assert store._entries[key].session_id == result.session_id\n+\n+    def test_live_entry_returned_unchanged(self, tmp_path):\n+        \"\"\"A session still alive in the DB is returned as-is (no churn).\"\"\"\n+        source = _source()\n+        db = _db_returning({\"sid_live\": {\"end_reason\": None, \"id\": \"sid_live\"}})\n+        store = _make_store_with_db(tmp_path, db)\n+        key = store._generate_session_key(source)\n+        store._entries[key] = _make_entry(key, \"sid_live\")\n+\n+        result = store.get_or_create_session(source)\n+\n+        assert result.session_id == \"sid_live\"\n+        db.find_latest_gateway_session_for_peer.assert_not_called()\n+        db.create_session.assert_not_called()\n+\n+    def test_stale_check_wins_over_suspended(self, tmp_path):\n+        \"\"\"A stale entry that is ALSO suspended is still dropped via the stale\n+        path — we must not consult the dead entry's reset/suspend state.\"\"\"\n+        source = _source()\n+        db = _db_returning({\"sid_stale\": {\"end_reason\": \"agent_close\", \"id\": \"sid_stale\"}})\n+        db.find_latest_gateway_session_for_peer.return_value = None  # → fresh\n+        store = _make_store_with_db(tmp_path, db)\n+        key = store._generate_session_key(source)\n+        store._entries[key] = _make_entry(key, \"sid_stale\", suspended=True)\n+\n+        result = store.get_or_create_session(source)\n+\n+        # Did not return the stale (suspended) entry; created a fresh session.\n+        assert result.session_id != \"sid_stale\"\n+        db.create_session.assert_called_once()\n+\n+    def test_force_new_skips_stale_check(self, tmp_path):\n+        \"\"\"force_new short-circuits the whole existing-entry branch; the stale\n+        DB lookup must not even run.\"\"\"\n+        source = _source()\n+        db = _db_returning({\"sid_old\": {\"end_reason\": \"agent_close\", \"id\": \"sid_old\"}})\n+        store = _make_store_with_db(tmp_path, db)\n+        key = store._generate_session_key(source)\n+        store._entries[key] = _make_entry(key, \"sid_old\")\n+\n+        result = store.get_or_create_session(source, force_new=True)\n+\n+        assert result.session_id != \"sid_old\"\n+        db.get_session.assert_not_called()\n"]}]},{"kind":"pr","number":61242,"title":"fix(gateway): set end_reason='session_reset' when finalizing expired sessions","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":61489,"title":"fix(session): write end_reason='session_reset' to state.db on expiry finalization (#61220)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61220","number":61220},{"kind":"pr","value":"#61242","number":61242}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":62012,"title":"fix(gateway): never resurrect ended sessions on restart — only recover live peers (#61993)","url":null,"state":"open","close_reason":{"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":62672,"title":"fix(gateway): set end_reason='session_reset' on expiry finalization to prevent recovery loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61242","number":61242}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":63068,"title":"fix(gateway): end finalized expired sessions as reset","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#61242","number":61242}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":65783,"title":"fix(gateway): unify session reset boundaries vs recovery — stop resurrecting reset sessions","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["docs/session-lifecycle.md","gateway/platforms/api_server.py","gateway/platforms/base.py","gateway/platforms/webhook.py","gateway/run.py","gateway/session.py","hermes_state.py","scripts/release.py","tests/gateway/test_clean_shutdown_marker.py","tests/gateway/test_restart_resume_pending.py","tests/gateway/test_session_reset_notify.py","tests/gateway/test_session_store_expiry_finalized.py","tests/gateway/test_session_store_lock_io.py","tests/gateway/test_session_store_runtime_stale_guard.py","tests/gateway/test_session_store_stale_prune.py"],"fix_hunks":[{"file":"docs/session-lifecycle.md","hunks":["@@ -548,7 +548,13 @@ The `_session_expiry_watcher` task runs in the gateway event loop every 300 seco\n    - Clean up cached AIAgent resources (close tool resources, shut down memory provider).\n    - Evict the cached agent entry.\n    - Clear per-session overrides (`_session_model_overrides`, reasoning overrides, etc.).\n-   - Mark `expiry_finalized=True` and persist.\n+   - Mark `expiry_finalized=True` and persist (sessions.json + state.db).\n+   - Promote the state.db session row to `end_reason='session_reset'` via\n+     `promote_to_session_reset()` — conditional: only live rows or rows ended with a\n+     recoverable accidental reason (`agent_close`, `ws_orphan_reap`) are promoted, so\n+     explicit boundaries (`compression`, `session_switch`, …) are never overwritten. This\n+     durably records the reset so stale-route recovery cannot resurrect the expired\n+     session with its full history (#61220, #61993, #63539).\n \n 2. **Sweep idle cached agents** — Calls `_sweep_idle_cached_agents()` to evict agents that\n    have been idle beyond `_AGENT_CACHE_IDLE_TTL_SECS` (3600s / 1h), regardless of session"]},{"file":"gateway/platforms/api_server.py","hunks":["@@ -932,6 +932,11 @@ class APIServerAdapter(BasePlatformAdapter):\n     # ``async_delivery_supported()``.\n     supports_async_delivery: bool = False\n \n+    # Same statelessness applies to the startup auto-resume prompt: no client\n+    # is waiting to answer \"session restored — what next?\", so a resumed turn\n+    # should complete the interrupted work rather than acknowledge (#57056).\n+    interactive_resume: bool = False\n+\n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.API_SERVER)\n         extra = config.extra or {}"]},{"file":"gateway/platforms/base.py","hunks":["@@ -2365,6 +2365,19 @@ class BasePlatformAdapter(ABC):\n     # generic seam; Slack is merely the first consumer).\n     supports_inchannel_continuable: bool = False\n \n+    # Whether a human is interactively present on this platform to answer a\n+    # \"session restored — what next?\" prompt.  The startup auto-resume turn\n+    # (``_schedule_resume_pending_sessions`` → the ``_is_resume_pending``\n+    # branch in ``_handle_message_with_agent``) reads this to pick its\n+    # guidance: interactive platforms (Telegram, Slack, Discord DMs, …) get\n+    # \"report the restore and ask what the user wants next\"; non-interactive\n+    # event platforms (webhook) get \"finish the interrupted work\" because\n+    # nobody is there to answer, and an acknowledgement would silently\n+    # abandon the task (#57056).  Read generically via ``getattr(adapter,\n+    # \"interactive_resume\", True)`` — no per-platform branching at the call\n+    # site.\n+    interactive_resume: bool = True\n+\n     # Back-reference to the running ``GatewayRunner``, injected by\n     # ``gateway/run.py`` after the adapter is created. Adapters consume it via\n     # ``getattr(self, \"gateway_runner\", None)`` for cross-platform delivery and"]},{"file":"gateway/platforms/webhook.py","hunks":["@@ -147,6 +147,12 @@ def check_webhook_requirements() -> bool:\n class WebhookAdapter(BasePlatformAdapter):\n     \"\"\"Generic webhook receiver that triggers agent runs from HTTP POSTs.\"\"\"\n \n+    # No human is present to answer a \"session restored — what next?\" prompt:\n+    # webhook runs are event-triggered.  The startup auto-resume turn must\n+    # instruct the model to FINISH the interrupted work instead of emitting an\n+    # interactive acknowledgement that abandons the task (#57056).\n+    interactive_resume: bool = False\n+\n     def __init__(self, config: PlatformConfig):\n         super().__init__(config, Platform.WEBHOOK)\n         # ``host`` may be None (dual-stack default) or a user-pinned string."]},{"file":"gateway/run.py","hunks":["@@ -712,6 +712,75 @@ def _is_fresh_gateway_interruption(\n     return current - timestamp <= window\n \n \n+def build_resume_recovery_note(\n+    reason: Optional[str],\n+    message: str = \"\",\n+    *,\n+    interactive: bool = True,\n+) -> str:\n+    \"\"\"Build the resume-pending recovery system note for an interrupted turn.\n+\n+    ``reason`` is the session's ``resume_reason`` (``restart_timeout``,\n+    ``shutdown_timeout``, or anything else → generic interruption phrasing).\n+    ``message`` is the user's NEW message text; empty means this is the\n+    startup auto-resume turn synthesized by\n+    ``_schedule_resume_pending_sessions`` with no human message attached.\n+\n+    ``interactive`` selects the empty-message guidance: on interactive\n+    platforms a human is present, so \"report the restore and ask what next\"\n+    is right.  On non-interactive event platforms (webhook, API server —\n+    adapters with ``interactive_resume = False``) nobody can answer; the\n+    resumed turn must instead complete the interrupted work, or the task is\n+    silently abandoned behind a \"restored\" acknowledgement that goes\n+    nowhere (#57056).\n+    \"\"\"\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+    if message:\n+        resume_guidance = (\n+            \"Address the user's NEW message below FIRST and focus \"\n+            \"on what the user is asking now.\"\n+        )\n+        tail_guidance = (\n+            \"Do NOT re-execute old tool calls — skip any \"\n+            \"unfinished work from the conversation history.\"\n+        )\n+    elif interactive:\n+        resume_guidance = (\n+            \"Report to the user that the session was restored \"\n+            \"successfully and ask what they would like to do next.\"\n+        )\n+        tail_guidance = (\n+            \"Do NOT re-execute old tool calls — skip any \"\n+            \"unfinished work from the conversation history.\"\n+        )\n+    else:\n+        resume_guidance = (\n+            \"No user is present on this non-interactive platform, \"\n+            \"so do NOT emit a 'session restored' acknowledgement \"\n+            \"or ask questions. Review the conversation history and \"\n+            \"CONTINUE the interrupted task to completion.\"\n+        )\n+        tail_guidance = (\n+            \"Do NOT re-run tool calls whose results already \"\n+            \"appear in the history — resume from the first step \"\n+            \"that has no recorded result.\"\n+        )\n+    return (\n+        f\"[System note: The previous turn was interrupted by \"\n+        f\"{reason_phrase}; the gateway is now back online. \"\n+        f\"Any restart/shutdown command in the history has already \"\n+        f\"run — do NOT re-execute or verify it. {resume_guidance} \"\n+        f\"{tail_guidance}]\"\n+        + (f\"\\n\\n{message}\" if message else \"\")\n+    )\n+\n+\n # Assistant-message fields that must survive transcript replay so multi-turn\n # reasoning context, prefix-cache hits, and provider-specific echo\n # requirements all behave the same on the gateway as they do in the CLI.","@@ -6186,7 +6255,7 @@ async def _cleanup_agent_resources_off_loop(\n         \"\"\"\n         if agent is None:\n             return\n-        if context.startswith(\"shutdown\"):\n+        if context.startswith(\"shutdown\") or context == \"session expiry\":\n             try:\n                 agent._end_session_on_close = False\n             except Exception:","@@ -11317,6 +11386,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 context_note = \"[System note: The user's previous session was stopped and suspended. This is a fresh conversation with no prior context.]\"\n             elif reset_reason == \"daily\":\n                 context_note = \"[System note: The user's session was automatically reset by the daily schedule. This is a fresh conversation with no prior context.]\"\n+            elif reset_reason == \"resume_pending_expired\":\n+                context_note = \"[System note: The previous gateway session could not be recovered after a restart (API recovery timed out). This is a fresh conversation — use /resume to restore history if needed.]\"\n             else:\n                 context_note = \"[System note: The user's previous session expired due to inactivity. This is a fresh conversation with no prior context.]\"\n             context_prompt = context_note + \"\\n\\n\" + context_prompt","@@ -11332,9 +11403,11 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                 )\n                 platform_name = source.platform.value if source.platform else \"\"\n                 had_activity = getattr(session_entry, 'reset_had_activity', False)\n-                # Suspended sessions always notify (they were explicitly stopped\n-                # or crashed mid-operation) — skip the policy check.\n-                should_notify = reset_reason == \"suspended\" or (\n+                # Suspended and restart-recovery-expired sessions always notify\n+                # regardless of policy.notify — the user had an active session\n+                # that was silently replaced, so they need to know they can\n+                # /resume it.  Idle/daily resets respect the policy flag.\n+                should_notify = reset_reason in {\"suspended\", \"resume_pending_expired\"} or (\n                     policy.notify\n                     and had_activity\n                     and platform_name not in policy.notify_exclude_platforms","@@ -11344,6 +11417,8 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g\n                     if adapter:\n                         if reset_reason == \"suspended\":\n                             reason_text = \"previous session was stopped or interrupted\"\n+                        elif reset_reason == \"resume_pending_expired\":\n+                            reason_text = \"gateway restart recovery timed out\"\n                         elif reset_reason == \"daily\":\n                             reason_text = f\"daily schedule at {policy.at_hour}:00\"\n                         else:","@@ -19514,36 +19589,21 @@ def _approval_notify_sync(approval_data: dict) -> None:\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                 _persist_user_message_override = message\n                 # The empty-message case is the auto-resume startup turn\n                 # synthesized by _schedule_resume_pending_sessions — there is\n-                # no NEW user message to address, so tell the model to report\n-                # recovery instead of the (nonexistent) \"new message\".\n-                if message:\n-                    _resume_guidance = (\n-                        \"Address the user's NEW message below FIRST and focus \"\n-                        \"on what the user is asking now.\"\n-                    )\n-                else:\n-                    _resume_guidance = (\n-                        \"Report to the user that the session was restored \"\n-                        \"successfully and ask what they would like to do next.\"\n-                    )\n-                message = (\n-                    f\"[System note: The previous turn was interrupted by \"\n-                    f\"{_reason_phrase}; the gateway is now back online. \"\n-                    f\"Any restart/shutdown command in the history has already \"\n-                    f\"run — do NOT re-execute or verify it. {_resume_guidance} \"\n-                    f\"Do NOT re-execute old tool calls — skip any unfinished \"\n-                    f\"work from the conversation history.]\"\n-                    + (f\"\\n\\n{message}\" if message else \"\")\n+                # no NEW user message to address.  Guidance is adapter-aware:\n+                # interactive platforms report the restore and ask what next;\n+                # non-interactive event platforms (webhook, API server)\n+                # continue the interrupted work instead, because nobody is\n+                # present to answer and an acknowledgement would silently\n+                # abandon the task (#57056).\n+                _resume_adapter = self._adapter_for_source(source)\n+                _interactive_resume = bool(\n+                    getattr(_resume_adapter, \"interactive_resume\", True)\n+                )\n+                message = build_resume_recovery_note(\n+                    _reason, message, interactive=_interactive_resume,\n                 )\n             elif _has_fresh_tool_tail:\n                 _persist_user_message_override = message","@@ -19584,22 +19644,13 @@ def _approval_notify_sync(approval_data: dict) -> None:\n                 _sn_reason = (\n                     getattr(_resume_entry, \"resume_reason\", None) or \"restart_timeout\"\n                 )\n-                _sn_reason_phrase = (\n-                    \"a gateway restart\"\n-                    if _sn_reason == \"restart_timeout\"\n-                    else \"a gateway shutdown\"\n-                    if _sn_reason == \"shutdown_timeout\"\n-                    else \"a gateway interruption\"\n-                )\n-                message = (\n-                    f\"[System note: The previous turn was interrupted by \"\n-                    f\"{_sn_reason_phrase}; the gateway is now back online. \"\n-                    f\"Any restart/shutdown command in the history has already \"\n-                    f\"run — do NOT re-execute or verify it. Report to the user \"\n-                    f\"that the session was restored successfully and ask what \"\n-                    f\"they would like to do next. Do NOT re-execute old tool \"\n-                    f\"calls — skip any unfinished work from the conversation \"\n-                    f\"history.]\"\n+                _sn_adapter = self._adapter_for_source(source)\n+                message = build_resume_recovery_note(\n+                    _sn_reason,\n+                    \"\",\n+                    interactive=bool(\n+                        getattr(_sn_adapter, \"interactive_resume\", True)\n+                    ),\n                 )\n \n             _approval_session_key = session_key or \"\""]},{"file":"gateway/session.py","hunks":["@@ -1043,6 +1043,21 @@ def __init__(self, sessions_dir: Path, config: GatewayConfig,\n             self._db = SessionDB()\n         except Exception as e:\n             print(f\"[gateway] Warning: SQLite session store unavailable, falling back to JSONL: {e}\")\n+\n+    def _has_active_processes_safe(self, session_key: str, *, context: str) -> bool:\n+        \"\"\"Return whether a session has active work, failing closed on registry errors.\"\"\"\n+        if self._has_active_processes_fn is None:\n+            return False\n+        try:\n+            return bool(self._has_active_processes_fn(session_key))\n+        except Exception as exc:\n+            logger.warning(\n+                \"has_active_processes_fn raised during %s for %s; keeping session alive: %s\",\n+                context,\n+                session_key,\n+                exc,\n+            )\n+            return True\n     \n     def _ensure_loaded(self) -> None:\n         \"\"\"Load sessions index from disk if not already loaded.\"\"\"","@@ -1178,12 +1193,14 @@ def _prune_stale_sessions_locked(self) -> None:\n                 # end_reason not None -> session ended — prune\n                 if row is not None and row.get(\"end_reason\") is not None:\n                     recovered_entry = None\n+                    recovery_lookup_failed = False\n                     if entry.origin is not None:\n                         try:\n                             recovered_entry = self._recover_session_from_db(\n                                 session_key=key,\n                                 source=entry.origin,\n                                 now=_now(),\n+                                raise_on_lookup_error=True,\n                             )\n                         except Exception as exc:\n                             logger.debug(","@@ -1193,6 +1210,10 @@ def _prune_stale_sessions_locked(self) -> None:\n                                 entry.session_id,\n                                 exc,\n                             )\n+                            recovery_lookup_failed = True\n+\n+                    if recovery_lookup_failed:\n+                        continue\n \n                     # If the stale entry points at a compression-ended parent but\n                     # a newer live child session exists for the exact same gateway","@@ -1416,6 +1437,7 @@ def _recover_session_from_db(\n         session_key: str,\n         source: SessionSource,\n         now: datetime,\n+        raise_on_lookup_error: bool = False,\n     ) -> Optional[SessionEntry]:\n         \"\"\"Rebuild a missing session-key mapping from durable state.db data.\"\"\"\n         if not self._db:","@@ -1434,6 +1456,8 @@ def _recover_session_from_db(\n             )\n         except Exception as exc:\n             logger.debug(\"Gateway session DB recovery failed for %s: %s\", session_key, exc)\n+            if raise_on_lookup_error:\n+                raise\n             return None\n         if not recovered:\n             return None","@@ -1582,6 +1606,22 @@ def set_expiry_finalized(\n                         \"Session DB expiry_finalized write failed for %s: %s\",\n                         entry.session_id, exc,\n                     )\n+            try:\n+                # Expiry finalization is a real conversation boundary. Without\n+                # a durable ``session_reset`` end_reason, later agent cleanup can\n+                # close the row as ``agent_close``; stale-route recovery treats\n+                # that as resumable and resurrects the expired full history.\n+                #\n+                # promote_to_session_reset is conditional: it only promotes\n+                # live rows or rows ended with ``agent_close``.  Explicit\n+                # boundaries (compression, session_reset, new_command, etc.)\n+                # are preserved — the first writer wins.\n+                self._db.promote_to_session_reset(entry.session_id)\n+            except Exception as exc:\n+                logger.debug(\n+                    \"Session DB promote_to_session_reset failed for %s: %s\",\n+                    entry.session_id, exc,\n+                )\n     \n     def _is_session_expired(self, entry: SessionEntry) -> bool:\n         \"\"\"Check if a session has expired based on its reset policy.","@@ -1590,13 +1630,12 @@ def _is_session_expired(self, entry: SessionEntry) -> bool:\n         Used by the background expiry watcher to proactively flush memories.\n         Sessions with active background processes are never considered expired.\n         \"\"\"\n-        if self._has_active_processes_fn:\n-            if self._has_active_processes_fn(entry.session_key):\n-                logger.debug(\n-                    \"Session %s not expired — active background processes\",\n-                    entry.session_key,\n-                )\n-                return False\n+        if self._has_active_processes_safe(entry.session_key, context=\"expiry\"):\n+            logger.debug(\n+                \"Session %s not expired — active background processes\",\n+                entry.session_key,\n+            )\n+            return False\n \n         policy = self.config.get_reset_policy(\n             platform=entry.platform,","@@ -1691,14 +1730,13 @@ def _should_reset(self, entry: SessionEntry, source: SessionSource) -> Optional[\n         \n         Sessions with active background processes are never reset.\n         \"\"\"\n-        if self._has_active_processes_fn:\n-            session_key = self._generate_session_key(source)\n-            if self._has_active_processes_fn(session_key):\n-                logger.debug(\n-                    \"Session reset skipped for %s — active background processes\",\n-                    session_key,\n-                )\n-                return None\n+        session_key = self._generate_session_key(source)\n+        if self._has_active_processes_safe(session_key, context=\"reset\"):\n+            logger.debug(\n+                \"Session reset skipped for %s — active background processes\",\n+                session_key,\n+            )\n+            return None\n \n         policy = self.config.get_reset_policy(\n             platform=source.platform,","@@ -1895,13 +1933,24 @@ def _get_or_create_session_impl(\n             elif _entry_for_checks.resume_pending:\n                 _reset_reason = self._should_reset(_entry_for_checks, source)\n                 if not _reset_reason:\n-                    _fw = auto_continue_freshness_window()\n-                    _ref_time = (\n-                        _entry_for_checks.last_resume_marked_at\n-                        or _entry_for_checks.updated_at\n+                    # Freshness-gate stale resume_pending zombies (#46934) —\n+                    # but honor an explicit ``session_reset.mode: none``: the\n+                    # user opted out of ALL automatic resets, so an expired\n+                    # resume marker must fall through to a normal resume of\n+                    # the preserved transcript, never a silent fresh session\n+                    # (#61052).\n+                    _policy = self.config.get_reset_policy(\n+                        platform=source.platform,\n+                        session_type=source.chat_type,\n                     )\n-                    if _fw > 0 and (now - _ref_time).total_seconds() > _fw:\n-                        _reset_reason = \"resume_pending_expired\"\n+                    if _policy.mode != \"none\":\n+                        _fw = auto_continue_freshness_window()\n+                        _ref_time = (\n+                            _entry_for_checks.last_resume_marked_at\n+                            or _entry_for_checks.updated_at\n+                        )\n+                        if _fw > 0 and (now - _ref_time).total_seconds() > _fw:\n+                            _reset_reason = \"resume_pending_expired\"\n             else:\n                 _reset_reason = self._should_reset(_entry_for_checks, source)\n ","@@ -1938,6 +1987,14 @@ def _get_or_create_session_impl(\n                         session_key, entry.session_id,\n                     )\n                     self._entries.pop(session_key, None)\n+                    # If an expiry watcher (daily/idle reset) already finalized\n+                    # this session, honour the reset decision instead of silently\n+                    # reopening it via recovery.\n+                    if _reset_reason:\n+                        was_auto_reset = True\n+                        auto_reset_reason = _reset_reason\n+                        reset_had_activity = entry.last_prompt_tokens > 0\n+                        db_end_session_id = entry.session_id\n                     entry = None\n                     _needs_recover = True\n                 elif entry.session_id != _stale_session_id:","@@ -2023,8 +2080,21 @@ def _get_or_create_session_impl(\n \n         # SQLite operations outside the lock (unchanged).\n         if self._db and db_end_session_id:\n+            # Use the specific reset reason so state.db is auditable (e.g.\n+            # \"resume_pending_expired\" is distinguishable from a normal\n+            # \"session_reset\" caused by idle/daily expiry).\n+            _db_end_reason = auto_reset_reason if auto_reset_reason else \"session_reset\"\n             try:\n-                self._db.end_session(db_end_session_id, \"session_reset\")\n+                # promote_to_session_reset, not end_session: the row may\n+                # already be ended with a recoverable accidental reason\n+                # (agent_close / ws_orphan_reap), which first-reason-wins\n+                # end_session would preserve — leaving the reset session\n+                # resurrectable by stale-route recovery (#61220, #61993).\n+                _promote = getattr(self._db, \"promote_to_session_reset\", None)\n+                if callable(_promote):\n+                    _promote(db_end_session_id, _db_end_reason)\n+                else:\n+                    self._db.end_session(db_end_session_id, _db_end_reason)\n             except Exception as e:\n                 logger.debug(\"Session DB operation failed: %s\", e)\n ","@@ -2193,19 +2263,8 @@ def prune_old_entries(self, max_age_days: int) -> int:\n                 # The callback is keyed by session_key (see process_registry.\n                 # has_active_for_session); passing session_id here used to\n                 # never match, so active sessions got pruned anyway.\n-                if self._has_active_processes_fn is not None:\n-                    try:\n-                        if self._has_active_processes_fn(entry.session_key):\n-                            continue\n-                    except Exception as exc:\n-                        logger.debug(\n-                            \"has_active_processes_fn raised during prune for %s: %s\",\n-                            entry.session_key, exc,\n-                        )\n-                        # Fail safe: if we can't tell whether a background\n-                        # process is attached, keep the entry rather than\n-                        # risk orphaning live work.\n-                        continue\n+                if self._has_active_processes_safe(entry.session_key, context=\"prune\"):\n+                    continue\n                 if entry.updated_at < cutoff:\n                     removed_keys.append(key)\n             for key in removed_keys:","@@ -2301,7 +2360,15 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) ->\n \n         if self._db and db_end_session_id:\n             try:\n-                self._db.end_session(db_end_session_id, \"session_reset\")\n+                # Promote (not plain end_session): an accidental\n+                # agent_close/ws_orphan_reap end must not survive an explicit\n+                # user reset, or recovery resurrects the reset session\n+                # (#61993 — the user's /new was silently undone).\n+                _promote = getattr(self._db, \"promote_to_session_reset\", None)\n+                if callable(_promote):\n+                    _promote(db_end_session_id, \"session_reset\")\n+                else:\n+                    self._db.end_session(db_end_session_id, \"session_reset\")\n             except Exception as e:\n                 logger.debug(\"Session DB operation failed: %s\", e)\n ","@@ -2362,7 +2429,15 @@ def switch_session(self, session_key: str, target_session_id: str) -> Optional[S\n \n         if self._db and db_end_session_id:\n             try:\n-                self._db.end_session(db_end_session_id, \"session_switch\")\n+                # Promote (not plain end_session): a stale agent_close /\n+                # ws_orphan_reap end on the outgoing session must be upgraded\n+                # to the explicit switch boundary, or recovery can resurrect\n+                # it over the user's /resume choice (#61220 bug class).\n+                _promote = getattr(self._db, \"promote_to_session_reset\", None)\n+                if callable(_promote):\n+                    _promote(db_end_session_id, \"session_switch\")\n+                else:\n+                    self._db.end_session(db_end_session_id, \"session_switch\")\n             except Exception as e:\n                 logger.debug(\"Session DB end_session failed: %s\", e)\n "]},{"file":"hermes_state.py","hunks":["@@ -2286,6 +2286,55 @@ def _do(conn):\n             )\n         self._execute_write(_do)\n \n+    def promote_to_session_reset(\n+        self, session_id: str, reason: str = \"session_reset\"\n+    ) -> bool:\n+        \"\"\"Durably mark a session as ended by an intentional reset boundary.\n+\n+        Promotes *only* live rows (``ended_at IS NULL``) or rows carrying an\n+        accidental end_reason that the recovery query\n+        (``find_latest_gateway_session_for_peer``) treats as recoverable:\n+        ``agent_close`` (older gateway cleanup bug) and ``ws_orphan_reap``\n+        (mistaken TUI reaper).  Explicit conversation boundaries such as\n+        ``compression``, ``session_reset``, ``session_switch``, etc. are\n+        preserved — the first writer wins for those, and a later expiry\n+        finalization must not silently overwrite them.\n+\n+        Plain ``end_session()`` is NOT sufficient for reset boundaries: it\n+        no-ops on an already-ended row, so a row that agent cleanup already\n+        closed as ``agent_close`` would stay recoverable and stale-route\n+        recovery would resurrect the reset session with its full history\n+        (#61220, #61993, #63539).\n+\n+        Keep this promotion set in sync with the recoverable set in\n+        ``find_latest_gateway_session_for_peer`` — any reason recovery would\n+        reopen must be promotable here.\n+\n+        ``reason`` lets reset paths keep their auditable specific reasons\n+        (``idle``, ``daily``, ``suspended``, ``resume_pending_expired``).\n+\n+        Returns ``True`` when the row was promoted, ``False`` when skipped\n+        (already has a different explicit end_reason, or row not found).\n+        \"\"\"\n+        if not session_id:\n+            return False\n+        now = time.time()\n+\n+        def _do(conn):\n+            cursor = conn.execute(\n+                \"UPDATE sessions SET ended_at = ?, end_reason = ? \"\n+                \"WHERE id = ? AND (ended_at IS NULL \"\n+                \"OR end_reason IN ('agent_close', 'ws_orphan_reap'))\",\n+                (now, reason, session_id),\n+            )\n+            return cursor.rowcount\n+\n+        try:\n+            rows = self._execute_write(_do)\n+            return bool(rows)\n+        except Exception:\n+            return False\n+\n     def update_session_cwd(\n         self, session_id: str, cwd: str, git_branch: str = None, git_repo_root: str = None\n     ) -> None:"]},{"file":"scripts/release.py","hunks":["@@ -314,6 +314,8 @@\n     \"[redacted:email]\": \"Diyoncrz18\",\n     \"[redacted:email]\": \"ali-nld\",\n     \"[redacted:email]\": \"necoweb3\",\n+    \"[redacted:email]\": \"DNAlec\",  # PR #61743 salvage (honor reset policy in #54878 stale-heal recovery)\n+    \"[redacted:email]\": \"joelbrilliant\",  # PR #58486 salvage (session-expiry cleanup must not end row as agent_close)\n     \"[redacted:email]\": \"hydracoco7\",  # PR #61382 salvage (id-less cron job freeze)\n     \"[redacted:email]\": \"AlexFucuson9\",  # PR #61209 salvage (hygiene compression data loss)\n     \"[redacted:email]\": \"adambiggs\",  # PR #43819 salvage (holographic shared SQLite connection)"]},{"file":"tests/gateway/test_clean_shutdown_marker.py","hunks":["@@ -245,6 +245,33 @@ async def _run():\n         assert agent._end_session_on_close is False\n         agent.close.assert_called_once()\n \n+    def test_session_expiry_cleanup_preserves_lazy_reset_boundary(self, tmp_path, monkeypatch):\n+        \"\"\"Session expiry cleanup must not turn an expired chat into an agent_close row.\n+\n+        The expiry watcher only tears down cached resources. The next inbound\n+        message owns the reset boundary, creating a fresh session with the\n+        normal auto-reset notice. If cleanup lets ``agent.close()`` end the\n+        SQLite row as ``agent_close``, stale-route recovery treats it as\n+        recoverable and resurrects the expired session instead.\n+        \"\"\"\n+        monkeypatch.setattr(\"gateway.run._hermes_home\", tmp_path)\n+        from gateway.run import GatewayRunner\n+\n+        runner = object.__new__(GatewayRunner)\n+        agent = MagicMock()\n+        agent._end_session_on_close = True\n+\n+        async def _run():\n+            await GatewayRunner._cleanup_agent_resources_off_loop(\n+                runner, agent, context=\"session expiry\"\n+            )\n+\n+        import asyncio\n+        asyncio.get_event_loop().run_until_complete(_run())\n+\n+        assert agent._end_session_on_close is False\n+        agent.close.assert_called_once()\n+\n \n # ---------------------------------------------------------------------------\n # resume_pending freshness gate (#46934)","@@ -285,7 +312,12 @@ def test_fresh_resume_pending_returns_same_session(self, tmp_path):\n \n     def test_stale_resume_pending_falls_through_to_reset(self, tmp_path, monkeypatch):\n         monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"3600\")\n-        store = _make_store(tmp_path)\n+        # The freshness gate only applies when the user has opted into\n+        # automatic resets — session_reset.mode: none disables it (#61052).\n+        from gateway.config import SessionResetPolicy\n+        store = _make_store(\n+            tmp_path, policy=SessionResetPolicy(mode=\"idle\", idle_minutes=999999)\n+        )\n         source = _make_source()\n         entry = self._mark_resume_pending(store, source)\n ","@@ -302,6 +334,25 @@ def test_stale_resume_pending_falls_through_to_reset(self, tmp_path, monkeypatch\n         assert fresh.session_id != entry.session_id\n         assert not fresh.resume_pending\n \n+    def test_reset_mode_none_disables_freshness_gate(self, tmp_path, monkeypatch):\n+        \"\"\"session_reset.mode: none opts out of ALL automatic resets —\n+        including the resume_pending freshness gate (#61052).\"\"\"\n+        monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"3600\")\n+        from gateway.config import SessionResetPolicy\n+        store = _make_store(tmp_path, policy=SessionResetPolicy(mode=\"none\"))\n+        source = _make_source()\n+        entry = self._mark_resume_pending(store, source)\n+\n+        with store._lock:\n+            entry.last_resume_marked_at = datetime.now() - timedelta(seconds=7200)\n+            entry.updated_at = datetime.now()\n+            store._save()\n+\n+        refreshed = store.get_or_create_session(source)\n+        # Explicit opt-out honored: same session back, transcript preserved.\n+        assert refreshed.session_id == entry.session_id\n+        assert refreshed.resume_pending\n+\n     def test_freshness_gate_disabled_returns_stale_session(self, tmp_path, monkeypatch):\n         # Opt-out: window <= 0 restores the pre-fix \"always fresh\" behaviour.\n         monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"0\")"]},{"file":"tests/gateway/test_restart_resume_pending.py","hunks":["@@ -41,6 +41,7 @@\n     _is_fresh_gateway_interruption,\n     _last_transcript_timestamp,\n     _should_clear_resume_pending_after_turn,\n+    build_resume_recovery_note,\n )\n from gateway.session import SessionEntry, SessionSource, SessionStore\n from tests.gateway.restart_test_helpers import (","@@ -152,32 +153,9 @@ def _simulate_note_injection(\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-        if message:\n-            resume_guidance = (\n-                \"Address the user's NEW message below FIRST and focus \"\n-                \"on what the user is asking now.\"\n-            )\n-        else:\n-            resume_guidance = (\n-                \"Report to the user that the session was restored \"\n-                \"successfully and ask what they would like to do next.\"\n-            )\n-        message = (\n-            f\"[System note: The previous turn was interrupted by \"\n-            f\"{reason_phrase}; the gateway is now back online. \"\n-            f\"Any restart/shutdown command in the history has already \"\n-            f\"run — do NOT re-execute or verify it. {resume_guidance} \"\n-            f\"Do NOT re-execute old tool calls — skip any unfinished \"\n-            f\"work from the conversation history.]\"\n-            + (f\"\\n\\n{message}\" if message else \"\")\n-        )\n+        # Real production note builder — extracted to module scope in\n+        # gateway/run.py so tests exercise the actual strings.\n+        message = build_resume_recovery_note(reason, message)\n     elif has_fresh_tool_tail:\n         message = (\n             \"[System note: A new message has arrived. The conversation \"","@@ -196,23 +174,7 @@ def _simulate_note_injection(\n         and getattr(resume_entry, \"resume_pending\", False)\n     ):\n         sn_reason = getattr(resume_entry, \"resume_reason\", None) or \"restart_timeout\"\n-        sn_reason_phrase = (\n-            \"a gateway restart\"\n-            if sn_reason == \"restart_timeout\"\n-            else \"a gateway shutdown\"\n-            if sn_reason == \"shutdown_timeout\"\n-            else \"a gateway interruption\"\n-        )\n-        message = (\n-            f\"[System note: The previous turn was interrupted by \"\n-            f\"{sn_reason_phrase}; the gateway is now back online. \"\n-            f\"Any restart/shutdown command in the history has already \"\n-            f\"run — do NOT re-execute or verify it. Report to the user \"\n-            f\"that the session was restored successfully and ask what \"\n-            f\"they would like to do next. Do NOT re-execute old tool \"\n-            f\"calls — skip any unfinished work from the conversation \"\n-            f\"history.]\"\n-        )\n+        message = build_resume_recovery_note(sn_reason, \"\")\n     return message\n \n ","@@ -521,6 +483,34 @@ def test_resume_pending_shutdown_note_mentions_shutdown(self):\n         )\n         assert \"gateway shutdown\" in result\n \n+    def test_empty_message_interactive_note_asks_what_next(self):\n+        \"\"\"Interactive platforms: the startup auto-resume turn reports the\n+        restore and asks the (present) human what to do next.\"\"\"\n+        note = build_resume_recovery_note(\"restart_timeout\", \"\", interactive=True)\n+        assert \"session was restored\" in note\n+        assert \"ask what they would like to do next\" in note\n+        assert \"skip any unfinished work\" in note\n+\n+    def test_empty_message_noninteractive_note_continues_task(self):\n+        \"\"\"Non-interactive platforms (webhook, API server): nobody can answer\n+        'what next?', so the resumed turn must complete the interrupted work\n+        instead of acknowledging (#57056).\"\"\"\n+        note = build_resume_recovery_note(\"restart_timeout\", \"\", interactive=False)\n+        assert \"CONTINUE the interrupted task\" in note\n+        assert \"session was restored\" not in note\n+        assert \"ask what they would like to do next\" not in note\n+        # Must not tell the model to skip the unfinished work it should finish.\n+        assert \"skip any unfinished work\" not in note\n+        # But still guards against re-running already-recorded tool calls.\n+        assert \"already appear in the history\" in note\n+\n+    def test_new_message_guidance_identical_regardless_of_interactivity(self):\n+        \"\"\"A real NEW user message always wins — same guidance either way.\"\"\"\n+        a = build_resume_recovery_note(\"restart_timeout\", \"do the thing\", interactive=True)\n+        b = build_resume_recovery_note(\"restart_timeout\", \"do the thing\", interactive=False)\n+        assert a == b\n+        assert \"NEW message\" in a\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``.\"\"\""]},{"file":"tests/gateway/test_session_reset_notify.py","hunks":["@@ -5,10 +5,11 @@\n - SessionEntry captures auto_reset_reason\n - SessionResetPolicy.notify controls whether notifications are sent\n - notify_exclude_platforms skips notifications for excluded platforms\n+- resume_pending_expired auto-reset sets the correct reason and DB end_reason\n \"\"\"\n \n from datetime import datetime, timedelta\n-\n+from unittest.mock import MagicMock, patch\n \n from gateway.config import (\n     GatewayConfig,","@@ -30,11 +31,15 @@ def _make_source(platform=Platform.TELEGRAM, chat_id=\"123\", user_id=\"u1\"):\n     )\n \n \n-def _make_store(policy=None, tmp_path=None):\n+def _make_store(policy=None, tmp_path=None, has_active_processes_fn=None):\n     config = GatewayConfig()\n     if policy:\n         config.default_reset_policy = policy\n-    store = SessionStore(sessions_dir=tmp_path or \"/tmp/test-sessions\", config=config)\n+    store = SessionStore(\n+        sessions_dir=tmp_path or \"/tmp/test-sessions\",\n+        config=config,\n+        has_active_processes_fn=has_active_processes_fn,\n+    )\n     return store\n \n ","@@ -100,6 +105,45 @@ def test_returns_none_when_mode_is_none(self, tmp_path):\n         source = _make_source()\n         assert store._should_reset(entry, source) is None\n \n+    def test_returns_none_when_active_process_check_raises(self, tmp_path):\n+        def _raise(_session_key):\n+            raise RuntimeError(\"process registry unavailable\")\n+\n+        store = _make_store(\n+            SessionResetPolicy(mode=\"idle\", idle_minutes=30),\n+            tmp_path,\n+            has_active_processes_fn=_raise,\n+        )\n+        entry = SessionEntry(\n+            session_key=\"test\",\n+            session_id=\"s1\",\n+            created_at=datetime.now() - timedelta(hours=2),\n+            updated_at=datetime.now() - timedelta(hours=1),\n+        )\n+        source = _make_source()\n+\n+        assert store._should_reset(entry, source) is None\n+\n+    def test_is_session_expired_fails_closed_when_active_process_check_raises(self, tmp_path):\n+        def _raise(_session_key):\n+            raise RuntimeError(\"process registry unavailable\")\n+\n+        store = _make_store(\n+            SessionResetPolicy(mode=\"idle\", idle_minutes=30),\n+            tmp_path,\n+            has_active_processes_fn=_raise,\n+        )\n+        entry = SessionEntry(\n+            session_key=\"test\",\n+            session_id=\"s1\",\n+            platform=Platform.TELEGRAM,\n+            chat_type=\"dm\",\n+            created_at=datetime.now() - timedelta(hours=2),\n+            updated_at=datetime.now() - timedelta(hours=1),\n+        )\n+\n+        assert store._is_session_expired(entry) is False\n+\n \n # ---------------------------------------------------------------------------\n # SessionEntry captures reason","@@ -278,3 +322,161 @@ def test_auto_reset_reason_none_roundtrip(self, tmp_path):\n         assert reloaded.was_auto_reset is False\n         assert reloaded.auto_reset_reason is None\n         assert reloaded.reset_had_activity is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# resume_pending_expired: auto_reset_reason and DB end_reason (#58933)\n+# ---------------------------------------------------------------------------\n+\n+def _make_db_mock() -> MagicMock:\n+    \"\"\"Return a SessionDB mock with safe defaults for all lookup methods.\"\"\"\n+    db = MagicMock()\n+    db.get_session.return_value = None\n+    db.get_compression_tip.return_value = None  # avoids MagicMock leaking into session_id\n+    db.find_latest_gateway_session_for_peer.return_value = None\n+    db.reopen_session.return_value = None\n+    db.create_session.return_value = None\n+    return db\n+\n+\n+def _make_store_with_db(tmp_path, db_mock=None, policy=None) -> SessionStore:\n+    \"\"\"Build a SessionStore with a mock SessionDB, bypassing disk load.\"\"\"\n+    cfg_policy = policy or SessionResetPolicy(mode=\"none\")\n+    config = GatewayConfig(default_reset_policy=cfg_policy)\n+    with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+        store = SessionStore(sessions_dir=tmp_path, config=config)\n+    store._db = db_mock if db_mock is not None else _make_db_mock()\n+    store._loaded = True\n+    return store\n+\n+\n+class TestResumePendingExpiredAutoReset:\n+    \"\"\"resume_pending sessions past the freshness window should fire\n+    was_auto_reset=True with auto_reset_reason='resume_pending_expired' and\n+    persist that reason to state.db (#58933).\"\"\"\n+\n+    def _seed_stale_resume_pending(self, store, source, freshness_seconds=3600):\n+        \"\"\"Create a session, mark it resume_pending, then backdate the mark\n+        past the freshness window so get_or_create_session treats it as a\n+        zombie.\"\"\"\n+        entry = store.get_or_create_session(source)\n+        store.mark_resume_pending(entry.session_key)\n+        with store._lock:\n+            entry = store._entries[entry.session_key]\n+            entry.last_resume_marked_at = (\n+                datetime.now() - timedelta(seconds=freshness_seconds + 60)\n+            )\n+            entry.updated_at = datetime.now()  # keep updated_at fresh\n+            store._save()\n+        return entry\n+\n+    def test_stale_resume_pending_sets_auto_reset_reason(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"Stale resume_pending triggers was_auto_reset=True with reason\n+        'resume_pending_expired', NOT 'idle'.\"\"\"\n+        monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"3600\")\n+        # The freshness gate requires an opted-in reset policy — mode \"none\"\n+        # disables it entirely (#61052). Use a huge idle window so only the\n+        # freshness gate (not the idle policy) can fire.\n+        store = _make_store_with_db(\n+            tmp_path,\n+            policy=SessionResetPolicy(mode=\"idle\", idle_minutes=999999),\n+        )\n+        source = _make_source()\n+\n+        old = self._seed_stale_resume_pending(store, source)\n+\n+        new = store.get_or_create_session(source)\n+\n+        assert new.session_id != old.session_id, \"should have created a new session\"\n+        assert new.was_auto_reset is True\n+        assert new.auto_reset_reason == \"resume_pending_expired\"\n+\n+    def test_stale_resume_pending_had_activity_flag(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"reset_had_activity reflects whether the old session was used.\"\"\"\n+        monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"3600\")\n+        store = _make_store_with_db(\n+            tmp_path,\n+            policy=SessionResetPolicy(mode=\"idle\", idle_minutes=999999),\n+        )\n+        source = _make_source()\n+\n+        old = self._seed_stale_resume_pending(store, source)\n+        # Simulate some conversation on the old session.\n+        with store._lock:\n+            old.last_prompt_tokens = [redacted:secret-assignment]\n+            store._save()\n+\n+        new = store.get_or_create_session(source)\n+        assert new.reset_had_activity is True\n+\n+    def test_stale_resume_pending_db_end_reason_is_specific(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"state.db must record end_reason='resume_pending_expired', NOT the\n+        generic 'session_reset', so the event is auditable (#58933 fix).\"\"\"\n+        monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"3600\")\n+        db = _make_db_mock()\n+        store = _make_store_with_db(\n+            tmp_path, db,\n+            policy=SessionResetPolicy(mode=\"idle\", idle_minutes=999999),\n+        )\n+        source = _make_source()\n+\n+        old = self._seed_stale_resume_pending(store, source)\n+        store.get_or_create_session(source)\n+\n+        # Auto-reset now writes through promote_to_session_reset so an\n+        # accidental agent_close end can't shadow the reset boundary.\n+        db.promote_to_session_reset.assert_called_once()\n+        ended_id, ended_reason = db.promote_to_session_reset.call_args.args\n+        assert ended_id == old.session_id\n+        assert ended_reason == \"resume_pending_expired\", (\n+            f\"expected 'resume_pending_expired', got {ended_reason!r} — \"\n+            \"the DB end_reason must not be the generic 'session_reset'\"\n+        )\n+\n+    def test_idle_reset_db_end_reason_reflects_idle(\n+        self, tmp_path\n+    ):\n+        \"\"\"Regular idle auto-reset persists 'idle' as end_reason so that all\n+        auto-reset paths are auditable (#58933 should not regress the common\n+        idle/daily path).\"\"\"\n+        db = _make_db_mock()\n+        store = _make_store_with_db(\n+            tmp_path, db, policy=SessionResetPolicy(mode=\"idle\", idle_minutes=1)\n+        )\n+        source = _make_source()\n+\n+        entry = store.get_or_create_session(source)\n+        # Age past idle threshold.\n+        with store._lock:\n+            entry.updated_at = datetime.now() - timedelta(minutes=5)\n+            store._save()\n+\n+        store.get_or_create_session(source)\n+\n+        db.promote_to_session_reset.assert_called_once()\n+        _, ended_reason = db.promote_to_session_reset.call_args.args\n+        assert ended_reason == \"idle\"\n+\n+    def test_freshness_disabled_skips_resume_pending_expired(\n+        self, tmp_path, monkeypatch\n+    ):\n+        \"\"\"When gateway_auto_continue_freshness=0, resume_pending is never\n+        expired — the same session is returned regardless of age.\"\"\"\n+        monkeypatch.setenv(\"HERMES_AUTO_CONTINUE_FRESHNESS\", \"0\")\n+        db = _make_db_mock()\n+        store = _make_store_with_db(tmp_path, db)\n+        source = _make_source()\n+\n+        old = self._seed_stale_resume_pending(store, source, freshness_seconds=999_999)\n+\n+        refreshed = store.get_or_create_session(source)\n+        # Freshness disabled → same session, no DB end_session call.\n+        assert refreshed.session_id == old.session_id\n+        db.end_session.assert_not_called()\n+        db.promote_to_session_reset.assert_not_called()"]},{"file":"tests/gateway/test_session_store_expiry_finalized.py","hunks":["@@ -0,0 +1,184 @@\n+\"\"\"Session expiry finalization closes sessions as session_reset.\n+\n+Regression coverage for #61220: the expiry watcher marks a session expired,\n+then agent cleanup can close it as ``agent_close``. Stale routing recovery treats\n+``agent_close`` as recoverable, so expired sessions were reopened with full\n+history unless expiry finalization also persisted the real conversation boundary\n+as ``end_reason='session_reset'``.\n+\n+These tests use a real ``SessionDB`` (in-memory) to verify the actual recovery\n+contract in ``find_latest_gateway_session_for_peer`` — not just call counts on\n+a MagicMock.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import time\n+from pathlib import Path\n+\n+import pytest\n+\n+from hermes_state import SessionDB\n+\n+\n[redacted:email]\n+def db(tmp_path: Path) -> SessionDB:\n+    return SessionDB(tmp_path / \"state.db\")\n+\n+\n+_SESSION_KEY = \"agent:main:telegram:dm:8494508720\"\n+_SOURCE = \"telegram\"\n+_USER_ID = \"8494508720\"\n+\n+\n+# ------------------------------------------------------------------\n+# promote_to_session_reset — unit tests on real DB\n+# ------------------------------------------------------------------\n+\n+class TestPromoteToSessionReset:\n+    \"\"\"promote_to_session_reset promotes only safe rows.\"\"\"\n+\n+    def test_promotes_live_row(self, db: SessionDB) -> None:\n+        \"\"\"A live row (ended_at IS NULL) is promoted to session_reset.\"\"\"\n+        db.create_session(\n+            \"sid-live\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        # Seed a message so recovery would match\n+        db.append_message(\"sid-live\", \"user\", \"hello\")\n+\n+        assert db.promote_to_session_reset(\"sid-live\") is True\n+        row = db.get_session(\"sid-live\")\n+        assert row[\"end_reason\"] == \"session_reset\"\n+        assert row[\"ended_at\"] is not None\n+\n+    def test_promotes_agent_close_row(self, db: SessionDB) -> None:\n+        \"\"\"A row ended with agent_close is promoted to session_reset.\"\"\"\n+        db.create_session(\n+            \"sid-ac\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.append_message(\"sid-ac\", \"user\", \"hello\")\n+        db.end_session(\"sid-ac\", \"agent_close\")\n+\n+        assert db.promote_to_session_reset(\"sid-ac\") is True\n+        row = db.get_session(\"sid-ac\")\n+        assert row[\"end_reason\"] == \"session_reset\"\n+\n+    def test_does_not_overwrite_compression(self, db: SessionDB) -> None:\n+        \"\"\"An existing compression boundary must not be overwritten.\"\"\"\n+        db.create_session(\n+            \"sid-comp\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.end_session(\"sid-comp\", \"compression\")\n+\n+        assert db.promote_to_session_reset(\"sid-comp\") is False\n+        row = db.get_session(\"sid-comp\")\n+        assert row[\"end_reason\"] == \"compression\"\n+\n+    def test_does_not_overwrite_existing_session_reset(self, db: SessionDB) -> None:\n+        \"\"\"Already-promoted rows are idempotently skipped.\"\"\"\n+        db.create_session(\n+            \"sid-reset\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.end_session(\"sid-reset\", \"session_reset\")\n+\n+        # Should be a no-op (rowcount = 0)\n+        assert db.promote_to_session_reset(\"sid-reset\") is False\n+        row = db.get_session(\"sid-reset\")\n+        assert row[\"end_reason\"] == \"session_reset\"\n+\n+    def test_does_not_overwrite_new_command(self, db: SessionDB) -> None:\n+        \"\"\"A /new-command boundary is preserved.\"\"\"\n+        db.create_session(\n+            \"sid-new\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.end_session(\"sid-new\", \"new_command\")\n+\n+        assert db.promote_to_session_reset(\"sid-new\") is False\n+        row = db.get_session(\"sid-new\")\n+        assert row[\"end_reason\"] == \"new_command\"\n+\n+    def test_noop_on_missing_session(self, db: SessionDB) -> None:\n+        \"\"\"Non-existent session_id returns False without error.\"\"\"\n+        assert db.promote_to_session_reset(\"nonexistent\") is False\n+\n+    def test_noop_on_empty_session_id(self, db: SessionDB) -> None:\n+        assert db.promote_to_session_reset(\"\") is False\n+\n+\n+# ------------------------------------------------------------------\n+# Integration: promotion blocks stale-route recovery\n+# ------------------------------------------------------------------\n+\n+class TestPromotionBlocksRecovery:\n+    \"\"\"After promotion, find_latest_gateway_session_for_peer must NOT\n+    recover the session — it is now ended with session_reset, which\n+    the recovery query excludes (only live/agent_close are recoverable).\n+    \"\"\"\n+\n+    def test_live_session_recoverable_before_promotion(self, db: SessionDB) -> None:\n+        db.create_session(\n+            \"sid-pre\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.append_message(\"sid-pre\", \"user\", \"hello\")\n+\n+        recovered = db.find_latest_gateway_session_for_peer(\n+            source=_SOURCE, session_key=_SESSION_KEY,\n+            user_id=_USER_ID, chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        assert recovered is not None\n+        assert recovered[\"id\"] == \"sid-pre\"\n+\n+    def test_promoted_session_not_recoverable(self, db: SessionDB) -> None:\n+        db.create_session(\n+            \"sid-post\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.append_message(\"sid-post\", \"user\", \"hello\")\n+        db.promote_to_session_reset(\"sid-post\")\n+\n+        recovered = db.find_latest_gateway_session_for_peer(\n+            source=_SOURCE, session_key=_SESSION_KEY,\n+            user_id=_USER_ID, chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        # session_reset rows are not in the recovery set\n+        assert recovered is None\n+\n+    def test_agent_close_session_recoverable_but_not_after_promotion(\n+        self, db: SessionDB\n+    ) -> None:\n+        db.create_session(\n+            \"sid-ac-rec\", _SOURCE,\n+            user_id=_USER_ID, session_key=_SESSION_KEY,\n+            chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        db.append_message(\"sid-ac-rec\", \"user\", \"hello\")\n+        db.end_session(\"sid-ac-rec\", \"agent_close\")\n+\n+        # agent_close is recoverable\n+        recovered = db.find_latest_gateway_session_for_peer(\n+            source=_SOURCE, session_key=_SESSION_KEY,\n+            user_id=_USER_ID, chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        assert recovered is not None\n+        assert recovered[\"id\"] == \"sid-ac-rec\"\n+\n+        # After promotion, it is no longer recoverable\n+        db.promote_to_session_reset(\"sid-ac-rec\")\n+        recovered2 = db.find_latest_gateway_session_for_peer(\n+            source=_SOURCE, session_key=_SESSION_KEY,\n+            user_id=_USER_ID, chat_id=\"8494508720\", chat_type=\"dm\",\n+        )\n+        assert recovered2 is None"]},{"file":"tests/gateway/test_session_store_lock_io.py","hunks":["@@ -272,7 +272,13 @@ def test_auto_reset_does_not_recover_session_being_ended(tmp_path):\n     assert entry.session_id != old.session_id\n     assert entry.was_auto_reset is True\n     db.reopen_session.assert_not_called()\n-    db.end_session.assert_called_once_with(old.session_id, \"session_reset\")\n+    # Auto-reset now writes through promote_to_session_reset (upgrades\n+    # accidental agent_close/ws_orphan_reap ends) with the specific\n+    # auditable reason — a suspended session resets as \"suspended\".\n+    db.promote_to_session_reset.assert_called_once_with(\n+        old.session_id, \"suspended\"\n+    )\n+    db.end_session.assert_not_called()\n \n \n def test_legacy_and_off_lock_saves_share_one_serialization_lock(tmp_path):"]},{"file":"tests/gateway/test_session_store_runtime_stale_guard.py","hunks":["@@ -220,3 +220,50 @@ def test_force_new_skips_stale_check(self, tmp_path):\n \n         assert result.session_id != \"sid_old\"\n         db.get_session.assert_not_called()\n+\n+    def test_stale_agent_close_overdue_policy_creates_fresh_session(\n+        self, tmp_path,\n+    ):\n+        \"\"\"Stale `agent_close` entry + overdue reset policy → fresh session.\n+\n+        The #54878 self-healing path popped the stale sessions.json entry and\n+        recovered the same session_id from the DB without checking whether a\n+        daily/idle reset was actually due.  This test guards the fix at\n+        gateway/session.py:1765 — when the session is overdue under the\n+        configured reset policy, we must create a fresh session (new id,\n+        auto-reset metadata set, reopen_session NOT called).\n+        \"\"\"\n+        source = _source()\n+        # Idle policy: reset after 60 minutes of inactivity.\n+        config = GatewayConfig(\n+            default_reset_policy=SessionResetPolicy(mode=\"idle\", idle_minutes=60),\n+        )\n+        db = _db_returning({\"sid_stale\": {\"end_reason\": \"agent_close\", \"id\": \"sid_stale\"}})\n+        # Recovery would normally reopen this row — but it shouldn't, because\n+        # the reset policy says this session is overdue.\n+        db.find_latest_gateway_session_for_peer.return_value = {\n+            \"id\": \"sid_stale\",\n+            \"started_at\": (datetime.now() - timedelta(hours=3)).timestamp(),\n+        }\n+\n+        with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+            store = SessionStore(sessions_dir=tmp_path, config=config)\n+        store._db = db\n+        store._loaded = True\n+\n+        key = store._generate_session_key(source)\n+        # Entry last updated 2 hours ago → well past the 60-minute idle window.\n+        store._entries[key] = _make_entry(key, \"sid_stale\")\n+        store._entries[key].updated_at = datetime.now() - timedelta(hours=2)\n+\n+        result = store.get_or_create_session(source)\n+\n+        # Fresh session — NOT the stale session_id.\n+        assert result.session_id != \"sid_stale\"\n+        # Auto-reset metadata is set.\n+        assert result.was_auto_reset is True\n+        assert result.auto_reset_reason == \"idle\"\n+        # reopen_session must NOT have been called (we skipped recovery).\n+        db.reopen_session.assert_not_called()\n+        # A brand-new session row was created.\n+        db.create_session.assert_called_once()"]},{"file":"tests/gateway/test_session_store_stale_prune.py","hunks":["@@ -151,6 +151,25 @@ def test_prunes_stale_entry_when_recovery_only_finds_same_ended_session(self, tm\n \n         assert key not in store._entries\n \n+    def test_keeps_stale_entry_when_recovery_lookup_raises(self, tmp_path):\n+        \"\"\"Indeterminate recovery must not delete the only routing handle.\n+\n+        Startup pruning sees an ended parent and tries to repoint it to the\n+        latest live gateway child.  If that recovery query raises, deleting the\n+        sessions.json entry loses the routing key entirely; keeping it lets the\n+        runtime stale guard retry recovery on the next message.\n+        \"\"\"\n+        key = \"agent:main:telegram:dm:5140768830\"\n+        db = _db_returning({\"sid_parent\": {\"end_reason\": \"compression\", \"id\": \"sid_parent\"}})\n+        db.find_latest_gateway_session_for_peer.side_effect = RuntimeError(\"db busy\")\n+        store = _make_store_with_db(tmp_path, db)\n+        store._entries[key] = _make_entry_with_origin(key, \"sid_parent\")\n+\n+        store._prune_stale_sessions_locked()\n+\n+        assert key in store._entries\n+        assert store._entries[key].session_id == \"sid_parent\"\n+\n     def test_noop_when_db_is_none(self, tmp_path):\n         config = GatewayConfig(default_reset_policy=SessionResetPolicy(mode=\"none\"))\n         with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n"]}]},{"kind":"pr","number":67937,"title":"fix(state): exclude expiry-finalized sessions from stale-route recovery (#67781)","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"79283ec16203","number":0},{"kind":"pr","value":"#67781","number":67781},{"kind":"pr","value":"#67937","number":67937},{"kind":"pr","value":"#67937","number":67937},{"kind":"pr","value":"#67937","number":67937},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#67781","number":67781},{"kind":"pr","value":"#67937","number":67937}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":68617,"title":"fix(gateway): respect reset boundaries during recovery (#68539)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#82743","number":82743},{"kind":"pr","value":"#82633","number":82633}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":68795,"title":"fix(gateway): respect reset boundaries during session recovery (#68539)","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"ab758aa70bd6","number":0},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#61489","number":61489},{"kind":"pr","value":"#62672","number":62672},{"kind":"pr","value":"#63068","number":63068},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#66264","number":66264},{"kind":"pr","value":"#71530","number":71530},{"kind":"pr","value":"#68617","number":68617},{"kind":"pr","value":"#68795","number":68795},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#61220","number":61220},{"kind":"pr","value":"#66255","number":66255},{"kind":"pr","value":"#68539","number":68539},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#61489","number":61489},{"kind":"pr","value":"#61220","number":61220},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#62672","number":62672},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#63068","number":63068},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#61220","number":61220},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#61489","number":61489},{"kind":"pr","value":"#62672","number":62672},{"kind":"pr","value":"#63068","number":63068},{"kind":"pr","value":"#66264","number":66264},{"kind":"pr","value":"#66255","number":66255},{"kind":"pr","value":"#71530","number":71530},{"kind":"pr","value":"#68617","number":68617},{"kind":"pr","value":"#68539","number":68539},{"kind":"pr","value":"#68795","number":68795},{"kind":"pr","value":"#68795","number":68795},{"kind":"pr","value":"#68617","number":68617},{"kind":"pr","value":"#68795","number":68795},{"kind":"pr","value":"#71530","number":71530},{"kind":"pr","value":"#66255","number":66255},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#61489","number":61489},{"kind":"pr","value":"#62672","number":62672},{"kind":"pr","value":"#63068","number":63068},{"kind":"pr","value":"#61220","number":61220},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#66264","number":66264},{"kind":"pr","value":"#71530","number":71530},{"kind":"pr","value":"#66255","number":66255},{"kind":"pr","value":"#68795","number":68795},{"kind":"pr","value":"#68617","number":68617},{"kind":"pr","value":"#68539","number":68539},{"kind":"pr","value":"#61242","number":61242},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#61489","number":61489},{"kind":"pr","value":"#62672","number":62672},{"kind":"pr","value":"#63068","number":63068},{"kind":"pr","value":"#65783","number":65783},{"kind":"pr","value":"#71530","number":71530},{"kind":"pr","value":"#66264","number":66264},{"kind":"pr","value":"#71530","number":71530},{"kind":"pr","value":"#68617","number":68617},{"kind":"pr","value":"#68795","number":68795},{"kind":"pr","value":"#68617","number":68617},{"kind":"pr","value":"#68795","number":68795},{"kind":"pr","value":"#374151","number":374151},{"kind":"pr","value":"#68539","number":68539},{"kind":"pr","value":"#68617","number":68617},{"kind":"pr","value":"#68795","number":68795}],"category":"partial_fix","implication":"needs_llm"},"merged":false},{"kind":"pr","number":82633,"title":"fix(gateway): make session identity durable so chat continuity survives crashes and restarts","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/session.py","hermes_state.py","tests/gateway/test_session_continuity_82616.py"]},{"kind":"pr","number":82743,"title":"fix(gateway): session recovery honors reset boundaries and real idle time (#68617 + #78618 salvage)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["contributors/emails/[redacted:email]","gateway/session.py","hermes_state.py","tests/gateway/test_session_store_runtime_stale_guard.py","tests/gateway/test_session_store_stale_prune.py","tests/test_hermes_state.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}