fix(status): keep fatal platform entries visible when gateway startup failed

Follow-up to #80451. /api/status cleared gateway_platforms whenever the
gateway process was down — correct for a clean stop (stale 'connected'
states are noise) but wrong for startup_failed, where the fatal entries
ARE the diagnosis: per-profile credential collisions and auth failures
(multiplex '<profile>:<platform>' keys) that the single exit_reason
string cannot express. #80451's writer-identity and freshness filters
already drop entries from other/older processes, so preserving
fatal-state entries here cannot leak another gateway's live state.

Live-validated shape: a real multiplex gateway (2 secondary profiles,
rejected tokens) persists telegram / alpha:telegram / beta:telegram
fatals in gateway_state.json; /api/status previously reported {} for
platforms while state was startup_failed.
This commit is contained in:
Teknium
2026-08-16 20:40:06 -07:00
parent 3b9a963b8e
commit 0e4552199a
2 changed files with 91 additions and 1 deletions
+17 -1
View File
@@ -3586,7 +3586,23 @@ async def get_status(profile: Optional[str] = None):
gateway_updated_at = normalize_updated_at(runtime.get("updated_at"))
if not gateway_running:
gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped"
gateway_platforms = {}
# A cleanly stopped gateway's platform states are stale noise —
# clear them so a dead process can't report "connected". But a
# startup_failed gateway's FATAL entries are the diagnosis:
# they carry per-profile credential collisions and auth
# failures (multiplex entries under ``<profile>:<platform>``)
# that the single exit_reason string can't express. Writer
# -identity and freshness filtering upstream already dropped
# entries from other/older processes, so keeping fatals here
# cannot leak another gateway's live state (#80451 follow-up).
if gateway_state == "startup_failed":
gateway_platforms = {
key: value
for key, value in gateway_platforms.items()
if isinstance(value, dict) and value.get("state") == "fatal"
}
else:
gateway_platforms = {}
elif gateway_running and remote_health_body is not None:
# The health probe confirmed the gateway is alive, but the local
# runtime status file may be stale (cross-container). Override
@@ -502,6 +502,80 @@ class TestProfileScopedGateway:
assert data["gateway_state"] == "running"
assert data["gateway_platforms"] == {"telegram": {"state": "connected"}}
def test_status_keeps_fatal_platforms_on_startup_failed(
self, client, isolated_profiles, monkeypatch
):
"""startup_failed keeps FATAL per-profile entries — they're the diagnosis.
A multiplex gateway that dies at startup persists per-profile fatal
entries (``alpha:telegram`` etc.). The dead-gateway platform clear must
not erase them: exit_reason alone can't say which profile failed how.
Non-fatal leftovers (e.g. a platform that connected before the crash)
are still dropped — only fatals survive.
"""
import hermes_cli.web_server as web_server
runtime = {
"pid": 4242,
"gateway_state": "startup_failed",
"platforms": {
"telegram": {"state": "fatal", "error_code": "telegram_auth_error"},
"alpha:telegram": {"state": "fatal", "error_code": "credential_collision"},
"beta:discord": {"state": "connected"},
},
"exit_reason": "telegram: token rejected",
"updated_at": "2026-06-17T00:00:00+00:00",
}
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(
web_server, "get_running_pid_cached", lambda *a, **k: None
)
monkeypatch.setattr(web_server, "read_runtime_status", lambda *a, **k: runtime)
# Bare platform keys are checked against the configured set (fail
# closed) — mirror a host that actually has telegram configured.
monkeypatch.setattr(
web_server, "_load_configured_gateway_platforms", lambda: {"telegram"}
)
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
resp = client.get("/api/status", params={"profile": "worker_beta"})
assert resp.status_code == 200
data = resp.json()
assert data["gateway_running"] is False
assert data["gateway_state"] == "startup_failed"
assert data["gateway_exit_reason"] == "telegram: token rejected"
# Fatal entries (root and namespaced) survive; the stale non-fatal is dropped.
assert set(data["gateway_platforms"]) == {"telegram", "alpha:telegram"}
assert data["gateway_platforms"]["alpha:telegram"]["error_code"] == "credential_collision"
def test_status_clears_platforms_on_clean_stop(
self, client, isolated_profiles, monkeypatch
):
"""A cleanly stopped gateway still reports no platforms (stale-noise rule)."""
import hermes_cli.web_server as web_server
runtime = {
"pid": 4242,
"gateway_state": "stopped",
"platforms": {"telegram": {"state": "connected"}},
"exit_reason": None,
"updated_at": "2026-06-17T00:00:00+00:00",
}
monkeypatch.setattr(web_server, "check_config_version", lambda: (1, 1))
monkeypatch.setattr(
web_server, "get_running_pid_cached", lambda *a, **k: None
)
monkeypatch.setattr(web_server, "read_runtime_status", lambda *a, **k: runtime)
monkeypatch.setattr(web_server, "_GATEWAY_HEALTH_URL", None)
resp = client.get("/api/status", params={"profile": "worker_beta"})
assert resp.status_code == 200
data = resp.json()
assert data["gateway_state"] == "stopped"
assert data["gateway_platforms"] == {}
class TestProfileScopedTelegramOnboarding:
def test_apply_writes_target_profile_and_restarts_target(