{"repo":"NousResearch/hermes-agent","public_id":"33906","display_label":"Context compression session metadata loss and orphan sessions","summary":"These issues and fixes concern data loss during context compression rotation, specifically around session metadata (gateway origin, user_id, chat fields, indexing) not being preserved or persisted to state.db in continuation sessions, and orphan sessions being created as a result.","subsystem":"agent","counts":{"members":10,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":2,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":49772},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-05T21:01:04+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.15733333333333333},"fix_edges":[{"issue_number":27633,"pr_number":49772,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":33618,"pr_number":49772,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":33906,"pr_number":49772,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":39260,"pr_number":58899,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":33906,"title":"Context compression creates orphan sessions missing from state.db","url":null,"state":"closed"},{"kind":"issue","number":33907,"title":"Context compression creates orphan sessions missing from state.db","url":null,"state":"closed"},{"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":"issue","number":59527,"title":"Compression continuations lose gateway origin metadata, breaking `/sessions` and `/resume`","url":null,"state":"closed"},{"kind":"pr","number":34048,"title":"fix(compression): prevent orphan sessions when state.db write fails (#33906)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#49772","number":49772}],"category":"merged_via","implication":"superseded"},"merged":false},{"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":49772,"title":"fix(compression): preserve goal, platform, and session indexing across rotation","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/conversation_compression.py","hermes_cli/goals.py","tests/agent/test_compression_rotation_state.py","tests/hermes_cli/test_goals.py"],"fix_hunks":[{"file":"agent/conversation_compression.py","hunks":["@@ -592,14 +592,62 @@ def _release_lock() -> None:\n                 except Exception:\n                     pass\n                 agent._session_db_created = False\n-                agent._session_db.create_session(\n-                    session_id=agent.session_id,\n-                    source=agent.platform or os.environ.get(\"HERMES_SESSION_SOURCE\", \"cli\"),\n-                    model=agent.model,\n-                    model_config=agent._session_init_model_config,\n-                    parent_session_id=old_session_id,\n-                )\n+                try:\n+                    agent._session_db.create_session(\n+                        session_id=agent.session_id,\n+                        source=agent.platform or os.environ.get(\"HERMES_SESSION_SOURCE\", \"cli\"),\n+                        model=agent.model,\n+                        model_config=agent._session_init_model_config,\n+                        parent_session_id=old_session_id,\n+                    )\n+                except Exception as _cs_err:\n+                    # The child row could not be created (e.g. FK constraint,\n+                    # contended write). Previously the outer handler simply\n+                    # warned and let the agent continue on the NEW id — which\n+                    # has no row in state.db, producing an orphan: the parent\n+                    # is ended, the child is never indexed, and every\n+                    # subsequent message is attributed to a session that\n+                    # doesn't exist (#33906/#33907). Roll the live id back to\n+                    # the parent so the conversation stays attached to a real,\n+                    # indexed session instead of a phantom.\n+                    logger.warning(\n+                        \"Compression child session create failed (%s) — \"\n+                        \"rolling back to parent session %s to avoid an orphan.\",\n+                        _cs_err, old_session_id,\n+                    )\n+                    agent.session_id = old_session_id\n+                    try:\n+                        from gateway.session_context import set_current_session_id\n+                        set_current_session_id(agent.session_id)\n+                    except Exception:\n+                        os.environ[\"HERMES_SESSION_ID\"] = agent.session_id\n+                    try:\n+                        from hermes_logging import set_session_context\n+                        set_session_context(agent.session_id)\n+                    except Exception:\n+                        pass\n+                    # Re-open the parent: it was ended above, but we're\n+                    # continuing on it, so it must not stay closed.\n+                    try:\n+                        agent._session_db.reopen_session(old_session_id)\n+                    except Exception:\n+                        pass\n+                    old_session_id = None  # no rotation happened\n+                    # The parent row already exists in state.db, so mark the\n+                    # session as created — _ensure_db_session would otherwise\n+                    # retry a (harmless INSERT OR IGNORE) create next turn.\n+                    agent._session_db_created = True\n+                    raise\n                 agent._session_db_created = True\n+                # Carry a persistent /goal onto the continuation session.\n+                # Compression mints a fresh child id; load_goal does a flat\n+                # per-session lookup with no parent walk, so without this an\n+                # active goal silently dies at the boundary (#33618).\n+                try:\n+                    from hermes_cli.goals import migrate_goal_to_session\n+                    migrate_goal_to_session(old_session_id, agent.session_id, reason=\"compression\")\n+                except Exception as _goal_err:\n+                    logger.debug(\"Could not migrate goal on compression: %s\", _goal_err)\n                 # Auto-number the title for the continuation session\n                 if old_title:\n                     try:","@@ -615,7 +663,18 @@ def _release_lock() -> None:\n             agent._session_db.update_system_prompt(agent.session_id, new_system_prompt)\n             agent._last_flushed_db_idx = 0\n         except Exception as e:\n-            logger.warning(\"Session DB compression split failed — new session will NOT be indexed: %s\", e)\n+            # If the rotation rolled back to the parent (orphan-avoidance\n+            # above), agent.session_id is the still-indexed parent and\n+            # old_session_id was cleared — so this is recovery, not an\n+            # un-indexed orphan. Otherwise an earlier step failed before the\n+            # child was created and the warning's original meaning holds.\n+            if locals().get(\"old_session_id\") is None and not in_place:\n+                logger.warning(\n+                    \"Compression rotation aborted and rolled back to the \"\n+                    \"parent session (%s): %s\", agent.session_id or \"?\", e,\n+                )\n+            else:\n+                logger.warning(\"Session DB compression split failed — new session will NOT be indexed: %s\", e)\n \n     # Compaction-boundary bookkeeping, computed once. `old_session_id` is only\n     # bound in the rotation branch; in-place leaves it unset. `_boundary_parent`","@@ -637,6 +696,7 @@ def _release_lock() -> None:\n                 agent.session_id or \"\",\n                 boundary_reason=\"compression\",\n                 old_session_id=_boundary_parent,\n+                platform=getattr(agent, \"platform\", None) or \"cli\",\n                 conversation_id=getattr(agent, \"_gateway_session_key\", None),\n             )\n     except Exception as _ce_err:"]},{"file":"hermes_cli/goals.py","hunks":["@@ -279,6 +279,44 @@ def clear_goal(session_id: str) -> None:\n     save_goal(session_id, state)\n \n \n+def migrate_goal_to_session(old_session_id: str, new_session_id: str, *, reason: str = \"\") -> bool:\n+    \"\"\"Carry a persistent /goal from a parent session to its continuation.\n+\n+    Context compression rotates ``session_id`` to a fresh child session,\n+    but ``load_goal`` does a flat ``goal:<session_id>`` lookup with no\n+    parent-lineage walk — so an active goal silently dies at the\n+    compaction boundary (#33618). Copy the goal onto the new session and\n+    archive the old row as ``cleared`` so exactly one active goal row\n+    exists per logical conversation (avoids the \"two active goals\"\n+    hazard of a pure copy).\n+\n+    Returns True when a goal was migrated, False when there was nothing\n+    to migrate or the DB was unavailable. Best-effort and never raises —\n+    a failure here must not block compression.\n+    \"\"\"\n+    if not old_session_id or not new_session_id or old_session_id == new_session_id:\n+        return False\n+    try:\n+        state = load_goal(old_session_id)\n+        if state is None or getattr(state, \"status\", None) == \"cleared\":\n+            return False\n+        # Don't clobber a goal already set on the child (e.g. a resumed\n+        # lineage that re-established its own goal).\n+        if load_goal(new_session_id) is not None:\n+            return False\n+        save_goal(new_session_id, state)\n+        # Archive the parent's row so it isn't double-counted as active.\n+        clear_goal(old_session_id)\n+        logger.debug(\n+            \"GoalManager: migrated goal %s -> %s (%s)\",\n+            old_session_id, new_session_id, reason or \"rotation\",\n+        )\n+        return True\n+    except Exception as exc:  # pragma: no cover - defensive\n+        logger.debug(\"GoalManager: goal migration failed: %s\", exc)\n+        return False\n+\n+\n # ──────────────────────────────────────────────────────────────────────\n # Judge\n # ──────────────────────────────────────────────────────────────────────","@@ -907,6 +945,7 @@ def _log(msg: str) -> None:\n     \"load_goal\",\n     \"save_goal\",\n     \"clear_goal\",\n+    \"migrate_goal_to_session\",\n     \"judge_goal\",\n     \"run_kanban_goal_loop\",\n ]"]},{"file":"tests/agent/test_compression_rotation_state.py","hunks":["@@ -0,0 +1,129 @@\n+\"\"\"Compression rotation hardening — state-loss fixes at the compaction boundary.\n+\n+When auto-compression rotates ``agent.session_id`` to a continuation child,\n+three pieces of state used to be lost or corrupted:\n+\n+  * #33618 — a persistent ``/goal`` did not follow the rotation (``load_goal``\n+    is a flat per-session lookup with no lineage walk), so it silently died.\n+  * #33906/#33907 — if the child ``create_session`` raised, the outer handler\n+    only warned and let the agent continue on the NEW (un-indexed) id,\n+    producing an orphan session missing from state.db.\n+  * #27633 — the compaction-boundary ``on_session_start`` notification omitted\n+    the ``platform`` kwarg, so context-engine plugins saw ``source=unknown``\n+    for every message after the boundary.\n+\n+These tests drive the real ``compress_context`` path against a real SessionDB.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import os\n+from pathlib import Path\n+from unittest.mock import MagicMock, patch\n+\n+from hermes_state import SessionDB\n+\n+\n+def _build_agent_with_db(db: SessionDB, session_id: str, platform: str = \"telegram\"):\n+    with patch.dict(os.environ, {\"OPENROUTER_API_KEY\": \"test-key\"}):\n+        from run_agent import AIAgent\n+\n+        agent = AIAgent(\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=\"https://openrouter.ai/api/v1\",\n+            model=\"test/model\",\n+            platform=platform,\n+            quiet_mode=True,\n+            session_db=db,\n+            session_id=session_id,\n+            skip_context_files=True,\n+            skip_memory=True,\n+        )\n+\n+    compressor = MagicMock()\n+    compressor.compress.return_value = [\n+        {\"role\": \"user\", \"content\": \"[CONTEXT COMPACTION] summary\"},\n+        {\"role\": \"user\", \"content\": \"tail\"},\n+    ]\n+    compressor.compression_count = 1\n+    compressor.last_prompt_tokens = 0\n+    compressor.last_completion_tokens = 0\n+    compressor._last_summary_error = None\n+    compressor._last_compress_aborted = False\n+    compressor._last_summary_auth_failure = False\n+    compressor._last_aux_model_failure_model = None\n+    compressor._last_aux_model_failure_error = None\n+    agent.context_compressor = compressor\n+    return agent\n+\n+\n+def _msgs(n=20):\n+    return [{\"role\": \"user\", \"content\": f\"m{i}\"} for i in range(n)]\n+\n+\n+class TestGoalMigratesOnRotation:\n+    def test_goal_follows_compression_rotation(self, tmp_path: Path):\n+        db = SessionDB(db_path=tmp_path / \"state.db\")\n+        parent = \"PARENT_GOAL_ROT\"\n+        db.create_session(parent, source=\"cli\")\n+        agent = _build_agent_with_db(db, parent)\n+\n+        # Set a persistent goal on the parent via the real persistence path.\n+        with patch.dict(os.environ, {\"HERMES_HOME\": str(tmp_path / \".hermes\")}):\n+            (tmp_path / \".hermes\").mkdir(exist_ok=True)\n+            import hermes_cli.goals as goals\n+            goals._DB_CACHE.clear()\n+            # Point the goal DB at the same state.db the agent uses.\n+            with patch.object(goals, \"_get_session_db\", return_value=db):\n+                goals.save_goal(parent, goals.GoalState(goal=\"finish the migration\"))\n+\n+                agent._compress_context(_msgs(), \"sys\", approx_tokens=[redacted:secret-assignment]\n+                child = agent.session_id\n+                assert child != parent  # rotation happened\n+\n+                migrated = goals.load_goal(child)\n+                assert migrated is not None\n+                assert migrated.goal == \"finish the migration\"\n+            goals._DB_CACHE.clear()\n+\n+\n+class TestOrphanRollbackOnCreateFailure:\n+    def test_rolls_back_to_parent_when_child_create_fails(self, tmp_path: Path):\n+        db = SessionDB(db_path=tmp_path / \"state.db\")\n+        parent = \"PARENT_ORPHAN_ROT\"\n+        db.create_session(parent, source=\"cli\")\n+        agent = _build_agent_with_db(db, parent)\n+\n+        # Make the CHILD create_session raise, but let the initial parent\n+        # end_session/reopen work. We patch create_session to blow up.\n+        real_create = db.create_session\n+\n+        def _boom(*a, **k):\n+            raise RuntimeError(\"FOREIGN KEY constraint failed\")\n+\n+        with patch.object(db, \"create_session\", side_effect=_boom):\n+            agent._compress_context(_msgs(), \"sys\", approx_tokens=[redacted:secret-assignment]\n+\n+        # The live id must roll back to the still-indexed parent — NOT a\n+        # phantom child id that has no row in state.db.\n+        assert agent.session_id == parent\n+        assert db.get_session(parent) is not None\n+        _ = real_create  # silence unused\n+\n+\n+class TestPlatformForwardedAtBoundary:\n+    def test_on_session_start_receives_platform(self, tmp_path: Path):\n+        db = SessionDB(db_path=tmp_path / \"state.db\")\n+        parent = \"PARENT_PLATFORM_ROT\"\n+        db.create_session(parent, source=\"telegram\")\n+        agent = _build_agent_with_db(db, parent, platform=\"telegram\")\n+\n+        agent._compress_context(_msgs(), \"sys\", approx_tokens=[redacted:secret-assignment]\n+\n+        # The boundary notify must forward the platform so context-engine\n+        # plugins don't fall back to source=unknown (#27633).\n+        calls = [c for c in agent.context_compressor.on_session_start.call_args_list]\n+        assert calls, \"on_session_start was not called at the boundary\"\n+        kwargs = calls[-1].kwargs\n+        assert kwargs.get(\"platform\") == \"telegram\"\n+        assert kwargs.get(\"boundary_reason\") == \"compression\""]},{"file":"tests/hermes_cli/test_goals.py","hunks":["@@ -547,6 +547,47 @@ def test_subgoals_round_trip(self):\n         assert rt.subgoals == [\"a\", \"b\", \"c\"]\n \n \n+class TestMigrateGoalToSession:\n+    \"\"\"migrate_goal_to_session carries a /goal from a parent session to its\n+    compression continuation child (#33618). load_goal does a flat\n+    per-session lookup with no lineage walk, so without migration an active\n+    goal silently dies when compression rotates session_id.\"\"\"\n+\n+    def test_migrates_active_goal_to_child(self, hermes_home):\n+        from hermes_cli.goals import save_goal, load_goal, migrate_goal_to_session, GoalState\n+        save_goal(\"parent-sid\", GoalState(goal=\"ship the feature\"))\n+        assert migrate_goal_to_session(\"parent-sid\", \"child-sid\", reason=\"compression\") is True\n+        child = load_goal(\"child-sid\")\n+        assert child is not None and child.goal == \"ship the feature\"\n+        # Parent row archived (cleared) so only the child is active.\n+        parent = load_goal(\"parent-sid\")\n+        assert parent is not None and parent.status == \"cleared\"\n+\n+    def test_no_goal_to_migrate_returns_false(self, hermes_home):\n+        from hermes_cli.goals import migrate_goal_to_session, load_goal\n+        assert migrate_goal_to_session(\"empty-parent\", \"child2\") is False\n+        assert load_goal(\"child2\") is None\n+\n+    def test_does_not_clobber_existing_child_goal(self, hermes_home):\n+        from hermes_cli.goals import save_goal, load_goal, migrate_goal_to_session, GoalState\n+        save_goal(\"p3\", GoalState(goal=\"parent goal\"))\n+        save_goal(\"c3\", GoalState(goal=\"child already has one\"))\n+        assert migrate_goal_to_session(\"p3\", \"c3\") is False\n+        assert load_goal(\"c3\").goal == \"child already has one\"\n+\n+    def test_same_id_is_noop(self, hermes_home):\n+        from hermes_cli.goals import save_goal, migrate_goal_to_session, GoalState\n+        save_goal(\"same\", GoalState(goal=\"g\"))\n+        assert migrate_goal_to_session(\"same\", \"same\") is False\n+\n+    def test_cleared_goal_not_migrated(self, hermes_home):\n+        from hermes_cli.goals import save_goal, clear_goal, migrate_goal_to_session, load_goal, GoalState\n+        save_goal(\"p4\", GoalState(goal=\"done already\"))\n+        clear_goal(\"p4\")\n+        assert migrate_goal_to_session(\"p4\", \"c4\") is False\n+        assert load_goal(\"c4\") is None\n+\n+\n class TestGoalManagerSubgoals:\n     def test_add_subgoal(self, hermes_home):\n         from hermes_cli.goals import GoalManager\n"]}]},{"kind":"pr","number":58899,"title":"refactor: consolidate gateway session metadata into state.db","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"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\": input_tokens,\n+        \"output_tokens\": output_tokens,\n+        \"total_tokens\": input_tokens + 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"]}]},{"kind":"pr","number":59581,"title":"fix(agent): preserve gateway metadata across compression rotation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d5b4879d4a734b963b0f5d822754094acda44f74","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":59870,"title":"fix(compression): preserve gateway origin on continuation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"d5b4879d4a734b963b0f5d822754094acda44f74","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}