fix: use root home for gateway health status

This commit is contained in:
Michael Lam 2026-05-08 08:26:56 -07:00 committed by nesquena-hermes
parent c7272dbfc9
commit 4366daba24
2 changed files with 129 additions and 6 deletions

View file

@ -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=<root>/profiles/<name>, but gateway.pid,
gateway.lock, and gateway_state.json still live under <root>.
"""
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

View file

@ -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