From 4366daba244cf5661c47f6aad4d8948e56c94f92 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Fri, 8 May 2026 08:26:56 -0700 Subject: [PATCH] fix: use root home for gateway health status --- api/agent_health.py | 77 ++++++++++++++++++++++++-- tests/test_issue716_agent_heartbeat.py | 58 ++++++++++++++++++- 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/api/agent_health.py b/api/agent_health.py index 5660d28f..c68375b8 100644 --- a/api/agent_health.py +++ b/api/agent_health.py @@ -21,9 +21,14 @@ gateway already writes that file on every tick. from __future__ import annotations import importlib +import json from datetime import datetime, timezone +from pathlib import Path from typing import Any +_GATEWAY_PID_FILE = "gateway.pid" +_GATEWAY_RUNTIME_STATUS_FILE = "gateway_state.json" + # Two cron ticks (~60s each). Chosen to avoid false negatives during brief # gateway restarts while still surfacing a true outage within a couple of @@ -91,6 +96,69 @@ def _gateway_status_module(): return importlib.import_module("gateway.status") +def _gateway_root_pid_path() -> Path | None: + """Return the root Hermes gateway PID path. + + 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 . + """ + try: + from hermes_constants import get_default_hermes_root + return get_default_hermes_root() / _GATEWAY_PID_FILE + except Exception: + return None + + +def _read_runtime_status_path(path: Path) -> dict[str, Any] | None: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return None + if isinstance(payload, dict): + return payload + return None + + +def _read_gateway_runtime_status(gateway_status: Any, pid_path: Path | None) -> dict[str, Any] | None: + read_runtime_status = gateway_status.read_runtime_status + if pid_path is not None: + try: + return read_runtime_status(pid_path=pid_path) + except TypeError: + try: + return read_runtime_status(pid_path) + except TypeError: + if getattr(gateway_status, "__name__", "") == "gateway.status" or hasattr( + gateway_status, + "_read_json_file", + ): + runtime_status_file = str( + getattr(gateway_status, "_RUNTIME_STATUS_FILE", _GATEWAY_RUNTIME_STATUS_FILE) + ) + runtime_status = _read_runtime_status_path(pid_path.with_name(runtime_status_file)) + if runtime_status is not None: + return runtime_status + return read_runtime_status() + + +def _gateway_running_pid(gateway_status: Any, pid_path: Path | None) -> int | None: + get_running_pid = gateway_status.get_running_pid + if pid_path is not None: + try: + return get_running_pid(pid_path=pid_path, cleanup_stale=False) + except TypeError: + try: + return get_running_pid(pid_path, cleanup_stale=False) + except TypeError: + pass + try: + return get_running_pid(cleanup_stale=False) + except TypeError: + # Older agent versions may not expose cleanup_stale. Keep compatibility. + return get_running_pid() + + def _runtime_detail_subset(runtime_status: dict[str, Any] | None) -> dict[str, Any]: """Return only non-sensitive runtime fields for the browser. @@ -154,17 +222,16 @@ def build_agent_health_payload() -> dict[str, Any]: }, } + gateway_pid_path = _gateway_root_pid_path() + runtime_status = None try: - runtime_status = gateway_status.read_runtime_status() + runtime_status = _read_gateway_runtime_status(gateway_status, gateway_pid_path) except Exception: runtime_status = None try: - running_pid = gateway_status.get_running_pid(cleanup_stale=False) - except TypeError: - # Older agent versions may not expose cleanup_stale. Keep compatibility. - running_pid = gateway_status.get_running_pid() + running_pid = _gateway_running_pid(gateway_status, gateway_pid_path) except Exception: running_pid = None diff --git a/tests/test_issue716_agent_heartbeat.py b/tests/test_issue716_agent_heartbeat.py index 2a2033cb..b0cdb0a7 100644 --- a/tests/test_issue716_agent_heartbeat.py +++ b/tests/test_issue716_agent_heartbeat.py @@ -2,10 +2,13 @@ from __future__ import annotations +import json import pathlib - +import sys +import types REPO_ROOT = pathlib.Path(__file__).parent.parent + UI_JS = (REPO_ROOT / "static" / "ui.js").read_text(encoding="utf-8") INDEX_HTML = (REPO_ROOT / "static" / "index.html").read_text(encoding="utf-8") STYLE_CSS = (REPO_ROOT / "static" / "style.css").read_text(encoding="utf-8") @@ -25,6 +28,33 @@ class _FakeGatewayStatus: return self._running_pid +class _PathSensitiveGatewayStatus: + _RUNTIME_STATUS_FILE = "gateway_state.json" + + def __init__(self, root_home: pathlib.Path): + self.root_home = root_home + self.runtime_pid_path = None + self.running_pid_path = None + + def read_runtime_status(self, pid_path=None): + self.runtime_pid_path = pathlib.Path(pid_path) if pid_path is not None else None + if self.runtime_pid_path: + base = self.runtime_pid_path.parent + else: + base = self.root_home / "profiles" / "troubleshooting" + path = base / self._RUNTIME_STATUS_FILE + if not path.exists(): + return None + return json.loads(path.read_text(encoding="utf-8")) + + def get_running_pid(self, pid_path=None, cleanup_stale=False): + assert cleanup_stale is False + self.running_pid_path = pathlib.Path(pid_path) if pid_path is not None else None + if self.running_pid_path == self.root_home / "gateway.pid": + return 98765 + return None + + def _runtime_status(**overrides): payload = { "gateway_state": "running", @@ -45,6 +75,32 @@ def _runtime_status(**overrides): return payload +def test_agent_health_uses_root_gateway_state_when_hermes_home_is_profile(monkeypatch, tmp_path): + from api import agent_health + + root_home = tmp_path / "root-home" + profile_home = root_home / "profiles" / "troubleshooting" + profile_home.mkdir(parents=True) + (root_home / "gateway.pid").write_text(json.dumps({"pid": 98765}), encoding="utf-8") + (root_home / "gateway_state.json").write_text(json.dumps(_runtime_status()), encoding="utf-8") + fake_gateway_status = _PathSensitiveGatewayStatus(root_home) + + monkeypatch.setenv("HERMES_HOME", str(profile_home)) + monkeypatch.setitem( + sys.modules, + "hermes_constants", + types.SimpleNamespace(get_default_hermes_root=lambda: root_home), + ) + monkeypatch.setattr(agent_health, "_gateway_status_module", lambda: fake_gateway_status) + + payload = agent_health.build_agent_health_payload() + + assert payload["alive"] is True + assert payload["details"]["state"] == "alive" + assert fake_gateway_status.runtime_pid_path == root_home / "gateway.pid" + assert fake_gateway_status.running_pid_path == root_home / "gateway.pid" + + def test_agent_health_payload_alive_uses_safe_runtime_details(monkeypatch): from api import agent_health