{"repo":"NousResearch/hermes-agent","public_id":"39260","display_label":"Persisting gateway session chat metadata in state.db","summary":"These three items share one theme: gateway chat/session metadata (such as chat_id, thread_id, and session origin) being stored in the state.db sessions data. They include a bug report, a fix-labeled pull request, and a refactor proposal to consolidate this metadata; titles alone do not show their current status.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":58899,"merged_at":"2026-07-05T21:01:04+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-05T21:01:04+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":39260,"pr_number":58899,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-05T21:01:04+00:00"}],"members":[{"kind":"issue","number":39260,"title":"Bug: Gateway does not persist chat metadata (chat_id, chat_type, thread_id, parent_chat_id, chat_name, session_key) to state.db sessions","url":null,"state":"closed"},{"kind":"pr","number":40061,"title":"fix(gateway): persist session origin metadata in state.db","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"747386ecfa34eb5cbc10e104d326259fb538f565","number":0},{"kind":"pr","value":"#58899","number":58899}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":58899,"title":"refactor: consolidate gateway session metadata into state.db","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/channel_directory.py","gateway/mirror.py","gateway/run.py","gateway/session.py","hermes_cli/status.py","hermes_state.py","mcp_serve.py","tests/test_hermes_state.py","tests/test_mcp_serve.py"],"fix_hunks":[{"file":"gateway/channel_directory.py","hunks":["@@ -263,7 +263,67 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]:\n \n \n def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:\n-    \"\"\"Pull known channels/contacts from sessions.json origin data.\"\"\"\n+    \"\"\"Pull known channels/contacts from gateway session origin data.\n+\n+    state.db is the primary source (#9006): gateway session rows persist\n+    origin_json.  Falls back to sessions.json for pre-migration databases.\n+    \"\"\"\n+    entries = _build_from_sessions_db(platform_name)\n+    if entries:\n+        return entries\n+    return _build_from_sessions_json(platform_name)\n+\n+\n+def _build_from_sessions_db(platform_name: str) -> List[Dict[str, str]]:\n+    \"\"\"Pull channels/contacts from state.db gateway session rows.\"\"\"\n+    entries: List[Dict[str, str]] = []\n+    try:\n+        from hermes_state import SessionDB\n+        db = SessionDB()\n+        try:\n+            lister = getattr(db, \"list_gateway_sessions\", None)\n+            if not callable(lister):\n+                return []\n+            rows = lister(platform=platform_name, active_only=False)\n+        finally:\n+            db.close()\n+\n+        seen_ids = set()\n+        for row in rows:\n+            origin: Dict[str, Any] = {}\n+            if row.get(\"origin_json\"):\n+                try:\n+                    parsed = json.loads(row[\"origin_json\"])\n+                    if isinstance(parsed, dict):\n+                        origin = parsed\n+                except (TypeError, ValueError):\n+                    pass\n+            if not origin:\n+                origin = {\n+                    \"chat_id\": row.get(\"chat_id\"),\n+                    \"thread_id\": row.get(\"thread_id\"),\n+                    \"chat_name\": row.get(\"display_name\"),\n+                }\n+            entry_id = _session_entry_id(origin)\n+            if not entry_id or entry_id in seen_ids:\n+                continue\n+            seen_ids.add(entry_id)\n+            entries.append({\n+                \"id\": entry_id,\n+                \"name\": _session_entry_name(origin),\n+                \"type\": row.get(\"chat_type\") or \"dm\",\n+                \"thread_id\": origin.get(\"thread_id\"),\n+            })\n+    except Exception as e:\n+        logger.debug(\n+            \"Channel directory: state.db session read failed for %s: %s\",\n+            platform_name, e,\n+        )\n+    return entries\n+\n+\n+def _build_from_sessions_json(platform_name: str) -> List[Dict[str, str]]:\n+    \"\"\"Legacy fallback: pull channels/contacts from sessions.json origin data.\"\"\"\n     sessions_path = get_hermes_home() / \"sessions\" / \"sessions.json\"\n     if not sessions_path.exists():\n         return []"]},{"file":"gateway/mirror.py","hunks":["@@ -102,14 +102,36 @@ def _find_session_id(\n     \"\"\"\n     Find the active session_id for a platform + chat_id pair.\n \n-    Scans sessions.json entries and matches where origin.chat_id == chat_id\n-    on the right platform.  DM session keys don't embed the chat_id\n-    (e.g. \"agent:main:telegram:dm\"), so we check the origin dict.\n+    Queries state.db gateway session rows (primary source since #9006);\n+    falls back to scanning sessions.json for pre-migration databases.\n+    DM session keys don't embed the chat_id (e.g. \"agent:main:telegram:dm\"),\n+    so we match on the persisted chat origin, not the key.\n \n     When *user_id* is provided, prefer exact sender matches. If multiple\n     same-chat candidates exist and none matches the user, return None instead\n     of guessing and contaminating another participant's session.\n     \"\"\"\n+    # Primary: state.db\n+    try:\n+        from hermes_state import SessionDB\n+        db = SessionDB()\n+        try:\n+            finder = getattr(db, \"find_session_by_origin\", None)\n+            if callable(finder):\n+                session_id = finder(\n+                    platform=platform,\n+                    chat_id=chat_id,\n+                    thread_id=thread_id,\n+                    user_id=user_id,\n+                )\n+                if session_id:\n+                    return str(session_id)\n+        finally:\n+            db.close()\n+    except Exception as e:\n+        logger.debug(\"Mirror state.db session lookup failed: %s\", e)\n+\n+    # Fallback: sessions.json (pre-migration databases)\n     if not _SESSIONS_INDEX.exists():\n         return None\n "]},{"file":"gateway/run.py","hunks":["@@ -7540,14 +7540,11 @@ async def _session_expiry_watcher(self, interval: int = 300):\n                         _update_prompt_pending = getattr(self, \"_update_prompt_pending\", None)\n                         if isinstance(_update_prompt_pending, dict):\n                             _update_prompt_pending.pop(key, None)\n-                        with self.session_store._lock:\n-                            entry.expiry_finalized = True\n-                            # Session finalization is a conversation boundary —\n-                            # drop the persisted /model override too so a later\n-                            # message doesn't rehydrate it after the in-memory\n-                            # override was popped above.\n-                            entry.model_override = None\n-                            self.session_store._save()\n+                        # Persist the finalized flag to sessions.json AND\n+                        # state.db (single write-path, #9006) — also drops\n+                        # the persisted /model override, since finalization\n+                        # is a conversation boundary.\n+                        self.session_store.set_expiry_finalized(entry)\n                         logger.debug(\n                             \"Session expiry finalized for %s\",\n                             entry.session_id,","@@ -7562,9 +7559,9 @@ async def _session_expiry_watcher(self, interval: int = 300):\n                                 \"Marking as finalized to prevent infinite retry loop.\",\n                                 failures, entry.session_id, e,\n                             )\n-                            with self.session_store._lock:\n-                                entry.expiry_finalized = True\n-                                self.session_store._save()\n+                            self.session_store.set_expiry_finalized(\n+                                entry, clear_model_override=False\n+                            )\n                             _finalize_failures.pop(entry.session_id, None)\n                         else:\n                             logger.debug("]},{"file":"gateway/session.py","hunks":["@@ -1202,6 +1202,7 @@ def _record_gateway_session_peer(\n         session_id: str,\n         session_key: str,\n         source: Optional[SessionSource],\n+        display_name: Optional[str] = None,\n     ) -> None:\n         \"\"\"Persist the routing peer for an existing gateway session row.\"\"\"\n         if not self._db or not source:","@@ -1210,6 +1211,11 @@ def _record_gateway_session_peer(\n         if not callable(recorder):\n             return\n         try:\n+            origin_json = None\n+            try:\n+                origin_json = json.dumps(source.to_dict())\n+            except Exception:\n+                pass\n             recorder(\n                 session_id,\n                 source=source.platform.value,","@@ -1218,9 +1224,56 @@ def _record_gateway_session_peer(\n                 chat_id=source.chat_id,\n                 chat_type=source.chat_type,\n                 thread_id=source.thread_id,\n+                display_name=display_name or source.chat_name,\n+                origin_json=origin_json,\n             )\n+        except TypeError:\n+            # Older SessionDB without display_name/origin_json kwargs.\n+            try:\n+                recorder(\n+                    session_id,\n+                    source=source.platform.value,\n+                    user_id=source.user_id,\n+                    session_key=session_key,\n+                    chat_id=source.chat_id,\n+                    chat_type=source.chat_type,\n+                    thread_id=source.thread_id,\n+                )\n+            except Exception as exc:\n+                logger.debug(\"Gateway session peer record failed for %s: %s\", session_key, exc)\n         except Exception as exc:\n             logger.debug(\"Gateway session peer record failed for %s: %s\", session_key, exc)\n+\n+    def set_expiry_finalized(\n+        self, entry: SessionEntry, *, clear_model_override: bool = True\n+    ) -> None:\n+        \"\"\"Mark a session entry expiry-finalized in memory, sessions.json, AND state.db.\n+\n+        Single write-path for the expiry watcher (#9006): keeps the durable\n+        state.db flag in sync with the JSON routing index so the flag\n+        survives sessions.json pruning/loss.\n+\n+        ``clear_model_override=False`` preserves the give-up path's original\n+        behavior (flag only, no override drop).\n+        \"\"\"\n+        with self._lock:\n+            entry.expiry_finalized = True\n+            if clear_model_override:\n+                # Session finalization is a conversation boundary — drop the\n+                # persisted /model override too so a later message doesn't\n+                # rehydrate it after the in-memory override was popped.\n+                entry.model_override = None\n+            self._save()\n+        if self._db:\n+            setter = getattr(self._db, \"set_expiry_finalized\", None)\n+            if callable(setter):\n+                try:\n+                    setter(entry.session_id, True)\n+                except Exception as exc:\n+                    logger.debug(\n+                        \"Session DB expiry_finalized write 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.","@@ -1618,6 +1671,7 @@ def get_or_create_session(\n                     session_id,\n                     session_key,\n                     source,\n+                    display_name=entry.display_name,\n                 )\n             except Exception as e:\n                 print(f\"[gateway] Warning: Failed to create SQLite session: {e}\")","@@ -1643,6 +1697,7 @@ def update_session(\n                     entry.session_id,\n                     session_key,\n                     entry.origin,\n+                    display_name=entry.display_name,\n                 )\n \n     def set_model_override(","@@ -1888,6 +1943,7 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) ->\n                     session_id,\n                     session_key,\n                     old_entry.origin,\n+                    display_name=new_entry.display_name if new_entry else None,\n                 )\n             except Exception as e:\n                 logger.debug(\"Session DB operation failed: %s\", e)","@@ -1950,6 +2006,7 @@ def switch_session(self, session_key: str, target_session_id: str) -> Optional[S\n                 target_session_id,\n                 session_key,\n                 new_entry.origin if new_entry else None,\n+                display_name=new_entry.display_name if new_entry else None,\n             )\n \n         return new_entry"]},{"file":"hermes_cli/status.py","hunks":["@@ -543,17 +543,39 @@ def _resolve_env(env_ref) -> str:\n     print()\n     print(color(\"◆ Sessions\", Colors.CYAN, Colors.BOLD))\n \n-    sessions_file = get_hermes_home() / \"sessions\" / \"sessions.json\"\n-    if sessions_file.exists():\n-        import json\n+    # Gateway session count: state.db is the source of truth (#9006);\n+    # fall back to sessions.json for pre-migration installs.\n+    _session_count = None\n+    try:\n+        from hermes_state import SessionDB\n+        _db = SessionDB()\n         try:\n-            with open(sessions_file, encoding=\"utf-8\") as f:\n-                data = json.load(f)\n-                print(f\"  Active:       {len(data)} session(s)\")\n-        except Exception:\n-            print(\"  Active:       (error reading sessions file)\")\n+            _lister = getattr(_db, \"list_gateway_sessions\", None)\n+            if callable(_lister):\n+                _session_count = len(_lister(active_only=True))\n+        finally:\n+            _db.close()\n+    except Exception:\n+        _session_count = None\n+\n+    if _session_count is not None and _session_count > 0:\n+        print(f\"  Active:       {_session_count} session(s)\")\n     else:\n-        print(\"  Active:       0\")\n+        sessions_file = get_hermes_home() / \"sessions\" / \"sessions.json\"\n+        if sessions_file.exists():\n+            import json\n+            try:\n+                with open(sessions_file, encoding=\"utf-8\") as f:\n+                    data = json.load(f)\n+                    _entries = {\n+                        k: v for k, v in data.items()\n+                        if not str(k).startswith(\"_\")\n+                    } if isinstance(data, dict) else {}\n+                    print(f\"  Active:       {len(_entries)} session(s)\")\n+            except Exception:\n+                print(\"  Active:       (error reading sessions file)\")\n+        else:\n+            print(f\"  Active:       {_session_count if _session_count is not None else 0}\")\n \n     # =========================================================================\n     # Deep checks"]},{"file":"hermes_state.py","hunks":["@@ -122,7 +122,7 @@ def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]:\n \n DEFAULT_DB_PATH = get_hermes_home() / \"state.db\"\n \n-SCHEMA_VERSION = 17\n+SCHEMA_VERSION = 18\n \n # Cap on user-controlled FTS5 query input before regex/sanitizer processing.\n # Search queries do not need to be arbitrarily large, and bounding them keeps","@@ -705,6 +705,9 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A\n     chat_id TEXT,\n     chat_type TEXT,\n     thread_id TEXT,\n+    display_name TEXT,\n+    origin_json TEXT,\n+    expiry_finalized INTEGER DEFAULT 0,\n     model TEXT,\n     model_config TEXT,\n     system_prompt TEXT,","@@ -1522,6 +1525,19 @@ def _init_schema(self):\n                     )\n                 except sqlite3.OperationalError:\n                     pass\n+            if current_version < 18:\n+                # v18: gateway metadata consolidation (#9006). Backfill\n+                # display_name / origin_json / expiry_finalized from\n+                # sessions.json so pre-migration gateway sessions are\n+                # discoverable from state.db without the JSON index.\n+                try:\n+                    self._backfill_gateway_metadata_from_sessions_json(cursor)\n+                except Exception as exc:\n+                    # Backfill is best-effort: sessions.json may be absent,\n+                    # corrupted, or partially stale. Missing metadata simply\n+                    # means consumers fall back to sessions.json for those\n+                    # rows until the gateway rewrites them.\n+                    logger.debug(\"v18 gateway metadata backfill skipped: %s\", exc)\n             if current_version < SCHEMA_VERSION and fts_migrations_complete:\n                 cursor.execute(\n                     \"UPDATE schema_version SET version = ?\",","@@ -1647,8 +1663,17 @@ def record_gateway_session_peer(\n         chat_id: str = None,\n         chat_type: str = None,\n         thread_id: str = None,\n+        display_name: str = None,\n+        origin_json: str = None,\n     ) -> None:\n-        \"\"\"Persist the gateway routing peer for an existing session row.\"\"\"\n+        \"\"\"Persist the gateway routing peer for an existing session row.\n+\n+        ``display_name`` / ``origin_json`` carry the gateway's presentation\n+        and full origin metadata (#9006) so consumers (mcp_serve, mirror,\n+        channel directory) can read routing data from state.db instead of\n+        sessions.json.  They are COALESCE'd only in the sense that ``None``\n+        leaves the existing value untouched.\n+        \"\"\"\n         if not session_id or not session_key:\n             return\n ","@@ -1656,7 +1681,9 @@ def _do(conn):\n             conn.execute(\n                 \"\"\"UPDATE sessions\n                    SET session_key = ?, source = ?, user_id = ?, chat_id = ?,\n-                       chat_type = ?, thread_id = ?\n+                       chat_type = ?, thread_id = ?,\n+                       display_name = COALESCE(?, display_name),\n+                       origin_json = COALESCE(?, origin_json)\n                    WHERE id = ?\"\"\",\n                 (\n                     session_key,","@@ -1665,12 +1692,168 @@ def _do(conn):\n                     chat_id,\n                     chat_type,\n                     thread_id,\n+                    display_name,\n+                    origin_json,\n                     session_id,\n                 ),\n             )\n \n         self._execute_write(_do)\n \n+    def set_expiry_finalized(self, session_id: str, finalized: bool = True) -> None:\n+        \"\"\"Mark a gateway session's expiry-finalization flag in state.db.\n+\n+        Mirrors ``SessionEntry.expiry_finalized`` (sessions.json) so the flag\n+        survives even if the JSON index is pruned or lost (#9006).\n+        \"\"\"\n+        if not session_id:\n+            return\n+\n+        def _do(conn):\n+            conn.execute(\n+                \"UPDATE sessions SET expiry_finalized = ? WHERE id = ?\",\n+                (1 if finalized else 0, session_id),\n+            )\n+\n+        self._execute_write(_do)\n+\n+    def list_gateway_sessions(\n+        self,\n+        *,\n+        platform: Optional[str] = None,\n+        active_only: bool = True,\n+    ) -> List[Dict[str, Any]]:\n+        \"\"\"List gateway sessions (rows with a session_key) from state.db.\n+\n+        Returns the newest row per session_key — the same shape consumers got\n+        from sessions.json: one live mapping per routing key.  ``platform``\n+        filters on ``source``; ``active_only`` restricts to sessions that\n+        have not ended.\n+        \"\"\"\n+        query = \"\"\"\n+            SELECT sessions.*,\n+                   COALESCE(\n+                       (SELECT MAX(m.timestamp) FROM messages m\n+                        WHERE m.session_id = sessions.id),\n+                       sessions.started_at\n+                   ) AS last_active\n+            FROM sessions\n+            WHERE session_key IS NOT NULL\n+              AND started_at = (\n+                  SELECT MAX(s2.started_at) FROM sessions s2\n+                  WHERE s2.session_key = sessions.session_key\n+              )\n+        \"\"\"\n+        params: list = []\n+        if platform:\n+            query += \" AND LOWER(source) = LOWER(?)\"\n+            params.append(platform)\n+        if active_only:\n+            query += \" AND ended_at IS NULL\"\n+        query += \" ORDER BY last_active DESC\"\n+        with self._lock:\n+            rows = self._conn.execute(query, params).fetchall()\n+        return [dict(r) for r in rows]\n+\n+    def find_session_by_origin(\n+        self,\n+        *,\n+        platform: str,\n+        chat_id: str,\n+        thread_id: Optional[str] = None,\n+        user_id: Optional[str] = None,\n+    ) -> Optional[str]:\n+        \"\"\"Find the most recent live session_id for a platform + chat origin.\n+\n+        Equivalent of gateway/mirror's sessions.json scan: matches on\n+        source + chat_id (+ thread_id when provided).  When ``user_id`` is\n+        provided, exact sender matches are preferred; if multiple distinct\n+        users share the chat and none matches, returns None rather than\n+        contaminating another participant's session.\n+        \"\"\"\n+        if not platform or chat_id in (None, \"\"):\n+            return None\n+        query = \"\"\"\n+            SELECT id, user_id, started_at FROM sessions\n+            WHERE LOWER(source) = LOWER(?)\n+              AND session_key IS NOT NULL\n+              AND chat_id = ?\n+              AND ended_at IS NULL\n+        \"\"\"\n+        params: list = [platform, str(chat_id)]\n+        if thread_id is not None:\n+            query += \" AND COALESCE(thread_id, '') = ?\"\n+            params.append(str(thread_id))\n+        query += \" ORDER BY started_at DESC\"\n+        with self._lock:\n+            rows = [dict(r) for r in self._conn.execute(query, params).fetchall()]\n+        if not rows:\n+            return None\n+        if user_id:\n+            exact = [r for r in rows if str(r.get(\"user_id\") or \"\") == str(user_id)]\n+            if exact:\n+                return str(exact[0][\"id\"])\n+            if len(rows) > 1:\n+                return None\n+        elif len(rows) > 1:\n+            distinct_users = {\n+                str(r.get(\"user_id\") or \"\").strip()\n+                for r in rows\n+                if str(r.get(\"user_id\") or \"\").strip()\n+            }\n+            if len(distinct_users) > 1:\n+                return None\n+        return str(rows[0][\"id\"])\n+\n+    def _backfill_gateway_metadata_from_sessions_json(\n+        self, cursor: sqlite3.Cursor\n+    ) -> None:\n+        \"\"\"One-time v18 backfill of gateway metadata from sessions.json.\n+\n+        Existing gateway sessions predate the display_name / origin_json /\n+        expiry_finalized columns; copy what sessions.json knows so consumers\n+        can switch to state.db without losing pre-migration sessions.\n+        Only fills NULL columns — never overwrites data written by newer code.\n+        \"\"\"\n+        sessions_file = get_hermes_home() / \"sessions\" / \"sessions.json\"\n+        if not sessions_file.exists():\n+            return\n+        with open(sessions_file, \"r\", encoding=\"utf-8\") as f:\n+            data = json.load(f)\n+        if not isinstance(data, dict):\n+            return\n+        for key, entry in data.items():\n+            if str(key).startswith(\"_\") or not isinstance(entry, dict):\n+                continue\n+            session_id = entry.get(\"session_id\")\n+            if not session_id:\n+                continue\n+            origin = entry.get(\"origin\")\n+            cursor.execute(\n+                \"\"\"UPDATE sessions\n+                   SET session_key = COALESCE(session_key, ?),\n+                       chat_id = COALESCE(chat_id, ?),\n+                       chat_type = COALESCE(chat_type, ?),\n+                       thread_id = COALESCE(thread_id, ?),\n+                       display_name = COALESCE(display_name, ?),\n+                       origin_json = COALESCE(origin_json, ?),\n+                       expiry_finalized = CASE\n+                           WHEN COALESCE(expiry_finalized, 0) = 0 AND ? = 1 THEN 1\n+                           ELSE expiry_finalized\n+                       END\n+                   WHERE id = ?\"\"\",\n+                (\n+                    entry.get(\"session_key\") or key,\n+                    (origin or {}).get(\"chat_id\") if isinstance(origin, dict) else None,\n+                    entry.get(\"chat_type\"),\n+                    (origin or {}).get(\"thread_id\") if isinstance(origin, dict) else None,\n+                    entry.get(\"display_name\"),\n+                    json.dumps(origin) if isinstance(origin, dict) else None,\n+                    1 if entry.get(\"expiry_finalized\") or entry.get(\"memory_flushed\") else 0,\n+                    str(session_id),\n+                ),\n+            )\n+\n     def find_latest_gateway_session_for_peer(\n         self,\n         *,"]},{"file":"mcp_serve.py","hunks":["@@ -37,6 +37,7 @@\n import threading\n import time\n from dataclasses import dataclass, field\n+from datetime import datetime\n from pathlib import Path\n from typing import Dict, List, Optional\n ","@@ -79,10 +80,99 @@ def _get_session_db():\n \n \n def _load_sessions_index() -> dict:\n-    \"\"\"Load the gateway sessions.json index directly.\n+    \"\"\"Load the gateway session routing index.\n \n     Returns a dict of session_key -> entry_dict with platform routing info.\n-    This avoids importing the full SessionStore which needs GatewayConfig.\n+\n+    state.db is the primary source (#9006): gateway sessions persist their\n+    routing metadata (session_key, chat/thread ids, display_name, origin) on\n+    the durable session row, so a single database read replaces the old\n+    dual-file sessions.json dependency.  Falls back to sessions.json for\n+    pre-migration databases where no gateway rows carry a session_key yet.\n+    \"\"\"\n+    entries = _load_sessions_index_from_db()\n+    if entries:\n+        return entries\n+    return _load_sessions_index_from_json()\n+\n+\n+def _row_to_index_entry(row: dict) -> dict:\n+    \"\"\"Convert a state.db gateway session row to the sessions.json entry shape.\"\"\"\n+    origin = {}\n+    origin_json = row.get(\"origin_json\")\n+    if origin_json:\n+        try:\n+            parsed = json.loads(origin_json)\n+            if isinstance(parsed, dict):\n+                origin = parsed\n+        except (TypeError, ValueError):\n+            pass\n+    if not origin:\n+        # Pre-origin_json rows: synthesize the minimal origin from columns.\n+        origin = {\n+            \"platform\": row.get(\"source\", \"\"),\n+            \"chat_id\": row.get(\"chat_id\"),\n+            \"chat_type\": row.get(\"chat_type\"),\n+            \"thread_id\": row.get(\"thread_id\"),\n+            \"user_id\": row.get(\"user_id\"),\n+        }\n+\n+    def _iso(ts) -> str:\n+        try:\n+            return datetime.fromtimestamp(float(ts)).isoformat() if ts else \"\"\n+        except (TypeError, ValueError, OSError):\n+            return \"\"\n+\n+    input_tokens = [redacted:secret-assignment]\"input_tokens\") or 0)\n+    output_tokens = [redacted:secret-assignment]\"output_tokens\") or 0)\n+    return {\n+        \"session_id\": str(row.get(\"id\", \"\")),\n+        \"session_key\": row.get(\"session_key\", \"\"),\n+        \"platform\": row.get(\"source\", \"\"),\n+        \"chat_type\": row.get(\"chat_type\") or origin.get(\"chat_type\", \"\"),\n+        \"display_name\": row.get(\"display_name\") or origin.get(\"chat_name\") or \"\",\n+        \"origin\": origin,\n+        \"created_at\": _iso(row.get(\"started_at\")),\n+        \"updated_at\": _iso(row.get(\"last_active\") or row.get(\"started_at\")),\n+        \"input_tokens\": [redacted:secret-assignment]\n+        \"output_tokens\": [redacted:secret-assignment]\n+        \"total_tokens\": [redacted:secret-assignment] + output_tokens,\n+    }\n+\n+\n+def _load_sessions_index_from_db() -> dict:\n+    \"\"\"Build the routing index from state.db gateway session rows.\"\"\"\n+    db = _get_session_db()\n+    if db is None:\n+        return {}\n+    try:\n+        lister = getattr(db, \"list_gateway_sessions\", None)\n+        if not callable(lister):\n+            return {}\n+        rows = lister(active_only=True)\n+        entries = {}\n+        for row in rows:\n+            key = row.get(\"session_key\")\n+            if not key:\n+                continue\n+            entries[key] = _row_to_index_entry(row)\n+        return entries\n+    except Exception as e:\n+        logger.debug(\"Failed to load gateway sessions from state.db: %s\", e)\n+        return {}\n+    finally:\n+        try:\n+            db.close()\n+        except Exception:\n+            pass\n+\n+\n+def _load_sessions_index_from_json() -> dict:\n+    \"\"\"Legacy fallback: load the gateway sessions.json index directly.\n+\n+    Used only for pre-migration databases whose gateway rows don't carry a\n+    session_key yet.  This avoids importing the full SessionStore which\n+    needs GatewayConfig.\n     \"\"\"\n     sessions_file = _get_sessions_dir() / \"sessions.json\"\n     if not sessions_file.exists():","@@ -226,8 +316,7 @@ def __init__(self):\n         self._last_poll_timestamps: Dict[str, float] = {}  # session_key -> unix timestamp\n         # In-memory approval tracking (populated from events)\n         self._pending_approvals: Dict[str, dict] = {}\n-        # mtime cache — skip expensive work when files haven't changed\n-        self._sessions_json_mtime: float = 0.0\n+        # mtime cache — skip expensive work when state.db hasn't changed\n         self._state_db_mtime: float = 0.0\n         self._cached_sessions_index: dict = {}\n ","@@ -353,24 +442,14 @@ def _poll_loop(self):\n     def _poll_once(self, db):\n         \"\"\"Check for new messages across all sessions.\n \n-        Uses mtime checks on sessions.json and state.db to skip work\n-        when nothing has changed — makes 200ms polling essentially free.\n+        Uses a single mtime check on state.db to skip work when nothing\n+        has changed — makes 200ms polling essentially free.  Since #9006\n+        the routing index itself lives in state.db (session rows carry\n+        session_key/origin metadata), so a new conversation and its first\n+        message land in the SAME file and one mtime check covers both —\n+        eliminating the old dual-file (sessions.json + state.db) race that\n+        could drop brand-new conversations (#8925).\n         \"\"\"\n-        # Check if sessions.json has changed (mtime check is ~1μs).\n-        # Capture the previously-seen mtime *before* refreshing the cache so the\n-        # skip guard below can still tell whether sessions.json changed this tick.\n-        prev_sessions_json_mtime = self._sessions_json_mtime\n-        sessions_file = _get_sessions_dir() / \"sessions.json\"\n-        try:\n-            sj_mtime = sessions_file.stat().st_mtime if sessions_file.exists() else 0.0\n-        except OSError:\n-            sj_mtime = 0.0\n-\n-        if sj_mtime != self._sessions_json_mtime:\n-            self._sessions_json_mtime = sj_mtime\n-            self._cached_sessions_index = _load_sessions_index()\n-\n-        # Check if state.db has changed\n         try:\n             from hermes_constants import get_hermes_home\n             db_file = get_hermes_home() / \"state.db\"","@@ -382,19 +461,14 @@ def _poll_once(self, db):\n         except OSError:\n             db_mtime = 0.0\n \n-        # Skip only when NEITHER file changed since the last poll. Comparing\n-        # against ``prev_sessions_json_mtime`` (not the freshly-stored\n-        # ``self._sessions_json_mtime``) is essential: the latter was just set to\n-        # ``sj_mtime`` above, so using it would make the sessions.json term\n-        # always true and collapse the guard to a db-only check. That would\n-        # discard a tick where only sessions.json changed — e.g. a brand-new\n-        # conversation registered after its first message already landed in\n-        # state.db on an earlier tick — and the new chat's messages would never\n-        # be emitted until state.db happened to change again.\n-        if db_mtime == self._state_db_mtime and sj_mtime == prev_sessions_json_mtime:\n+        if db_mtime == self._state_db_mtime:\n             return  # Nothing changed since last poll — skip entirely\n \n         self._state_db_mtime = db_mtime\n+        # Refresh the routing index from state.db on every change tick —\n+        # it's a single indexed query and it can never lag the messages\n+        # table (both live in the same database file).\n+        self._cached_sessions_index = _load_sessions_index()\n         entries = self._cached_sessions_index\n \n         for session_key, entry in entries.items():"]},{"file":"tests/test_hermes_state.py","hunks":["@@ -2,6 +2,7 @@\n \n import sqlite3\n import time\n+import json\n import pytest\n \n import hermes_state","@@ -4831,6 +4832,174 @@ def test_gateway_session_recovery_reopens_legacy_agent_close_rows(db):\n     ) is None\n \n \n+def test_gateway_metadata_display_name_origin_round_trip(db):\n+    \"\"\"record_gateway_session_peer persists display_name/origin_json (#9006).\"\"\"\n+    db.create_session(\"gw-meta\", \"telegram\", user_id=\"u1\")\n+    origin = {\"platform\": \"telegram\", \"chat_id\": \"c1\", \"chat_name\": \"Alice\", \"chat_type\": \"dm\"}\n+    db.record_gateway_session_peer(\n+        \"gw-meta\",\n+        source=\"telegram\",\n+        user_id=\"u1\",\n+        session_key=\"agent:main:telegram:dm:c1\",\n+        chat_id=\"c1\",\n+        chat_type=\"dm\",\n+        thread_id=None,\n+        display_name=\"Alice\",\n+        origin_json=json.dumps(origin),\n+    )\n+    row = db.get_session(\"gw-meta\")\n+    assert row[\"display_name\"] == \"Alice\"\n+    assert json.loads(row[\"origin_json\"])[\"chat_name\"] == \"Alice\"\n+\n+    # None values must not clobber existing metadata.\n+    db.record_gateway_session_peer(\n+        \"gw-meta\",\n+        source=\"telegram\",\n+        user_id=\"u1\",\n+        session_key=\"agent:main:telegram:dm:c1\",\n+        chat_id=\"c1\",\n+        chat_type=\"dm\",\n+    )\n+    row = db.get_session(\"gw-meta\")\n+    assert row[\"display_name\"] == \"Alice\"\n+    assert row[\"origin_json\"] is not None\n+\n+\n+def test_set_expiry_finalized_round_trip(db):\n+    db.create_session(\"gw-exp\", \"telegram\", session_key=\"agent:main:telegram:dm:x\")\n+    row = db.get_session(\"gw-exp\")\n+    assert not row[\"expiry_finalized\"]\n+    db.set_expiry_finalized(\"gw-exp\")\n+    assert db.get_session(\"gw-exp\")[\"expiry_finalized\"] == 1\n+    db.set_expiry_finalized(\"gw-exp\", False)\n+    assert db.get_session(\"gw-exp\")[\"expiry_finalized\"] == 0\n+\n+\n+def test_list_gateway_sessions_filters_and_dedupes(db):\n+    # Two rows on the same session_key: only the newest should be returned.\n+    db.create_session(\n+        \"gw-old\", \"telegram\",\n+        session_key=\"agent:main:telegram:dm:c1\", chat_id=\"c1\", chat_type=\"dm\",\n+    )\n+    db._conn.execute(\n+        \"UPDATE sessions SET started_at = started_at - 100 WHERE id = 'gw-old'\"\n+    )\n+    db._conn.commit()\n+    db.create_session(\n+        \"gw-new\", \"telegram\",\n+        session_key=\"agent:main:telegram:dm:c1\", chat_id=\"c1\", chat_type=\"dm\",\n+    )\n+    db.create_session(\n+        \"gw-discord\", \"discord\",\n+        session_key=\"agent:main:discord:group:g1:u1\", chat_id=\"g1\", chat_type=\"group\",\n+    )\n+    # Non-gateway session (no session_key) must never appear.\n+    db.create_session(\"cli-session\", \"cli\")\n+    # Ended gateway session excluded when active_only.\n+    db.create_session(\n+        \"gw-ended\", \"slack\",\n+        session_key=\"agent:main:slack:dm:s1\", chat_id=\"s1\", chat_type=\"dm\",\n+    )\n+    db.end_session(\"gw-ended\", \"session_reset\")\n+\n+    rows = db.list_gateway_sessions(active_only=True)\n+    ids = {r[\"id\"] for r in rows}\n+    assert ids == {\"gw-new\", \"gw-discord\"}\n+\n+    tg_rows = db.list_gateway_sessions(platform=\"telegram\", active_only=True)\n+    assert [r[\"id\"] for r in tg_rows] == [\"gw-new\"]\n+\n+    all_rows = db.list_gateway_sessions(active_only=False)\n+    assert \"gw-ended\" in {r[\"id\"] for r in all_rows}\n+    assert \"cli-session\" not in {r[\"id\"] for r in all_rows}\n+\n+\n+def test_find_session_by_origin_matching_rules(db):\n+    db.create_session(\n+        \"gw-o1\", \"telegram\", user_id=\"u1\",\n+        session_key=\"agent:main:telegram:group:c9:u1\", chat_id=\"c9\", chat_type=\"group\",\n+    )\n+    db.create_session(\n+        \"gw-o2\", \"telegram\", user_id=\"u2\",\n+        session_key=\"agent:main:telegram:group:c9:u2\", chat_id=\"c9\", chat_type=\"group\",\n+    )\n+\n+    # Exact user match wins.\n+    assert db.find_session_by_origin(\n+        platform=\"telegram\", chat_id=\"c9\", user_id=\"u2\"\n+    ) == \"gw-o2\"\n+    # Unknown user among multiple distinct users -> None (no contamination).\n+    assert db.find_session_by_origin(\n+        platform=\"telegram\", chat_id=\"c9\", user_id=\"u3\"\n+    ) is None\n+    # No user given + multiple distinct users -> None.\n+    assert db.find_session_by_origin(platform=\"telegram\", chat_id=\"c9\") is None\n+    # Ended sessions are ignored: only gw-o1 remains as a live candidate.\n+    # A single remaining candidate is returned even without an exact user\n+    # match — mirrors the original sessions.json scan semantics.\n+    db.end_session(\"gw-o2\", \"session_reset\")\n+    assert db.find_session_by_origin(\n+        platform=\"telegram\", chat_id=\"c9\", user_id=\"u2\"\n+    ) == \"gw-o1\"\n+    # Single remaining candidate resolves without user_id.\n+    assert db.find_session_by_origin(platform=\"telegram\", chat_id=\"c9\") == \"gw-o1\"\n+    # Thread filter.\n+    db.create_session(\n+        \"gw-th\", \"discord\", user_id=\"u9\",\n+        session_key=\"agent:main:discord:thread:t7\", chat_id=\"ch7\",\n+        chat_type=\"thread\", thread_id=\"t7\",\n+    )\n+    assert db.find_session_by_origin(\n+        platform=\"discord\", chat_id=\"ch7\", thread_id=\"t7\"\n+    ) == \"gw-th\"\n+    assert db.find_session_by_origin(\n+        platform=\"discord\", chat_id=\"ch7\", thread_id=\"other\"\n+    ) is None\n+\n+\n+def test_v18_backfill_from_sessions_json(tmp_path, monkeypatch):\n+    \"\"\"Migration backfills display_name/origin_json/expiry_finalized from sessions.json.\"\"\"\n+    import hermes_state as hs\n+\n+    home = tmp_path / \".hermes\"\n+    (home / \"sessions\").mkdir(parents=True)\n+    monkeypatch.setenv(\"HERMES_HOME\", str(home))\n+    monkeypatch.setattr(hs, \"DEFAULT_DB_PATH\", home / \"state.db\")\n+\n+    # Seed a pre-v18 database: create schema, downgrade version, add a bare row.\n+    db = hs.SessionDB(home / \"state.db\")\n+    db.create_session(\"legacy-gw\", \"telegram\", user_id=\"u1\")\n+    db._conn.execute(\"UPDATE schema_version SET version = 17\")\n+    db._conn.execute(\n+        \"UPDATE sessions SET session_key = NULL, display_name = NULL, \"\n+        \"origin_json = NULL WHERE id = 'legacy-gw'\"\n+    )\n+    db._conn.commit()\n+    db.close()\n+\n+    origin = {\"platform\": \"telegram\", \"chat_id\": \"123\", \"chat_name\": \"Alice\",\n+              \"chat_type\": \"dm\", \"user_id\": \"u1\"}\n+    (home / \"sessions\" / \"sessions.json\").write_text(json.dumps({\n+        \"_README\": \"sentinel\",\n+        \"agent:main:telegram:dm:123\": {\n+            \"session_id\": \"legacy-gw\",\n+            \"display_name\": \"Alice\",\n+            \"chat_type\": \"dm\",\n+            \"expiry_finalized\": True,\n+            \"origin\": origin,\n+        },\n+    }))\n+\n+    db = hs.SessionDB(home / \"state.db\")\n+    row = db.get_session(\"legacy-gw\")\n+    db.close()\n+    assert row[\"session_key\"] == \"agent:main:telegram:dm:123\"\n+    assert row[\"display_name\"] == \"Alice\"\n+    assert row[\"chat_id\"] == \"123\"\n+    assert json.loads(row[\"origin_json\"])[\"chat_name\"] == \"Alice\"\n+    assert row[\"expiry_finalized\"] == 1\n+\n+\n def test_compression_failure_cooldown_round_trips_and_clears(db):\n     db.create_session(\"s1\", \"cli\")\n "]},{"file":"tests/test_mcp_serve.py","hunks":["@@ -1232,18 +1232,19 @@ def get_messages(self, sid):\n         assert len(r2[\"events\"]) == 1\n         assert r2[\"events\"][0][\"content\"] == \"New reply!\"\n \n-    def test_poll_picks_up_new_conversation_when_only_sessions_json_changed(\n+    def test_poll_picks_up_new_conversation_on_db_change(\n         self, tmp_path, monkeypatch\n     ):\n-        \"\"\"A conversation registered in sessions.json *after* its first message\n-        already landed in state.db must still be picked up, even when state.db's\n-        mtime did not move on this tick.\n-\n-        Regression guard: the skip-when-unchanged check must compare against the\n-        sessions.json mtime seen on the *previous* poll. If it instead compares\n-        against the just-refreshed value, the sessions.json term is always true,\n-        the guard collapses to a db-only check, and a sessions.json-only change\n-        is silently dropped — the new chat's messages never reach MCP clients.\n+        \"\"\"A brand-new conversation must be picked up on the tick where\n+        state.db changes.\n+\n+        Since #9006 the routing index lives IN state.db (session rows carry\n+        session_key/origin metadata), so a new conversation's registration and\n+        its first message land in the same file — a single mtime check covers\n+        both and the old dual-file (sessions.json + state.db) race (#8925) is\n+        structurally impossible. This test asserts the index is refreshed on a\n+        db-mtime bump, so a conversation the bridge has never seen before is\n+        emitted on the same tick.\n         \"\"\"\n         import mcp_serve\n ","@@ -1257,14 +1258,20 @@ def test_poll_picks_up_new_conversation_when_only_sessions_json_changed(\n         db_path.write_text(\"placeholder\")\n \n         session_id = \"20260329_150000_late_register\"\n-        sessions_json = sessions_dir / \"sessions.json\"\n-        sessions_json.write_text(json.dumps({\n-            \"agent:main:telegram:dm:late\": {\n-                \"session_id\": session_id,\n-                \"platform\": \"telegram\",\n-                \"origin\": {\"platform\": \"telegram\", \"chat_id\": \"late\"},\n-            }\n-        }))\n+        # The routing index now comes from _load_sessions_index() (state.db\n+        # primary, sessions.json fallback). Stub it to return the new\n+        # conversation, simulating the gateway having just written the\n+        # session row + first message in one state.db transaction.\n+        monkeypatch.setattr(\n+            mcp_serve, \"_load_sessions_index\",\n+            lambda: {\n+                \"agent:main:telegram:dm:late\": {\n+                    \"session_id\": session_id,\n+                    \"platform\": \"telegram\",\n+                    \"origin\": {\"platform\": \"telegram\", \"chat_id\": \"late\"},\n+                }\n+            },\n+        )\n \n         class DB:\n             def get_messages(self, sid):","@@ -1275,12 +1282,11 @@ def get_messages(self, sid):\n                 }]\n \n         bridge = mcp_serve.EventBridge()\n-        # Simulate the boundary state: state.db has NOT changed since the last\n-        # poll (its message landed on an earlier tick), but sessions.json was\n-        # only updated with this conversation now — the bridge has not yet seen\n-        # the current sessions.json content.\n-        bridge._state_db_mtime = db_path.stat().st_mtime\n-        bridge._sessions_json_mtime = 0.0\n+        # Bridge has never seen this db state (mtime differs) and has an\n+        # empty cached index — exactly the state after a new conversation's\n+        # first write.\n+        bridge._state_db_mtime = 0.0\n+        assert bridge._cached_sessions_index == {}\n \n         bridge._poll_once(DB())\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}