From eab39f14dbf5711f913d7bd8e4fb6b3cca29863a Mon Sep 17 00:00:00 2001 From: skspade Date: Wed, 6 May 2026 14:28:03 -0400 Subject: [PATCH 1/2] fix: gateway status card shows 'not running' when no platforms connected Use agent_health.build_agent_health_payload() as the authoritative running signal instead of bool(identity_map). An empty identity_map means zero connected messaging platforms, not that the gateway is down. Falls back to identity_map heuristic when agent_health module is unavailable (e.g. WebUI-only deployments). --- api/routes.py | 25 ++- tests/test_gateway_status_agent_health.py | 223 ++++++++++++++++++++++ 2 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 tests/test_gateway_status_agent_health.py diff --git a/api/routes.py b/api/routes.py index 1477e5b0..ff65e39e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3291,7 +3291,30 @@ def handle_get(handler, parsed) -> bool: import datetime identity_map = _load_gateway_session_identity_map() sessions_path = _gateway_session_metadata_path() - running = bool(identity_map) + + # Detect whether the gateway process is alive, independent of + # connected messaging platforms. An empty identity_map just + # means zero platforms connected, not that the gateway is down. + # + # agent_health.build_agent_health_payload() is the authoritative + # signal: it reads gateway.status runtime metadata and returns a + # tri-state `alive` field (True/False/None). This avoids the + # false-negative where the gateway is running but has zero active + # messaging sessions (empty identity_map). + running = False + try: + health = build_agent_health_payload() + running = health.get("alive") is True + except Exception: + # Fall back to identity_map heuristic when the agent_health + # module is not available (e.g. WebUI-only deployments). + import logging + logging.getLogger("routes").warning( + "agent_health.build_agent_health_payload() raised; " + "falling back to identity_map heuristic for gateway status" + ) + running = bool(identity_map) + platforms_set: set[str] = set() for meta in identity_map.values(): raw = meta.get("raw_source") or meta.get("platform") or "" diff --git a/tests/test_gateway_status_agent_health.py b/tests/test_gateway_status_agent_health.py new file mode 100644 index 00000000..d209ba27 --- /dev/null +++ b/tests/test_gateway_status_agent_health.py @@ -0,0 +1,223 @@ +"""Regression coverage: /api/gateway/status uses agent_health payload as +the authoritative 'running' signal (#d0568682 / parent review t_9098e3db). + +Before the fix, the handler called gateway.status.get_running_pid() directly +and fell back to bool(identity_map) when the module was unavailable. The fix +makes it consult agent_health.build_agent_health_payload() so the tri-state +`alive` field is the single source of truth for gateway process health. + +Tests use handle_get + monkeypatched build_agent_health_payload() and +_load_gateway_session_identity_map() to isolate the gateway status route +from real filesystem state. +""" + +from __future__ import annotations + +import json +from urllib.parse import urlparse + + +# ── FakeHandler (mirrors test_1560_password_env_var_no_op._FakeHandler) ──────── + +class _FakeHandler: + """Minimal BaseHTTPRequestHandler stand-in for routes.handle_get.""" + + def __init__(self): + self.status = None + self.sent_headers: list[tuple[str, str]] = [] + self.body = bytearray() + self.wfile = self + + def send_response(self, code): + self.status = code + + def send_header(self, key, value): + self.sent_headers.append((key, value)) + + def end_headers(self): + pass + + def write(self, data): + """Accumulate bytes written to wfile.""" + self.body.extend(data if isinstance(data, (bytes, bytearray)) else data.encode("utf-8")) + + def get_json(self): + """Parse the accumulated body as JSON.""" + return json.loads(self.body.decode("utf-8")) + + +# ── Helpers ────────────────────────────────────────────────────────────────── + +def _call_gateway_status(monkeypatch, agent_health_alive, identity_map=None): + """Invoke handle_get for /api/gateway/status and return the parsed JSON. + + monkeypatches build_agent_health_payload to return the given `alive` value + and _load_gateway_session_identity_map to return the given identity_map. + """ + from api import routes + + monkeypatch.setattr( + routes, + "build_agent_health_payload", + lambda: { + "alive": agent_health_alive, + "checked_at": "2026-05-06T12:00:00+00:00", + "details": {}, + }, + ) + + if identity_map is not None: + monkeypatch.setattr( + routes, + "_load_gateway_session_identity_map", + lambda: identity_map, + ) + + handler = _FakeHandler() + parsed = urlparse("http://example.com/api/gateway/status") + routes.handle_get(handler, parsed) + return handler.get_json() + + +# ── Acceptance criteria tests ───────────────────────────────────────────────── + +def test_gateway_status_running_true_when_agent_health_alive_and_no_sessions(monkeypatch): + """AC1: alive=true + empty identity_map → running=true, platforms=[]""" + result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map={}) + assert result["running"] is True + assert result["platforms"] == [] + + +def test_gateway_status_running_false_when_agent_health_alive_false_and_no_sessions(monkeypatch): + """AC2: alive=false + empty identity_map → running=false, platforms=[]""" + result = _call_gateway_status(monkeypatch, agent_health_alive=False, identity_map={}) + assert result["running"] is False + assert result["platforms"] == [] + + +def test_gateway_status_running_false_when_agent_health_alive_none_and_no_sessions(monkeypatch): + """AC2 (extended): alive=None + empty identity_map → running=false, platforms=[]""" + result = _call_gateway_status(monkeypatch, agent_health_alive=None, identity_map={}) + assert result["running"] is False + assert result["platforms"] == [] + + +def test_gateway_status_running_true_and_platforms_when_agent_health_alive_and_sessions(monkeypatch): + """AC3: alive=true + sessions with platforms → running=true, platforms populated""" + identity_map = { + "sess_a": {"raw_source": "telegram", "platform": "telegram"}, + "sess_b": {"raw_source": "discord", "platform": "discord"}, + } + result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map=identity_map) + assert result["running"] is True + assert len(result["platforms"]) == 2 + names = {p["name"] for p in result["platforms"]} + assert names == {"telegram", "discord"} + + +# ── Edge case tests ─────────────────────────────────────────────────────────── + +def test_gateway_status_handles_agent_health_unavailable_fallback_to_sessions(monkeypatch): + """Edge: agent_health raises → fall back to session-only detection (current behavior).""" + from api import routes + + monkeypatch.setattr( + routes, + "build_agent_health_payload", + lambda: (_ for _ in ()).throw(RuntimeError("gateway module busted")), + ) + monkeypatch.setattr( + routes, + "_load_gateway_session_identity_map", + lambda: {"sess_c": {"raw_source": "telegram", "platform": "telegram"}}, + ) + + handler = _FakeHandler() + parsed = urlparse("http://example.com/api/gateway/status") + routes.handle_get(handler, parsed) + result = handler.get_json() + # With sessions present, fallback should report running=true + assert result["running"] is True + + +def test_gateway_status_handles_corrupted_sessions_json(monkeypatch): + """Edge: sessions.json is corrupted → identity_map empty, rely on agent_health alone.""" + from api import routes + + monkeypatch.setattr( + routes, + "build_agent_health_payload", + lambda: {"alive": True, "checked_at": "2026-05-06T12:00:00+00:00", "details": {}}, + ) + # _load_gateway_session_identity_map already returns {} on JSON parse failure; + # we monkeypatch it to return {} to simulate corrupted file. + monkeypatch.setattr(routes, "_load_gateway_session_identity_map", lambda: {}) + + handler = _FakeHandler() + parsed = urlparse("http://example.com/api/gateway/status") + routes.handle_get(handler, parsed) + result = handler.get_json() + assert result["running"] is True + assert result["platforms"] == [] + assert result["session_count"] == 0 + + +def test_gateway_status_blank_platform_fields_empty_platforms_running_true(monkeypatch): + """Edge: sessions exist but all have blank/missing platform fields → platforms=[], running=true.""" + from api import routes + + monkeypatch.setattr( + routes, + "build_agent_health_payload", + lambda: {"alive": True, "checked_at": "2026-05-06T12:00:00+00:00", "details": {}}, + ) + monkeypatch.setattr( + routes, + "_load_gateway_session_identity_map", + lambda: { + "sess_d": {"raw_source": "", "platform": ""}, + "sess_e": {}, # no platform field at all + }, + ) + + handler = _FakeHandler() + parsed = urlparse("http://example.com/api/gateway/status") + routes.handle_get(handler, parsed) + result = handler.get_json() + assert result["running"] is True + assert result["platforms"] == [] + + +# ── Existing behavior preservation tests ────────────────────────────────────── + +def test_gateway_status_running_false_when_agent_health_down_even_with_sessions(monkeypatch): + """When agent_health says alive=false, running should be false regardless of sessions.""" + from api import routes + + monkeypatch.setattr( + routes, + "build_agent_health_payload", + lambda: {"alive": False, "checked_at": "2026-05-06T12:00:00+00:00", "details": {}}, + ) + monkeypatch.setattr( + routes, + "_load_gateway_session_identity_map", + lambda: {"sess_f": {"raw_source": "telegram", "platform": "telegram"}}, + ) + + handler = _FakeHandler() + parsed = urlparse("http://example.com/api/gateway/status") + routes.handle_get(handler, parsed) + result = handler.get_json() + # Running should be false even though sessions exist — agent_health is authoritative + assert result["running"] is False + # But platforms should still be extracted from sessions + assert len(result["platforms"]) == 1 + assert result["platforms"][0]["name"] == "telegram" + + +def test_gateway_status_missing_r_field_handled_by_frontend(monkeypatch): + """Edge: response still has running field; frontend handles missing field via catch block. + This test verifies the backend always includes the 'running' field in responses.""" + result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map={}) + assert "running" in result \ No newline at end of file From 7193cee1523e19e09ca8999436b4fb56d6dde8b5 Mon Sep 17 00:00:00 2001 From: skspade Date: Wed, 6 May 2026 15:36:07 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20tri-state=20gateway=20status=20?= =?UTF-8?q?=E2=80=94=20distinguish=20not-configured=20from=20not-running?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: return `configured` field alongside `running`. When alive=None (no gateway metadata), configured=false with fallback to identity_map heuristic. - Frontend: amber "Gateway not configured" when configured=false, red "Gateway not running" only when configured but process is down, green "Running" when both true. - Replace dead try/except fallback with explicit tri-state check on health["alive"]. - Add regression test for last_active guard when alive=true and identity_map is empty. All 87 gateway-related tests pass. --- api/routes.py | 29 ++++++++------ static/panels.js | 4 ++ tests/test_gateway_status_agent_health.py | 46 +++++++++++++++++------ 3 files changed, 56 insertions(+), 23 deletions(-) diff --git a/api/routes.py b/api/routes.py index ff65e39e..9016636f 100644 --- a/api/routes.py +++ b/api/routes.py @@ -3301,19 +3301,23 @@ def handle_get(handler, parsed) -> bool: # tri-state `alive` field (True/False/None). This avoids the # false-negative where the gateway is running but has zero active # messaging sessions (empty identity_map). - running = False - try: - health = build_agent_health_payload() - running = health.get("alive") is True - except Exception: - # Fall back to identity_map heuristic when the agent_health - # module is not available (e.g. WebUI-only deployments). - import logging - logging.getLogger("routes").warning( - "agent_health.build_agent_health_payload() raised; " - "falling back to identity_map heuristic for gateway status" - ) + # + # `alive` tri-state semantics: + # True → gateway process is alive + # False → gateway metadata exists but process is down + # None → no gateway metadata/status available; this WebUI + # setup is probably not configured with a gateway + health = build_agent_health_payload() + alive = health.get("alive") + if alive is True: + running = True + configured = True + elif alive is False: + running = False + configured = True + else: # alive is None → gateway not configured / unavailable running = bool(identity_map) + configured = False platforms_set: set[str] = set() for meta in identity_map.values(): @@ -3341,6 +3345,7 @@ def handle_get(handler, parsed) -> bool: pass return j(handler, { "running": running, + "configured": configured, "platforms": platforms, "last_active": last_active, "session_count": len(identity_map), diff --git a/static/panels.js b/static/panels.js index 12531130..be20e724 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5413,6 +5413,10 @@ function loadGatewayStatus(){ if(!card) return; api('/api/gateway/status').then(r=>{ if(!r) return; + if(!r.configured){ + card.innerHTML=`
Gateway not configured
`; + return; + } if(!r.running){ card.innerHTML=`
Gateway not running
`; return; diff --git a/tests/test_gateway_status_agent_health.py b/tests/test_gateway_status_agent_health.py index d209ba27..843f7cd5 100644 --- a/tests/test_gateway_status_agent_health.py +++ b/tests/test_gateway_status_agent_health.py @@ -82,34 +82,39 @@ def _call_gateway_status(monkeypatch, agent_health_alive, identity_map=None): # ── Acceptance criteria tests ───────────────────────────────────────────────── def test_gateway_status_running_true_when_agent_health_alive_and_no_sessions(monkeypatch): - """AC1: alive=true + empty identity_map → running=true, platforms=[]""" + """AC1: alive=true + empty identity_map → running=true, configured=true, platforms=[]""" result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map={}) assert result["running"] is True + assert result["configured"] is True assert result["platforms"] == [] def test_gateway_status_running_false_when_agent_health_alive_false_and_no_sessions(monkeypatch): - """AC2: alive=false + empty identity_map → running=false, platforms=[]""" + """AC2: alive=false + empty identity_map → running=false, configured=true, platforms=[]""" result = _call_gateway_status(monkeypatch, agent_health_alive=False, identity_map={}) assert result["running"] is False + assert result["configured"] is True assert result["platforms"] == [] def test_gateway_status_running_false_when_agent_health_alive_none_and_no_sessions(monkeypatch): - """AC2 (extended): alive=None + empty identity_map → running=false, platforms=[]""" + """When alive=None (not configured): fall back to identity_map heuristic, + and set configured=false so frontend can show 'not configured' state.""" result = _call_gateway_status(monkeypatch, agent_health_alive=None, identity_map={}) assert result["running"] is False + assert result["configured"] is False assert result["platforms"] == [] def test_gateway_status_running_true_and_platforms_when_agent_health_alive_and_sessions(monkeypatch): - """AC3: alive=true + sessions with platforms → running=true, platforms populated""" + """AC3: alive=true + sessions with platforms → running=true, configured=true, platforms populated""" identity_map = { "sess_a": {"raw_source": "telegram", "platform": "telegram"}, "sess_b": {"raw_source": "discord", "platform": "discord"}, } result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map=identity_map) assert result["running"] is True + assert result["configured"] is True assert len(result["platforms"]) == 2 names = {p["name"] for p in result["platforms"]} assert names == {"telegram", "discord"} @@ -117,14 +122,15 @@ def test_gateway_status_running_true_and_platforms_when_agent_health_alive_and_s # ── Edge case tests ─────────────────────────────────────────────────────────── -def test_gateway_status_handles_agent_health_unavailable_fallback_to_sessions(monkeypatch): - """Edge: agent_health raises → fall back to session-only detection (current behavior).""" +def test_gateway_status_alive_none_falls_back_to_identity_map_heuristic(monkeypatch): + """When alive=None (not configured) but sessions exist, running reflects identity_map. + configured=false tells the frontend to show 'not configured' state.""" from api import routes monkeypatch.setattr( routes, "build_agent_health_payload", - lambda: (_ for _ in ()).throw(RuntimeError("gateway module busted")), + lambda: {"alive": None, "checked_at": "2026-05-06T12:00:00+00:00", "details": {}}, ) monkeypatch.setattr( routes, @@ -136,8 +142,10 @@ def test_gateway_status_handles_agent_health_unavailable_fallback_to_sessions(mo parsed = urlparse("http://example.com/api/gateway/status") routes.handle_get(handler, parsed) result = handler.get_json() - # With sessions present, fallback should report running=true + # Fallback to identity_map: sessions exist → running=true assert result["running"] is True + # But configured=false because alive was None (no gateway metadata) + assert result["configured"] is False def test_gateway_status_handles_corrupted_sessions_json(monkeypatch): @@ -211,13 +219,29 @@ def test_gateway_status_running_false_when_agent_health_down_even_with_sessions( result = handler.get_json() # Running should be false even though sessions exist — agent_health is authoritative assert result["running"] is False + # But configured=true because alive=False means gateway metadata exists + assert result["configured"] is True # But platforms should still be extracted from sessions assert len(result["platforms"]) == 1 assert result["platforms"][0]["name"] == "telegram" def test_gateway_status_missing_r_field_handled_by_frontend(monkeypatch): - """Edge: response still has running field; frontend handles missing field via catch block. - This test verifies the backend always includes the 'running' field in responses.""" + """Edge: response always has 'running' and 'configured' fields. + Frontend handles missing field via catch block. This test verifies the backend + always includes both fields in responses.""" result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map={}) - assert "running" in result \ No newline at end of file + assert "running" in result + assert "configured" in result + + +def test_gateway_status_last_active_empty_when_alive_and_no_sessions_path(monkeypatch): + """Bonus: alive=true + identity_map={} → last_active is empty string. + This guards the 'if running and sessions_path.exists()' guard from being + silently removed in a future refactor that might expose a stale timestamp.""" + result = _call_gateway_status(monkeypatch, agent_health_alive=True, identity_map={}) + assert result["running"] is True + assert result["configured"] is True + # In test context, sessions_path won't exist (no real filesystem), + # so last_active must be empty. + assert result["last_active"] == "" \ No newline at end of file