Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,7 @@ 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\n (``KeepAlive``) will relaunch the gateway after any exit, including\n clean zero-code exits from a planned restart.

This is the drain-aware alternative to ``systemctl restart`` / ``SIGTERM``,
which SIGKILL in-flight agents after a short timeout.
Expand Down Expand Up @@ -3083,10 +3081,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>
Expand Down Expand Up @@ -3249,7 +3244,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 unconditional.
# `hermes gateway start` re-bootstraps when it detects the job is unloaded.
try:
subprocess.run(["launchctl", "bootout", target], check=True, timeout=90)
Expand Down
14 changes: 14 additions & 0 deletions tests/hermes_cli/test_gateway_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1629,6 +1629,20 @@ def test_launchd_plist_includes_profile(self, tmp_path, monkeypatch):
assert "<string>--profile</string>" in plist
assert "<string>mybot</string>" in plist

def test_launchd_keepalive_is_unconditional(self, tmp_path, monkeypatch):
"""generate_launchd_plist should use unconditional KeepAlive so launchd
restarts the gateway after any exit, including clean zero-code exits
from --replace handoffs (#37388)."""
import re

home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(gateway_cli, "get_hermes_home", lambda: home)
plist = gateway_cli.generate_launchd_plist()
# Must have unconditional KeepAlive, not SuccessfulExit dict
assert "<key>KeepAlive</key>\n <true/>" in plist
assert "SuccessfulExit" not in plist

def test_launchd_plist_path_uses_real_user_home_not_profile_home(self, tmp_path, monkeypatch):
profile_dir = tmp_path / ".hermes" / "profiles" / "orcha"
profile_dir.mkdir(parents=True)
Expand Down
Loading