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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user