{"repo":"NousResearch/hermes-agent","public_id":"4402","display_label":"Multi-profile gateway isolation and cross-profile interference fixes","summary":"These issues and fixes center on adding isolated profile support to Hermes and correcting gateway operations (status, stop, replace, PID resolution, lock release) that incorrectly affect all profiles instead of only the active one. A recurring theme is cross-profile SIGTERM/SIGKILL interference caused by the --replace mechanism ignoring HERMES_PROFILE.","subsystem":"gateway","counts":{"members":20,"open_issues":2,"open_issues_distinct":2,"open_prs":2,"closed_issues":6,"merged_prs":2,"attempt_prs":10},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":19582},"resolution":{"state":"partially_resolved","by":"pr-53930"},"freshness":{"state":"verified","last_verified_at":"2026-06-28T02:43:02+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.0783157894736842},"fix_edges":[{"issue_number":19113,"pr_number":19582,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":29092,"pr_number":53930,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":4402,"title":"gateway stop kills ALL profile gateways instead of only the current profile","url":null,"state":"closed"},{"kind":"issue","number":4587,"title":"Multi-profile gateway is not safe: kill_gateway_processes() and release_all_scoped_locks() are profile-blind","url":null,"state":"closed"},{"kind":"issue","number":19113,"title":"[Bug]: `gateway status` reports running when only a different profile's gateway is alive","url":null,"state":"closed"},{"kind":"issue","number":29092,"title":"[Bug]: Two profile gateway services enter SIGTERM flap loop — `--replace` + PID file cross-profile mis-routing","url":null,"state":"closed"},{"kind":"issue","number":29948,"title":"--replace singleton pidfile resolver ignores HERMES_PROFILE, causing cross-gateway SIGKILL on multi-persona hosts","url":null,"state":"closed"},{"kind":"issue","number":30155,"title":"`--replace` cross-kills sibling gateways when multiple HERMES_PROFILE values share one HERMES_HOME","url":null,"state":"open"},{"kind":"issue","number":47788,"title":"[印证] Gateway SIGTERM leak in multi-profile concurrent startup (--replace mechanism)","url":null,"state":"closed"},{"kind":"issue","number":60269,"title":"[Bug]: Multi-profile gateway autostart race condition — --replace causes crash loop when two profiles start simultaneously","url":null,"state":"open"},{"kind":"pr","number":19115,"title":"fix(gateway): show other profiles in `gateway status` to prevent confusion","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19582","number":19582}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":19134,"title":"fix: scope gateway status to current profile","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#19115","number":19115},{"kind":"pr","value":"#16375","number":16375},{"kind":"pr","value":"#19115","number":19115},{"kind":"pr","value":"#16375","number":16375}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":19504,"title":"fix(gateway): filter status PIDs by profile","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":19582,"title":"fix(gateway): show other profiles in 'gateway status' to prevent confusion","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/gateway.py"],"fix_hunks":[{"file":"hermes_cli/gateway.py","hunks":["@@ -690,6 +690,32 @@ def _print_gateway_process_mismatch(snapshot: GatewayRuntimeSnapshot) -> None:\n     print(\"  can refuse to start another copy until this process stops.\")\n \n \n+def _print_other_profiles_gateway_status() -> None:\n+    \"\"\"Print a summary of gateway status across all profiles.\n+\n+    Shown at the bottom of ``hermes gateway status`` output so users with\n+    multiple profiles can tell at a glance which gateways are running and\n+    avoid confusing another profile's process with the current one.\n+    \"\"\"\n+    try:\n+        from hermes_cli.profiles import get_active_profile_name\n+\n+        current = get_active_profile_name()\n+        other_processes = [\n+            p for p in find_profile_gateway_processes()\n+            if p.profile != current\n+        ]\n+        if not other_processes:\n+            return\n+\n+        print()\n+        print(\"Other profiles:\")\n+        for proc in other_processes:\n+            print(f\"  ✓ {proc.profile:<16s} — PID {proc.pid}\")\n+    except Exception:\n+        pass\n+\n+\n def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None,\n                            all_profiles: bool = False) -> int:\n     \"\"\"Kill any running gateway processes. Returns count killed.","@@ -4456,6 +4482,9 @@ def _gateway_command_inner(args):\n                     print(\"  hermes gateway install  # Install as user service\")\n                     print(\"  sudo hermes gateway install --system  # Install as boot-time system service\")\n \n+        # Show other profiles' gateway status for multi-profile awareness\n+        _print_other_profiles_gateway_status()\n+\n     elif subcmd == \"migrate-legacy\":\n         # Stop, disable, and remove legacy Hermes gateway unit files from\n         # pre-rename installs (e.g. hermes.service). Profile units and\n"]}]},{"kind":"pr","number":29111,"title":"fix(gateway): keep launchd gateways alive after clean exits","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":29952,"title":"fix(cli): honour HERMES_PROFILE env var in _apply_profile_override","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":30043,"title":"fix(constants): honor HERMES_PROFILE env var in get_hermes_home()","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":30196,"title":"fix(gateway): refuse --replace across HERMES_PROFILE mismatch (#30155)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30043","number":30043}],"category":"wontfix_bydesign","implication":"wontfix"},"merged":false},{"kind":"pr","number":31414,"title":"fix(gateway): prevent cross-profile SIGTERM flap loop in multi-profile systemd setups","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"2b73dd1ca645166be6d9a1cf47bde60c9990b1f8","number":0},{"kind":"pr","value":"#53930","number":53930},{"kind":"pr","value":"#34597","number":34597}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":53930,"title":"fix(gateway): namespace --replace takeover marker by HERMES_HOME to stop cross-profile flap (#29092)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/status.py","tests/gateway/test_status.py"],"fix_hunks":[{"file":"gateway/status.py","hunks":["@@ -1133,6 +1133,22 @@ def _consume_pid_marker_for_self(\n             pass\n         return False\n \n+    # Cross-profile guard (#29092): reject markers written by a gateway\n+    # running under a different HERMES_HOME. When two profile gateway\n+    # services share the same default ~/.hermes (HERMES_HOME not set\n+    # distinctly), the marker path resolves to the same file for both. A\n+    # --replace from profile B could land in profile A's marker, match on\n+    # PID + start_time by coincidence of a shared PID namespace, and make\n+    # profile A exit 0 — only to be revived by systemd Restart=always,\n+    # which then races the replacer again, flapping indefinitely. The\n+    # field is absent in markers written by older Hermes versions; treat\n+    # absent as \"same home\" so old markers and single-profile setups are\n+    # unaffected. Leave a mismatched marker in place so the correct\n+    # profile can still consume it.\n+    replacer_home = record.get(\"replacer_hermes_home\")\n+    if replacer_home is not None and replacer_home != str(get_hermes_home()):\n+        return False\n+\n     our_pid = os.getpid()\n     our_start_time = _get_process_start_time(our_pid)\n     # Start-time is a PID-reuse guard. It is only meaningful when both","@@ -1179,6 +1195,7 @@ def write_takeover_marker(target_pid: int) -> bool:\n             \"target_pid\": target_pid,\n             \"target_start_time\": target_start_time,\n             \"replacer_pid\": os.getpid(),\n+            \"replacer_hermes_home\": str(get_hermes_home()),\n             \"written_at\": _utc_now_iso(),\n         }\n         _write_json_file(_get_takeover_marker_path(), record)"]},{"file":"tests/gateway/test_status.py","hunks":["@@ -1128,6 +1128,64 @@ def test_consume_ignores_marker_for_different_process_and_prevents_stale_grief(\n         # We are not the target — must NOT consume as planned\n         assert result is False\n \n+    def test_write_marker_records_replacer_hermes_home(self, tmp_path, monkeypatch):\n+        \"\"\"The marker stamps the replacer's HERMES_HOME for cross-profile guard (#29092).\"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        monkeypatch.setattr(status, \"_get_process_start_time\", lambda pid: 42)\n+\n+        status.write_takeover_marker(target_pid=12345)\n+\n+        payload = json.loads((tmp_path / \".gateway-takeover.json\").read_text())\n+        assert payload[\"replacer_hermes_home\"] == str(tmp_path)\n+\n+    def test_consume_rejects_marker_from_different_profile(self, tmp_path, monkeypatch):\n+        \"\"\"Regression (#29092): a marker written by a gateway under a DIFFERENT\n+        HERMES_HOME must be rejected even when PID + start_time coincidentally\n+        match — otherwise two profile services sharing a default ~/.hermes flap\n+        each other in an infinite SIGTERM/Restart loop. The mismatched marker is\n+        left in place so the profile it was actually meant for can consume it.\n+        \"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        monkeypatch.setattr(status, \"_get_process_start_time\", lambda pid: 100)\n+        marker_path = tmp_path / \".gateway-takeover.json\"\n+        from datetime import datetime, timezone\n+        # Marker names OUR pid + start_time (the coincidental match the bug\n+        # relied on) but was written by a gateway in a different profile.\n+        marker_path.write_text(json.dumps({\n+            \"target_pid\": os.getpid(),\n+            \"target_start_time\": 100,\n+            \"replacer_pid\": 99999,\n+            \"replacer_hermes_home\": str(tmp_path / \"profiles\" / \"other\"),\n+            \"written_at\": datetime.now(timezone.utc).isoformat(),\n+        }))\n+\n+        result = status.consume_takeover_marker_for_self()\n+\n+        assert result is False\n+        # Left in place for the correct profile, not griefed away.\n+        assert marker_path.exists()\n+\n+    def test_consume_accepts_legacy_marker_without_hermes_home(self, tmp_path, monkeypatch):\n+        \"\"\"Back-compat (#29092): markers written by older Hermes versions have no\n+        ``replacer_hermes_home`` field; an absent field is treated as same-home so\n+        single-profile setups and mixed old/new deployments keep working.\n+        \"\"\"\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+        monkeypatch.setattr(status, \"_get_process_start_time\", lambda pid: 100)\n+        marker_path = tmp_path / \".gateway-takeover.json\"\n+        from datetime import datetime, timezone\n+        marker_path.write_text(json.dumps({\n+            \"target_pid\": os.getpid(),\n+            \"target_start_time\": 100,\n+            \"replacer_pid\": 99999,\n+            \"written_at\": datetime.now(timezone.utc).isoformat(),\n+        }))\n+\n+        result = status.consume_takeover_marker_for_self()\n+\n+        assert result is True\n+        assert not marker_path.exists()\n+\n \n class TestPlannedStopMarker:\n     \"\"\"Tests for intentional service/manual gateway stop markers.\"\"\"\n"]}]},{"kind":"pr","number":60610,"title":"fix(local): guard killpg against own process group (#47788)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47788","number":47788},{"kind":"pr","value":"#47788","number":47788}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":60828,"title":"fix(gateway): scope replace checks by profile","url":null,"state":"open","close_reason":{"refs":[{"kind":"commit","value":"a23319d0f78","number":0},{"kind":"commit","value":"5de30f36f16","number":0},{"kind":"pr","value":"#106406","number":106406},{"kind":"pr","value":"#60269","number":60269},{"kind":"pr","value":"#30196","number":30196},{"kind":"pr","value":"#60269","number":60269}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}