From f0208a1412d9c0f2488fb26bd340790011dfdd3b Mon Sep 17 00:00:00 2001 From: Malk13r <9999047+Malk13r@users.noreply.github.com> Date: Thu, 13 Aug 2026 01:02:20 +0200 Subject: [PATCH 1/2] fix(gateway): support dedicated lifecycle channels --- gateway/config.py | 50 ++++++- gateway/run.py | 133 +++++++++++------ tests/gateway/test_config.py | 166 +++++++++++++++++++++ tests/gateway/test_restart_drain.py | 56 +++++++ tests/gateway/test_restart_notification.py | 71 +++++++++ website/docs/user-guide/messaging/index.md | 7 +- 6 files changed, 433 insertions(+), 50 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index a00fa0f9a1ca..7fe8a3400f88 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -642,6 +642,9 @@ class PlatformConfig: token: Optional[str] = None # Bot token (Telegram, Discord) api_key: Optional[str] = None # API key if different from token home_channel: Optional[HomeChannel] = None + # Optional operations destination for gateway lifecycle notices. When + # unset, notices retain the historical home-channel fallback. + gateway_restart_channel: Optional[HomeChannel] = None # Reply threading mode (Telegram/Slack) # - "off": Never thread replies to original message @@ -696,6 +699,8 @@ def to_dict(self) -> Dict[str, Any]: result["api_key"] = self.api_key if self.home_channel: result["home_channel"] = self.home_channel.to_dict() + if self.gateway_restart_channel: + result["gateway_restart_channel"] = self.gateway_restart_channel.to_dict() if self.channel_overrides: result["channel_overrides"] = { cid: ov.to_dict() for cid, ov in self.channel_overrides.items() @@ -709,6 +714,27 @@ def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": if isinstance(data.get("home_channel"), dict): home_channel = HomeChannel.from_dict(data["home_channel"]) + gateway_restart_channel = None + raw_restart_channel = data.get("gateway_restart_channel") + if raw_restart_channel is not None: + if isinstance(raw_restart_channel, dict): + try: + gateway_restart_channel = HomeChannel.from_dict( + raw_restart_channel + ) + except (KeyError, TypeError, ValueError) as exc: + logger.warning( + "Ignoring invalid gateway_restart_channel; " + "lifecycle notices will fall back to the home channel: %s", + exc, + ) + else: + logger.warning( + "Ignoring invalid gateway_restart_channel; expected a mapping, " + "got %s", + type(raw_restart_channel).__name__, + ) + # gateway_restart_notification may be bridged into extra via the # shared-key loop in load_gateway_config(); check both top-level # and extra so YAML ``discord: gateway_restart_notification: false`` @@ -743,6 +769,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": token=data.get("token"), api_key=data.get("api_key"), home_channel=home_channel, + gateway_restart_channel=gateway_restart_channel, reply_to_mode=data.get("reply_to_mode", "first"), gateway_restart_notification=_coerce_bool(_grn, True), typing_indicator=_coerce_bool(_typing, True), @@ -1664,6 +1691,22 @@ def _merge_platform_map(source_platforms: Any) -> None: bridged["channel_prompts"] = channel_prompts if "gateway_restart_notification" in platform_cfg: bridged["gateway_restart_notification"] = platform_cfg["gateway_restart_notification"] + has_restart_channel = "gateway_restart_channel" in platform_cfg + if has_restart_channel: + raw_restart_channel = platform_cfg.get("gateway_restart_channel") + plat_data, _extra = _ensure_platform_extra_dict( + platforms_data, plat.value + ) + # _merge_platform_map already resolved precedence for + # gateway.platforms.* versus platforms.*. Only a direct + # top-level platform block may override that merged value. + # Preserve malformed values too so PlatformConfig can emit + # a warning instead of silently discarding user input. + if ( + _cfg_toplevel + or "gateway_restart_channel" not in plat_data + ): + plat_data["gateway_restart_channel"] = raw_restart_channel if "typing_indicator" in platform_cfg: bridged["typing_indicator"] = platform_cfg["typing_indicator"] if "typing_status_text" in platform_cfg: @@ -1699,7 +1742,12 @@ def _merge_platform_map(source_platforms: Any) -> None: if isinstance(ov_data, dict) } enabled_was_explicit = _cfg_toplevel and "enabled" in platform_cfg - if not bridged and not enabled_was_explicit and not has_channel_overrides: + if ( + not bridged + and not enabled_was_explicit + and not has_channel_overrides + and not has_restart_channel + ): continue plat_data, extra = _ensure_platform_extra_dict(platforms_data, plat.value) if enabled_was_explicit: diff --git a/gateway/run.py b/gateway/run.py index 2fff508784e6..03258a7590b8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10809,59 +10809,85 @@ async def _notify_active_sessions_of_shutdown(self) -> None: # fail toward the louder, more-visible behaviour. logger.debug("drain_notification_suppressed check failed: %s", e) - # Snapshot adapters up front: adapter.send() can hit a fatal error - # path that pops the adapter from self.adapters (see _handle_fatal - # elsewhere), which would otherwise trigger - # ``RuntimeError: dictionary changed size during iteration`` — - # observed in a user report during gateway shutdown. - for platform, adapter in list(self.adapters.items()): - home = self.config.get_home_channel(platform) - if not home or not home.chat_id: - continue - - platform_cfg = self.config.platforms.get(platform) - if platform_cfg is not None and not platform_cfg.gateway_restart_notification: + # Iterate logical platform configs rather than only native adapters so + # Relay-fronted lifecycle targets remain reachable during shutdown. + for platform, platform_cfg in list(self.config.platforms.items()): + if not platform_cfg.gateway_restart_notification: logger.info( - "Shutdown notification suppressed for home channel: %s has gateway_restart_notification=false", + "Shutdown notification suppressed for lifecycle channel: %s has gateway_restart_notification=false", platform.value, ) continue - dedup_key = (platform.value, str(home.chat_id), str(home.thread_id) if home.thread_id else None) + lifecycle_channel = ( + platform_cfg.gateway_restart_channel or platform_cfg.home_channel + ) + if not lifecycle_channel or not lifecycle_channel.chat_id: + continue + + transport = resolve_delivery_transport( + platform, self.config, self.adapters + ) + if transport is None: + continue + + dedup_key = ( + platform.value, + str(lifecycle_channel.chat_id), + str(lifecycle_channel.thread_id) + if lifecycle_channel.thread_id + else None, + ) if dedup_key in notified: continue try: metadata = self._thread_metadata_for_target( platform, - home.chat_id, - home.thread_id, - adapter=adapter, + lifecycle_channel.chat_id, + lifecycle_channel.thread_id, + adapter=transport.adapter, ) - if metadata: - result = await adapter.send(str(home.chat_id), msg, metadata=metadata) + if transport.is_relay: + metadata = dict(metadata or {}) + if lifecycle_channel.user_id: + metadata["user_id"] = lifecycle_channel.user_id + if lifecycle_channel.scope_id: + metadata["scope_id"] = lifecycle_channel.scope_id + send_metadata = _non_conversational_metadata( + metadata, platform=platform + ) + if send_metadata is not None or transport.is_relay: + result = await transport.send( + platform, + str(lifecycle_channel.chat_id), + msg, + metadata=send_metadata, + ) else: - result = await adapter.send(str(home.chat_id), msg) + result = await transport.adapter.send( + str(lifecycle_channel.chat_id), msg + ) if result is not None and getattr(result, "success", True) is False: logger.debug( - "Failed to send shutdown notification to home channel %s:%s: %s", + "Failed to send shutdown notification to lifecycle channel %s:%s: %s", platform.value, - home.chat_id, + lifecycle_channel.chat_id, getattr(result, "error", "send returned success=False"), ) continue notified.add(dedup_key) logger.info( - "Sent shutdown notification to home channel %s:%s", + "Sent shutdown notification to lifecycle channel %s:%s", platform.value, - home.chat_id, + lifecycle_channel.chat_id, ) except Exception as e: logger.debug( - "Failed to send shutdown notification to home channel %s:%s: %s", + "Failed to send shutdown notification to lifecycle channel %s:%s: %s", platform.value, - home.chat_id, + lifecycle_channel.chat_id, e, ) @@ -24134,10 +24160,11 @@ async def _send_home_channel_startup_notifications( *, skip_targets: Optional[set[tuple[str, str, Optional[str]]]] = None, ) -> set[tuple[str, str, Optional[str]]]: - """Notify configured home channels that the gateway is back online. + """Notify configured lifecycle channels that the gateway is back online. The notification is best-effort and sent once per connected platform - home channel. ``skip_targets`` lets startup avoid duplicate messages + lifecycle channel (falling back to home). ``skip_targets`` lets startup + avoid duplicate messages when a more specific restart notification is queued for the same chat. """ delivered: set[tuple[str, str, Optional[str]]] = set() @@ -24145,68 +24172,78 @@ async def _send_home_channel_startup_notifications( message = "♻️ Gateway online — Hermes is back and ready." for platform, platform_cfg in self.config.platforms.items(): - home = platform_cfg.home_channel - if not home or not home.chat_id: - continue - transport = resolve_delivery_transport(platform, self.config, self.adapters) if transport is None: continue if not platform_cfg.gateway_restart_notification: logger.info( - "Home-channel startup notification suppressed: %s has gateway_restart_notification=false", + "Lifecycle-channel startup notification suppressed: %s has gateway_restart_notification=false", platform.value, ) continue - target = (platform.value, str(home.chat_id), str(home.thread_id) if home.thread_id else None) + lifecycle_channel = ( + platform_cfg.gateway_restart_channel or platform_cfg.home_channel + ) + if not lifecycle_channel or not lifecycle_channel.chat_id: + continue + + target = ( + platform.value, + str(lifecycle_channel.chat_id), + str(lifecycle_channel.thread_id) + if lifecycle_channel.thread_id + else None, + ) if target in skipped or target in delivered: continue try: metadata = self._thread_metadata_for_target( platform, - home.chat_id, - home.thread_id, + lifecycle_channel.chat_id, + lifecycle_channel.thread_id, adapter=transport.adapter, ) if transport.is_relay: metadata = dict(metadata or {}) - if home.user_id: - metadata["user_id"] = home.user_id - if home.scope_id: - metadata["scope_id"] = home.scope_id + if lifecycle_channel.user_id: + metadata["user_id"] = lifecycle_channel.user_id + if lifecycle_channel.scope_id: + metadata["scope_id"] = lifecycle_channel.scope_id send_metadata = _non_conversational_metadata(metadata, platform=platform) if send_metadata is not None or transport.is_relay: result = await transport.send( platform, - str(home.chat_id), + str(lifecycle_channel.chat_id), message, metadata=send_metadata, ) else: - result = await transport.adapter.send(str(home.chat_id), message) + result = await transport.adapter.send( + str(lifecycle_channel.chat_id), message + ) if result is not None and getattr(result, "success", True) is False: logger.warning( - "Home-channel startup notification failed for %s:%s: %s", + "Lifecycle-channel startup notification failed for %s:%s: %s", platform.value, - home.chat_id, + lifecycle_channel.chat_id, getattr(result, "error", "send returned success=False"), ) continue delivered.add(target) logger.info( - "Sent home-channel startup notification to %s:%s", + "Sent lifecycle-channel startup notification to %s:%s", platform.value, - home.chat_id, + lifecycle_channel.chat_id, ) except Exception as exc: logger.warning( - "Home-channel startup notification failed for %s:%s: %s", + "Lifecycle-channel startup notification failed for %s:%s: %s", platform.value, - home.chat_id, + lifecycle_channel.chat_id, exc, ) diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 29c71a387bc4..8b346cada9fe 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -83,6 +83,53 @@ def test_gateway_restart_notification_roundtrip_false(self): restored = PlatformConfig.from_dict(pc.to_dict()) assert restored.gateway_restart_notification is False + def test_gateway_restart_channel_roundtrip(self): + pc = PlatformConfig( + enabled=True, + gateway_restart_channel=HomeChannel( + platform=Platform.SLACK, + chat_id="C-ops", + name="system-messages", + ), + ) + restored = PlatformConfig.from_dict(pc.to_dict()) + + assert restored.gateway_restart_channel is not None + assert restored.gateway_restart_channel.platform == Platform.SLACK + assert restored.gateway_restart_channel.chat_id == "C-ops" + + @pytest.mark.parametrize( + "malformed", + [ + {"platform": "slck", "chat_id": "C-ops"}, + {"platform": "slack"}, + {"chat_id": "C-ops"}, + "C-ops", + ], + ) + def test_malformed_restart_channel_preserves_platform_config( + self, malformed, caplog + ): + restored = PlatformConfig.from_dict( + { + "enabled": True, + "token": "test-token", + "home_channel": { + "platform": "slack", + "chat_id": "C-home", + "name": "Home", + }, + "gateway_restart_channel": malformed, + } + ) + + assert restored.enabled is True + assert restored.token == "test-token" + assert restored.home_channel is not None + assert restored.home_channel.chat_id == "C-home" + assert restored.gateway_restart_channel is None + assert "Ignoring invalid gateway_restart_channel" in caplog.text + def test_typing_status_text_resolved_from_extra(self): # Same bridge route as typing_indicator: the shared-key loop copies a @@ -278,6 +325,125 @@ def test_email_can_opt_into_pairing_for_unauthorized_dm_behavior(self): class TestLoadGatewayConfig: + def test_top_level_restart_channel_reaches_platform_config( + self, tmp_path, monkeypatch + ): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "slack:\n" + " gateway_restart_channel:\n" + " platform: slack\n" + " chat_id: C-ops\n" + " name: system-messages\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + lifecycle_channel = config.platforms[Platform.SLACK].gateway_restart_channel + assert lifecycle_channel is not None + assert lifecycle_channel.chat_id == "C-ops" + assert lifecycle_channel.name == "system-messages" + + def test_nested_restart_channel_reaches_platform_config( + self, tmp_path, monkeypatch + ): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "gateway:\n" + " platforms:\n" + " slack:\n" + " gateway_restart_channel:\n" + " platform: slack\n" + " chat_id: C-ops\n" + " name: system-messages\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + lifecycle_channel = config.platforms[Platform.SLACK].gateway_restart_channel + assert lifecycle_channel is not None + assert lifecycle_channel.chat_id == "C-ops" + + def test_top_level_malformed_restart_channel_warns_and_falls_back( + self, tmp_path, monkeypatch, caplog + ): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "slack:\n gateway_restart_channel: C-ops\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.platforms[Platform.SLACK].gateway_restart_channel is None + assert "Ignoring invalid gateway_restart_channel" in caplog.text + + def test_malformed_restart_channel_falls_back_without_dropping_platform( + self, tmp_path, monkeypatch, caplog + ): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "platforms:\n" + " slack:\n" + " enabled: true\n" + " home_channel:\n" + " platform: slack\n" + " chat_id: C-home\n" + " name: Home\n" + " gateway_restart_channel:\n" + " platform: slck\n" + " chat_id: C-ops\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + slack = config.platforms[Platform.SLACK] + assert slack.enabled is True + assert slack.home_channel is not None + assert slack.home_channel.chat_id == "C-home" + assert slack.gateway_restart_channel is None + assert "Ignoring invalid gateway_restart_channel" in caplog.text + + def test_platforms_restart_channel_overrides_gateway_platforms_value( + self, tmp_path, monkeypatch + ): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "gateway:\n" + " platforms:\n" + " slack:\n" + " gateway_restart_channel:\n" + " platform: slack\n" + " chat_id: C-nested\n" + " name: Nested\n" + "platforms:\n" + " slack:\n" + " gateway_restart_channel:\n" + " platform: slack\n" + " chat_id: C-primary\n" + " name: Primary\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + lifecycle_channel = config.platforms[Platform.SLACK].gateway_restart_channel + assert lifecycle_channel is not None + assert lifecycle_channel.chat_id == "C-primary" + def test_shipped_template_does_not_enable_auto_reset(self, tmp_path, monkeypatch): """A fresh install seeded from cli-config.yaml.example must not auto-reset sessions. diff --git a/tests/gateway/test_restart_drain.py b/tests/gateway/test_restart_drain.py index 590223f51c4f..686edefc2bb5 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -311,6 +311,62 @@ def fake_popen(cmd, **kwargs): # ── Shutdown notification tests ────────────────────────────────────── +@pytest.mark.asyncio +async def test_shutdown_notification_prefers_restart_channel_over_home(): + """Lifecycle alerts use the operations channel without changing home routing.""" + from gateway.config import HomeChannel, Platform + + runner, adapter = make_restart_runner() + cfg = runner.config.platforms[Platform.TELEGRAM] + cfg.home_channel = HomeChannel( + platform=Platform.TELEGRAM, chat_id="daily-digest", name="Digest" + ) + cfg.gateway_restart_channel = HomeChannel( + platform=Platform.TELEGRAM, chat_id="system-messages", name="Ops" + ) + adapter.send = AsyncMock() + + await runner._notify_active_sessions_of_shutdown() + + adapter.send.assert_called_once() + assert adapter.send.call_args.args[0] == "system-messages" + + +@pytest.mark.asyncio +async def test_relay_fronted_shutdown_uses_lifecycle_channel_with_provenance(): + from gateway.config import HomeChannel, Platform, PlatformConfig + from gateway.platforms.base import SendResult + + runner, _native = make_restart_runner() + relay = MagicMock() + relay.fronts_platform.side_effect = lambda platform: platform == Platform.SLACK + relay.send_for_platform = AsyncMock( + return_value=SendResult(success=True, message_id="shutdown") + ) + runner.adapters = {Platform.RELAY: relay} + runner.config.platforms = { + Platform.RELAY: PlatformConfig(enabled=True), + Platform.SLACK: PlatformConfig( + enabled=False, + gateway_restart_channel=HomeChannel( + platform=Platform.SLACK, + chat_id="COPS", + name="Operations", + user_id="U123", + scope_id="T123", + ), + ), + } + + await runner._notify_active_sessions_of_shutdown() + + relay.send_for_platform.assert_awaited_once() + assert relay.send_for_platform.await_args.args[:2] == (Platform.SLACK, "COPS") + metadata = relay.send_for_platform.await_args.kwargs["metadata"] + assert metadata["user_id"] == "U123" + assert metadata["scope_id"] == "T123" + + @pytest.mark.asyncio async def test_shutdown_notification_uses_persisted_origin_for_colon_ids(): """Shutdown notifications should route from persisted origin, not reparsed keys.""" diff --git a/tests/gateway/test_restart_notification.py b/tests/gateway/test_restart_notification.py index aaca6ea025e7..049c8b42d9a7 100644 --- a/tests/gateway/test_restart_notification.py +++ b/tests/gateway/test_restart_notification.py @@ -168,6 +168,31 @@ def _fake_save_env_value(key, value): # ── home-channel startup notifications ───────────────────────────────────── +@pytest.mark.asyncio +async def test_startup_notification_prefers_restart_channel_over_home( + tmp_path, monkeypatch +): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + runner, adapter = make_restart_runner() + cfg = runner.config.platforms[Platform.TELEGRAM] + cfg.home_channel = HomeChannel( + platform=Platform.TELEGRAM, chat_id="daily-digest", name="Digest" + ) + cfg.gateway_restart_channel = HomeChannel( + platform=Platform.TELEGRAM, chat_id="system-messages", name="Ops" + ) + adapter.send = AsyncMock() + + delivered = await runner._send_home_channel_startup_notifications() + + assert delivered == {("telegram", "system-messages", None)} + adapter.send.assert_called_once_with( + "system-messages", + "♻️ Gateway online — Hermes is back and ready.", + ) + + @pytest.mark.asyncio async def test_send_home_channel_startup_notification_preserves_thread_metadata( tmp_path, monkeypatch @@ -243,6 +268,52 @@ async def test_relay_fronted_logical_home_gets_startup_notification(tmp_path, mo assert relay.send_for_platform.await_args.kwargs["metadata"]["scope_id"] == "T123" +@pytest.mark.asyncio +async def test_relay_fronted_lifecycle_channel_preserves_owner_provenance( + tmp_path, monkeypatch +): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + runner, _native = make_restart_runner() + relay = MagicMock() + relay.fronts_platform.side_effect = lambda platform: platform == Platform.SLACK + relay.send_for_platform = AsyncMock( + return_value=SendResult(success=True, message_id="lifecycle") + ) + runner.adapters = {Platform.RELAY: relay} + runner.config.platforms = { + Platform.RELAY: PlatformConfig(enabled=True), + Platform.SLACK: PlatformConfig( + enabled=False, + home_channel=HomeChannel( + platform=Platform.SLACK, + chat_id="D123", + name="Owner DM", + ), + gateway_restart_channel=HomeChannel( + platform=Platform.SLACK, + chat_id="COPS", + name="Operations", + user_id="U123", + scope_id="T123", + ), + ), + } + + delivered = await runner._send_home_channel_startup_notifications() + + assert delivered == {("slack", "COPS", None)} + relay.send_for_platform.assert_awaited_once() + assert relay.send_for_platform.await_args.args[:3] == ( + Platform.SLACK, + "COPS", + "♻️ Gateway online — Hermes is back and ready.", + ) + metadata = relay.send_for_platform.await_args.kwargs["metadata"] + assert metadata["user_id"] == "U123" + assert metadata["scope_id"] == "T123" + + # ── _send_restart_notification ─────────────────────────────────────────── diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index fb6098e00380..bf2f8fd22492 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -690,7 +690,7 @@ Once upstream is healthy, `/platform resume ` clears the breaker and re-ar ### Restart notifications -When the gateway restarts (or is shut down with in-flight sessions), it can send a one-shot "the agent is back" / "the agent was interrupted" message to each platform's home channel. This is controlled per-platform by the `gateway_restart_notification` flag in `gateway-config.yaml`, which defaults to `true`: +When the gateway restarts (or is shut down with in-flight sessions), it can send a one-shot "the agent is back" / "the agent was interrupted" message to each platform's lifecycle channel. By default this falls back to the platform home channel. Set `gateway_restart_channel` when operational alerts belong somewhere else, and use `gateway_restart_notification` (default `true`) to disable them entirely: ```yaml gateway: @@ -701,6 +701,11 @@ gateway: discord: home_chat_id: "987654321" # gateway_restart_notification omitted → defaults to true + slack: + gateway_restart_channel: + platform: slack + chat_id: "C0123456789" + name: system-messages ``` Disable it on noisy or low-priority platforms while leaving it on for your primary chat. The notification is sent once per restart, regardless of how many sessions were in flight. From 1c6c9d46980db38a95ffab6353f31eeaad86c0dd Mon Sep 17 00:00:00 2001 From: Malk13r <9999047+Malk13r@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:38:29 +0200 Subject: [PATCH 2/2] fix(gateway): harden lifecycle channel routing --- gateway/config.py | 28 ++++++- gateway/run.py | 80 +++++++++++++++++--- tests/gateway/restart_test_helpers.py | 4 +- tests/gateway/test_config.py | 86 ++++++++++++++++++++++ tests/gateway/test_restart_drain.py | 52 +++++++++++++ tests/gateway/test_restart_notification.py | 81 ++++++++++++++++++-- website/docs/user-guide/messaging/index.md | 2 + 7 files changed, 311 insertions(+), 22 deletions(-) diff --git a/gateway/config.py b/gateway/config.py index 7fe8a3400f88..44aea0a47b60 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -708,7 +708,12 @@ def to_dict(self) -> Dict[str, Any]: return result @classmethod - def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": + def from_dict( + cls, + data: Dict[str, Any], + *, + expected_platform: Optional[Platform] = None, + ) -> "PlatformConfig": data = _coerce_dict(data) home_channel = None if isinstance(data.get("home_channel"), dict): @@ -719,6 +724,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": if raw_restart_channel is not None: if isinstance(raw_restart_channel, dict): try: + raw_chat_id = raw_restart_channel.get("chat_id") + if raw_chat_id is None or not str(raw_chat_id).strip(): + raise ValueError("chat_id must be non-empty") gateway_restart_channel = HomeChannel.from_dict( raw_restart_channel ) @@ -735,6 +743,20 @@ def from_dict(cls, data: Dict[str, Any]) -> "PlatformConfig": type(raw_restart_channel).__name__, ) + if ( + gateway_restart_channel is not None + and expected_platform is not None + and gateway_restart_channel.platform != expected_platform + ): + logger.warning( + "Ignoring gateway_restart_channel platform mismatch; " + "expected %s, got %s; lifecycle notices will fall back " + "to the home channel", + expected_platform.value, + gateway_restart_channel.platform.value, + ) + gateway_restart_channel = None + # gateway_restart_notification may be bridged into extra via the # shared-key loop in load_gateway_config(); check both top-level # and extra so YAML ``discord: gateway_restart_notification: false`` @@ -1170,7 +1192,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "GatewayConfig": continue try: platform = Platform(platform_name) - platforms[platform] = PlatformConfig.from_dict(platform_data) + platforms[platform] = PlatformConfig.from_dict( + platform_data, expected_platform=platform + ) except ValueError: pass # Skip unknown platforms diff --git a/gateway/run.py b/gateway/run.py index 03258a7590b8..505e023b3a29 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2547,6 +2547,7 @@ def _platform_has_bot_credential(platform: "Platform", platform_config: "Platfor from gateway.config import ( ChannelOverride, + HomeChannel, Platform, _BUILTIN_PLATFORM_VALUES, GatewayConfig, @@ -2568,6 +2569,7 @@ def _platform_has_bot_credential(platform: "Platform", platform_config: "Platfor neutralize_untrusted_inline_text, ) from gateway.delivery import ( + DeliveryTransport, DeliveryRouter, looks_like_telegram_private_chat_id, resolve_delivery_transport, @@ -10666,6 +10668,62 @@ async def _notify_interrupted_cron_jobs(self, job_ids) -> int: ) return len(notified) + def _resolve_lifecycle_transport( + self, platform: Platform, platform_cfg: PlatformConfig + ) -> Optional[DeliveryTransport]: + """Resolve delivery without masking a failed enabled native adapter. + + Generic delivery may fall back to Relay when it fronts a logical + platform. Lifecycle broadcasts are stricter: an enabled native + platform must have connected successfully, while an intentionally + disabled logical platform may still be delivered through Relay. + """ + if platform_cfg.enabled and (self.adapters or {}).get(platform) is None: + logger.debug( + "Skipping lifecycle notification for enabled platform without " + "a connected native adapter: %s", + platform.value, + ) + return None + return resolve_delivery_transport(platform, self.config, self.adapters) + + def _resolve_lifecycle_channel( + self, platform: Platform, platform_cfg: PlatformConfig + ) -> Optional[HomeChannel]: + """Resolve a valid same-platform lifecycle target with home fallback.""" + restart_channel = platform_cfg.gateway_restart_channel + if restart_channel is not None: + if restart_channel.platform != platform: + logger.warning( + "Ignoring gateway_restart_channel platform mismatch at " + "delivery; expected %s, got %s; lifecycle notices will " + "fall back to the home channel", + platform.value, + restart_channel.platform.value, + ) + elif str(restart_channel.chat_id).strip(): + return restart_channel + else: + logger.warning( + "Ignoring gateway_restart_channel without a usable chat_id " + "at delivery; lifecycle notices will fall back to the home " + "channel for %s", + platform.value, + ) + + home_channel = platform_cfg.home_channel + if home_channel is None or not str(home_channel.chat_id).strip(): + return None + if home_channel.platform != platform: + logger.warning( + "Ignoring home-channel platform mismatch for lifecycle " + "notification; expected %s, got %s", + platform.value, + home_channel.platform.value, + ) + return None + return home_channel + async def _notify_active_sessions_of_shutdown(self) -> None: """Send shutdown/restart notifications to active chats and home channels. @@ -10819,15 +10877,13 @@ async def _notify_active_sessions_of_shutdown(self) -> None: ) continue - lifecycle_channel = ( - platform_cfg.gateway_restart_channel or platform_cfg.home_channel + lifecycle_channel = self._resolve_lifecycle_channel( + platform, platform_cfg ) - if not lifecycle_channel or not lifecycle_channel.chat_id: + if lifecycle_channel is None: continue - transport = resolve_delivery_transport( - platform, self.config, self.adapters - ) + transport = self._resolve_lifecycle_transport(platform, platform_cfg) if transport is None: continue @@ -13127,7 +13183,7 @@ async def _connect_one_startup(p, p_cfg, adp): # chat/topic instead of also leaking it to the configured home channel. if planned_restart_notification_pending: try: - await self._send_home_channel_startup_notifications( + await self._send_lifecycle_channel_startup_notifications( skip_targets=None, ) finally: @@ -24155,7 +24211,7 @@ async def _send_restart_notification(self) -> Optional[tuple[str, str, Optional[ finally: notify_path.unlink(missing_ok=True) - async def _send_home_channel_startup_notifications( + async def _send_lifecycle_channel_startup_notifications( self, *, skip_targets: Optional[set[tuple[str, str, Optional[str]]]] = None, @@ -24172,7 +24228,7 @@ async def _send_home_channel_startup_notifications( message = "♻️ Gateway online — Hermes is back and ready." for platform, platform_cfg in self.config.platforms.items(): - transport = resolve_delivery_transport(platform, self.config, self.adapters) + transport = self._resolve_lifecycle_transport(platform, platform_cfg) if transport is None: continue @@ -24183,10 +24239,10 @@ async def _send_home_channel_startup_notifications( ) continue - lifecycle_channel = ( - platform_cfg.gateway_restart_channel or platform_cfg.home_channel + lifecycle_channel = self._resolve_lifecycle_channel( + platform, platform_cfg ) - if not lifecycle_channel or not lifecycle_channel.chat_id: + if lifecycle_channel is None: continue target = ( diff --git a/tests/gateway/restart_test_helpers.py b/tests/gateway/restart_test_helpers.py index 589cdca1d82e..f8ff6dd1fee0 100644 --- a/tests/gateway/restart_test_helpers.py +++ b/tests/gateway/restart_test_helpers.py @@ -106,8 +106,8 @@ def make_restart_runner( runner._send_restart_notification = GatewayRunner._send_restart_notification.__get__( runner, GatewayRunner ) - runner._send_home_channel_startup_notifications = ( - GatewayRunner._send_home_channel_startup_notifications.__get__(runner, GatewayRunner) + runner._send_lifecycle_channel_startup_notifications = ( + GatewayRunner._send_lifecycle_channel_startup_notifications.__get__(runner, GatewayRunner) ) runner._status_action_label = GatewayRunner._status_action_label.__get__( runner, GatewayRunner diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index 8b346cada9fe..bc76e3eb5a20 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -98,11 +98,64 @@ def test_gateway_restart_channel_roundtrip(self): assert restored.gateway_restart_channel.platform == Platform.SLACK assert restored.gateway_restart_channel.chat_id == "C-ops" + def test_restart_channel_must_match_containing_platform(self, caplog): + config = GatewayConfig.from_dict( + { + "platforms": { + "slack": { + "enabled": True, + "token": "test-token", + "home_channel": { + "platform": "slack", + "chat_id": "C-home", + "name": "Home", + }, + "gateway_restart_channel": { + "platform": "telegram", + "chat_id": "C-ops", + "name": "Operations", + }, + } + } + } + ) + + slack = config.platforms[Platform.SLACK] + assert slack.enabled is True + assert slack.token == "test-token" + assert slack.home_channel is not None + assert slack.home_channel.chat_id == "C-home" + assert slack.gateway_restart_channel is None + assert "gateway_restart_channel platform mismatch" in caplog.text + assert "expected slack, got telegram" in caplog.text + assert "C-ops" not in caplog.text + + def test_restart_channel_matching_containing_platform_roundtrips(self): + config = GatewayConfig.from_dict( + { + "platforms": { + "slack": { + "gateway_restart_channel": { + "platform": "slack", + "chat_id": "C-ops", + "name": "Operations", + } + } + } + } + ) + + channel = config.platforms[Platform.SLACK].gateway_restart_channel + assert channel is not None + assert channel.platform == Platform.SLACK + assert channel.chat_id == "C-ops" + @pytest.mark.parametrize( "malformed", [ {"platform": "slck", "chat_id": "C-ops"}, {"platform": "slack"}, + {"platform": "slack", "chat_id": " "}, {"chat_id": "C-ops"}, "C-ops", ], @@ -346,6 +399,8 @@ def test_top_level_restart_channel_reaches_platform_config( assert lifecycle_channel is not None assert lifecycle_channel.chat_id == "C-ops" assert lifecycle_channel.name == "system-messages" + assert config.platforms[Platform.SLACK].enabled is False + assert Platform.SLACK not in config.get_connected_platforms() def test_nested_restart_channel_reaches_platform_config( self, tmp_path, monkeypatch @@ -369,6 +424,8 @@ def test_nested_restart_channel_reaches_platform_config( lifecycle_channel = config.platforms[Platform.SLACK].gateway_restart_channel assert lifecycle_channel is not None assert lifecycle_channel.chat_id == "C-ops" + assert config.platforms[Platform.SLACK].enabled is False + assert Platform.SLACK not in config.get_connected_platforms() def test_top_level_malformed_restart_channel_warns_and_falls_back( self, tmp_path, monkeypatch, caplog @@ -415,6 +472,35 @@ def test_malformed_restart_channel_falls_back_without_dropping_platform( assert slack.gateway_restart_channel is None assert "Ignoring invalid gateway_restart_channel" in caplog.text + def test_loader_rejects_cross_platform_restart_channel( + self, tmp_path, monkeypatch, caplog + ): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "platforms:\n" + " slack:\n" + " home_channel:\n" + " platform: slack\n" + " chat_id: C-home\n" + " name: Home\n" + " gateway_restart_channel:\n" + " platform: telegram\n" + " chat_id: C-ops\n" + " name: Wrong platform\n", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + slack = config.platforms[Platform.SLACK] + assert slack.home_channel is not None + assert slack.home_channel.chat_id == "C-home" + assert slack.gateway_restart_channel is None + assert "gateway_restart_channel platform mismatch" in caplog.text + assert "C-ops" not in caplog.text + def test_platforms_restart_channel_overrides_gateway_platforms_value( self, tmp_path, monkeypatch ): diff --git a/tests/gateway/test_restart_drain.py b/tests/gateway/test_restart_drain.py index 686edefc2bb5..edf0a04ee2e6 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -332,6 +332,29 @@ async def test_shutdown_notification_prefers_restart_channel_over_home(): assert adapter.send.call_args.args[0] == "system-messages" +@pytest.mark.asyncio +async def test_shutdown_rejects_programmatic_cross_platform_restart_channel(): + """Delivery rechecks target ownership even when config loading is bypassed.""" + from gateway.config import HomeChannel, Platform + + runner, adapter = make_restart_runner() + cfg = runner.config.platforms[Platform.TELEGRAM] + cfg.home_channel = HomeChannel( + platform=Platform.TELEGRAM, chat_id="daily-digest", name="Digest" + ) + cfg.gateway_restart_channel = HomeChannel( + platform=Platform.SLACK, + chat_id="C-wrong-platform", + name="Wrong platform", + ) + adapter.send = AsyncMock() + + await runner._notify_active_sessions_of_shutdown() + + adapter.send.assert_called_once() + assert adapter.send.call_args.args[0] == "daily-digest" + + @pytest.mark.asyncio async def test_relay_fronted_shutdown_uses_lifecycle_channel_with_provenance(): from gateway.config import HomeChannel, Platform, PlatformConfig @@ -367,6 +390,35 @@ async def test_relay_fronted_shutdown_uses_lifecycle_channel_with_provenance(): assert metadata["scope_id"] == "T123" +@pytest.mark.asyncio +async def test_failed_enabled_native_platform_does_not_relay_shutdown_notification(): + from gateway.config import HomeChannel, Platform, PlatformConfig + from gateway.platforms.base import SendResult + + runner, _native = make_restart_runner() + relay = MagicMock() + relay.fronts_platform.side_effect = lambda platform: platform == Platform.SLACK + relay.send_for_platform = AsyncMock( + return_value=SendResult(success=True, message_id="unexpected") + ) + runner.adapters = {Platform.RELAY: relay} + runner.config.platforms = { + Platform.RELAY: PlatformConfig(enabled=True), + Platform.SLACK: PlatformConfig( + enabled=True, + gateway_restart_channel=HomeChannel( + platform=Platform.SLACK, + chat_id="COPS", + name="Operations", + ), + ), + } + + await runner._notify_active_sessions_of_shutdown() + + relay.send_for_platform.assert_not_awaited() + + @pytest.mark.asyncio async def test_shutdown_notification_uses_persisted_origin_for_colon_ids(): """Shutdown notifications should route from persisted origin, not reparsed keys.""" diff --git a/tests/gateway/test_restart_notification.py b/tests/gateway/test_restart_notification.py index 049c8b42d9a7..900f4590e215 100644 --- a/tests/gateway/test_restart_notification.py +++ b/tests/gateway/test_restart_notification.py @@ -165,7 +165,7 @@ def _fake_save_env_value(key, value): assert home.thread_id == "topic-7" -# ── home-channel startup notifications ───────────────────────────────────── +# ── lifecycle-channel startup notifications ──────────────────────────────── @pytest.mark.asyncio @@ -184,7 +184,7 @@ async def test_startup_notification_prefers_restart_channel_over_home( ) adapter.send = AsyncMock() - delivered = await runner._send_home_channel_startup_notifications() + delivered = await runner._send_lifecycle_channel_startup_notifications() assert delivered == {("telegram", "system-messages", None)} adapter.send.assert_called_once_with( @@ -194,7 +194,45 @@ async def test_startup_notification_prefers_restart_channel_over_home( @pytest.mark.asyncio -async def test_send_home_channel_startup_notification_preserves_thread_metadata( +@pytest.mark.parametrize( + "invalid_restart_channel", + [ + HomeChannel( + platform=Platform.SLACK, + chat_id="C-wrong-platform", + name="Wrong platform", + ), + HomeChannel( + platform=Platform.TELEGRAM, + chat_id=" ", + name="Blank target", + ), + ], +) +async def test_invalid_programmatic_restart_channel_falls_back_to_home( + tmp_path, monkeypatch, invalid_restart_channel +): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + runner, adapter = make_restart_runner() + cfg = runner.config.platforms[Platform.TELEGRAM] + cfg.home_channel = HomeChannel( + platform=Platform.TELEGRAM, chat_id="daily-digest", name="Digest" + ) + cfg.gateway_restart_channel = invalid_restart_channel + adapter.send = AsyncMock() + + delivered = await runner._send_lifecycle_channel_startup_notifications() + + assert delivered == {("telegram", "daily-digest", None)} + adapter.send.assert_called_once_with( + "daily-digest", + "♻️ Gateway online — Hermes is back and ready.", + ) + + +@pytest.mark.asyncio +async def test_lifecycle_channel_startup_notification_preserves_thread_metadata( tmp_path, monkeypatch ): monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) @@ -218,7 +256,7 @@ def _get_dm_topic_info(self, chat_id, thread_id): adapter.__class__ = _DmTopicAdapter adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="home")) - delivered = await runner._send_home_channel_startup_notifications() + delivered = await runner._send_lifecycle_channel_startup_notifications() assert delivered == {("telegram", "parent-42", "777")} adapter.send.assert_called_once_with( @@ -255,7 +293,7 @@ async def test_relay_fronted_logical_home_gets_startup_notification(tmp_path, mo ), } - delivered = await runner._send_home_channel_startup_notifications() + delivered = await runner._send_lifecycle_channel_startup_notifications() assert delivered == {("slack", "D123", None)} relay.send_for_platform.assert_awaited_once() @@ -268,6 +306,37 @@ async def test_relay_fronted_logical_home_gets_startup_notification(tmp_path, mo assert relay.send_for_platform.await_args.kwargs["metadata"]["scope_id"] == "T123" +@pytest.mark.asyncio +async def test_failed_enabled_native_platform_does_not_relay_startup_notification( + tmp_path, monkeypatch +): + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + runner, _native = make_restart_runner() + relay = MagicMock() + relay.fronts_platform.side_effect = lambda platform: platform == Platform.SLACK + relay.send_for_platform = AsyncMock( + return_value=SendResult(success=True, message_id="unexpected") + ) + runner.adapters = {Platform.RELAY: relay} + runner.config.platforms = { + Platform.RELAY: PlatformConfig(enabled=True), + Platform.SLACK: PlatformConfig( + enabled=True, + gateway_restart_channel=HomeChannel( + platform=Platform.SLACK, + chat_id="COPS", + name="Operations", + ), + ), + } + + delivered = await runner._send_lifecycle_channel_startup_notifications() + + assert delivered == set() + relay.send_for_platform.assert_not_awaited() + + @pytest.mark.asyncio async def test_relay_fronted_lifecycle_channel_preserves_owner_provenance( tmp_path, monkeypatch @@ -300,7 +369,7 @@ async def test_relay_fronted_lifecycle_channel_preserves_owner_provenance( ), } - delivered = await runner._send_home_channel_startup_notifications() + delivered = await runner._send_lifecycle_channel_startup_notifications() assert delivered == {("slack", "COPS", None)} relay.send_for_platform.assert_awaited_once() diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index bf2f8fd22492..fb0b90ab996f 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -710,6 +710,8 @@ gateway: Disable it on noisy or low-priority platforms while leaving it on for your primary chat. The notification is sent once per restart, regardless of how many sessions were in flight. +`gateway_restart_channel.platform` must match the containing platform block (`slack` in the example). A mismatched target is ignored and lifecycle notices fall back to that platform's home channel. Configuring only `gateway_restart_channel` does not enable or connect the platform. Enabled native platforms receive lifecycle notices only through a successfully connected native adapter; an intentionally disabled logical platform can still receive them through a connected Relay adapter that explicitly fronts it. + ### Typing indicators While the agent is processing a message, the gateway shows a live typing status on platforms that support it — a "typing…" bubble on Telegram/Discord/Signal, or the "is thinking…" assistant status on Slack. This is controlled per-platform by the `typing_indicator` flag in `gateway-config.yaml`, which defaults to `true`: