diff --git a/gateway/run_profile_reconcile.py b/gateway/run_profile_reconcile.py index 68434a1549..1901867a14 100644 --- a/gateway/run_profile_reconcile.py +++ b/gateway/run_profile_reconcile.py @@ -273,6 +273,8 @@ class GatewayProfileReconcileMixin: # Only acknowledge the configuration observed before connecting; # a setup save during an awaited handshake needs another scan. scan_signature = profile_serve_signature(current[name]) + if name in changed: + self._drop_stale_platform_verdicts(name) try: connected = await self._start_one_profile_adapters(name, current[name], claimed) except MultiplexConfigError as exc: @@ -328,6 +330,19 @@ class GatewayProfileReconcileMixin: result["served_profiles"] = self.served_profile_names() return result + def _drop_stale_platform_verdicts(self, name: str) -> None: + """Before a changed profile's rescan, drop its ``:`` status entries for + platforms with no live or queued adapter: the rescan re-publishes every verdict that still + holds, so a fatal/disabled entry about a credential the user just removed can't outlive it.""" + from gateway.run import _write_runtime_status_quiet + live = set((getattr(self, "_profile_adapters", None) or {}).get(name) or {}) + live |= set((getattr(self, "_profile_failed_platforms", None) or {}).get(name) or {}) + noted = getattr(self, "_unserved_secondary_platforms", None) + if isinstance(noted, set): + noted -= {entry for entry in noted if entry[0] == name} + _write_runtime_status_quiet(drop_profile_platforms=name, + keep_platforms={f"{name}:{getattr(p, 'value', p)}" for p in live}) + def _parked_profile_names(self) -> list: """Profiles that exist but could not be served (unusable store, home owned elsewhere); boot's ``initialize_gateway_runtime`` parks into the same published ``name -> reason`` map.""" diff --git a/gateway/status.py b/gateway/status.py index 1fa16014e0..614607a913 100644 --- a/gateway/status.py +++ b/gateway/status.py @@ -19,7 +19,7 @@ import time from dataclasses import dataclass from datetime import datetime, timezone, UTC from pathlib import Path -from typing import Any, Callable, NamedTuple, Optional +from typing import Any, Callable, Iterable, NamedTuple, Optional from hermes_dir_acl import make_user_dir from gateway.status_inline_source import ( @@ -1093,7 +1093,7 @@ def _prepare_runtime_status_update( parked_profiles: Any = _UNSET, multiplex_standalone_reason: Any = _UNSET, platform_metrics: Any = _UNSET, ingress_url: Any = _UNSET, listener_base: Any = _UNSET, clear_profile_platforms: bool = False, - drop_profile_platforms: Optional[str] = None, + drop_profile_platforms: Optional[str] = None, keep_platforms: Iterable[str] = (), load_existing: bool = True, reload_existing: bool = False, ) -> tuple[Path, dict[str, Any], dict[str, Any]]: """Merge one update into the process-wide canonical status snapshot.""" @@ -1117,7 +1117,7 @@ def _prepare_runtime_status_update( payload["platforms"] = { k: v for k, v in payload["platforms"].items() if not isinstance(k, str) or ":" not in k - or (drop_prefix is not None and not k.startswith(drop_prefix)) + or (drop_prefix is not None and not k.startswith(drop_prefix)) or k in keep_platforms } payload.update({key: current_record[key] for key in ("kind", "pid", "argv", "start_time")}) payload["updated_at"] = _utc_now_iso() diff --git a/tests/gateway/test_reconcile_drops_stale_platform_verdicts.py b/tests/gateway/test_reconcile_drops_stale_platform_verdicts.py new file mode 100644 index 0000000000..ab63f9724c --- /dev/null +++ b/tests/gateway/test_reconcile_drops_stale_platform_verdicts.py @@ -0,0 +1,42 @@ +"""A changed profile's rescan drops status verdicts about credentials the user removed. + +Removing a duplicated bot token from a secondary profile's .env must clear its +``:`` fatal entry (Desktop: "Backend telegram down"), while a +platform that is still live keeps its entry. +""" +import json +from unittest.mock import patch + +import pytest + +from gateway.config import Platform +from gateway.status import flush_runtime_status, publish_runtime_status +from tests.gateway.test_multiplex_hot_serve import _mkprofile, _runner + + +def _platforms(home): + flush_runtime_status() + return json.loads((home / "gateway_state.json").read_text(encoding="utf-8")).get("platforms") or {} + + +@pytest.mark.asyncio +async def test_rescan_drops_fatal_for_removed_credential_keeps_live(tmp_path, monkeypatch): + runner, home = _runner(tmp_path, monkeypatch) + alpha = _mkprofile(home, "alpha", "DISCORD_BOT_TOKEN=alpha-token\n") + with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"): + await runner._start_secondary_profile_adapters() + assert Platform.DISCORD in runner._profile_adapters["alpha"] + publish_runtime_status(platform="alpha:discord", platform_state="connected") + publish_runtime_status(platform="alpha:telegram", platform_state="fatal", + error_code="duplicate_credential", error_message="dup") + publish_runtime_status(platform="telegram", platform_state="connected") + assert _platforms(home)["alpha:telegram"]["state"] == "fatal" + + # User deletes the copied TELEGRAM_BOT_TOKEN line: the .env change triggers a rescan. + (alpha / ".env").write_text("DISCORD_BOT_TOKEN=alpha-token\n# edited\n", encoding="utf-8") + assert (await runner.reconcile_served_profiles())["rescanned"] == ["alpha"] + + platforms = _platforms(home) + assert "alpha:telegram" not in platforms + assert platforms["alpha:discord"]["state"] == "connected" + assert platforms["telegram"]["state"] == "connected"