diff --git a/gateway/run.py b/gateway/run.py index 64eb8eb560e1..3527c2b09a5e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10623,8 +10623,17 @@ async def _handle_restart_command(self, event: MessageEvent) -> Union[str, Ephem # 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 + # launchd doesn't set INVOCATION_ID; it sets XPC_SERVICE_NAME to the job + # label (e.g. "ai.hermes.gateway-alex") for managed jobs, and "0" for + # shell-launched processes. Without this, macOS LaunchAgents take the + # detached self-restart path, exit 0, and KeepAlive{SuccessfulExit=false} + # never relaunches them — the gateway stays dead until manual recovery. + # (NousResearch/hermes-agent #29180 / #9659 / #35043) + _under_launchd = sys.platform == "darwin" and 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: + if _under_service or _under_launchd or _in_container: self.request_restart(detached=False, via_service=True) else: self.request_restart(detached=True, via_service=False) diff --git a/tests/e2e/test_platform_commands.py b/tests/e2e/test_platform_commands.py index 4924eed6a9e2..74fc16f1164e 100644 --- a/tests/e2e/test_platform_commands.py +++ b/tests/e2e/test_platform_commands.py @@ -11,6 +11,7 @@ """ import asyncio +import sys from unittest.mock import AsyncMock, MagicMock import pytest @@ -114,6 +115,43 @@ async def test_plaintext_restart_gateway_in_group_stays_plain_text(self, adapter assert response_text == "agent-handled" runner.request_restart.assert_not_called() + @pytest.mark.asyncio + async def test_plaintext_restart_gateway_uses_service_path_under_launchd(self, adapter, runner, platform, monkeypatch): + if platform != Platform.TELEGRAM: + pytest.skip("Plaintext restart shortcut is intentionally DM/Telegram-focused") + + # macOS launchd sets XPC_SERVICE_NAME (not INVOCATION_ID). The gateway + # must take the service-restart path (exit 75) so KeepAlive relaunches + # it; the detached path would exit 0 and the job would stay dead. + # (NousResearch/hermes-agent #29180 / #9659 / #35043) + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.delenv("INVOCATION_ID", raising=False) + monkeypatch.setenv("XPC_SERVICE_NAME", "ai.hermes.gateway-test") + runner.request_restart = MagicMock(return_value=True) + + send = await send_and_capture(adapter, "restart gateway", platform) + + send.assert_called_once() + runner.request_restart.assert_called_once_with(detached=False, via_service=True) + + @pytest.mark.asyncio + async def test_plaintext_restart_gateway_bare_process_uses_detached_path(self, adapter, runner, platform, monkeypatch): + if platform != Platform.TELEGRAM: + pytest.skip("Plaintext restart shortcut is intentionally DM/Telegram-focused") + + # No service manager: a shell-launched macOS process reports + # XPC_SERVICE_NAME="0" and no INVOCATION_ID, so the detached + # self-restart path must be kept (nothing else would relaunch it). + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.delenv("INVOCATION_ID", raising=False) + monkeypatch.setenv("XPC_SERVICE_NAME", "0") + runner.request_restart = MagicMock(return_value=True) + + send = await send_and_capture(adapter, "restart gateway", platform) + + send.assert_called_once() + runner.request_restart.assert_called_once_with(detached=True, via_service=False) + @pytest.mark.asyncio async def test_personality_lists_options(self, adapter, platform): send = await send_and_capture(adapter, "/personality", platform)