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
- 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
Open PRs0
Fixes on record
-
#51734 fix(gateway): detect launchd in /restart service-manager probe (#43475)
files:
gateway/slash_commands.py,scripts/release.py,tests/gateway/test_restart_service_detection.pyShow 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.00means 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 LabAt a glance
- Subsystem
- gateway
- Merged PRs
- #51734
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 24 Jun 2026