# NousResearch/hermes-agent · macOS launchd plist refresh self-disruption fixes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #47842. Verify it addresses your case.
- **Recommended:** read PR #47842 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-47842`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member 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.

## Closed / duplicate issues

- #43842: macOS: plist refresh bootout from inside the gateway kills the CLI before bootstrap — agent-initiated self-update leaves service unloaded

## Prior & competing PRs

- **#47842: fix(gateway): defer macOS launchd reload when run inside the gateway tree**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `hermes_cli/gateway.py`, `tests/hermes_cli/test_gateway_service.py`
  - fix hunks:
  - `hermes_cli/gateway.py`

    ```diff
    @@ -7,6 +7,7 @@
     import asyncio
     import logging
     import os
    +import shlex
     import shutil
     import signal
     import subprocess
    ```

    ```diff
    @@ -3467,14 +3468,60 @@ def refresh_launchd_plist_if_needed() -> bool:
     
         plist_path.write_text(new_plist, encoding="utf-8")
         label = get_launchd_label()
    +    domain = _launchd_domain()
    +    target = f"{domain}/{label}"
    +
    +    # If this refresh is running INSIDE the gateway's own launchd process tree
    +    # (e.g. the agent triggered a self-update via its terminal tool), a direct
    +    # `launchctl bootout` tears down the service's process group — which
    +    # includes THIS CLI — before the follow-up `bootstrap` can run. The gateway
    +    # then stays unloaded and KeepAlive can't revive it (#43842). Detect that
    +    # case and hand the reload to a detached session that survives the bootout.
    +    gateway_pid = None
    +    try:
    +        from gateway.status import get_running_pid
    +        gateway_pid = get_running_pid()
    +    except Exception:
    +        gateway_pid = None
    +
    +    if (
    +        gateway_pid is not None
    +        and _is_pid_ancestor_of_current_process(gateway_pid)
    +        and hasattr(os, "setsid")  # POSIX-only; launchd is macOS so always true here
    +    ):
    +        # Delegate to a new session: `start_new_session=True` detaches the
    +        # helper from the gateway's process group, so the bootout that kills
    +        # the gateway (and us) does not kill the helper before it bootstraps.
    +        reload_script = (
    +            f"sleep 2; "
    +            f"launchctl bootout {shlex.quote(target)} 2>/dev/null; "
    +            f"sleep 1; "
    +            f"launchctl bootstrap {shlex.quote(domain)} {shlex.quote(str(plist_path))} 2>/dev/null"
    +        )
    +        try:
    +            subprocess.Popen(
    +                ["/bin/bash", "-c", reload_script],
    +                start_new_session=True,
    +                stdout=subprocess.DEVNULL,
    +                stderr=subprocess.DEVNULL,
    +            )
    +        except Exception as e:
    +            logger.warning("Deferred launchd reload could not be spawned: %s", e)
    +            return False
    +        print(
    +            "↻ Updated gateway launchd service definition; reload deferred to a "
    +            "detached helper (refresh ran inside the gateway process tree)"
    +        )
    +        return True
    +
         # Bootout/bootstrap so launchd picks up the new definition
         subprocess.run(
    -        ["launchctl", "bootout", f"{_launchd_domain()}/{label}"],
    +        ["launchctl", "bootout", target],
             check=False,
             timeout=90,
         )
         subprocess.run(
    -        ["launchctl", "bootstrap", _launchd_domain(), str(plist_path)],
    +        ["launchctl", "bootstrap", domain, str(plist_path)],
             check=False,
             timeout=30,
         )
    ```
  - `tests/hermes_cli/test_gateway_service.py`

    ```diff
    @@ -599,6 +599,8 @@ def fake_run(cmd, check=False, **kwargs):
                 return SimpleNamespace(returncode=0, stdout="", stderr="")
     
             monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
    +        # Not running inside the gateway tree → direct bootout/bootstrap path.
    +        monkeypatch.setattr("gateway.status.get_running_pid", lambda *a, **k: None)
     
             gateway_cli.launchd_install()
     
    ```

    ```diff
    @@ -613,6 +615,110 @@ def fake_run(cmd, check=False, **kwargs):
                 ["launchctl", "bootstrap", domain, str(plist_path)],
             ]
     
    +    def test_refresh_defers_reload_when_running_inside_gateway_tree(self, tmp_path, monkeypatch):
    +        """#43842: when the refresh runs inside the gateway's own process tree,
    +        a direct bootout would kill this CLI before bootstrap. The reload must
    +        be delegated to a detached helper instead."""
    +        plist_path = tmp_path / "ai.hermes.gateway.plist"
    +        plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
    +
    +        monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
    +        monkeypatch.setattr(gateway_cli, "launchd_plist_is_current", lambda: False)
    +        monkeypatch.setattr(
    +            gateway_cli,
    +            "generate_launchd_plist",
    +            lambda: (
    +                "<plist>--replace\n<key>HERMES_HOME</key>"
    +                "<string>/Users/alice/.hermes</string></plist>"
    +            ),
    +        )
    +        # Pretend the gateway is running and that we ARE inside its tree.
    +        monkeypatch.setattr("gateway.status.get_running_pid", lambda *a, **k: 4242)
    +        monkeypatch.setattr(
    +            gateway_cli, "_is_pid_ancestor_of_current_process", lambda pid: pid == 4242
    +        )
    +
    +        run_calls = []
    +
    +        def fake_run(cmd, check=False, **kwargs):
    +            run_calls.append(cmd)
    +            return SimpleNamespace(returncode=0, stdout="", stderr="")
    +
    +        monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
    +
    +        popen_calls = []
    +
    +        def fake_popen(cmd, **kwargs):
    +            popen_calls.append((cmd, kwargs))
    +            return SimpleNamespace(pid=9999)
    +
    +        monkeypatch.setattr(gateway_cli.subprocess, "Popen", fake_popen)
    +
    +        result = gateway_cli.refresh_launchd_plist_if_needed()
    +
    +        assert result is True
    +        # The new plist was written.
    +        assert "--replace" in plist_path.read_text(encoding="utf-8")
    +        # No DIRECT bootout/bootstrap ran (those would kill us mid-sequence).
    +        assert not [c for c in run_calls if "bootout" in c or "bootstrap" in c]
    +        # Exactly one detached helper was spawned, in a new session, and it
    +        # performs both bootout and bootstrap.
    +        assert len(popen_calls) == 1
    +        cmd, kwargs = popen_calls[0]
    +        assert kwargs.get("start_new_session") is True
    +        script = cmd[-1]
    +        assert "bootout" in script and "bootstrap" in script
    +        assert str(plist_path) in script
    +
    +    def test_refresh_uses_direct_reload_when_not_inside_gateway_tree(self, tmp_path, monkeypatch):
    +        """Normal CLI-initiated refresh (outside the service tree) keeps the
    +        direct synchronous bootout/bootstrap path."""
    +        plist_path = tmp_path / "ai.hermes.gateway.plist"
    +        plist_path.write_text("<plist>old content</plist>", encoding="utf-8")
    +
    +        monkeypatch.setattr(gateway_cli, "get_launchd_plist_path", lambda: plist_path)
    +        monkeypatch.setattr(gateway_cli, "launchd_plist_is_current", lambda: False)
    +        monkeypatch.setattr(
    +            gateway_cli,
    +            "generate_launchd_plist",
    +            lambda: (
    +                "<plist>--replace\n<key>HERMES_HOME</key>"
    +                "<string>/Users/alice/.hermes</string></plist>"
    +            ),
    +        )
    +        # Gateway running, but we are NOT inside its tree.
    +        monkeypatch.setattr("gateway.status.get_running_pid", lambda *a, **k: 4242)
    +        monkeypatch.setattr(
    +            gateway_cli, "_is_pid_ancestor_of_current_process", lambda pid: False
    +        )
    +
    +        run_calls = []
    +
    +        def fake_run(cmd, check=False, **kwargs):
    +            run_calls.append(cmd)
    +            return SimpleNamespace(returncode=0, stdout="", stderr="")
    +
    +        monkeypatch.setattr(gateway_cli.subprocess, "run", fake_run)
    +
    +        popen_calls = []
    +        monkeypatch.setattr(
    +            gateway_cli.subprocess, "Popen",
    +            lambda cmd, **kw: popen_calls.append(cmd) or SimpleNamespace(pid=1),
    +        )
    +
    +        result = gateway_cli.refresh_launchd_plist_if_needed()
    +
    +        assert result is True
    +        # No detached helper — direct path taken.
    +        assert not popen_calls
    +        label = gateway_cli.get_launchd_label()
    +        domain = gateway_cli._launchd_domain()
    +        service_calls = [c for c in run_calls if "bootout" in c or "bootstrap" in c]
    +        assert service_calls[:2] == [
    +            ["launchctl", "bootout", f"{domain}/{label}"],
    +            ["launchctl", "bootstrap", domain, str(plist_path)],
    +        ]
    +
         def test_launchd_start_reloads_unloaded_job_and_retries(self, tmp_path, monkeypatch):
             plist_path = tmp_path / "ai.hermes.gateway.plist"
             plist_path.write_text(gateway_cli.generate_launchd_plist(), encoding="utf-8")
    ```
- **#43870: fix(gateway): skip bootout/bootstrap when refresh is called from inside the service**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `e48803daec3a1dccae24e2af09e1b467593f4de3`, `#47842`, `#43842`
- **#43922: fix(gateway): use kickstart instead of bootout/bootstrap in plist refresh (#43842)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `e48803daec3a1dccae24e2af09e1b467593f4de3`, `#47842`, `#43842`

## Fix status

- Merged to main in PR #47842 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-17`.
- Subsystem: `hermes_cli`
