From 0e4552199a8ece86098cef00a5290a4c59afe3cd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:40:06 -0700 Subject: [PATCH] fix(status): keep fatal platform entries visible when gateway startup failed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ':' 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. --- hermes_cli/web_server.py | 18 ++++- .../test_web_server_profile_unification.py | 74 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 91394d6416..f3cd4e8c0b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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 ``:``) + # 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 diff --git a/tests/hermes_cli/test_web_server_profile_unification.py b/tests/hermes_cli/test_web_server_profile_unification.py index 2182a730cb..d525f497e8 100644 --- a/tests/hermes_cli/test_web_server_profile_unification.py +++ b/tests/hermes_cli/test_web_server_profile_unification.py @@ -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(