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).
This commit is contained in:
liuhao1024
2026-09-19 07:04:45 +08:00
committed by Teknium
parent 12c1ccb5d8
commit 2b86e0b223
2 changed files with 66 additions and 0 deletions

View File

@@ -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 <a profile that holds a %s credential>`.",
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)}

View File

@@ -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)