diff --git a/api/agent_health.py b/api/agent_health.py index 2ee564e5..897827ab 100644 --- a/api/agent_health.py +++ b/api/agent_health.py @@ -174,10 +174,24 @@ def _gateway_root_pid_path() -> Path | None: Gateway runtime files are root-level singletons. A profile-scoped WebUI process may have HERMES_HOME=/profiles/, but gateway.pid, gateway.lock, and gateway_state.json still live under . + + When the root-level gateway.pid is absent (profile-scoped gateway + deployments write it under /profiles//), fall back to the + active profile's directory so the gateway is detected correctly. """ try: from hermes_constants import get_default_hermes_root - return get_default_hermes_root() / _GATEWAY_PID_FILE + root_pid = get_default_hermes_root() / _GATEWAY_PID_FILE + if root_pid.exists(): + return root_pid + try: + from api.profiles import get_active_hermes_home + profile_pid = Path(get_active_hermes_home()) / _GATEWAY_PID_FILE + if profile_pid.exists(): + return profile_pid + except Exception: + pass + return root_pid except Exception: return None diff --git a/tests/test_agent_health_pid_path_fallback.py b/tests/test_agent_health_pid_path_fallback.py new file mode 100644 index 00000000..e0a5b37d --- /dev/null +++ b/tests/test_agent_health_pid_path_fallback.py @@ -0,0 +1,108 @@ +"""Regression coverage for _gateway_root_pid_path() profile-scoped fallback. + +Before the fix, _gateway_root_pid_path() unconditionally returned +/gateway.pid. Profile-scoped gateways (running via +``gateway run --profile `` or with ``active_profile`` set) write +their PID file under /profiles//gateway.pid instead of +the root, so the root-level file never existed. The WebUI's +build_agent_health_payload() therefore always received a non-existent +pid_path, fell through to the stale root-level gateway_state.json, and +returned alive=None — causing the cron page to display "Gateway not +configured" even though the gateway was running. + +Fix: when the root-level gateway.pid is absent, _gateway_root_pid_path() +now falls back to the active profile's directory. +""" + +from __future__ import annotations + +from pathlib import Path + + +def _call(monkeypatch, root: Path, profile_dir: Path | None = None) -> Path | None: + """Call _gateway_root_pid_path() with mocked filesystem roots.""" + import hermes_constants + import api.profiles as profiles + + monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: root) + if profile_dir is not None: + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: str(profile_dir)) + + from api.agent_health import _gateway_root_pid_path + return _gateway_root_pid_path() + + +# ── core behaviour ──────────────────────────────────────────────────────────── + +def test_returns_root_pid_when_root_level_file_exists(tmp_path, monkeypatch): + """Root-level gateway.pid present → return it (original behaviour unchanged).""" + root = tmp_path / "hermes" + root.mkdir() + root_pid = root / "gateway.pid" + root_pid.write_text("1234") + profile_dir = root / "profiles" / "other" + profile_dir.mkdir(parents=True) + (profile_dir / "gateway.pid").write_text("9999") + + result = _call(monkeypatch, root=root, profile_dir=profile_dir) + assert result == root_pid + + +def test_falls_back_to_profile_pid_when_root_absent(tmp_path, monkeypatch): + """Root gateway.pid absent + profile-level exists → return profile path.""" + root = tmp_path / "hermes" + root.mkdir() + # root-level gateway.pid intentionally not created + profile_dir = root / "profiles" / "safeline" + profile_dir.mkdir(parents=True) + profile_pid = profile_dir / "gateway.pid" + profile_pid.write_text("5678") + + result = _call(monkeypatch, root=root, profile_dir=profile_dir) + assert result == profile_pid + + +def test_returns_root_path_when_neither_pid_exists(tmp_path, monkeypatch): + """Neither root nor profile gateway.pid exists → return root path (graceful).""" + root = tmp_path / "hermes" + root.mkdir() + profile_dir = root / "profiles" / "empty" + profile_dir.mkdir(parents=True) + # no gateway.pid created anywhere + + result = _call(monkeypatch, root=root, profile_dir=profile_dir) + assert result == root / "gateway.pid" + + +def test_returns_root_path_when_profile_lookup_raises(tmp_path, monkeypatch): + """get_active_hermes_home() raising must be caught; root path returned.""" + root = tmp_path / "hermes" + root.mkdir() + + import hermes_constants + import api.profiles as profiles + + monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: root) + + def _raise(): + raise RuntimeError("profile resolution failed") + + monkeypatch.setattr(profiles, "get_active_hermes_home", _raise) + + from api.agent_health import _gateway_root_pid_path + result = _gateway_root_pid_path() + assert result == root / "gateway.pid" + + +def test_root_takes_priority_over_profile_when_both_exist(tmp_path, monkeypatch): + """Root gateway.pid present even when profile pid also exists → root wins.""" + root = tmp_path / "hermes" + root.mkdir() + root_pid = root / "gateway.pid" + root_pid.write_text("1111") + profile_dir = root / "profiles" / "safeline" + profile_dir.mkdir(parents=True) + (profile_dir / "gateway.pid").write_text("2222") + + result = _call(monkeypatch, root=root, profile_dir=profile_dir) + assert result == root_pid