Context compression session metadata loss and orphan sessions

NousResearch/hermes-agent · cluster 33906 · agent

Data as of

Nothing open on GitHub. Fix merged in #49772. Check it covers your case.

The short answer

0open issues
0open PRs
10items grouped
Known?
Yes. 10 related items grouped here, 4 issues already closed.
Fix event
A fix was merged to main in PR #49772. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #49772 fix(compression): preserve goal, platform, and session indexing across rotation

    merged to main

    files: agent/conversation_compression.py, hermes_cli/goals.py, tests/agent/test_compression_rotation_state.py, tests/hermes_cli/test_goals.py

    Show the diff
    • agent/conversation_compression.py
      @@ -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:
      @@ -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`
      @@ -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
      @@ -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
       # ──────────────────────────────────────────────────────────────────────
      @@ -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
      @@ -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
      @@ -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

    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

    Show the diff
    • gateway/channel_directory.py
      @@ -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
      @@ -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
      @@ -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,
      @@ -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
      @@ -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:
      @@ -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,
      @@ -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.
      @@ -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}")
      @@ -1643,6 +1697,7 @@ def update_session(
                           entry.session_id,
                           session_key,
                           entry.origin,
      +                    display_name=entry.display_name,
                       )
       
           def set_model_override(
      @@ -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)
      @@ -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
      @@ -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
      @@ -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
      @@ -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,
      @@ -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 = ?",
      @@ -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
       
      @@ -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,
      @@ -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
      @@ -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
       
      @@ -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():
      @@ -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 = {}
       
      @@ -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"
      @@ -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
      @@ -2,6 +2,7 @@
       
       import sqlite3
       import time
      +import json
       import pytest
       
       import hermes_state
      @@ -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
      @@ -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
       
      @@ -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):
      @@ -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())
       

4 PRs closed without merging

From the close comments: done elsewhere 3 · merged via another change 1

  • #34048 fix(compression): prevent orphan sessions when state.db write fails (#33906) · merged via another change → #49772
  • #40061 fix(gateway): persist session origin metadata in state.db · done elsewhere → 747386e, #58899
  • #59581 fix(agent): preserve gateway metadata across compression rotation · done elsewhere → d5b4879
  • #59870 fix(compression): preserve gateway origin on continuation · done elsewhere → d5b4879
Closed issues · 4
  • Closed#33906Context compression creates orphan sessions missing from state.db
  • Closed#33907Context compression creates orphan sessions missing from state.db
  • Closed#39260Bug: Gateway does not persist chat metadata (chat_id, chat_type, thread_id, parent_chat_id, chat_name, session_key) to state.db sessions
  • Closed#59527Compression continuations lose gateway origin metadata, breaking `/sessions` and `/resume`
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#49772, #58899
Closed issues
4
Attempts
4 PRs closed without merging
Fix link checked
5 Jul 2026

For your agent

The same facts, typed.