diff --git a/gateway/run.py b/gateway/run.py index ef49bf67c47f..1738def1e59a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -7035,7 +7035,25 @@ async def start(self) -> bool: enabled_platform_count = 0 startup_nonretryable_errors: list[str] = [] startup_retryable_errors: list[str] = [] - + + # Reconcile explicitly-disabled platforms up front: mark each as + # "disabled" and clear any stale connected/paused/error metadata a + # previous run left in gateway_state.json. The connect loop below skips + # disabled platforms, so without this their old status would persist and + # mislead `hermes status` / readiness probes into reporting a turned-off + # platform as live. See gateway.status.write_runtime_status. + try: + disabled_platform_values = [ + p.value + for p, pc in self.config.platforms.items() + if not getattr(pc, "enabled", False) + ] + if disabled_platform_values: + from gateway.status import write_runtime_status + write_runtime_status(disabled_platforms=disabled_platform_values) + except Exception: + logger.debug("disabled-platform status reconcile failed", exc_info=True) + # Initialize and connect each configured platform for platform, platform_config in self.config.platforms.items(): if await self._abort_startup_if_shutdown_requested(): diff --git a/gateway/status.py b/gateway/status.py index 7b8e9ff57583..a4def15747e0 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -805,8 +805,19 @@ def write_runtime_status( error_code: Any = _UNSET, error_message: Any = _UNSET, served_profiles: Any = _UNSET, + disabled_platforms: Any = _UNSET, ) -> None: - """Persist gateway runtime health information for diagnostics/status.""" + """Persist gateway runtime health information for diagnostics/status. + + ``disabled_platforms`` reconciles the persisted ``platforms`` map against + the platforms explicitly disabled in the *current* config: each named + platform is written as ``state="disabled"`` with its connection/error + metadata cleared. Without this, a ``gateway_state.json`` left over from a + previous run keeps stale ``connected``/``paused``/``fatal`` entries for a + platform the operator has since turned off — the startup connect loop skips + disabled platforms, so nothing else ever overwrites those entries and + ``hermes status`` / readiness probes report a dead platform as live. + """ path = _get_runtime_status_path() payload = _read_json_file(path) or _build_runtime_status_record() current_record = _build_pid_record() @@ -842,6 +853,23 @@ def write_runtime_status( platform_payload["updated_at"] = _utc_now_iso() payload["platforms"][platform] = platform_payload + if disabled_platforms is not _UNSET: + # Reconcile every explicitly-disabled platform to a clean "disabled" + # record for this run, clearing stale connection/error metadata a prior + # run may have left behind. Never touch a platform that was updated in + # THIS call (e.g. an enabled platform mid-connect) — the explicit + # `platform=` argument wins. + now = _utc_now_iso() + for name in disabled_platforms or []: + if name == platform: + continue + payload["platforms"][name] = { + "state": "disabled", + "error_code": None, + "error_message": None, + "updated_at": now, + } + _write_json_file(path, payload) diff --git a/tests/gateway/test_status.py b/tests/gateway/test_status.py index 16ce08207731..592ed3db9bf3 100644 --- a/tests/gateway/test_status.py +++ b/tests/gateway/test_status.py @@ -669,6 +669,141 @@ def test_write_runtime_status_explicit_none_clears_stale_fields(self, tmp_path, assert payload["platforms"]["discord"]["error_code"] is None assert payload["platforms"]["discord"]["error_message"] is None + def test_write_runtime_status_marks_disabled_platform_and_clears_stale_metadata( + self, tmp_path, monkeypatch + ): + """Regression: a platform disabled in the current config must not keep + stale connected/error metadata from a previous run (misleads status).""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + # Previous run: telegram was connected, discord failed fatally. + state_path = tmp_path / "gateway_state.json" + state_path.write_text(json.dumps({ + "pid": 99999, + "start_time": 1000.0, + "kind": "hermes-gateway", + "gateway_state": "running", + "platforms": { + "telegram": { + "state": "connected", + "error_code": None, + "error_message": None, + "updated_at": "2025-01-01T00:00:00Z", + }, + "discord": { + "state": "fatal", + "error_code": "discord_timeout", + "error_message": "boom", + "updated_at": "2025-01-01T00:00:00Z", + }, + }, + "updated_at": "2025-01-01T00:00:00Z", + })) + + # New run: both telegram and discord are explicitly disabled in config. + status.write_runtime_status( + gateway_state="starting", + disabled_platforms=["telegram", "discord"], + ) + + payload = status.read_runtime_status() + for name in ("telegram", "discord"): + assert payload["platforms"][name]["state"] == "disabled" + assert payload["platforms"][name]["error_code"] is None + assert payload["platforms"][name]["error_message"] is None + # PID/start_time are refreshed for the current run too. + assert payload["pid"] == os.getpid() + + def test_write_runtime_status_disabled_preserves_enabled_platforms( + self, tmp_path, monkeypatch + ): + """Reconciling disabled platforms must not touch enabled ones.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + state_path = tmp_path / "gateway_state.json" + state_path.write_text(json.dumps({ + "pid": 99999, + "kind": "hermes-gateway", + "gateway_state": "running", + "platforms": { + "telegram": { + "state": "connected", + "error_code": None, + "error_message": None, + "updated_at": "2025-01-01T00:00:00Z", + }, + "discord": { + "state": "paused", + "error_code": "x", + "error_message": "y", + "updated_at": "2025-01-01T00:00:00Z", + }, + }, + "updated_at": "2025-01-01T00:00:00Z", + })) + + # Only discord is disabled now; telegram stays enabled. + status.write_runtime_status( + gateway_state="starting", + disabled_platforms=["discord"], + ) + + payload = status.read_runtime_status() + # Enabled platform is untouched. + assert payload["platforms"]["telegram"]["state"] == "connected" + # Disabled platform is reconciled. + assert payload["platforms"]["discord"]["state"] == "disabled" + assert payload["platforms"]["discord"]["error_code"] is None + assert payload["platforms"]["discord"]["error_message"] is None + + def test_write_runtime_status_disabled_adds_missing_platform_entry( + self, tmp_path, monkeypatch + ): + """A disabled platform with no prior entry is written as disabled.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + status.write_runtime_status( + gateway_state="starting", + disabled_platforms=["slack"], + ) + + payload = status.read_runtime_status() + assert payload["platforms"]["slack"]["state"] == "disabled" + assert payload["platforms"]["slack"]["error_code"] is None + assert payload["platforms"]["slack"]["error_message"] is None + + def test_write_runtime_status_disabled_combines_with_platform_update( + self, tmp_path, monkeypatch + ): + """A single call can both reconcile disabled platforms and record an + enabled platform's live state without them clobbering each other.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + state_path = tmp_path / "gateway_state.json" + state_path.write_text(json.dumps({ + "kind": "hermes-gateway", + "platforms": { + "discord": { + "state": "connected", + "error_code": None, + "error_message": None, + "updated_at": "2025-01-01T00:00:00Z", + }, + }, + "updated_at": "2025-01-01T00:00:00Z", + })) + + status.write_runtime_status( + gateway_state="starting", + platform="telegram", + platform_state="connecting", + disabled_platforms=["discord"], + ) + + payload = status.read_runtime_status() + assert payload["platforms"]["telegram"]["state"] == "connecting" + assert payload["platforms"]["discord"]["state"] == "disabled" + class TestGetProcessStartTime: """Start-time fingerprint backing the PID-reuse guard (#43846 / #50468).