{"repo":"NousResearch/hermes-agent","public_id":"43842","display_label":"macOS launchd plist refresh self-disruption fixes","summary":"These issues and PRs address a macOS-specific problem where the gateway service disrupts itself during plist refresh by running bootout/bootstrap from within the running service. Fixes explore deferring reloads, skipping disruptive operations, and switching to kickstart as a safer alternative.","subsystem":"hermes_cli","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":47842,"merged_at":"2026-06-17T12:19:22+00:00"},"resolution":{"state":"resolved","by":"pr-47842"},"freshness":{"state":"verified","last_verified_at":"2026-06-17T12:19:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":43842,"pr_number":47842,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-17T12:19:22+00:00"}],"members":[{"kind":"issue","number":43842,"title":"macOS: plist refresh bootout from inside the gateway kills the CLI before bootstrap — agent-initiated self-update leaves service unloaded","url":null,"state":"closed"},{"kind":"pr","number":43870,"title":"fix(gateway): skip bootout/bootstrap when refresh is called from inside the service","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e48803daec3a1dccae24e2af09e1b467593f4de3","number":0},{"kind":"pr","value":"#47842","number":47842},{"kind":"pr","value":"#43842","number":43842}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":43922,"title":"fix(gateway): use kickstart instead of bootout/bootstrap in plist refresh (#43842)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e48803daec3a1dccae24e2af09e1b467593f4de3","number":0},{"kind":"pr","value":"#47842","number":47842},{"kind":"pr","value":"#43842","number":43842}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":47842,"title":"fix(gateway): defer macOS launchd reload when run inside the gateway tree","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/gateway.py","tests/hermes_cli/test_gateway_service.py"],"fix_hunks":[{"file":"hermes_cli/gateway.py","hunks":["@@ -7,6 +7,7 @@\n import asyncio\n import logging\n import os\n+import shlex\n import shutil\n import signal\n import subprocess","@@ -3467,14 +3468,60 @@ def refresh_launchd_plist_if_needed() -> bool:\n \n     plist_path.write_text(new_plist, encoding=\"utf-8\")\n     label = get_launchd_label()\n+    domain = _launchd_domain()\n+    target = f\"{domain}/{label}\"\n+\n+    # If this refresh is running INSIDE the gateway's own launchd process tree\n+    # (e.g. the agent triggered a self-update via its terminal tool), a direct\n+    # `launchctl bootout` tears down the service's process group — which\n+    # includes THIS CLI — before the follow-up `bootstrap` can run. The gateway\n+    # then stays unloaded and KeepAlive can't revive it (#43842). Detect that\n+    # case and hand the reload to a detached session that survives the bootout.\n+    gateway_pid = None\n+    try:\n+        from gateway.status import get_running_pid\n+        gateway_pid = get_running_pid()\n+    except Exception:\n+        gateway_pid = None\n+\n+    if (\n+        gateway_pid is not None\n+        and _is_pid_ancestor_of_current_process(gateway_pid)\n+        and hasattr(os, \"setsid\")  # POSIX-only; launchd is macOS so always true here\n+    ):\n+        # Delegate to a new session: `start_new_session=True` detaches the\n+        # helper from the gateway's process group, so the bootout that kills\n+        # the gateway (and us) does not kill the helper before it bootstraps.\n+        reload_script = (\n+            f\"sleep 2; \"\n+            f\"launchctl bootout {shlex.quote(target)} 2>/dev/null; \"\n+            f\"sleep 1; \"\n+            f\"launchctl bootstrap {shlex.quote(domain)} {shlex.quote(str(plist_path))} 2>/dev/null\"\n+        )\n+        try:\n+            subprocess.Popen(\n+                [\"/bin/bash\", \"-c\", reload_script],\n+                start_new_session=True,\n+                stdout=subprocess.DEVNULL,\n+                stderr=subprocess.DEVNULL,\n+            )\n+        except Exception as e:\n+            logger.warning(\"Deferred launchd reload could not be spawned: %s\", e)\n+            return False\n+        print(\n+            \"↻ Updated gateway launchd service definition; reload deferred to a \"\n+            \"detached helper (refresh ran inside the gateway process tree)\"\n+        )\n+        return True\n+\n     # Bootout/bootstrap so launchd picks up the new definition\n     subprocess.run(\n-        [\"launchctl\", \"bootout\", f\"{_launchd_domain()}/{label}\"],\n+        [\"launchctl\", \"bootout\", target],\n         check=False,\n         timeout=90,\n     )\n     subprocess.run(\n-        [\"launchctl\", \"bootstrap\", _launchd_domain(), str(plist_path)],\n+        [\"launchctl\", \"bootstrap\", domain, str(plist_path)],\n         check=False,\n         timeout=30,\n     )"]},{"file":"tests/hermes_cli/test_gateway_service.py","hunks":["@@ -599,6 +599,8 @@ def fake_run(cmd, check=False, **kwargs):\n             return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n \n         monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+        # Not running inside the gateway tree → direct bootout/bootstrap path.\n+        monkeypatch.setattr(\"gateway.status.get_running_pid\", lambda *a, **k: None)\n \n         gateway_cli.launchd_install()\n ","@@ -613,6 +615,110 @@ def fake_run(cmd, check=False, **kwargs):\n             [\"launchctl\", \"bootstrap\", domain, str(plist_path)],\n         ]\n \n+    def test_refresh_defers_reload_when_running_inside_gateway_tree(self, tmp_path, monkeypatch):\n+        \"\"\"#43842: when the refresh runs inside the gateway's own process tree,\n+        a direct bootout would kill this CLI before bootstrap. The reload must\n+        be delegated to a detached helper instead.\"\"\"\n+        plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n+        plist_path.write_text(\"<plist>old content</plist>\", encoding=\"utf-8\")\n+\n+        monkeypatch.setattr(gateway_cli, \"get_launchd_plist_path\", lambda: plist_path)\n+        monkeypatch.setattr(gateway_cli, \"launchd_plist_is_current\", lambda: False)\n+        monkeypatch.setattr(\n+            gateway_cli,\n+            \"generate_launchd_plist\",\n+            lambda: (\n+                \"<plist>--replace\\n<key>HERMES_HOME</key>\"\n+                \"<string>/Users/alice/.hermes</string></plist>\"\n+            ),\n+        )\n+        # Pretend the gateway is running and that we ARE inside its tree.\n+        monkeypatch.setattr(\"gateway.status.get_running_pid\", lambda *a, **k: 4242)\n+        monkeypatch.setattr(\n+            gateway_cli, \"_is_pid_ancestor_of_current_process\", lambda pid: pid == 4242\n+        )\n+\n+        run_calls = []\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            run_calls.append(cmd)\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        popen_calls = []\n+\n+        def fake_popen(cmd, **kwargs):\n+            popen_calls.append((cmd, kwargs))\n+            return SimpleNamespace(pid=9999)\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"Popen\", fake_popen)\n+\n+        result = gateway_cli.refresh_launchd_plist_if_needed()\n+\n+        assert result is True\n+        # The new plist was written.\n+        assert \"--replace\" in plist_path.read_text(encoding=\"utf-8\")\n+        # No DIRECT bootout/bootstrap ran (those would kill us mid-sequence).\n+        assert not [c for c in run_calls if \"bootout\" in c or \"bootstrap\" in c]\n+        # Exactly one detached helper was spawned, in a new session, and it\n+        # performs both bootout and bootstrap.\n+        assert len(popen_calls) == 1\n+        cmd, kwargs = popen_calls[0]\n+        assert kwargs.get(\"start_new_session\") is True\n+        script = cmd[-1]\n+        assert \"bootout\" in script and \"bootstrap\" in script\n+        assert str(plist_path) in script\n+\n+    def test_refresh_uses_direct_reload_when_not_inside_gateway_tree(self, tmp_path, monkeypatch):\n+        \"\"\"Normal CLI-initiated refresh (outside the service tree) keeps the\n+        direct synchronous bootout/bootstrap path.\"\"\"\n+        plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n+        plist_path.write_text(\"<plist>old content</plist>\", encoding=\"utf-8\")\n+\n+        monkeypatch.setattr(gateway_cli, \"get_launchd_plist_path\", lambda: plist_path)\n+        monkeypatch.setattr(gateway_cli, \"launchd_plist_is_current\", lambda: False)\n+        monkeypatch.setattr(\n+            gateway_cli,\n+            \"generate_launchd_plist\",\n+            lambda: (\n+                \"<plist>--replace\\n<key>HERMES_HOME</key>\"\n+                \"<string>/Users/alice/.hermes</string></plist>\"\n+            ),\n+        )\n+        # Gateway running, but we are NOT inside its tree.\n+        monkeypatch.setattr(\"gateway.status.get_running_pid\", lambda *a, **k: 4242)\n+        monkeypatch.setattr(\n+            gateway_cli, \"_is_pid_ancestor_of_current_process\", lambda pid: False\n+        )\n+\n+        run_calls = []\n+\n+        def fake_run(cmd, check=False, **kwargs):\n+            run_calls.append(cmd)\n+            return SimpleNamespace(returncode=0, stdout=\"\", stderr=\"\")\n+\n+        monkeypatch.setattr(gateway_cli.subprocess, \"run\", fake_run)\n+\n+        popen_calls = []\n+        monkeypatch.setattr(\n+            gateway_cli.subprocess, \"Popen\",\n+            lambda cmd, **kw: popen_calls.append(cmd) or SimpleNamespace(pid=1),\n+        )\n+\n+        result = gateway_cli.refresh_launchd_plist_if_needed()\n+\n+        assert result is True\n+        # No detached helper — direct path taken.\n+        assert not popen_calls\n+        label = gateway_cli.get_launchd_label()\n+        domain = gateway_cli._launchd_domain()\n+        service_calls = [c for c in run_calls if \"bootout\" in c or \"bootstrap\" in c]\n+        assert service_calls[:2] == [\n+            [\"launchctl\", \"bootout\", f\"{domain}/{label}\"],\n+            [\"launchctl\", \"bootstrap\", domain, str(plist_path)],\n+        ]\n+\n     def test_launchd_start_reloads_unloaded_job_and_retries(self, tmp_path, monkeypatch):\n         plist_path = tmp_path / \"ai.hermes.gateway.plist\"\n         plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding=\"utf-8\")\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}