{"repo":"NousResearch/hermes-agent","public_id":"58032","display_label":"Cross-profile session routing bug and fixes","summary":"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.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":59325,"merged_at":"2026-07-07T08:25:39+00:00"},"resolution":{"state":"resolved","by":"pr-59325"},"freshness":{"state":"verified","last_verified_at":"2026-07-07T08:25:39+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":58032,"pr_number":59325,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-07T08:25:39+00:00"}],"members":[{"kind":"issue","number":58032,"title":"Bug: multiplex_profiles: false leaves orphaned sessions that cause wrong profile routing","url":null,"state":"closed"},{"kind":"pr","number":58119,"title":"fix(gateway): avoid cross-profile session recovery","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f1fde49e453e508b9dc49ec5b70694fbbf0279c0","number":0},{"kind":"pr","value":"#59325","number":59325}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":59325,"title":"fix(gateway): avoid cross-profile session recovery (#58119 salvage)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/session.py","tests/gateway/test_multiplex_phase0.py"],"fix_hunks":[{"file":"gateway/session.py","hunks":["@@ -1225,6 +1225,45 @@ def _resolve_profile_for_key(self, source: Optional[SessionSource] = None) -> Op\n         except Exception:\n             return None\n \n+    @staticmethod\n+    def _profile_from_session_key(session_key: Optional[str]) -> Optional[str]:\n+        \"\"\"Extract the profile namespace encoded in a gateway session key.\"\"\"\n+        if not session_key:\n+            return None\n+        parts = str(session_key).split(\":\")\n+        if len(parts) < 2 or parts[0] != \"agent\":\n+            return None\n+        namespace = parts[1] or \"main\"\n+        return \"default\" if namespace == \"main\" else namespace\n+\n+    @staticmethod\n+    def _active_profile_name() -> str:\n+        try:\n+            from hermes_cli.profiles import get_active_profile_name\n+            return get_active_profile_name() or \"default\"\n+        except Exception:\n+            return \"default\"\n+\n+    def _recovered_row_allowed_for_active_profile(\n+        self,\n+        *,\n+        requested_session_key: str,\n+        recovered: Dict[str, Any],\n+    ) -> bool:\n+        \"\"\"Prevent non-multiplexed gateways from reviving another profile's row.\"\"\"\n+        if getattr(self.config, \"multiplex_profiles\", False):\n+            return True\n+\n+        recovered_key = str(recovered.get(\"session_key\") or \"\")\n+        if not recovered_key or recovered_key == requested_session_key:\n+            return True\n+\n+        recovered_profile = self._profile_from_session_key(recovered_key)\n+        if recovered_profile is None:\n+            return True\n+\n+        return recovered_profile == self._active_profile_name()\n+\n     def _generate_session_key(self, source: SessionSource) -> str:\n         \"\"\"Generate a session key from a source.\"\"\"\n         return build_session_key(","@@ -1285,6 +1324,18 @@ def _recover_session_from_db(\n             return None\n         if not recovered:\n             return None\n+        if not self._recovered_row_allowed_for_active_profile(\n+            requested_session_key=session_key,\n+            recovered=recovered,\n+        ):\n+            logger.warning(\n+                \"Gateway session DB recovery ignored %s for %s because \"\n+                \"multiplex_profiles is disabled and the row belongs to a \"\n+                \"different profile\",\n+                recovered.get(\"session_key\"),\n+                session_key,\n+            )\n+            return None\n         try:\n             self._db.reopen_session(str(recovered[\"id\"]))\n         except Exception as exc:"]},{"file":"tests/gateway/test_multiplex_phase0.py","hunks":["@@ -9,6 +9,7 @@\n      on.\n \"\"\"\n import pytest\n+from datetime import datetime\n from unittest.mock import patch\n import yaml\n ","@@ -201,3 +202,66 @@ def test_flag_on_default_profile_stays_legacy(self, tmp_path):\n         with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"default\"):\n             assert store._generate_session_key(s) == \"agent:main:telegram:dm:99\"\n \n+\n+class _RecoveringDB:\n+    def __init__(self, row):\n+        self.row = row\n+        self.reopened = []\n+\n+    def find_latest_gateway_session_for_peer(self, **_kwargs):\n+        return self.row\n+\n+    def reopen_session(self, session_id):\n+        self.reopened.append(session_id)\n+\n+\n+class TestSessionStoreUnmultiplexedRecovery:\n+    \"\"\"Turning multiplexing off must not recover another profile's session.\"\"\"\n+\n+    def _store_with_row(self, tmp_path, row, **cfg_kw):\n+        config = GatewayConfig(**cfg_kw)\n+        with patch(\"gateway.session.SessionStore._ensure_loaded\"):\n+            store = SessionStore(sessions_dir=tmp_path, config=config)\n+        store._db = _RecoveringDB(row)\n+        store._loaded = True\n+        return store\n+\n+    def test_flag_off_rejects_other_profile_peer_fallback(self, tmp_path):\n+        row = {\n+            \"id\": \"sess-coder\",\n+            \"started_at\": 1700000000,\n+            \"session_key\": \"agent:coder:telegram:dm:99\",\n+        }\n+        store = self._store_with_row(tmp_path, row)\n+        source = _src(chat_id=\"99\", chat_type=\"dm\")\n+\n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"default\"):\n+            recovered = store._recover_session_from_db(\n+                session_key=\"agent:main:telegram:dm:99\",\n+                source=source,\n+                now=datetime.fromtimestamp(1700000001),\n+            )\n+\n+        assert recovered is None\n+        assert store._db.reopened == []\n+\n+    def test_flag_off_allows_active_profile_peer_fallback(self, tmp_path):\n+        row = {\n+            \"id\": \"sess-coder\",\n+            \"started_at\": 1700000000,\n+            \"session_key\": \"agent:coder:telegram:dm:99\",\n+        }\n+        store = self._store_with_row(tmp_path, row)\n+        source = _src(chat_id=\"99\", chat_type=\"dm\")\n+\n+        with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"coder\"):\n+            recovered = store._recover_session_from_db(\n+                session_key=\"agent:main:telegram:dm:99\",\n+                source=source,\n+                now=datetime.fromtimestamp(1700000001),\n+            )\n+\n+        assert recovered is not None\n+        assert recovered.session_id == \"sess-coder\"\n+        assert recovered.session_key == \"agent:main:telegram:dm:99\"\n+        assert store._db.reopened == [\"sess-coder\"]\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}