fix(agent_health): detect profile-scoped gateway.pid for correct status

_gateway_root_pid_path() unconditionally returned <hermes_root>/gateway.pid.
Profile-scoped gateways (started with --profile <name> or via active_profile)
write their runtime files under <hermes_root>/profiles/<name>/ instead of the
root, so the root-level path never existed.

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.
This caused the cron/scheduled-jobs page to display "Gateway not configured" even
when a gateway was actively running.

Fix: after failing to find a root-level gateway.pid, fall back to the active
profile directory via get_active_hermes_home(). Root-level wins when it exists,
so deployments that do write there are unaffected. Errors from profile lookup are
swallowed and the root path is returned, preserving the previous safe default.

Adds five focused unit tests covering the new fallback, the priority rule, and
the error-handling path.
This commit is contained in:
carryzuo00 committed 2026-05-25 10:35:37 +00:00
1 parent 4ea762ae0d
commit 2881fcec89
2 files changed
+123 -1

No files matched your search

+15 -1
View File
@@ -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=<root>/profiles/<name>, but gateway.pid,
gateway.lock, and gateway_state.json still live under <root>.
When the root-level gateway.pid is absent (profile-scoped gateway
deployments write it under <root>/profiles/<name>/), 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
@@ -0,0 +1,108 @@
"""Regression coverage for _gateway_root_pid_path() profile-scoped fallback.
Before the fix, _gateway_root_pid_path() unconditionally returned
<hermes_root>/gateway.pid. Profile-scoped gateways (running via
``gateway run --profile <name>`` or with ``active_profile`` set) write
their PID file under <hermes_root>/profiles/<name>/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