Files
hermes-agent/tests/hermes_cli/test_web_status_watchdog_surfaces.py
teknium1 a3cce7b974 fix(gateway): warn when a live gateway's heartbeat goes stale instead of printing running
The reporter's case in #113372 is "not a crash": the process stays alive,
`gateway_state.json` keeps saying `running`, and housekeeping, cron and the
kanban dispatcher are frozen. Nothing wrote `updated_at` periodically, so the
file was a stored status, not a heartbeat, and both `hermes gateway status`
and `/api/status` rendered a wedged gateway as healthy (the existing stale arm
only fires when the recorded PID is gone).

Make the housekeeping tick re-stamp `gateway_state.json` first thing every
tick (60 s), so `updated_at` is a heartbeat that stops when the thread — or a
chore blocked on the loop — wedges. Readers then warn on `running`/`starting`
+ stale stamp + live PID: `hermes gateway status` prints
`⚠ Gateway heartbeat stale: housekeeping has not refreshed gateway_state.json
for N s (event loop or housekeeping wedged; pid X alive)`, `/api/status`
carries `gateway_heartbeat_stale_s` (null when healthy) and the sidebar strip
shows "Heartbeat stale". Liveness (`gateway_running`, busy/drainable) still
keys off the PID, never the stamp. Draining is excluded: shutdown drains the
housekeeping thread before the process exits, so its stamp legitimately ages.

Invariant tests: the CLI line names the age and the live PID and is silent on
a fresh stamp; /api/status sets/clears `gateway_heartbeat_stale_s` the same way.
2026-09-18 09:25:59 -07:00

67 lines
2.8 KiB
Python

"""``/api/status`` agrees with ``hermes gateway status`` on the two #113372 shapes:
* PID alive but the heartbeat stamp is past the freshness TTL (loop/housekeeping wedged while
``gateway_state.json`` still says ``running``) -> ``gateway_heartbeat_stale_s`` is set.
* A watchdog hard-exited the process (``degraded`` + watchdog ``exit_reason``, PID gone) -> the
retained verdict stays ``degraded`` with its ``gateway_exit_reason`` instead of a bare ``stopped``.
"""
from datetime import datetime, timedelta, timezone
import pytest
import gateway.status as _gw_status
def _iso_age(seconds_ago: float) -> str:
return (datetime.now(timezone.utc) - timedelta(seconds=seconds_ago)).isoformat()
@pytest.fixture
def client(monkeypatch):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(_gw_status, "_pid_exists", lambda pid: False)
monkeypatch.setattr(_gw_status, "_get_process_start_time", lambda pid: None)
c = TestClient(app)
c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
return c
def test_status_reports_stale_heartbeat_when_pid_alive(client, monkeypatch):
record = {"gateway_state": "running", "pid": 1234, "start_time": 111.0,
"updated_at": _iso_age(900), "platforms": {}, "active_agents": 0}
monkeypatch.setattr(_gw_status, "get_running_pid_cached", lambda: 1234)
monkeypatch.setattr(_gw_status, "read_runtime_status", lambda: record)
data = client.get("/api/status").json()
assert data["gateway_running"] is True
assert data["gateway_state"] == "running"
assert 900 <= data["gateway_heartbeat_stale_s"] <= 930
record["updated_at"] = _iso_age(5)
assert client.get("/api/status").json()["gateway_heartbeat_stale_s"] is None
def test_status_keeps_watchdog_degraded_verdict_and_reason_for_dead_pid(client, monkeypatch):
record = {"gateway_state": "degraded", "exit_reason": "loop_liveness_watchdog",
"pid": 999_999_999, "start_time": 1.0, "updated_at": _iso_age(30), "platforms": {}}
monkeypatch.setattr(_gw_status, "get_running_pid_cached", lambda: None)
monkeypatch.setattr(_gw_status, "read_runtime_status", lambda: record)
data = client.get("/api/status").json()
assert data["gateway_running"] is False
assert data["gateway_state"] == "degraded"
assert data["gateway_exit_reason"] == "loop_liveness_watchdog"
assert data["gateway_heartbeat_stale_s"] is None
# ``hermes gateway stop`` afterwards records the operator's intent: no longer a current failure.
record["desired_state"] = "stopped"
data = client.get("/api/status").json()
assert data["gateway_state"] == "stopped"
assert data["gateway_exit_reason"] is None