# NousResearch/hermes-agent · Context compression session metadata loss and orphan sessions

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 10 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 2 merged PR(s), 4 PR(s) closed without merging.
- A fix was merged to main in PR #49772. Verify it addresses your case.
- **Recommended:** read PR #49772 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.16`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #33906: Context compression creates orphan sessions missing from state.db
- #33907: Context compression creates orphan sessions missing from state.db
- #39260: Bug: Gateway does not persist chat metadata (chat_id, chat_type, thread_id, parent_chat_id, chat_name, session_key) to state.db sessions
- #59527: Compression continuations lose gateway origin metadata, breaking \`/sessions\` and \`/resume\`

## Prior & competing PRs

- **#49772: fix(compression): preserve goal, platform, and session indexing across rotation**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/conversation_compression.py`, `hermes_cli/goals.py`, `tests/agent/test_compression_rotation_state.py`, `tests/hermes_cli/test_goals.py`
  - fix hunks:
  - `agent/conversation_compression.py`

    ```diff
    @@ -592,14 +592,62 @@ def _release_lock() -> None:
                     except Exception:
                         pass
                     agent._session_db_created = False
    -                agent._session_db.create_session(
    -                    session_id=agent.session_id,
    -                    source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
    -                    model=agent.model,
    -                    model_config=agent._session_init_model_config,
    -                    parent_session_id=old_session_id,
    -                )
    +                try:
    +                    agent._session_db.create_session(
    +                        session_id=agent.session_id,
    +                        source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
    +                        model=agent.model,
    +                        model_config=agent._session_init_model_config,
    +                        parent_session_id=old_session_id,
    +                    )
    +                except Exception as _cs_err:
    +                    # The child row could not be created (e.g. FK constraint,
    +                    # contended write). Previously the outer handler simply
    +                    # warned and let the agent continue on the NEW id — which
    +                    # has no row in state.db, producing an orphan: the parent
    +                    # is ended, the child is never indexed, and every
    +                    # subsequent message is attributed to a session that
    +                    # doesn't exist (#33906/#33907). Roll the live id back to
    +                    # the parent so the conversation stays attached to a real,
    +                    # indexed session instead of a phantom.
    +                    logger.warning(
    +                        "Compression child session create failed (%s) — "
    +                        "rolling back to parent session %s to avoid an orphan.",
    +                        _cs_err, old_session_id,
    +                    )
    +                    agent.session_id = old_session_id
    +                    try:
    +                        from gateway.session_context import set_current_session_id
    +                        set_current_session_id(agent.session_id)
    +                    except Exception:
    +                        os.environ["HERMES_SESSION_ID"] = agent.session_id
    +                    try:
    +                        from hermes_logging import set_session_context
    +                        set_session_context(agent.session_id)
    +                    except Exception:
    +                        pass
    +                    # Re-open the parent: it was ended above, but we're
    +                    # continuing on it, so it must not stay closed.
    +                    try:
    +                        agent._session_db.reopen_session(old_session_id)
    +                    except Exception:
    +                        pass
    +                    old_session_id = None  # no rotation happened
    +                    # The parent row already exists in state.db, so mark the
    +                    # session as created — _ensure_db_session would otherwise
    +                    # retry a (harmless INSERT OR IGNORE) create next turn.
    +                    agent._session_db_created = True
    +                    raise
                     agent._session_db_created = True
    +                # Carry a persistent /goal onto the continuation session.
    +                # Compression mints a fresh child id; load_goal does a flat
    +                # per-session lookup with no parent walk, so without this an
    +                # active goal silently dies at the boundary (#33618).
    +                try:
    +                    from hermes_cli.goals import migrate_goal_to_session
    +                    migrate_goal_to_session(old_session_id, agent.session_id, reason="compression")
    +                except Exception as _goal_err:
    +                    logger.debug("Could not migrate goal on compression: %s", _goal_err)
                     # Auto-number the title for the continuation session
                     if old_title:
                         try:
    ```

    ```diff
    @@ -615,7 +663,18 @@ def _release_lock() -> None:
                 agent._session_db.update_system_prompt(agent.session_id, new_system_prompt)
                 agent._last_flushed_db_idx = 0
             except Exception as e:
    -            logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e)
    +            # If the rotation rolled back to the parent (orphan-avoidance
    +            # above), agent.session_id is the still-indexed parent and
    +            # old_session_id was cleared — so this is recovery, not an
    +            # un-indexed orphan. Otherwise an earlier step failed before the
    +            # child was created and the warning's original meaning holds.
    +            if locals().get("old_session_id") is None and not in_place:
    +                logger.warning(
    +                    "Compression rotation aborted and rolled back to the "
    +                    "parent session (%s): %s", agent.session_id or "?", e,
    +                )
    +            else:
    +                logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e)
     
         # Compaction-boundary bookkeeping, computed once. `old_session_id` is only
         # bound in the rotation branch; in-place leaves it unset. `_boundary_parent`
    ```

    ```diff
    @@ -637,6 +696,7 @@ def _release_lock() -> None:
                     agent.session_id or "",
                     boundary_reason="compression",
                     old_session_id=_boundary_parent,
    +                platform=getattr(agent, "platform", None) or "cli",
                     conversation_id=getattr(agent, "_gateway_session_key", None),
                 )
         except Exception as _ce_err:
    ```
  - `hermes_cli/goals.py`

    ```diff
    @@ -279,6 +279,44 @@ def clear_goal(session_id: str) -> None:
         save_goal(session_id, state)
     
     
    +def migrate_goal_to_session(old_session_id: str, new_session_id: str, *, reason: str = "") -> bool:
    +    """Carry a persistent /goal from a parent session to its continuation.
    +
    +    Context compression rotates ``session_id`` to a fresh child session,
    +    but ``load_goal`` does a flat ``goal:<session_id>`` lookup with no
    +    parent-lineage walk — so an active goal silently dies at the
    +    compaction boundary (#33618). Copy the goal onto the new session and
    +    archive the old row as ``cleared`` so exactly one active goal row
    +    exists per logical conversation (avoids the "two active goals"
    +    hazard of a pure copy).
    +
    +    Returns True when a goal was migrated, False when there was nothing
    +    to migrate or the DB was unavailable. Best-effort and never raises —
    +    a failure here must not block compression.
    +    """
    +    if not old_session_id or not new_session_id or old_session_id == new_session_id:
    +        return False
    +    try:
    +        state = load_goal(old_session_id)
    +        if state is None or getattr(state, "status", None) == "cleared":
    +            return False
    +        # Don't clobber a goal already set on the child (e.g. a resumed
    +        # lineage that re-established its own goal).
    +        if load_goal(new_session_id) is not None:
    +            return False
    +        save_goal(new_session_id, state)
    +        # Archive the parent's row so it isn't double-counted as active.
    +        clear_goal(old_session_id)
    +        logger.debug(
    +            "GoalManager: migrated goal %s -> %s (%s)",
    +            old_session_id, new_session_id, reason or "rotation",
    +        )
    +        return True
    +    except Exception as exc:  # pragma: no cover - defensive
    +        logger.debug("GoalManager: goal migration failed: %s", exc)
    +        return False
    +
    +
     # ──────────────────────────────────────────────────────────────────────
     # Judge
     # ──────────────────────────────────────────────────────────────────────
    ```

    ```diff
    @@ -907,6 +945,7 @@ def _log(msg: str) -> None:
         "load_goal",
         "save_goal",
         "clear_goal",
    +    "migrate_goal_to_session",
         "judge_goal",
         "run_kanban_goal_loop",
     ]
    ```
  - `tests/agent/test_compression_rotation_state.py`

    ```diff
    @@ -0,0 +1,129 @@
    +"""Compression rotation hardening — state-loss fixes at the compaction boundary.
    +
    +When auto-compression rotates ``agent.session_id`` to a continuation child,
    +three pieces of state used to be lost or corrupted:
    +
    +  * #33618 — a persistent ``/goal`` did not follow the rotation (``load_goal``
    +    is a flat per-session lookup with no lineage walk), so it silently died.
    +  * #33906/#33907 — if the child ``create_session`` raised, the outer handler
    +    only warned and let the agent continue on the NEW (un-indexed) id,
    +    producing an orphan session missing from state.db.
    +  * #27633 — the compaction-boundary ``on_session_start`` notification omitted
    +    the ``platform`` kwarg, so context-engine plugins saw ``source=unknown``
    +    for every message after the boundary.
    +
    +These tests drive the real ``compress_context`` path against a real SessionDB.
    +"""
    +
    +from __future__ import annotations
    +
    +import os
    +from pathlib import Path
    +from unittest.mock import MagicMock, patch
    +
    +from hermes_state import SessionDB
    +
    +
    +def _build_agent_with_db(db: SessionDB, session_id: str, platform: str = "telegram"):
    +    with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
    +        from run_agent import AIAgent
    +
    +        agent = AIAgent(
    +            api_key="[redacted:secret-assignment]",
    +            base_url="https://openrouter.ai/api/v1",
    +            model="test/model",
    +            platform=platform,
    +            quiet_mode=True,
    +            session_db=db,
    +            session_id=session_id,
    +            skip_context_files=True,
    +            skip_memory=True,
    +        )
    +
    +    compressor = MagicMock()
    +    compressor.compress.return_value = [
    +        {"role": "user", "content": "[CONTEXT COMPACTION] summary"},
    +        {"role": "user", "content": "tail"},
    +    ]
    +    compressor.compression_count = 1
    +    compressor.last_prompt_tokens = 0
    +    compressor.last_completion_tokens = 0
    +    compressor._last_summary_error = None
    +    compressor._last_compress_aborted = False
    +    compressor._last_summary_auth_failure = False
    +    compressor._last_aux_model_failure_model = None
    +    compressor._last_aux_model_failure_error = None
    +    agent.context_compressor = compressor
    +    return agent
    +
    +
    +def _msgs(n=20):
    +    return [{"role": "user", "content": f"m{i}"} for i in range(n)]
    +
    +
    +class TestGoalMigratesOnRotation:
    +    def test_goal_follows_compression_rotation(self, tmp_path: Path):
    +        db = SessionDB(db_path=tmp_path / "state.db")
    +        parent = "PARENT_GOAL_ROT"
    +        db.create_session(parent, source="cli")
    +        agent = _build_agent_with_db(db, parent)
    +
    +        # Set a persistent goal on the parent via the real persistence path.
    +        with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}):
    +            (tmp_path / ".hermes").mkdir(exist_ok=True)
    +            import hermes_cli.goals as goals
    +            goals._DB_CACHE.clear()
    +            # Point the goal DB at the same state.db the agent uses.
    +            with patch.object(goals, "_get_session_db", return_value=db):
    +                goals.save_goal(parent, goals.GoalState(goal="finish the migration"))
    +
    +                agent._compress_context(_msgs(), "sys", approx_tokens=[redacted:secret-assignment]
    +                child = agent.session_id
    +                assert child != parent  # rotation happened
    +
    +                migrated = goals.load_goal(child)
    +                assert migrated is not None
    +                assert migrated.goal == "finish the migration"
    +            goals._DB_CACHE.clear()
    +
    +
    +class TestOrphanRollbackOnCreateFailure:
    +    def test_rolls_back_to_parent_when_child_create_fails(self, tmp_path: Path):
    +        db = SessionDB(db_path=tmp_path / "state.db")
    +        parent = "PARENT_ORPHAN_ROT"
    +        db.create_session(parent, source="cli")
    +        agent = _build_agent_with_db(db, parent)
    +
    +        # Make the CHILD create_session raise, but let the initial parent
    +        # end_session/reopen work. We patch create_session to blow up.
    +        real_create = db.create_session
    +
    +        def _boom(*a, **k):
    +            raise RuntimeError("FOREIGN KEY constraint failed")
    +
    +        with patch.object(db, "create_session", side_effect=_boom):
    +            agent._compress_context(_msgs(), "sys", approx_tokens=[redacted:secret-assignment]
    +
    +        # The live id must roll back to the still-indexed parent — NOT a
    +        # phantom child id that has no row in state.db.
    +        assert agent.session_id == parent
    +        assert db.get_session(parent) is not None
    +        _ = real_create  # silence unused
    +
    +
    +class TestPlatformForwardedAtBoundary:
    +    def test_on_session_start_receives_platform(self, tmp_path: Path):
    +        db = SessionDB(db_path=tmp_path / "state.db")
    +        parent = "PARENT_PLATFORM_ROT"
    +        db.create_session(parent, source="telegram")
    +        agent = _build_agent_with_db(db, parent, platform="telegram")
    +
    +        agent._compress_context(_msgs(), "sys", approx_tokens=[redacted:secret-assignment]
    +
    +        # The boundary notify must forward the platform so context-engine
    +        # plugins don't fall back to source=unknown (#27633).
    +        calls = [c for c in agent.context_compressor.on_session_start.call_args_list]
    +        assert calls, "on_session_start was not called at the boundary"
    +        kwargs = calls[-1].kwargs
    +        assert kwargs.get("platform") == "telegram"
    +        assert kwargs.get("boundary_reason") == "compression"
    ```
  - `tests/hermes_cli/test_goals.py`

    ```diff
    @@ -547,6 +547,47 @@ def test_subgoals_round_trip(self):
             assert rt.subgoals == ["a", "b", "c"]
     
     
    +class TestMigrateGoalToSession:
    +    """migrate_goal_to_session carries a /goal from a parent session to its
    +    compression continuation child (#33618). load_goal does a flat
    +    per-session lookup with no lineage walk, so without migration an active
    +    goal silently dies when compression rotates session_id."""
    +
    +    def test_migrates_active_goal_to_child(self, hermes_home):
    +        from hermes_cli.goals import save_goal, load_goal, migrate_goal_to_session, GoalState
    +        save_goal("parent-sid", GoalState(goal="ship the feature"))
    +        assert migrate_goal_to_session("parent-sid", "child-sid", reason="compression") is True
    +        child = load_goal("child-sid")
    +        assert child is not None and child.goal == "ship the feature"
    +        # Parent row archived (cleared) so only the child is active.
    +        parent = load_goal("parent-sid")
    +        assert parent is not None and parent.status == "cleared"
    +
    +    def test_no_goal_to_migrate_returns_false(self, hermes_home):
    +        from hermes_cli.goals import migrate_goal_to_session, load_goal
    +        assert migrate_goal_to_session("empty-parent", "child2") is False
    +        assert load_goal("child2") is None
    +
    +    def test_does_not_clobber_existing_child_goal(self, hermes_home):
    +        from hermes_cli.goals import save_goal, load_goal, migrate_goal_to_session, GoalState
    +        save_goal("p3", GoalState(goal="parent goal"))
    +        save_goal("c3", GoalState(goal="child already has one"))
    +        assert migrate_goal_to_session("p3", "c3") is False
    +        assert load_goal("c3").goal == "child already has one"
    +
    +    def test_same_id_is_noop(self, hermes_home):
    +        from hermes_cli.goals import save_goal, migrate_goal_to_session, GoalState
    +        save_goal("same", GoalState(goal="g"))
    +        assert migrate_goal_to_session("same", "same") is False
    +
    +    def test_cleared_goal_not_migrated(self, hermes_home):
    +        from hermes_cli.goals import save_goal, clear_goal, migrate_goal_to_session, load_goal, GoalState
    +        save_goal("p4", GoalState(goal="done already"))
    +        clear_goal("p4")
    +        assert migrate_goal_to_session("p4", "c4") is False
    +        assert load_goal("c4") is None
    +
    +
     class TestGoalManagerSubgoals:
         def test_add_subgoal(self, hermes_home):
             from hermes_cli.goals import GoalManager
    ```
- **#58899: refactor: consolidate gateway session metadata into state.db**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `gateway/channel_directory.py`

    ```diff
    @@ -263,7 +263,67 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]:
     
     
     def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]:
    -    """Pull known channels/contacts from sessions.json origin data."""
    +    """Pull known channels/contacts from gateway session origin data.
    +
    +    state.db is the primary source (#9006): gateway session rows persist
    +    origin_json.  Falls back to sessions.json for pre-migration databases.
    +    """
    +    entries = _build_from_sessions_db(platform_name)
    +    if entries:
    +        return entries
    +    return _build_from_sessions_json(platform_name)
    +
    +
    +def _build_from_sessions_db(platform_name: str) -> List[Dict[str, str]]:
    +    """Pull channels/contacts from state.db gateway session rows."""
    +    entries: List[Dict[str, str]] = []
    +    try:
    +        from hermes_state import SessionDB
    +        db = SessionDB()
    +        try:
    +            lister = getattr(db, "list_gateway_sessions", None)
    +            if not callable(lister):
    +                return []
    +            rows = lister(platform=platform_name, active_only=False)
    +        finally:
    +            db.close()
    +
    +        seen_ids = set()
    +        for row in rows:
    +            origin: Dict[str, Any] = {}
    +            if row.get("origin_json"):
    +                try:
    +                    parsed = json.loads(row["origin_json"])
    +                    if isinstance(parsed, dict):
    +                        origin = parsed
    +                except (TypeError, ValueError):
    +                    pass
    +            if not origin:
    +                origin = {
    +                    "chat_id": row.get("chat_id"),
    +                    "thread_id": row.get("thread_id"),
    +                    "chat_name": row.get("display_name"),
    +                }
    +            entry_id = _session_entry_id(origin)
    +            if not entry_id or entry_id in seen_ids:
    +                continue
    +            seen_ids.add(entry_id)
    +            entries.append({
    +                "id": entry_id,
    +                "name": _session_entry_name(origin),
    +                "type": row.get("chat_type") or "dm",
    +                "thread_id": origin.get("thread_id"),
    +            })
    +    except Exception as e:
    +        logger.debug(
    +            "Channel directory: state.db session read failed for %s: %s",
    +            platform_name, e,
    +        )
    +    return entries
    +
    +
    +def _build_from_sessions_json(platform_name: str) -> List[Dict[str, str]]:
    +    """Legacy fallback: pull channels/contacts from sessions.json origin data."""
         sessions_path = get_hermes_home() / "sessions" / "sessions.json"
         if not sessions_path.exists():
             return []
    ```
  - `gateway/mirror.py`

    ```diff
    @@ -102,14 +102,36 @@ def _find_session_id(
         """
         Find the active session_id for a platform + chat_id pair.
     
    -    Scans sessions.json entries and matches where origin.chat_id == chat_id
    -    on the right platform.  DM session keys don't embed the chat_id
    -    (e.g. "agent:main:telegram:dm"), so we check the origin dict.
    +    Queries state.db gateway session rows (primary source since #9006);
    +    falls back to scanning sessions.json for pre-migration databases.
    +    DM session keys don't embed the chat_id (e.g. "agent:main:telegram:dm"),
    +    so we match on the persisted chat origin, not the key.
     
         When *user_id* is provided, prefer exact sender matches. If multiple
         same-chat candidates exist and none matches the user, return None instead
         of guessing and contaminating another participant's session.
         """
    +    # Primary: state.db
    +    try:
    +        from hermes_state import SessionDB
    +        db = SessionDB()
    +        try:
    +            finder = getattr(db, "find_session_by_origin", None)
    +            if callable(finder):
    +                session_id = finder(
    +                    platform=platform,
    +                    chat_id=chat_id,
    +                    thread_id=thread_id,
    +                    user_id=user_id,
    +                )
    +                if session_id:
    +                    return str(session_id)
    +        finally:
    +            db.close()
    +    except Exception as e:
    +        logger.debug("Mirror state.db session lookup failed: %s", e)
    +
    +    # Fallback: sessions.json (pre-migration databases)
         if not _SESSIONS_INDEX.exists():
             return None
     
    ```
  - `gateway/run.py`

    ```diff
    @@ -7540,14 +7540,11 @@ async def _session_expiry_watcher(self, interval: int = 300):
                             _update_prompt_pending = getattr(self, "_update_prompt_pending", None)
                             if isinstance(_update_prompt_pending, dict):
                                 _update_prompt_pending.pop(key, None)
    -                        with self.session_store._lock:
    -                            entry.expiry_finalized = True
    -                            # Session finalization is a conversation boundary —
    -                            # drop the persisted /model override too so a later
    -                            # message doesn't rehydrate it after the in-memory
    -                            # override was popped above.
    -                            entry.model_override = None
    -                            self.session_store._save()
    +                        # Persist the finalized flag to sessions.json AND
    +                        # state.db (single write-path, #9006) — also drops
    +                        # the persisted /model override, since finalization
    +                        # is a conversation boundary.
    +                        self.session_store.set_expiry_finalized(entry)
                             logger.debug(
                                 "Session expiry finalized for %s",
                                 entry.session_id,
    ```

    ```diff
    @@ -7562,9 +7559,9 @@ async def _session_expiry_watcher(self, interval: int = 300):
                                     "Marking as finalized to prevent infinite retry loop.",
                                     failures, entry.session_id, e,
                                 )
    -                            with self.session_store._lock:
    -                                entry.expiry_finalized = True
    -                                self.session_store._save()
    +                            self.session_store.set_expiry_finalized(
    +                                entry, clear_model_override=False
    +                            )
                                 _finalize_failures.pop(entry.session_id, None)
                             else:
                                 logger.debug(
    ```
  - `gateway/session.py`

    ```diff
    @@ -1202,6 +1202,7 @@ def _record_gateway_session_peer(
             session_id: str,
             session_key: str,
             source: Optional[SessionSource],
    +        display_name: Optional[str] = None,
         ) -> None:
             """Persist the routing peer for an existing gateway session row."""
             if not self._db or not source:
    ```

    ```diff
    @@ -1210,6 +1211,11 @@ def _record_gateway_session_peer(
             if not callable(recorder):
                 return
             try:
    +            origin_json = None
    +            try:
    +                origin_json = json.dumps(source.to_dict())
    +            except Exception:
    +                pass
                 recorder(
                     session_id,
                     source=source.platform.value,
    ```

    ```diff
    @@ -1218,9 +1224,56 @@ def _record_gateway_session_peer(
                     chat_id=source.chat_id,
                     chat_type=source.chat_type,
                     thread_id=source.thread_id,
    +                display_name=display_name or source.chat_name,
    +                origin_json=origin_json,
                 )
    +        except TypeError:
    +            # Older SessionDB without display_name/origin_json kwargs.
    +            try:
    +                recorder(
    +                    session_id,
    +                    source=source.platform.value,
    +                    user_id=source.user_id,
    +                    session_key=session_key,
    +                    chat_id=source.chat_id,
    +                    chat_type=source.chat_type,
    +                    thread_id=source.thread_id,
    +                )
    +            except Exception as exc:
    +                logger.debug("Gateway session peer record failed for %s: %s", session_key, exc)
             except Exception as exc:
                 logger.debug("Gateway session peer record failed for %s: %s", session_key, exc)
    +
    +    def set_expiry_finalized(
    +        self, entry: SessionEntry, *, clear_model_override: bool = True
    +    ) -> None:
    +        """Mark a session entry expiry-finalized in memory, sessions.json, AND state.db.
    +
    +        Single write-path for the expiry watcher (#9006): keeps the durable
    +        state.db flag in sync with the JSON routing index so the flag
    +        survives sessions.json pruning/loss.
    +
    +        ``clear_model_override=False`` preserves the give-up path's original
    +        behavior (flag only, no override drop).
    +        """
    +        with self._lock:
    +            entry.expiry_finalized = True
    +            if clear_model_override:
    +                # Session finalization is a conversation boundary — drop the
    +                # persisted /model override too so a later message doesn't
    +                # rehydrate it after the in-memory override was popped.
    +                entry.model_override = None
    +            self._save()
    +        if self._db:
    +            setter = getattr(self._db, "set_expiry_finalized", None)
    +            if callable(setter):
    +                try:
    +                    setter(entry.session_id, True)
    +                except Exception as exc:
    +                    logger.debug(
    +                        "Session DB expiry_finalized write failed for %s: %s",
    +                        entry.session_id, exc,
    +                    )
         
         def _is_session_expired(self, entry: SessionEntry) -> bool:
             """Check if a session has expired based on its reset policy.
    ```

    ```diff
    @@ -1618,6 +1671,7 @@ def get_or_create_session(
                         session_id,
                         session_key,
                         source,
    +                    display_name=entry.display_name,
                     )
                 except Exception as e:
                     print(f"[gateway] Warning: Failed to create SQLite session: {e}")
    ```

    ```diff
    @@ -1643,6 +1697,7 @@ def update_session(
                         entry.session_id,
                         session_key,
                         entry.origin,
    +                    display_name=entry.display_name,
                     )
     
         def set_model_override(
    ```

    ```diff
    @@ -1888,6 +1943,7 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) ->
                         session_id,
                         session_key,
                         old_entry.origin,
    +                    display_name=new_entry.display_name if new_entry else None,
                     )
                 except Exception as e:
                     logger.debug("Session DB operation failed: %s", e)
    ```

    ```diff
    @@ -1950,6 +2006,7 @@ def switch_session(self, session_key: str, target_session_id: str) -> Optional[S
                     target_session_id,
                     session_key,
                     new_entry.origin if new_entry else None,
    +                display_name=new_entry.display_name if new_entry else None,
                 )
     
             return new_entry
    ```
  - `hermes_cli/status.py`

    ```diff
    @@ -543,17 +543,39 @@ def _resolve_env(env_ref) -> str:
         print()
         print(color("◆ Sessions", Colors.CYAN, Colors.BOLD))
     
    -    sessions_file = get_hermes_home() / "sessions" / "sessions.json"
    -    if sessions_file.exists():
    -        import json
    +    # Gateway session count: state.db is the source of truth (#9006);
    +    # fall back to sessions.json for pre-migration installs.
    +    _session_count = None
    +    try:
    +        from hermes_state import SessionDB
    +        _db = SessionDB()
             try:
    -            with open(sessions_file, encoding="utf-8") as f:
    -                data = json.load(f)
    -                print(f"  Active:       {len(data)} session(s)")
    -        except Exception:
    -            print("  Active:       (error reading sessions file)")
    +            _lister = getattr(_db, "list_gateway_sessions", None)
    +            if callable(_lister):
    +                _session_count = len(_lister(active_only=True))
    +        finally:
    +            _db.close()
    +    except Exception:
    +        _session_count = None
    +
    +    if _session_count is not None and _session_count > 0:
    +        print(f"  Active:       {_session_count} session(s)")
         else:
    -        print("  Active:       0")
    +        sessions_file = get_hermes_home() / "sessions" / "sessions.json"
    +        if sessions_file.exists():
    +            import json
    +            try:
    +                with open(sessions_file, encoding="utf-8") as f:
    +                    data = json.load(f)
    +                    _entries = {
    +                        k: v for k, v in data.items()
    +                        if not str(k).startswith("_")
    +                    } if isinstance(data, dict) else {}
    +                    print(f"  Active:       {len(_entries)} session(s)")
    +            except Exception:
    +                print("  Active:       (error reading sessions file)")
    +        else:
    +            print(f"  Active:       {_session_count if _session_count is not None else 0}")
     
         # =========================================================================
         # Deep checks
    ```
  - `hermes_state.py`

    ```diff
    @@ -122,7 +122,7 @@ def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]:
     
     DEFAULT_DB_PATH = get_hermes_home() / "state.db"
     
    -SCHEMA_VERSION = 17
    +SCHEMA_VERSION = 18
     
     # Cap on user-controlled FTS5 query input before regex/sanitizer processing.
     # Search queries do not need to be arbitrarily large, and bounding them keeps
    ```

    ```diff
    @@ -705,6 +705,9 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A
         chat_id TEXT,
         chat_type TEXT,
         thread_id TEXT,
    +    display_name TEXT,
    +    origin_json TEXT,
    +    expiry_finalized INTEGER DEFAULT 0,
         model TEXT,
         model_config TEXT,
         system_prompt TEXT,
    ```

    ```diff
    @@ -1522,6 +1525,19 @@ def _init_schema(self):
                         )
                     except sqlite3.OperationalError:
                         pass
    +            if current_version < 18:
    +                # v18: gateway metadata consolidation (#9006). Backfill
    +                # display_name / origin_json / expiry_finalized from
    +                # sessions.json so pre-migration gateway sessions are
    +                # discoverable from state.db without the JSON index.
    +                try:
    +                    self._backfill_gateway_metadata_from_sessions_json(cursor)
    +                except Exception as exc:
    +                    # Backfill is best-effort: sessions.json may be absent,
    +                    # corrupted, or partially stale. Missing metadata simply
    +                    # means consumers fall back to sessions.json for those
    +                    # rows until the gateway rewrites them.
    +                    logger.debug("v18 gateway metadata backfill skipped: %s", exc)
                 if current_version < SCHEMA_VERSION and fts_migrations_complete:
                     cursor.execute(
                         "UPDATE schema_version SET version = ?",
    ```

    ```diff
    @@ -1647,8 +1663,17 @@ def record_gateway_session_peer(
             chat_id: str = None,
             chat_type: str = None,
             thread_id: str = None,
    +        display_name: str = None,
    +        origin_json: str = None,
         ) -> None:
    -        """Persist the gateway routing peer for an existing session row."""
    +        """Persist the gateway routing peer for an existing session row.
    +
    +        ``display_name`` / ``origin_json`` carry the gateway's presentation
    +        and full origin metadata (#9006) so consumers (mcp_serve, mirror,
    +        channel directory) can read routing data from state.db instead of
    +        sessions.json.  They are COALESCE'd only in the sense that ``None``
    +        leaves the existing value untouched.
    +        """
             if not session_id or not session_key:
                 return
     
    ```

    ```diff
    @@ -1656,7 +1681,9 @@ def _do(conn):
                 conn.execute(
                     """UPDATE sessions
                        SET session_key = ?, source = ?, user_id = ?, chat_id = ?,
    -                       chat_type = ?, thread_id = ?
    +                       chat_type = ?, thread_id = ?,
    +                       display_name = COALESCE(?, display_name),
    +                       origin_json = COALESCE(?, origin_json)
                        WHERE id = ?""",
                     (
                         session_key,
    ```

    ```diff
    @@ -1665,12 +1692,168 @@ def _do(conn):
                         chat_id,
                         chat_type,
                         thread_id,
    +                    display_name,
    +                    origin_json,
                         session_id,
                     ),
                 )
     
             self._execute_write(_do)
     
    +    def set_expiry_finalized(self, session_id: str, finalized: bool = True) -> None:
    +        """Mark a gateway session's expiry-finalization flag in state.db.
    +
    +        Mirrors ``SessionEntry.expiry_finalized`` (sessions.json) so the flag
    +        survives even if the JSON index is pruned or lost (#9006).
    +        """
    +        if not session_id:
    +            return
    +
    +        def _do(conn):
    +            conn.execute(
    +                "UPDATE sessions SET expiry_finalized = ? WHERE id = ?",
    +                (1 if finalized else 0, session_id),
    +            )
    +
    +        self._execute_write(_do)
    +
    +    def list_gateway_sessions(
    +        self,
    +        *,
    +        platform: Optional[str] = None,
    +        active_only: bool = True,
    +    ) -> List[Dict[str, Any]]:
    +        """List gateway sessions (rows with a session_key) from state.db.
    +
    +        Returns the newest row per session_key — the same shape consumers got
    +        from sessions.json: one live mapping per routing key.  ``platform``
    +        filters on ``source``; ``active_only`` restricts to sessions that
    +        have not ended.
    +        """
    +        query = """
    +            SELECT sessions.*,
    +                   COALESCE(
    +                       (SELECT MAX(m.timestamp) FROM messages m
    +                        WHERE m.session_id = sessions.id),
    +                       sessions.started_at
    +                   ) AS last_active
    +            FROM sessions
    +            WHERE session_key IS NOT NULL
    +              AND started_at = (
    +                  SELECT MAX(s2.started_at) FROM sessions s2
    +                  WHERE s2.session_key = sessions.session_key
    +              )
    +        """
    +        params: list = []
    +        if platform:
    +            query += " AND LOWER(source) = LOWER(?)"
    +            params.append(platform)
    +        if active_only:
    +            query += " AND ended_at IS NULL"
    +        query += " ORDER BY last_active DESC"
    +        with self._lock:
    +            rows = self._conn.execute(query, params).fetchall()
    +        return [dict(r) for r in rows]
    +
    +    def find_session_by_origin(
    +        self,
    +        *,
    +        platform: str,
    +        chat_id: str,
    +        thread_id: Optional[str] = None,
    +        user_id: Optional[str] = None,
    +    ) -> Optional[str]:
    +        """Find the most recent live session_id for a platform + chat origin.
    +
    +        Equivalent of gateway/mirror's sessions.json scan: matches on
    +        source + chat_id (+ thread_id when provided).  When ``user_id`` is
    +        provided, exact sender matches are preferred; if multiple distinct
    +        users share the chat and none matches, returns None rather than
    +        contaminating another participant's session.
    +        """
    +        if not platform or chat_id in (None, ""):
    +            return None
    +        query = """
    +            SELECT id, user_id, started_at FROM sessions
    +            WHERE LOWER(source) = LOWER(?)
    +              AND session_key IS NOT NULL
    +              AND chat_id = ?
    +              AND ended_at IS NULL
    +        """
    +        params: list = [platform, str(chat_id)]
    +        if thread_id is not None:
    +            query += " AND COALESCE(thread_id, '') = ?"
    +            params.append(str(thread_id))
    +        query += " ORDER BY started_at DESC"
    +        with self._lock:
    +            rows = [dict(r) for r in self._conn.execute(query, params).fetchall()]
    +        if not rows:
    +            return None
    +        if user_id:
    +            exact = [r for r in rows if str(r.get("user_id") or "") == str(user_id)]
    +            if exact:
    +                return str(exact[0]["id"])
    +            if len(rows) > 1:
    +                return None
    +        elif len(rows) > 1:
    +            distinct_users = {
    +                str(r.get("user_id") or "").strip()
    +                for r in rows
    +                if str(r.get("user_id") or "").strip()
    +            }
    +            if len(distinct_users) > 1:
    +                return None
    +        return str(rows[0]["id"])
    +
    +    def _backfill_gateway_metadata_from_sessions_json(
    +        self, cursor: sqlite3.Cursor
    +    ) -> None:
    +        """One-time v18 backfill of gateway metadata from sessions.json.
    +
    +        Existing gateway sessions predate the display_name / origin_json /
    +        expiry_finalized columns; copy what sessions.json knows so consumers
    +        can switch to state.db without losing pre-migration sessions.
    +        Only fills NULL columns — never overwrites data written by newer code.
    +        """
    +        sessions_file = get_hermes_home() / "sessions" / "sessions.json"
    +        if not sessions_file.exists():
    +            return
    +        with open(sessions_file, "r", encoding="utf-8") as f:
    +            data = json.load(f)
    +        if not isinstance(data, dict):
    +            return
    +        for key, entry in data.items():
    +            if str(key).startswith("_") or not isinstance(entry, dict):
    +                continue
    +            session_id = entry.get("session_id")
    +            if not session_id:
    +                continue
    +            origin = entry.get("origin")
    +            cursor.execute(
    +                """UPDATE sessions
    +                   SET session_key = COALESCE(session_key, ?),
    +                       chat_id = COALESCE(chat_id, ?),
    +                       chat_type = COALESCE(chat_type, ?),
    +                       thread_id = COALESCE(thread_id, ?),
    +                       display_name = COALESCE(display_name, ?),
    +                       origin_json = COALESCE(origin_json, ?),
    +                       expiry_finalized = CASE
    +                           WHEN COALESCE(expiry_finalized, 0) = 0 AND ? = 1 THEN 1
    +                           ELSE expiry_finalized
    +                       END
    +                   WHERE id = ?""",
    +                (
    +                    entry.get("session_key") or key,
    +                    (origin or {}).get("chat_id") if isinstance(origin, dict) else None,
    +                    entry.get("chat_type"),
    +                    (origin or {}).get("thread_id") if isinstance(origin, dict) else None,
    +                    entry.get("display_name"),
    +                    json.dumps(origin) if isinstance(origin, dict) else None,
    +                    1 if entry.get("expiry_finalized") or entry.get("memory_flushed") else 0,
    +                    str(session_id),
    +                ),
    +            )
    +
         def find_latest_gateway_session_for_peer(
             self,
             *,
    ```
  - `mcp_serve.py`

    ```diff
    @@ -37,6 +37,7 @@
     import threading
     import time
     from dataclasses import dataclass, field
    +from datetime import datetime
     from pathlib import Path
     from typing import Dict, List, Optional
     
    ```

    ```diff
    @@ -79,10 +80,99 @@ def _get_session_db():
     
     
     def _load_sessions_index() -> dict:
    -    """Load the gateway sessions.json index directly.
    +    """Load the gateway session routing index.
     
         Returns a dict of session_key -> entry_dict with platform routing info.
    -    This avoids importing the full SessionStore which needs GatewayConfig.
    +
    +    state.db is the primary source (#9006): gateway sessions persist their
    +    routing metadata (session_key, chat/thread ids, display_name, origin) on
    +    the durable session row, so a single database read replaces the old
    +    dual-file sessions.json dependency.  Falls back to sessions.json for
    +    pre-migration databases where no gateway rows carry a session_key yet.
    +    """
    +    entries = _load_sessions_index_from_db()
    +    if entries:
    +        return entries
    +    return _load_sessions_index_from_json()
    +
    +
    +def _row_to_index_entry(row: dict) -> dict:
    +    """Convert a state.db gateway session row to the sessions.json entry shape."""
    +    origin = {}
    +    origin_json = row.get("origin_json")
    +    if origin_json:
    +        try:
    +            parsed = json.loads(origin_json)
    +            if isinstance(parsed, dict):
    +                origin = parsed
    +        except (TypeError, ValueError):
    +            pass
    +    if not origin:
    +        # Pre-origin_json rows: synthesize the minimal origin from columns.
    +        origin = {
    +            "platform": row.get("source", ""),
    +            "chat_id": row.get("chat_id"),
    +            "chat_type": row.get("chat_type"),
    +            "thread_id": row.get("thread_id"),
    +            "user_id": row.get("user_id"),
    +        }
    +
    +    def _iso(ts) -> str:
    +        try:
    +            return datetime.fromtimestamp(float(ts)).isoformat() if ts else ""
    +        except (TypeError, ValueError, OSError):
    +            return ""
    +
    +    input_tokens = [redacted:secret-assignment]"input_tokens") or 0)
    +    output_tokens = [redacted:secret-assignment]"output_tokens") or 0)
    +    return {
    +        "session_id": str(row.get("id", "")),
    +        "session_key": row.get("session_key", ""),
    +        "platform": row.get("source", ""),
    +        "chat_type": row.get("chat_type") or origin.get("chat_type", ""),
    +        "display_name": row.get("display_name") or origin.get("chat_name") or "",
    +        "origin": origin,
    +        "created_at": _iso(row.get("started_at")),
    +        "updated_at": _iso(row.get("last_active") or row.get("started_at")),
    +        "input_tokens": input_tokens,
    +        "output_tokens": output_tokens,
    +        "total_tokens": input_tokens + output_tokens,
    +    }
    +
    +
    +def _load_sessions_index_from_db() -> dict:
    +    """Build the routing index from state.db gateway session rows."""
    +    db = _get_session_db()
    +    if db is None:
    +        return {}
    +    try:
    +        lister = getattr(db, "list_gateway_sessions", None)
    +        if not callable(lister):
    +            return {}
    +        rows = lister(active_only=True)
    +        entries = {}
    +        for row in rows:
    +            key = row.get("session_key")
    +            if not key:
    +                continue
    +            entries[key] = _row_to_index_entry(row)
    +        return entries
    +    except Exception as e:
    +        logger.debug("Failed to load gateway sessions from state.db: %s", e)
    +        return {}
    +    finally:
    +        try:
    +            db.close()
    +        except Exception:
    +            pass
    +
    +
    +def _load_sessions_index_from_json() -> dict:
    +    """Legacy fallback: load the gateway sessions.json index directly.
    +
    +    Used only for pre-migration databases whose gateway rows don't carry a
    +    session_key yet.  This avoids importing the full SessionStore which
    +    needs GatewayConfig.
         """
         sessions_file = _get_sessions_dir() / "sessions.json"
         if not sessions_file.exists():
    ```

    ```diff
    @@ -226,8 +316,7 @@ def __init__(self):
             self._last_poll_timestamps: Dict[str, float] = {}  # session_key -> unix timestamp
             # In-memory approval tracking (populated from events)
             self._pending_approvals: Dict[str, dict] = {}
    -        # mtime cache — skip expensive work when files haven't changed
    -        self._sessions_json_mtime: float = 0.0
    +        # mtime cache — skip expensive work when state.db hasn't changed
             self._state_db_mtime: float = 0.0
             self._cached_sessions_index: dict = {}
     
    ```

    ```diff
    @@ -353,24 +442,14 @@ def _poll_loop(self):
         def _poll_once(self, db):
             """Check for new messages across all sessions.
     
    -        Uses mtime checks on sessions.json and state.db to skip work
    -        when nothing has changed — makes 200ms polling essentially free.
    +        Uses a single mtime check on state.db to skip work when nothing
    +        has changed — makes 200ms polling essentially free.  Since #9006
    +        the routing index itself lives in state.db (session rows carry
    +        session_key/origin metadata), so a new conversation and its first
    +        message land in the SAME file and one mtime check covers both —
    +        eliminating the old dual-file (sessions.json + state.db) race that
    +        could drop brand-new conversations (#8925).
             """
    -        # Check if sessions.json has changed (mtime check is ~1μs).
    -        # Capture the previously-seen mtime *before* refreshing the cache so the
    -        # skip guard below can still tell whether sessions.json changed this tick.
    -        prev_sessions_json_mtime = self._sessions_json_mtime
    -        sessions_file = _get_sessions_dir() / "sessions.json"
    -        try:
    -            sj_mtime = sessions_file.stat().st_mtime if sessions_file.exists() else 0.0
    -        except OSError:
    -            sj_mtime = 0.0
    -
    -        if sj_mtime != self._sessions_json_mtime:
    -            self._sessions_json_mtime = sj_mtime
    -            self._cached_sessions_index = _load_sessions_index()
    -
    -        # Check if state.db has changed
             try:
                 from hermes_constants import get_hermes_home
                 db_file = get_hermes_home() / "state.db"
    ```

    ```diff
    @@ -382,19 +461,14 @@ def _poll_once(self, db):
             except OSError:
                 db_mtime = 0.0
     
    -        # Skip only when NEITHER file changed since the last poll. Comparing
    -        # against ``prev_sessions_json_mtime`` (not the freshly-stored
    -        # ``self._sessions_json_mtime``) is essential: the latter was just set to
    -        # ``sj_mtime`` above, so using it would make the sessions.json term
    -        # always true and collapse the guard to a db-only check. That would
    -        # discard a tick where only sessions.json changed — e.g. a brand-new
    -        # conversation registered after its first message already landed in
    -        # state.db on an earlier tick — and the new chat's messages would never
    -        # be emitted until state.db happened to change again.
    -        if db_mtime == self._state_db_mtime and sj_mtime == prev_sessions_json_mtime:
    +        if db_mtime == self._state_db_mtime:
                 return  # Nothing changed since last poll — skip entirely
     
             self._state_db_mtime = db_mtime
    +        # Refresh the routing index from state.db on every change tick —
    +        # it's a single indexed query and it can never lag the messages
    +        # table (both live in the same database file).
    +        self._cached_sessions_index = _load_sessions_index()
             entries = self._cached_sessions_index
     
             for session_key, entry in entries.items():
    ```
  - `tests/test_hermes_state.py`

    ```diff
    @@ -2,6 +2,7 @@
     
     import sqlite3
     import time
    +import json
     import pytest
     
     import hermes_state
    ```

    ```diff
    @@ -4831,6 +4832,174 @@ def test_gateway_session_recovery_reopens_legacy_agent_close_rows(db):
         ) is None
     
     
    +def test_gateway_metadata_display_name_origin_round_trip(db):
    +    """record_gateway_session_peer persists display_name/origin_json (#9006)."""
    +    db.create_session("gw-meta", "telegram", user_id="u1")
    +    origin = {"platform": "telegram", "chat_id": "c1", "chat_name": "Alice", "chat_type": "dm"}
    +    db.record_gateway_session_peer(
    +        "gw-meta",
    +        source="telegram",
    +        user_id="u1",
    +        session_key="agent:main:telegram:dm:c1",
    +        chat_id="c1",
    +        chat_type="dm",
    +        thread_id=None,
    +        display_name="Alice",
    +        origin_json=json.dumps(origin),
    +    )
    +    row = db.get_session("gw-meta")
    +    assert row["display_name"] == "Alice"
    +    assert json.loads(row["origin_json"])["chat_name"] == "Alice"
    +
    +    # None values must not clobber existing metadata.
    +    db.record_gateway_session_peer(
    +        "gw-meta",
    +        source="telegram",
    +        user_id="u1",
    +        session_key="agent:main:telegram:dm:c1",
    +        chat_id="c1",
    +        chat_type="dm",
    +    )
    +    row = db.get_session("gw-meta")
    +    assert row["display_name"] == "Alice"
    +    assert row["origin_json"] is not None
    +
    +
    +def test_set_expiry_finalized_round_trip(db):
    +    db.create_session("gw-exp", "telegram", session_key="agent:main:telegram:dm:x")
    +    row = db.get_session("gw-exp")
    +    assert not row["expiry_finalized"]
    +    db.set_expiry_finalized("gw-exp")
    +    assert db.get_session("gw-exp")["expiry_finalized"] == 1
    +    db.set_expiry_finalized("gw-exp", False)
    +    assert db.get_session("gw-exp")["expiry_finalized"] == 0
    +
    +
    +def test_list_gateway_sessions_filters_and_dedupes(db):
    +    # Two rows on the same session_key: only the newest should be returned.
    +    db.create_session(
    +        "gw-old", "telegram",
    +        session_key="agent:main:telegram:dm:c1", chat_id="c1", chat_type="dm",
    +    )
    +    db._conn.execute(
    +        "UPDATE sessions SET started_at = started_at - 100 WHERE id = 'gw-old'"
    +    )
    +    db._conn.commit()
    +    db.create_session(
    +        "gw-new", "telegram",
    +        session_key="agent:main:telegram:dm:c1", chat_id="c1", chat_type="dm",
    +    )
    +    db.create_session(
    +        "gw-discord", "discord",
    +        session_key="agent:main:discord:group:g1:u1", chat_id="g1", chat_type="group",
    +    )
    +    # Non-gateway session (no session_key) must never appear.
    +    db.create_session("cli-session", "cli")
    +    # Ended gateway session excluded when active_only.
    +    db.create_session(
    +        "gw-ended", "slack",
    +        session_key="agent:main:slack:dm:s1", chat_id="s1", chat_type="dm",
    +    )
    +    db.end_session("gw-ended", "session_reset")
    +
    +    rows = db.list_gateway_sessions(active_only=True)
    +    ids = {r["id"] for r in rows}
    +    assert ids == {"gw-new", "gw-discord"}
    +
    +    tg_rows = db.list_gateway_sessions(platform="telegram", active_only=True)
    +    assert [r["id"] for r in tg_rows] == ["gw-new"]
    +
    +    all_rows = db.list_gateway_sessions(active_only=False)
    +    assert "gw-ended" in {r["id"] for r in all_rows}
    +    assert "cli-session" not in {r["id"] for r in all_rows}
    +
    +
    +def test_find_session_by_origin_matching_rules(db):
    +    db.create_session(
    +        "gw-o1", "telegram", user_id="u1",
    +        session_key="agent:main:telegram:group:c9:u1", chat_id="c9", chat_type="group",
    +    )
    +    db.create_session(
    +        "gw-o2", "telegram", user_id="u2",
    +        session_key="agent:main:telegram:group:c9:u2", chat_id="c9", chat_type="group",
    +    )
    +
    +    # Exact user match wins.
    +    assert db.find_session_by_origin(
    +        platform="telegram", chat_id="c9", user_id="u2"
    +    ) == "gw-o2"
    +    # Unknown user among multiple distinct users -> None (no contamination).
    +    assert db.find_session_by_origin(
    +        platform="telegram", chat_id="c9", user_id="u3"
    +    ) is None
    +    # No user given + multiple distinct users -> None.
    +    assert db.find_session_by_origin(platform="telegram", chat_id="c9") is None
    +    # Ended sessions are ignored: only gw-o1 remains as a live candidate.
    +    # A single remaining candidate is returned even without an exact user
    +    # match — mirrors the original sessions.json scan semantics.
    +    db.end_session("gw-o2", "session_reset")
    +    assert db.find_session_by_origin(
    +        platform="telegram", chat_id="c9", user_id="u2"
    +    ) == "gw-o1"
    +    # Single remaining candidate resolves without user_id.
    +    assert db.find_session_by_origin(platform="telegram", chat_id="c9") == "gw-o1"
    +    # Thread filter.
    +    db.create_session(
    +        "gw-th", "discord", user_id="u9",
    +        session_key="agent:main:discord:thread:t7", chat_id="ch7",
    +        chat_type="thread", thread_id="t7",
    +    )
    +    assert db.find_session_by_origin(
    +        platform="discord", chat_id="ch7", thread_id="t7"
    +    ) == "gw-th"
    +    assert db.find_session_by_origin(
    +        platform="discord", chat_id="ch7", thread_id="other"
    +    ) is None
    +
    +
    +def test_v18_backfill_from_sessions_json(tmp_path, monkeypatch):
    +    """Migration backfills display_name/origin_json/expiry_finalized from sessions.json."""
    +    import hermes_state as hs
    +
    +    home = tmp_path / ".hermes"
    +    (home / "sessions").mkdir(parents=True)
    +    monkeypatch.setenv("HERMES_HOME", str(home))
    +    monkeypatch.setattr(hs, "DEFAULT_DB_PATH", home / "state.db")
    +
    +    # Seed a pre-v18 database: create schema, downgrade version, add a bare row.
    +    db = hs.SessionDB(home / "state.db")
    +    db.create_session("legacy-gw", "telegram", user_id="u1")
    +    db._conn.execute("UPDATE schema_version SET version = 17")
    +    db._conn.execute(
    +        "UPDATE sessions SET session_key = NULL, display_name = NULL, "
    +        "origin_json = NULL WHERE id = 'legacy-gw'"
    +    )
    +    db._conn.commit()
    +    db.close()
    +
    +    origin = {"platform": "telegram", "chat_id": "123", "chat_name": "Alice",
    +              "chat_type": "dm", "user_id": "u1"}
    +    (home / "sessions" / "sessions.json").write_text(json.dumps({
    +        "_README": "sentinel",
    +        "agent:main:telegram:dm:123": {
    +            "session_id": "legacy-gw",
    +            "display_name": "Alice",
    +            "chat_type": "dm",
    +            "expiry_finalized": True,
    +            "origin": origin,
    +        },
    +    }))
    +
    +    db = hs.SessionDB(home / "state.db")
    +    row = db.get_session("legacy-gw")
    +    db.close()
    +    assert row["session_key"] == "agent:main:telegram:dm:123"
    +    assert row["display_name"] == "Alice"
    +    assert row["chat_id"] == "123"
    +    assert json.loads(row["origin_json"])["chat_name"] == "Alice"
    +    assert row["expiry_finalized"] == 1
    +
    +
     def test_compression_failure_cooldown_round_trips_and_clears(db):
         db.create_session("s1", "cli")
     
    ```
  - `tests/test_mcp_serve.py`

    ```diff
    @@ -1232,18 +1232,19 @@ def get_messages(self, sid):
             assert len(r2["events"]) == 1
             assert r2["events"][0]["content"] == "New reply!"
     
    -    def test_poll_picks_up_new_conversation_when_only_sessions_json_changed(
    +    def test_poll_picks_up_new_conversation_on_db_change(
             self, tmp_path, monkeypatch
         ):
    -        """A conversation registered in sessions.json *after* its first message
    -        already landed in state.db must still be picked up, even when state.db's
    -        mtime did not move on this tick.
    -
    -        Regression guard: the skip-when-unchanged check must compare against the
    -        sessions.json mtime seen on the *previous* poll. If it instead compares
    -        against the just-refreshed value, the sessions.json term is always true,
    -        the guard collapses to a db-only check, and a sessions.json-only change
    -        is silently dropped — the new chat's messages never reach MCP clients.
    +        """A brand-new conversation must be picked up on the tick where
    +        state.db changes.
    +
    +        Since #9006 the routing index lives IN state.db (session rows carry
    +        session_key/origin metadata), so a new conversation's registration and
    +        its first message land in the same file — a single mtime check covers
    +        both and the old dual-file (sessions.json + state.db) race (#8925) is
    +        structurally impossible. This test asserts the index is refreshed on a
    +        db-mtime bump, so a conversation the bridge has never seen before is
    +        emitted on the same tick.
             """
             import mcp_serve
     
    ```

    ```diff
    @@ -1257,14 +1258,20 @@ def test_poll_picks_up_new_conversation_when_only_sessions_json_changed(
             db_path.write_text("placeholder")
     
             session_id = "20260329_150000_late_register"
    -        sessions_json = sessions_dir / "sessions.json"
    -        sessions_json.write_text(json.dumps({
    -            "agent:main:telegram:dm:late": {
    -                "session_id": session_id,
    -                "platform": "telegram",
    -                "origin": {"platform": "telegram", "chat_id": "late"},
    -            }
    -        }))
    +        # The routing index now comes from _load_sessions_index() (state.db
    +        # primary, sessions.json fallback). Stub it to return the new
    +        # conversation, simulating the gateway having just written the
    +        # session row + first message in one state.db transaction.
    +        monkeypatch.setattr(
    +            mcp_serve, "_load_sessions_index",
    +            lambda: {
    +                "agent:main:telegram:dm:late": {
    +                    "session_id": session_id,
    +                    "platform": "telegram",
    +                    "origin": {"platform": "telegram", "chat_id": "late"},
    +                }
    +            },
    +        )
     
             class DB:
                 def get_messages(self, sid):
    ```

    ```diff
    @@ -1275,12 +1282,11 @@ def get_messages(self, sid):
                     }]
     
             bridge = mcp_serve.EventBridge()
    -        # Simulate the boundary state: state.db has NOT changed since the last
    -        # poll (its message landed on an earlier tick), but sessions.json was
    -        # only updated with this conversation now — the bridge has not yet seen
    -        # the current sessions.json content.
    -        bridge._state_db_mtime = db_path.stat().st_mtime
    -        bridge._sessions_json_mtime = 0.0
    +        # Bridge has never seen this db state (mtime differs) and has an
    +        # empty cached index — exactly the state after a new conversation's
    +        # first write.
    +        bridge._state_db_mtime = 0.0
    +        assert bridge._cached_sessions_index == {}
     
             bridge._poll_once(DB())
     
    ```
- **#34048: fix(compression): prevent orphan sessions when state.db write fails (#33906)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#49772`
- **#40061: fix(gateway): persist session origin metadata in state.db**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `747386ecfa34eb5cbc10e104d326259fb538f565`, `#58899`
- **#59581: fix(agent): preserve gateway metadata across compression rotation**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `d5b4879d4a734b963b0f5d822754094acda44f74`
- **#59870: fix(compression): preserve gateway origin on continuation**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `d5b4879d4a734b963b0f5d822754094acda44f74`

## Fix status

- Merged to main in PR #49772 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-05`.
- Subsystem: `agent`
