{"repo":"NousResearch/hermes-agent","public_id":"21549","display_label":"Gateway launchd restart behavior and instance detection fixes","summary":"The three members all concern the gateway's interaction with launchd: two address exit behavior when another instance is detected (restart loop/double-spawn), and one addresses detecting launchd in the /restart service-manager probe. Their titles alone do not show whether they are duplicates or how they relate beyond this shared area.","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":51734,"merged_at":"2026-06-24T07:14:25+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-24T07:14:25+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":21549,"pr_number":51734,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-24T07:14:25+00:00"},{"issue_number":43475,"pr_number":51734,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-24T07:14:25+00:00"}],"members":[{"kind":"issue","number":21549,"title":"fix(gateway): launchd double-spawn triggers infinite restart death spiral (exit code 1 on instance detection)","url":null,"state":"closed"},{"kind":"pr","number":21555,"title":"fix(gateway): exit cleanly when another instance detected to avoid launchd restart loop","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#51734","number":51734},{"kind":"pr","value":"#37388","number":37388},{"kind":"pr","value":"#43475","number":43475},{"kind":"pr","value":"#21549","number":21549},{"kind":"pr","value":"#42446","number":42446},{"kind":"pr","value":"#42450","number":42450},{"kind":"pr","value":"#28561","number":28561}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":51734,"title":"fix(gateway): detect launchd in /restart service-manager probe (#43475)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/slash_commands.py","scripts/release.py","tests/gateway/test_restart_service_detection.py"],"fix_hunks":[{"file":"gateway/slash_commands.py","hunks":["@@ -960,7 +960,15 @@ async def _handle_restart_command(self, event: MessageEvent) -> Union[str, Ephem\n         # us.  The detached subprocess approach (setsid + bash) doesn't work\n         # under systemd (KillMode=mixed kills the cgroup) or Docker (tini\n         # exits when the gateway dies, taking the detached helper with it).\n-        _under_service = bool(os.environ.get(\"INVOCATION_ID\"))  # systemd sets this\n+        # systemd sets INVOCATION_ID; launchd sets XPC_SERVICE_NAME to the\n+        # job label.  Without the launchd check, macOS /restart takes the\n+        # detached path and exits 0, which KeepAlive.SuccessfulExit=false\n+        # treats as a deliberate stop — the gateway stays dead until next\n+        # login.  Interactive macOS shells inherit XPC_SERVICE_NAME=0, so\n+        # \"0\" must count as not-under-launchd.\n+        _under_service = bool(os.environ.get(\"INVOCATION_ID\")) or os.environ.get(\n+            \"XPC_SERVICE_NAME\", \"0\"\n+        ) not in (\"\", \"0\")\n         _in_container = os.path.exists(\"/.dockerenv\") or os.path.exists(\"/run/.containerenv\")\n         if _under_service or _in_container:\n             self.request_restart(detached=False, via_service=True)"]},{"file":"scripts/release.py","hunks":["@@ -1547,6 +1547,7 @@\n     \"6666242+bird@users.noreply.github.com\": \"bird\",  # PR #25219 (gateway docker exit-75 restart)\n     \"[redacted:email]\": \"davidcampbelldc\",  # PR #26834 (web_server proxy_headers=False)\n     \"165905879+davidcampbelldc@users.noreply.github.com\": \"davidcampbelldc\",\n+    \"[redacted:email]\": \"chazmaniandinkle\",  # PR #43888 (launchd /restart detection)\n     \"[redacted:email]\": \"hehehe0803\",  # PR #26212 salvage (codex kanban writable root)\n     \"26063003+hehehe0803@users.noreply.github.com\": \"hehehe0803\",\n     \"kasunvinod@users.noreply.github.com\": \"kasunvinod\",  # PR #24126 salvage (codex timeout propagation)"]},{"file":"tests/gateway/test_restart_service_detection.py","hunks":["@@ -0,0 +1,85 @@\n+\"\"\"Tests for /restart service-manager detection (launchd vs interactive).\n+\n+The /restart handler routes through ``request_restart(via_service=True)``\n+when a service manager supervises the gateway, so the process exits with\n+the service-restart code and the manager relaunches it.  Under macOS\n+launchd the plist uses ``KeepAlive.SuccessfulExit=false`` — a clean exit 0\n+is treated as a deliberate stop and the gateway stays dead (#43475) — so\n+launchd must be detected here in the handler, not only at the exit-code\n+site (which never runs unless ``via_service=True`` is already set).\n+\n+launchd sets ``XPC_SERVICE_NAME`` to the job label for processes it\n+spawns.  Interactive macOS shells inherit ``XPC_SERVICE_NAME=0`` (a\n+truthy string), so the probe must treat ``\"0\"`` as not-under-launchd:\n+routing an unsupervised interactive gateway to the service path would\n+make it exit non-zero with nothing to revive it.\n+\"\"\"\n+from unittest.mock import MagicMock\n+\n+import pytest\n+\n+import gateway.run as gateway_run\n+from gateway.platforms.base import MessageEvent, MessageType\n+from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source\n+\n+\n+def _make_restart_event(update_id: int | None = 100) -> MessageEvent:\n+    return MessageEvent(\n+        text=\"/restart\",\n+        message_type=MessageType.TEXT,\n+        source=make_restart_source(),\n+        message_id=\"m1\",\n+        platform_update_id=update_id,\n+    )\n+\n+\n+def _make_runner_with_mock_restart(tmp_path, monkeypatch):\n+    monkeypatch.setattr(gateway_run, \"_hermes_home\", tmp_path)\n+    monkeypatch.delenv(\"INVOCATION_ID\", raising=False)\n+    monkeypatch.delenv(\"XPC_SERVICE_NAME\", raising=False)\n+    runner, _adapter = make_restart_runner()\n+    runner.request_restart = MagicMock(return_value=True)\n+    return runner\n+\n+\n[redacted:email]\n+async def test_restart_under_launchd_uses_service_path(tmp_path, monkeypatch):\n+    \"\"\"launchd job label in XPC_SERVICE_NAME routes /restart via the service path.\"\"\"\n+    runner = _make_runner_with_mock_restart(tmp_path, monkeypatch)\n+    monkeypatch.setenv(\"XPC_SERVICE_NAME\", \"ai.hermes.gateway\")\n+\n+    await runner._handle_restart_command(_make_restart_event())\n+\n+    runner.request_restart.assert_called_once_with(detached=False, via_service=True)\n+\n+\n[redacted:email]\n+async def test_restart_in_interactive_macos_shell_uses_detached_path(tmp_path, monkeypatch):\n+    \"\"\"XPC_SERVICE_NAME=0 (inherited by interactive macOS shells) is NOT a service.\"\"\"\n+    runner = _make_runner_with_mock_restart(tmp_path, monkeypatch)\n+    monkeypatch.setenv(\"XPC_SERVICE_NAME\", \"0\")\n+\n+    await runner._handle_restart_command(_make_restart_event())\n+\n+    runner.request_restart.assert_called_once_with(detached=True, via_service=False)\n+\n+\n[redacted:email]\n+async def test_restart_without_service_env_uses_detached_path(tmp_path, monkeypatch):\n+    \"\"\"No service-manager env at all falls back to the detached restart.\"\"\"\n+    runner = _make_runner_with_mock_restart(tmp_path, monkeypatch)\n+\n+    await runner._handle_restart_command(_make_restart_event())\n+\n+    runner.request_restart.assert_called_once_with(detached=True, via_service=False)\n+\n+\n[redacted:email]\n+async def test_restart_under_systemd_uses_service_path(tmp_path, monkeypatch):\n+    \"\"\"INVOCATION_ID (systemd) still routes via the service path.\"\"\"\n+    runner = _make_runner_with_mock_restart(tmp_path, monkeypatch)\n+    monkeypatch.setenv(\"INVOCATION_ID\", \"abc123\")\n+\n+    await runner._handle_restart_command(_make_restart_event())\n+\n+    runner.request_restart.assert_called_once_with(detached=False, via_service=True)\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}