fix(agent-health): treat stale running gateway as unknown

(cherry picked from commit 4be346fece529118b652485d9045080f03e326cf)
This commit is contained in:
Lumen Yang
2026-05-11 14:00:11 +02:00
committed by nesquena-hermes
parent 0ee2a19cd8
commit e37c69cf57
2 changed files with 57 additions and 8 deletions

View File

@@ -14,8 +14,10 @@ volume is shared, those checks always return ``None`` and the dashboard
incorrectly shows "Gateway not running". To stay accurate without forcing a
``pid: "service:hermes-agent"`` compose workaround, we accept a recent
``updated_at`` timestamp on ``gateway_state.json`` (combined with
``gateway_state == "running"``) as an equivalent live-process signal — the
gateway already writes that file on every tick.
``gateway_state == "running"``) as an equivalent live-process signal. Older
gateway builds do not refresh that file periodically, so a stale
``gateway_state == "running"`` record is treated as inconclusive rather than a
confirmed outage.
"""
from __future__ import annotations
@@ -126,6 +128,41 @@ def _runtime_status_is_stale_stopped(
return age_s > threshold_s
def _runtime_status_is_stale_running(
runtime_status: dict[str, Any] | None,
*,
now: datetime | None = None,
threshold_s: float = GATEWAY_FRESHNESS_THRESHOLD_S,
) -> bool:
"""Return ``True`` when the gateway last self-reported running, but stale.
WebUI often runs in a separate container from the gateway. In that shape PID
checks can be impossible, and older gateway versions only update
``gateway_state.json`` on lifecycle/platform changes. A stale ``running``
file therefore means "not enough information from WebUI" rather than
"gateway is down".
"""
if not isinstance(runtime_status, dict):
return False
if runtime_status.get("gateway_state") != "running":
return False
raw_updated_at = runtime_status.get("updated_at")
if not isinstance(raw_updated_at, str) or not raw_updated_at:
return False
try:
updated_at = datetime.fromisoformat(raw_updated_at)
except (TypeError, ValueError):
return False
if updated_at.tzinfo is None:
return False
reference = now if now is not None else datetime.now(timezone.utc)
age_s = (reference - updated_at).total_seconds()
return age_s > threshold_s
def _gateway_status_module():
"""Load gateway.status lazily so tests and WebUI-only installs stay isolated."""
return importlib.import_module("gateway.status")
@@ -309,6 +346,17 @@ def build_agent_health_payload() -> dict[str, Any]:
},
}
if _runtime_status_is_stale_running(runtime_status):
return {
"alive": None,
"checked_at": checked_at,
"details": {
"state": "unknown",
"reason": "gateway_stale_running_state",
**safe_details,
},
}
if isinstance(runtime_status, dict):
return {
"alive": False,

View File

@@ -15,7 +15,7 @@ cross-container liveness signal.
These tests pin every behavior the fix promises:
* fresh + running gateway_state, no PID → alive (cross-container path)
* stale updated_at + running → down (no false positives)
* stale updated_at + running → unknown (old gateways may not tick)
* fresh updated_at + non-running state → down (crash-without-cleanup case)
* stale updated_at + stopped state → unknown (old root gateway was
intentionally stopped; do not nag profile-gateway users)
@@ -116,8 +116,8 @@ def test_cross_container_alive_path_does_not_leak_raw_process_fields(monkeypatch
# -- Stale / missing / malformed timestamps -----------------------------------
def test_stale_updated_at_reports_down_even_when_gateway_state_running(monkeypatch):
"""A long-dead gateway with a fossilised state file must surface as down."""
def test_stale_updated_at_with_running_state_reports_unknown(monkeypatch):
"""Older gateways may not refresh the file while still processing messages."""
from api import agent_health
stale_ts = _iso(datetime.now(timezone.utc) - timedelta(seconds=300))
@@ -130,9 +130,10 @@ def test_stale_updated_at_reports_down_even_when_gateway_state_running(monkeypat
payload = agent_health.build_agent_health_payload()
assert payload["alive"] is False
assert payload["details"]["state"] == "down"
assert payload["details"]["reason"] == "gateway_not_running"
assert payload["alive"] is None
assert payload["details"]["state"] == "unknown"
assert payload["details"]["reason"] == "gateway_stale_running_state"
assert payload["details"]["gateway_state"] == "running"
def test_fresh_updated_at_with_non_running_state_reports_down(monkeypatch):