NousResearch/hermes-agent

Gateway launchd restart behavior and instance detection fixes

Cluster 21549 · gateway. Data as of

Nothing open on GitHub. Fix merged in #51734 on 24 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #51734. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #51734 fix(gateway): detect launchd in /restart service-manager probe (#43475)

    merged to main

    files: gateway/slash_commands.py, scripts/release.py, tests/gateway/test_restart_service_detection.py

    Show the diff
    • gateway/slash_commands.py
      @@ -960,7 +960,15 @@ async def _handle_restart_command(self, event: MessageEvent) -> Union[str, Ephem
               # us.  The detached subprocess approach (setsid + bash) doesn't work
               # under systemd (KillMode=mixed kills the cgroup) or Docker (tini
               # exits when the gateway dies, taking the detached helper with it).
      -        _under_service = bool(os.environ.get("INVOCATION_ID"))  # systemd sets this
      +        # systemd sets INVOCATION_ID; launchd sets XPC_SERVICE_NAME to the
      +        # job label.  Without the launchd check, macOS /restart takes the
      +        # detached path and exits 0, which KeepAlive.SuccessfulExit=false
      +        # treats as a deliberate stop — the gateway stays dead until next
      +        # login.  Interactive macOS shells inherit XPC_SERVICE_NAME=0, so
      +        # "0" must count as not-under-launchd.
      +        _under_service = bool(os.environ.get("INVOCATION_ID")) or os.environ.get(
      +            "XPC_SERVICE_NAME", "0"
      +        ) not in ("", "0")
               _in_container = os.path.exists("/.dockerenv") or os.path.exists("/run/.containerenv")
               if _under_service or _in_container:
                   self.request_restart(detached=False, via_service=True)
    • scripts/release.py
      @@ -1547,6 +1547,7 @@
           "[email protected]": "bird",  # PR #25219 (gateway docker exit-75 restart)
           "[redacted:email]": "davidcampbelldc",  # PR #26834 (web_server proxy_headers=False)
           "[email protected]": "davidcampbelldc",
      +    "[redacted:email]": "chazmaniandinkle",  # PR #43888 (launchd /restart detection)
           "[redacted:email]": "hehehe0803",  # PR #26212 salvage (codex kanban writable root)
           "[email protected]": "hehehe0803",
           "[email protected]": "kasunvinod",  # PR #24126 salvage (codex timeout propagation)
    • tests/gateway/test_restart_service_detection.py
      @@ -0,0 +1,85 @@
      +"""Tests for /restart service-manager detection (launchd vs interactive).
      +
      +The /restart handler routes through ``request_restart(via_service=True)``
      +when a service manager supervises the gateway, so the process exits with
      +the service-restart code and the manager relaunches it.  Under macOS
      +launchd the plist uses ``KeepAlive.SuccessfulExit=false`` — a clean exit 0
      +is treated as a deliberate stop and the gateway stays dead (#43475) — so
      +launchd must be detected here in the handler, not only at the exit-code
      +site (which never runs unless ``via_service=True`` is already set).
      +
      +launchd sets ``XPC_SERVICE_NAME`` to the job label for processes it
      +spawns.  Interactive macOS shells inherit ``XPC_SERVICE_NAME=0`` (a
      +truthy string), so the probe must treat ``"0"`` as not-under-launchd:
      +routing an unsupervised interactive gateway to the service path would
      +make it exit non-zero with nothing to revive it.
      +"""
      +from unittest.mock import MagicMock
      +
      +import pytest
      +
      +import gateway.run as gateway_run
      +from gateway.platforms.base import MessageEvent, MessageType
      +from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source
      +
      +
      +def _make_restart_event(update_id: int | None = 100) -> MessageEvent:
      +    return MessageEvent(
      +        text="/restart",
      +        message_type=MessageType.TEXT,
      +        source=make_restart_source(),
      +        message_id="m1",
      +        platform_update_id=update_id,
      +    )
      +
      +
      +def _make_runner_with_mock_restart(tmp_path, monkeypatch):
      +    monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
      +    monkeypatch.delenv("INVOCATION_ID", raising=False)
      +    monkeypatch.delenv("XPC_SERVICE_NAME", raising=False)
      +    runner, _adapter = make_restart_runner()
      +    runner.request_restart = MagicMock(return_value=True)
      +    return runner
      +
      +
      [redacted:email]
      +async def test_restart_under_launchd_uses_service_path(tmp_path, monkeypatch):
      +    """launchd job label in XPC_SERVICE_NAME routes /restart via the service path."""
      +    runner = _make_runner_with_mock_restart(tmp_path, monkeypatch)
      +    monkeypatch.setenv("XPC_SERVICE_NAME", "ai.hermes.gateway")
      +
      +    await runner._handle_restart_command(_make_restart_event())
      +
      +    runner.request_restart.assert_called_once_with(detached=False, via_service=True)
      +
      +
      [redacted:email]
      +async def test_restart_in_interactive_macos_shell_uses_detached_path(tmp_path, monkeypatch):
      +    """XPC_SERVICE_NAME=0 (inherited by interactive macOS shells) is NOT a service."""
      +    runner = _make_runner_with_mock_restart(tmp_path, monkeypatch)
      +    monkeypatch.setenv("XPC_SERVICE_NAME", "0")
      +
      +    await runner._handle_restart_command(_make_restart_event())
      +
      +    runner.request_restart.assert_called_once_with(detached=True, via_service=False)
      +
      +
      [redacted:email]
      +async def test_restart_without_service_env_uses_detached_path(tmp_path, monkeypatch):
      +    """No service-manager env at all falls back to the detached restart."""
      +    runner = _make_runner_with_mock_restart(tmp_path, monkeypatch)
      +
      +    await runner._handle_restart_command(_make_restart_event())
      +
      +    runner.request_restart.assert_called_once_with(detached=True, via_service=False)
      +
      +
      [redacted:email]
      +async def test_restart_under_systemd_uses_service_path(tmp_path, monkeypatch):
      +    """INVOCATION_ID (systemd) still routes via the service path."""
      +    runner = _make_runner_with_mock_restart(tmp_path, monkeypatch)
      +    monkeypatch.setenv("INVOCATION_ID", "abc123")
      +
      +    await runner._handle_restart_command(_make_restart_event())
      +
      +    runner.request_restart.assert_called_once_with(detached=False, via_service=True)

1 PR closed without merging

From the close comments: done elsewhere 1

  • #21555 fix(gateway): exit cleanly when another instance detected to avoid launchd restart loop · done elsewhere → #51734, #37388, #43475, #21549, #42446, #42450, #28561
Closed issues · 1
  • Closed#21549fix(gateway): launchd double-spawn triggers infinite restart death spiral (exit code 1 on instance detection)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#51734
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
24 Jun 2026

For your agent

The same facts, typed.