From a21747fe4efc9c32bbb24b89cd17cf7ff8e96582 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:57:42 -0700 Subject: [PATCH] fix(kanban): an anchorless thread subscription warns once instead of vanishing (#110919) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on the salvaged #110928 (CLI `--parent-chat-id` / `--guild-id`): - `_claim_for_sub` skipped a thread-shaped row that matched no `profile_routes` entry at DEBUG on every tick. Legacy rows written before the flags existed can never match a channel-level route (no `parent_chat_id`), so the notifier now logs ONE WARNING per row naming the task, the thread and the re-subscribe command. Still fail-closed: the events stay unclaimed. - Docs: the kanban user guide explains the anchors and shows the Discord-thread subscribe command under `profile_routes`. - Test (red on origin/main): two collects → exactly one WARNING, events unseen. --- gateway/kanban_watchers_notifier.py | 25 +++++++++++++++++++ tests/gateway/test_kanban_routed_transport.py | 19 ++++++++++++++ website/docs/user-guide/features/kanban.md | 13 ++++++++++ 3 files changed, 57 insertions(+) diff --git a/gateway/kanban_watchers_notifier.py b/gateway/kanban_watchers_notifier.py index 9e168538d6..198a867d63 100644 --- a/gateway/kanban_watchers_notifier.py +++ b/gateway/kanban_watchers_notifier.py @@ -93,6 +93,30 @@ def _wake_scope_id(adapter: Any, sub: dict) -> Optional[str]: return str(resolved) if resolved else None +_ANCHORLESS_WARNED: set[tuple] = set() + + +def _warn_anchorless_thread_sub_once(sub: dict, platform: str) -> None: + """A thread-shaped subscription without ``parent_chat_id`` cannot match a channel-level + ``profile_routes`` entry, so the fail-closed route gate skips it on every tick. Say so ONCE per + row at WARNING — a subscription that can never deliver was invisible below DEBUG (#110919).""" + metadata = sub.get("delivery_metadata") or {} + thread_like = bool(sub.get("thread_id")) or (sub.get("chat_type") or metadata.get("chat_type")) in { + "thread", "forum", "forum_post", "forum-post", "topic"} + if not thread_like or metadata.get("parent_chat_id"): + return + key = (sub.get("task_id"), platform, sub.get("chat_id"), sub.get("thread_id") or "") + if key in _ANCHORLESS_WARNED: + return + _ANCHORLESS_WARNED.add(key) + logger.warning( + "kanban notifier: subscription for %s on %s thread %s has no parent_chat_id anchor and matched no " + "profile route; it will not be delivered. Re-subscribe with `hermes kanban notify-subscribe ... " + "--parent-chat-id [--guild-id ]`.", + sub.get("task_id"), platform, sub.get("chat_id"), + ) + + 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} @@ -225,6 +249,7 @@ class _Collector: return None from gateway.config import Platform if _adapter_for_subscription(self.runner, Platform(platform), sub, owner_profile or self.notifier_profile) is None: + _warn_anchorless_thread_sub_once(sub, platform) return None old_cursor, cursor, events = _kbn().claim_unseen_events_for_sub( conn, task_id=sub["task_id"], platform=sub["platform"], chat_id=sub["chat_id"], diff --git a/tests/gateway/test_kanban_routed_transport.py b/tests/gateway/test_kanban_routed_transport.py index 4742957ea9..d80403480c 100644 --- a/tests/gateway/test_kanban_routed_transport.py +++ b/tests/gateway/test_kanban_routed_transport.py @@ -193,3 +193,22 @@ def test_removed_profile_never_wakes_under_the_primary_runtime(tmp_path, monkeyp asyncio.run(deliver(runner, rows)) assert secondary.handled == [] assert unseen(task) + + +def test_anchorless_thread_subscription_warns_once_instead_of_silent_skip(tmp_path, monkeypatch, caplog): + """A CLI-created Discord thread sub with no ``parent_chat_id`` cannot match a channel-level + route and is skipped fail-closed — that skip must be visible ONCE at WARNING, not buried at + DEBUG on every tick forever (#110919).""" + import logging + from gateway import kanban_watchers_notifier as notifier + + runner = setup_runner(tmp_path, monkeypatch) + monkeypatch.setattr(notifier, "_ANCHORLESS_WARNED", set()) + task = completion(metadata={"chat_type": "thread"}) + 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 "parent_chat_id" in r.getMessage() and task in r.getMessage()] + assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING + assert "--parent-chat-id" in warnings[0].getMessage() + assert unseen(task) diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md index 0cd901f129..7ac47ba25f 100644 --- a/website/docs/user-guide/features/kanban.md +++ b/website/docs/user-guide/features/kanban.md @@ -1123,6 +1123,19 @@ hermes kanban notify-unsubscribe t_abcd \ A subscription removes itself automatically once the task reaches `done` or `archived`; no cleanup needed. +**Discord threads under `profile_routes`:** when the gateway multiplexes profiles and routes a *channel* +to a profile, a subscription created from the CLI for a *thread* in that channel needs the thread's route +anchors, or the notifier cannot match it to any route and skips it (logged once at WARNING). Pass them +explicitly — `--parent-chat-id ` and, for Discord, `--guild-id `: + +```bash +hermes kanban notify-subscribe t_abcd \ + --platform discord --chat-id --thread-id --chat-type thread \ + --parent-chat-id --guild-id --delivery-mode notify+wake +``` + +Subscriptions created from inside the chat (`/kanban create`, `kanban_create`) record these anchors automatically. + ### Delivery modes `--delivery-mode` controls **how** the notifier reacts to a terminal event. Every subscription is in one of three modes (`notify` is the default and the original behavior):