diff --git a/gateway/config.py b/gateway/config.py index a00fa0f9a1ca..44aea0a47b60 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() @@ -703,12 +708,55 @@ 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): 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: + 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 + ) + 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__, + ) + + 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`` @@ -743,6 +791,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), @@ -1143,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 @@ -1664,6 +1715,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 +1766,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..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. @@ -10809,59 +10867,83 @@ 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 = self._resolve_lifecycle_channel( + platform, platform_cfg + ) + if lifecycle_channel is None: + continue + + transport = self._resolve_lifecycle_transport(platform, platform_cfg) + 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, ) @@ -13101,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: @@ -24129,15 +24211,16 @@ 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, ) -> 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 +24228,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) + transport = self._resolve_lifecycle_transport(platform, platform_cfg) 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 = self._resolve_lifecycle_channel( + platform, platform_cfg + ) + if lifecycle_channel is None: + 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/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 29c71a387bc4..bc76e3eb5a20 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -83,6 +83,106 @@ 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" + + 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", + ], + ) + 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 +378,158 @@ 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" + 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 + ): + 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" + 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 + ): + 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_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 + ): + 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..edf0a04ee2e6 100644 --- a/tests/gateway/test_restart_drain.py +++ b/tests/gateway/test_restart_drain.py @@ -311,6 +311,114 @@ 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_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 + 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_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 aaca6ea025e7..900f4590e215 100644 --- a/tests/gateway/test_restart_notification.py +++ b/tests/gateway/test_restart_notification.py @@ -165,11 +165,74 @@ def _fake_save_env_value(key, value): assert home.thread_id == "topic-7" -# ── home-channel startup notifications ───────────────────────────────────── +# ── lifecycle-channel startup notifications ──────────────────────────────── @pytest.mark.asyncio -async def test_send_home_channel_startup_notification_preserves_thread_metadata( +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_lifecycle_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 +@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) @@ -193,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( @@ -230,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() @@ -243,6 +306,83 @@ 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 +): + 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_lifecycle_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..fb0b90ab996f 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,10 +701,17 @@ 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. +`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`: