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: 10 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions tests/e2e/test_platform_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"""

import asyncio
import sys
from unittest.mock import AsyncMock, MagicMock

import pytest
Expand Down Expand Up @@ -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)
Expand Down