Gateway process lifecycle and stale PID handling
NousResearch/hermes-agent · cluster 5126 · apps/desktop
Data as of
5 open items. Fix merged in #28341. Check it covers your case.
The short answer
- Known?
- Yes. 39 related items grouped here, 15 issues already closed.
- Fix event
- A fix was merged to main in PR #28341. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PRs #28341, #39000, #51734. That is separate from the fix event. - Still open
- 2 issues and 3 PRs.
Next step: read #28341, then check whether your report is one of the 2 still open.
Read #28341Pick up a Lab taskThese members address bugs and fixes related to gateway startup, restart, and shutdown race conditions, particularly around stale or recycled PID detection and recovery from unexpected termination signals.
Open issues2
Open PRs3
Fixes on record
-
#28341 fix(gateway): exit code 75 on service restart so launchd relaunches (#28200)
files:
gateway/run.pyShow the diff
-
gateway/run.py@@ -17346,6 +17346,19 @@ def restart_signal_handler(): ) return False # → sys.exit(1) in the caller + # When the gateway is restarting via the service manager (SIGUSR1 → + # launchd_restart or /restart / /update commands), exit with code 75 so + # that launchd's ``KeepAlive → SuccessfulExit → false`` policy treats + # the exit as *unsuccessful* and relaunches the service. This mirrors + # the systemd ``RestartForceExitStatus=75`` convention already used by + # the systemd unit template. + if runner._restart_via_service: + logger.info( + "Exiting with code 75 (service-restart requested) so " + "launchd KeepAlive relaunches the gateway." + ) + raise SystemExit(75) + return True
-
-
#39000 fix(cli): launchd KeepAlive unconditional restart (#37388)
files:
hermes_cli/gateway.py,scripts/release.py,tests/hermes_cli/test_gateway_service.pyShow the diff
-
hermes_cli/gateway.py@@ -227,9 +227,8 @@ def _graceful_restart_via_sigusr1(pid: int, drain_timeout: float) -> bool: SIGUSR1 is wired in gateway/run.py to ``request_restart(via_service=True)`` which drains in-flight agent runs (up to ``agent.restart_drain_timeout`` - seconds), then exits. systemd relaunches clean exits via - ``Restart=always``; launchd still uses a non-zero planned-restart exit - because its plist has ``KeepAlive.SuccessfulExit = false``. + seconds), then exits. Both systemd (``Restart=always``) and launchd + (unconditional ``<key>KeepAlive</key><true/>``) restart on any exit. This is the drain-aware alternative to ``systemctl restart`` / ``SIGTERM``, which SIGKILL in-flight agents after a short timeout. @@ -3083,10 +3082,7 @@ def generate_launchd_plist() -> str: <true/> <key>KeepAlive</key> - <dict> - <key>SuccessfulExit</key> - <false/> - </dict> + <true/> <key>StandardOutPath</key> <string>{log_dir}/gateway.log</string> @@ -3249,7 +3245,7 @@ def launchd_stop(): pass # bootout unloads the service definition so KeepAlive doesn't respawn # the process. A plain `kill SIGTERM` only signals the process — launchd - # immediately restarts it because KeepAlive.SuccessfulExit = false. + # immediately restarts it because KeepAlive is unconditionally true. # `hermes gateway start` re-bootstraps when it detects the job is unloaded. try: subprocess.run(["launchctl", "bootout", target], check=True, timeout=90) -
scripts/release.py@@ -72,6 +72,7 @@ "[redacted:email]": "kchuang1015", "[redacted:email]": "MaheshtheDev", "[redacted:email]": "kyssta-exe", + "[redacted:email]": "ashishpatel26", "[redacted:email]": "fujinice", "[redacted:email]": "carltonawong", "[redacted:email]": "EvilHumphrey", -
tests/hermes_cli/test_gateway_service.py@@ -2579,3 +2579,24 @@ def test_launchd_workingdirectory_is_hermes_home(self, tmp_path, monkeypatch): assert m, "plist has no WorkingDirectory entry" assert Path(m.group(1)).resolve() == home.resolve() assert "/.worktrees/" not in m.group(1) + + def test_launchd_plist_keepalive_unconditional(self, tmp_path, monkeypatch): + """KeepAlive must be unconditional <true/> so the gateway restarts on clean exits. + + Bug #37388: the old ``KeepAlive.SuccessfulExit = false`` dict form meant + launchd would NOT restart after a zero-exit (e.g. ``gateway run --replace`` + causes the old instance to exit cleanly). Switching to the scalar + ``<key>KeepAlive</key><true/>`` makes launchd restart regardless of exit code. + """ + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home) + plist = gateway_cli.generate_launchd_plist() + + # Scalar <true/> must be present immediately after the KeepAlive key + assert "<key>KeepAlive</key>" in plist + # The unconditional form + assert "<key>KeepAlive</key>\n <true/>" in plist + # The old conditional dict form must NOT appear + assert "SuccessfulExit" not in plist + assert "<key>KeepAlive</key>\n <dict>" not in plist
-
-
#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 @@ "[redacted:email]": "bird", # PR #25219 (gateway docker exit-75 restart) "[redacted:email]": "davidcampbelldc", # PR #26834 (web_server proxy_headers=False) "[redacted:email]": "davidcampbelldc", + "[redacted:email]": "chazmaniandinkle", # PR #43888 (launchd /restart detection) "[redacted:email]": "hehehe0803", # PR #26212 salvage (codex kanban writable root) "[redacted:email]": "hehehe0803", "[redacted:email]": "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)
-
16 PRs closed without merging
From the close comments: done elsewhere 6 · superseded by another PR 3 · duplicate 2 · merged via another change 2 · other 3
-
#5127 fix: use launchctl kickstart for atomic gateway restart on macOS · done elsewhere →
74ff62f -
#9689 fix(gateway): use unconditional KeepAlive in launchd plist · done elsewhere →
c9b6206,#37388 -
#10637 fix(gateway): use unconditional KeepAlive in launchd plist · duplicate →
#9689 -
#14332 fix(gateway): treat recycled PID with unreadable start_time as stale (#14176) · done elsewhere →
ccf5269,d682f32,#14176,#14128 -
#28200 fix(gateway): exit code 75 on service restart so launchd relaunches · merged via another change →
5987b24,#28341
Show 11 more
-
#29181 fix(gateway): detect macOS launchd in service-restart path · done elsewhere →
abc3662,#43475 - #34464 fix(gateway): refresh systemd units before update restart · other
-
#37411 fix(gateway): use unconditional KeepAlive in launchd plist so gateway restarts after clean --replace exit (#37388) · other →
#39000,#38796 -
#37534 fix(launchd): use unconditional KeepAlive so gateway restarts after clean exit #37388 · duplicate →
#39000,#38796 -
#38796 fix(cli): launchd KeepAlive unconditional restart (#37388) · other →
c9b6206 -
#43498 fix(gateway): exit non-zero on /restart so launchd revives the gateway · superseded by another PR →
#51734,#43475 -
#43596 fix(gateway): exit non-zero on macOS launchd restart path (#43475) · superseded by another PR →
#51734,#43475 -
#43888 fix(gateway): detect launchd in the /restart service-manager probe · merged via another change →
abc3662,#43475,#51734 -
#51112 fix(gateway): detect launchd via XPC_SERVICE_NAME for Telegram /restart path · done elsewhere →
abc3662,#43475 -
#53869 fix(cli): verify gateway liveness after macOS launchd restart (#53861) · superseded by another PR →
1bf9366,bee489d,#88848,#74973,#53861,#99685 -
#72752 fix(gateway): verify launchd service survivability after restart · done elsewhere →
1bf9366,ac2e420
Closed issues · 15
- Closed#5126`hermes gateway restart` hangs on macOS due to KeepAlive race condition
- Closed#9659launchd plist KeepAlive should be unconditional, not SuccessfulExit-based
- Closed#11258Gateway self-restart/update can exit cleanly into draining state and stay dead under systemd Restart=on-failure
- Closed#14176[Bug]: Gateway hang on clean exit / restart race with stale PID
- Closed#20694Gateway self-restart from active WhatsApp chat can self-block graceful drain and always hit drain timeout
- Closed#26198hermes gateway restart on macOS (launchd) can leave gateway in stopped state
- Closed#27592Telegram gateway can stay down after self-restart if launchd job is unloaded
- Closed#28135hermes update via gateway leaves launchd service unrestarted on macOS
- Closed#29180[Bug]: /restart does not relaunch the gateway under macOS launchd
- Closed#31048hermes update fails to restart gateway on Linux systemd (exit code 75 handoff broken)
- Closed#31162[BUG] Gateway not auto-restarted after `hermes update`
- Closed#37388[Bug]: gateway launchd KeepAlive (SuccessfulExit:false) doesn't restart after a clean --replace exit on macOS
- Closed#43475/restart bricks a launchd-managed gateway on macOS — exits 0, KeepAlive.SuccessfulExit=false won't revive it
- Closed#48746issue
- Closed#62128[Bug]: macOS launchd: post-update gateway restart left service dead (respawn pended) and desktop app never relaunched itself
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.04: 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 Lab