From 2b86e0b22332c4842906a56bc9d1a3caf15ff4c1 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sat, 19 Sep 2026 07:04:45 +0800 Subject: [PATCH] fix(kanban): warn once on credential-gated subs instead of silent rewind Kanban subscriptions fail-closed by the multiplex credential gate rewinded their claim every tick with only a DEBUG line: a profile_routes-pinned profile that runs other-platform adapters but none for the subscription's platform, and a sub stamped with a profile other than the route's, were permanent invisible dead-ends. Mirror the #110919/#111079 anchorless-thread warning: say so ONCE per row at WARNING, naming the dead-end and the re-subscribe escape hatch (#115460). --- gateway/kanban_watchers_notifier.py | 28 ++++++++++++++ tests/gateway/test_kanban_routed_transport.py | 38 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/gateway/kanban_watchers_notifier.py b/gateway/kanban_watchers_notifier.py index caa1a91780..b65d7ccbb3 100644 --- a/gateway/kanban_watchers_notifier.py +++ b/gateway/kanban_watchers_notifier.py @@ -128,6 +128,21 @@ def _warn_anchorless_thread_sub_once(sub: dict, platform: str) -> None: ) +_UNROUTABLE_WARNED: set[tuple] = set() + + +def _warn_unroutable_sub_once(sub: dict, platform: Any, message: str, *extra_args: Any) -> None: + """A routed subscription the credential gate fail-closes is a permanent dead-end: delivery + rewinds every tick with only a DEBUG line. Say so ONCE per row at WARNING, mirroring + ``_warn_anchorless_thread_sub_once`` (#115460).""" + key = (sub.get("task_id"), platform, sub.get("chat_id"), sub.get("thread_id") or "") + if key in _UNROUTABLE_WARNED: + return + _UNROUTABLE_WARNED.add(key) + logger.warning(message, sub.get("task_id"), getattr(platform, "value", platform), + sub.get("chat_id"), *extra_args) + + def _platform_names(mapping: Any) -> set[str]: """Lower-cased platform names of an adapters mapping (Platform enums or strings).""" return {getattr(platform, "value", str(platform)).lower() for platform in mapping} @@ -148,6 +163,13 @@ def _adapter_for_subscription(runner: Any, platform: Any, sub: dict, owner_profi # Empty maps are startup placeholders for route-only profiles; a connected # secondary on ANY platform establishes an independent credential boundary. if (getattr(runner, "_profile_adapters", {}) or {}).get(profile): + _warn_unroutable_sub_once( + sub, platform, + "kanban notifier: subscription for %s on %s chat %s is pinned to profile %s, which runs " + "other-platform adapters but none for %s; it will not be delivered. Give that profile a %s " + "adapter or make it route-only, then re-subscribe with `hermes kanban notify-subscribe ... " + "--notifier-profile `.", + profile, platform.value, platform.value, platform.value) return None metadata = sub.get("delivery_metadata") or {} guild = metadata.get("scope_id") or metadata.get("guild_id") @@ -165,6 +187,12 @@ def _adapter_for_subscription(runner: Any, platform: Any, sub: dict, owner_profi if route.matches(platform.value, guild_id=guild, chat_id=chat, thread_id=thread, parent_chat_id=parent, user_id=user_id): if route.profile != profile: + _warn_unroutable_sub_once( + sub, platform, + "kanban notifier: subscription for %s on %s chat %s is stamped with profile %s but a " + "profile_routes entry pins that chat to profile %s; it will not be delivered. " + "Re-subscribe with `hermes kanban notify-subscribe ... --notifier-profile %s`.", + profile, route.profile, route.profile) return None from gateway.run import _multiplex_profile_homes served = {name for name, _home in _multiplex_profile_homes(config)} diff --git a/tests/gateway/test_kanban_routed_transport.py b/tests/gateway/test_kanban_routed_transport.py index 5c9043d078..9f04a05908 100644 --- a/tests/gateway/test_kanban_routed_transport.py +++ b/tests/gateway/test_kanban_routed_transport.py @@ -234,3 +234,41 @@ def test_anchorless_thread_subscription_warns_once_instead_of_silent_skip(tmp_pa assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING assert "--parent-chat-id" in warnings[0].getMessage() assert unseen(task) + + +def test_credential_gate_denials_warn_once_instead_of_silent_rewind(tmp_path, monkeypatch, caplog): + """A pinned profile that runs other-platform adapters but none for the subscription's + platform, and a sub stamped with a profile other than the route's, are permanent dead-ends: + the notifier rewinds the claim every tick at DEBUG only. Both skips must surface ONCE per + row at WARNING with the re-subscribe escape hatch (#115460).""" + import logging + from gateway import kanban_watchers_notifier as notifier + + runner = setup_runner(tmp_path, monkeypatch) + monkeypatch.setattr(notifier, "_UNROUTABLE_WARNED", set()) + # The pinned profile holds a credential on another platform, so it is an independent + # credential boundary without a Discord adapter of its own. + runner._profile_adapters["yuki"] = {Platform.TELEGRAM: RecordingAdapter()} + task = completion() + with caplog.at_level(logging.WARNING, logger=notifier.logger.name): + assert not collect(runner) + assert not collect(runner) + warnings = [r for r in caplog.records if "none for discord" in r.getMessage() and task in r.getMessage()] + assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING + assert "--notifier-profile" in warnings[0].getMessage() + assert unseen(task) + + # An owner stamped with the invoking shell's profile (#76483) instead of the route's + # is the same silent dead-end. (Fresh DB: resetting the credential boundary above + # re-enables the first sub, which is the documented workaround, and its backlog must + # not pollute this claim.) + runner._profile_adapters["yuki"] = {} + monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "stamped-owner.db")) + stamped = completion(profile="default") + with caplog.at_level(logging.WARNING, logger=notifier.logger.name): + assert not collect(runner) + warnings = [r for r in caplog.records if "pins that chat to profile yuki" in r.getMessage() + and stamped in r.getMessage()] + assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING + assert "--notifier-profile yuki" in warnings[0].getMessage() + assert unseen(stamped)