fix(kanban): an anchorless thread subscription warns once instead of vanishing (#110919)

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.
This commit is contained in:
teknium1
2026-09-14 10:57:42 -07:00
committed by Teknium
parent 45b42202fa
commit a21747fe4e
3 changed files with 57 additions and 0 deletions
+25
View File
@@ -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 <channel id> [--guild-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"],
@@ -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)
@@ -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 <channel id>` and, for Discord, `--guild-id <guild id>`:
```bash
hermes kanban notify-subscribe t_abcd \
--platform discord --chat-id <thread id> --thread-id <thread id> --chat-type thread \
--parent-chat-id <channel id> --guild-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):