NousResearch/hermes-agent

Cross-profile session routing bug and fixes

Cluster 58032 · gateway. Data as of

Nothing open on GitHub. Fix merged in #59325 on 7 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #59325. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #59325. That is separate from the fix event.
Still open
Nothing open on GitHub.

A bug was identified where disabling multiplex_profiles leaves orphaned sessions that cause incorrect profile routing. Two related fix attempts address cross-profile session recovery in the gateway.

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.

  • #59325 fix(gateway): avoid cross-profile session recovery (#58119 salvage)

    merged to main

    files: gateway/session.py, tests/gateway/test_multiplex_phase0.py

    Show the diff
    • gateway/session.py
      @@ -1225,6 +1225,45 @@ def _resolve_profile_for_key(self, source: Optional[SessionSource] = None) -> Op
               except Exception:
                   return None
       
      +    @staticmethod
      +    def _profile_from_session_key(session_key: Optional[str]) -> Optional[str]:
      +        """Extract the profile namespace encoded in a gateway session key."""
      +        if not session_key:
      +            return None
      +        parts = str(session_key).split(":")
      +        if len(parts) < 2 or parts[0] != "agent":
      +            return None
      +        namespace = parts[1] or "main"
      +        return "default" if namespace == "main" else namespace
      +
      +    @staticmethod
      +    def _active_profile_name() -> str:
      +        try:
      +            from hermes_cli.profiles import get_active_profile_name
      +            return get_active_profile_name() or "default"
      +        except Exception:
      +            return "default"
      +
      +    def _recovered_row_allowed_for_active_profile(
      +        self,
      +        *,
      +        requested_session_key: str,
      +        recovered: Dict[str, Any],
      +    ) -> bool:
      +        """Prevent non-multiplexed gateways from reviving another profile's row."""
      +        if getattr(self.config, "multiplex_profiles", False):
      +            return True
      +
      +        recovered_key = str(recovered.get("session_key") or "")
      +        if not recovered_key or recovered_key == requested_session_key:
      +            return True
      +
      +        recovered_profile = self._profile_from_session_key(recovered_key)
      +        if recovered_profile is None:
      +            return True
      +
      +        return recovered_profile == self._active_profile_name()
      +
           def _generate_session_key(self, source: SessionSource) -> str:
               """Generate a session key from a source."""
               return build_session_key(
      @@ -1285,6 +1324,18 @@ def _recover_session_from_db(
                   return None
               if not recovered:
                   return None
      +        if not self._recovered_row_allowed_for_active_profile(
      +            requested_session_key=session_key,
      +            recovered=recovered,
      +        ):
      +            logger.warning(
      +                "Gateway session DB recovery ignored %s for %s because "
      +                "multiplex_profiles is disabled and the row belongs to a "
      +                "different profile",
      +                recovered.get("session_key"),
      +                session_key,
      +            )
      +            return None
               try:
                   self._db.reopen_session(str(recovered["id"]))
               except Exception as exc:
    • tests/gateway/test_multiplex_phase0.py
      @@ -9,6 +9,7 @@
            on.
       """
       import pytest
      +from datetime import datetime
       from unittest.mock import patch
       import yaml
       
      @@ -201,3 +202,66 @@ def test_flag_on_default_profile_stays_legacy(self, tmp_path):
               with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
                   assert store._generate_session_key(s) == "agent:main:telegram:dm:99"
       
      +
      +class _RecoveringDB:
      +    def __init__(self, row):
      +        self.row = row
      +        self.reopened = []
      +
      +    def find_latest_gateway_session_for_peer(self, **_kwargs):
      +        return self.row
      +
      +    def reopen_session(self, session_id):
      +        self.reopened.append(session_id)
      +
      +
      +class TestSessionStoreUnmultiplexedRecovery:
      +    """Turning multiplexing off must not recover another profile's session."""
      +
      +    def _store_with_row(self, tmp_path, row, **cfg_kw):
      +        config = GatewayConfig(**cfg_kw)
      +        with patch("gateway.session.SessionStore._ensure_loaded"):
      +            store = SessionStore(sessions_dir=tmp_path, config=config)
      +        store._db = _RecoveringDB(row)
      +        store._loaded = True
      +        return store
      +
      +    def test_flag_off_rejects_other_profile_peer_fallback(self, tmp_path):
      +        row = {
      +            "id": "sess-coder",
      +            "started_at": 1700000000,
      +            "session_key": "agent:coder:telegram:dm:99",
      +        }
      +        store = self._store_with_row(tmp_path, row)
      +        source = _src(chat_id="99", chat_type="dm")
      +
      +        with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
      +            recovered = store._recover_session_from_db(
      +                session_key="agent:main:telegram:dm:99",
      +                source=source,
      +                now=datetime.fromtimestamp(1700000001),
      +            )
      +
      +        assert recovered is None
      +        assert store._db.reopened == []
      +
      +    def test_flag_off_allows_active_profile_peer_fallback(self, tmp_path):
      +        row = {
      +            "id": "sess-coder",
      +            "started_at": 1700000000,
      +            "session_key": "agent:coder:telegram:dm:99",
      +        }
      +        store = self._store_with_row(tmp_path, row)
      +        source = _src(chat_id="99", chat_type="dm")
      +
      +        with patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"):
      +            recovered = store._recover_session_from_db(
      +                session_key="agent:main:telegram:dm:99",
      +                source=source,
      +                now=datetime.fromtimestamp(1700000001),
      +            )
      +
      +        assert recovered is not None
      +        assert recovered.session_id == "sess-coder"
      +        assert recovered.session_key == "agent:main:telegram:dm:99"
      +        assert store._db.reopened == ["sess-coder"]

1 PR closed without merging

From the close comments: done elsewhere 1

  • #58119 fix(gateway): avoid cross-profile session recovery · done elsewhere → f1fde49, #59325
Closed issues · 1
  • Closed#58032Bug: multiplex_profiles: false leaves orphaned sessions that cause wrong profile routing
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: 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
gateway
Merged PRs
#59325
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
7 Jul 2026

For your agent

The same facts, typed.