hermes-agent/tests/gateway/test_kanban_notifier.py
Salim c93ed07459 fix(kanban): route active named profile through the active adapter map
A gateway running under a named active profile (e.g. `hermes -p main gateway`)
stamps kanban auto-subscriptions with notifier_profile=main, but
_authorization_adapter() treated any name other than the literal "default"
as a multiplex secondary and consulted only _profile_adapters — empty on
standalone gateway-per-profile deployments. The helper failed closed, the
notifier rewound the claim, and the notification was silently retried
forever (#71340).

Recognize the gateway's own active profile name as primary so its stamped
subscriptions resolve via self.adapters; genuinely secondary profiles keep
the fail-closed lookup.

Salvaged from PR #62380 (the unrelated blocked-reason truncation change is
intentionally not taken).
2026-07-26 16:27:52 -07:00

746 lines
26 KiB
Python

import asyncio
import sqlite3
from pathlib import Path
from gateway.config import Platform
from gateway.run import GatewayRunner
from hermes_cli import kanban_db as kb
class RecordingAdapter:
def __init__(self):
self.sent = []
self.handled = []
async def send(self, chat_id, text, metadata=None):
self.sent.append({"chat_id": chat_id, "text": text, "metadata": metadata or {}})
async def handle_message(self, event):
self.handled.append(event)
class DisconnectedAdapters(dict):
"""Expose a platform during collection, then simulate disconnect on get()."""
def get(self, key, default=None):
return None
async def _run_one_notifier_tick(monkeypatch, runner):
real_sleep = asyncio.sleep
async def fake_sleep(delay):
if delay == 5:
return None
runner._running = False
await real_sleep(0)
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
await runner._kanban_notifier_watcher(interval=1)
def _make_runner(adapter):
runner = GatewayRunner.__new__(GatewayRunner)
runner._running = True
runner.adapters = {Platform.TELEGRAM: adapter}
runner._kanban_sub_fail_counts = {}
return runner
def _create_completed_subscription(summary="done once"):
conn = kb.connect()
try:
tid = kb.create_task(conn, title="notify once", assignee="worker")
kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1")
kb.complete_task(conn, tid, summary=summary)
return tid
finally:
conn.close()
def _unseen_terminal_events(tid):
conn = kb.connect()
try:
_, events = kb.unseen_events_for_sub(
conn,
task_id=tid,
platform="telegram",
chat_id="chat-1",
kinds=["completed", "blocked", "gave_up", "crashed", "timed_out"],
)
return events
finally:
conn.close()
def test_kanban_notifier_dedupes_board_slugs_pointing_to_same_db(tmp_path, monkeypatch):
db_path = tmp_path / "shared-kanban.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
kb.write_board_metadata("alias-a", name="Alias A")
kb.write_board_metadata("alias-b", name="Alias B")
tid = _create_completed_subscription()
adapter = RecordingAdapter()
runner = _make_runner(adapter)
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
assert len(adapter.sent) == 1
assert "Kanban" in adapter.sent[0]["text"]
assert tid in adapter.sent[0]["text"]
def test_kanban_notifier_claim_prevents_second_watcher_send(tmp_path, monkeypatch):
db_path = tmp_path / "single-owner.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
tid = _create_completed_subscription()
adapter1 = RecordingAdapter()
adapter2 = RecordingAdapter()
asyncio.run(_run_one_notifier_tick(monkeypatch, _make_runner(adapter1)))
asyncio.run(_run_one_notifier_tick(monkeypatch, _make_runner(adapter2)))
assert len(adapter1.sent) == 1
assert adapter2.sent == []
def test_kanban_notifier_replays_telegram_dm_topic_delivery_metadata(tmp_path, monkeypatch):
db_path = tmp_path / "dm-topic-metadata.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
conn = kb.connect()
try:
tid = kb.create_task(
conn,
title="dm topic task",
assignee="worker",
session_id="agent:main:telegram:dm:chat-1",
)
kb.add_notify_sub(
conn,
task_id=tid,
platform="telegram",
chat_id="chat-1",
thread_id="20197",
delivery_metadata={
"chat_type": "dm",
"direct_messages_topic_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
"thread_id": "20197",
},
)
kb.complete_task(conn, tid, summary="done")
finally:
conn.close()
adapter = RecordingAdapter()
runner = _make_runner(adapter)
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
assert len(adapter.sent) == 1
assert adapter.sent[0]["metadata"] == {
"chat_type": "dm",
"direct_messages_topic_id": "20197",
"telegram_dm_topic_reply_fallback": True,
"telegram_reply_to_message_id": "462",
"thread_id": "20197",
}
assert len(adapter.handled) == 1
assert adapter.handled[0].source.chat_type == "dm"
assert adapter.handled[0].source.thread_id == "20197"
def test_kanban_notifier_rewinds_claim_if_adapter_disconnects(tmp_path, monkeypatch):
db_path = tmp_path / "adapter-disconnect.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
tid = _create_completed_subscription()
runner = GatewayRunner.__new__(GatewayRunner)
runner._running = True
runner.adapters = DisconnectedAdapters({Platform.TELEGRAM: RecordingAdapter()})
runner._kanban_sub_fail_counts = {}
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
assert [ev.kind for ev in _unseen_terminal_events(tid)] == ["completed"]
def test_active_named_profile_subscription_is_delivered(tmp_path, monkeypatch):
"""A sub stamped with the gateway's own named profile uses self.adapters.
Regression for #71340: on a standalone (non-multiplex) gateway running a
named profile, _authorization_adapter() used to treat the active name as a
multiplex secondary, find no _profile_adapters entry, fail closed, and
rewind the claim forever — silent zero-delivery.
"""
db_path = tmp_path / "actionable-block.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
reason = "AGE-39 — https://linear.example/AGE-39 — publishing verified."
conn = kb.connect()
try:
tid = kb.create_task(conn, title="approval", assignee="publisher")
kb.add_notify_sub(
conn,
task_id=tid,
platform="telegram",
chat_id="chat-1",
notifier_profile="main",
)
kb.block_task(conn, tid, reason=reason, kind="needs_input")
finally:
conn.close()
adapter = RecordingAdapter()
runner = _make_runner(adapter)
runner._active_profile_name = lambda: "main"
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
assert len(adapter.sent) == 1
message = adapter.sent[0]["text"]
assert tid in message
assert "blocked" in message
def test_kanban_db_path_is_test_isolated_from_real_home():
hermes_home = Path(kb.kanban_home())
production_db = Path.home() / ".hermes" / "kanban.db"
assert kb.kanban_db_path().resolve() != production_db.resolve()
conn = kb.connect()
try:
tid = kb.create_task(conn, title="x", assignee="worker")
kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1")
finally:
conn.close()
assert kb.kanban_db_path().resolve().is_relative_to(hermes_home.resolve())
assert kb.kanban_db_path().resolve() != production_db.resolve()
class FailingAdapter:
"""Adapter whose send() always raises, simulating a transient send error."""
def __init__(self):
self.attempts = 0
async def send(self, chat_id, text, metadata=None):
self.attempts += 1
raise RuntimeError("simulated send failure")
def test_kanban_notifier_rewinds_claim_on_send_exception(tmp_path, monkeypatch):
"""A raising adapter rewinds the claim so the next tick can retry.
This is the second rewind path (distinct from the adapter-disconnect path
in test_kanban_notifier_rewinds_claim_if_adapter_disconnects). Here the
adapter is connected and the send call actually fires; the claim must
still rewind so the event isn't lost when send() raises mid-tick.
"""
db_path = tmp_path / "send-failure.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
tid = _create_completed_subscription()
adapter = FailingAdapter()
runner = _make_runner(adapter)
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
# Send was attempted (so we exercised the failure path, not just the
# disconnect path) and the claim was rewound — the unseen-events query
# still returns the event for retry on the next tick.
assert adapter.attempts >= 1, "send should have been attempted at least once"
assert [ev.kind for ev in _unseen_terminal_events(tid)] == ["completed"]
class ReportedFailureAdapter:
"""Adapter that REPORTS failure via SendResult(success=False) instead of
raising — the exact contract the Telegram adapter uses for 'Not connected'
and degraded-send paths."""
def __init__(self):
self.attempts = 0
async def send(self, chat_id, text, metadata=None):
self.attempts += 1
from gateway.platforms.base import SendResult
return SendResult(success=False, error="Not connected")
def test_kanban_notifier_rewinds_claim_on_reported_send_failure(tmp_path, monkeypatch):
"""A non-raising SendResult(success=False) must NOT advance the cursor.
Regression for the silent-drop bug: the notifier used to discard send()'s
return value, so a reported (not raised) failure — e.g. Telegram mid-
reconnect after a gateway restart — fell through to the success branch,
marked the event seen, and lost the notification forever. The event must
remain unseen for retry, exactly like the raised-exception path.
"""
db_path = tmp_path / "reported-failure.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
tid = _create_completed_subscription()
adapter = ReportedFailureAdapter()
runner = _make_runner(adapter)
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
assert adapter.attempts >= 1, "send should have been attempted"
assert [ev.kind for ev in _unseen_terminal_events(tid)] == ["completed"], (
"a reported send failure must rewind the claim, not silently drop the event"
)
def test_notifier_redelivers_same_kind_on_dispatch_cycle(tmp_path, monkeypatch):
"""A retry cycle (crashed → reclaimed → crashed) notifies the user twice.
Before #21398 the notifier auto-unsubscribed on any terminal event kind
(gave_up / crashed / timed_out), so the second crash in a respawn cycle
silently dropped — the subscription was already gone. This test pins the
new contract: subscription survives non-final terminal events; the
cursor handles dedup.
Two crashes ten seconds apart on the same task — both should land on
the adapter.
"""
db_path = tmp_path / "redeliver-cycle.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
conn = kb.connect()
try:
tid = kb.create_task(conn, title="cycle test", assignee="worker")
kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1")
# First crash — fired by the dispatcher when the worker PID dies.
kb._append_event(conn, tid, kind="crashed")
finally:
conn.close()
adapter = RecordingAdapter()
runner = _make_runner(adapter)
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
# First crash delivered.
assert len(adapter.sent) == 1
assert "crashed" in adapter.sent[0]["text"].lower()
# Subscription survives — the cursor advanced past event #1, but the
# row is still there.
conn = kb.connect()
try:
subs = kb.list_notify_subs(conn, tid)
assert len(subs) == 1, (
"Subscription must survive a crashed event so a respawn-cycle "
"second crash also notifies the user (issue #21398)."
)
# Second crash — same task, same dispatcher (or a respawn). Append
# another event to simulate the dispatcher firing crashed a second
# time during retry.
kb._append_event(conn, tid, kind="crashed")
finally:
conn.close()
# New tick: the second event has a fresh id past the cursor advance,
# so it gets claimed and delivered.
runner = _make_runner(adapter)
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
assert len(adapter.sent) == 2, (
f"Second crashed event should also notify; got {len(adapter.sent)} "
f"deliveries (texts: {[d['text'] for d in adapter.sent]})"
)
assert "crashed" in adapter.sent[1]["text"].lower()
def test_notifier_delivers_subscription_owned_by_active_profile(tmp_path, monkeypatch):
"""A single-profile gateway stamps active profile but keeps adapters primary."""
db_path = tmp_path / "active-profile-owner.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
conn = kb.connect()
try:
tid = kb.create_task(conn, title="owned by active profile", assignee="worker")
kb.add_notify_sub(
conn,
task_id=tid,
platform="telegram",
chat_id="chat-1",
notifier_profile="dev",
)
kb.complete_task(conn, tid, summary="done")
finally:
conn.close()
adapter = RecordingAdapter()
runner = _make_runner(adapter)
runner._active_profile_name = lambda: "dev"
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
assert len(adapter.sent) == 1
assert tid in adapter.sent[0]["text"]
def test_notifier_owning_profile_adapter_no_default_fallback(tmp_path, monkeypatch):
"""A subscription owned by a secondary profile whose profile-adapter
registry entry EXISTS but lacks this platform must NOT fall back to the
default profile's same-platform adapter — the notifier must route through
the shared ``_authorization_adapter`` chokepoint, which forbids that
fallback (gateway/authz_mixin.py). Delivering via the default profile's bot
is the exact cross-profile mis-delivery this whole change exists to fix
(`[230002] Bot can NOT be out of the chat`).
Mutation check: reverting kanban_watchers.py's adapter selection to the old
inline ``if adapter is None: adapter = self.adapters.get(plat)`` fallback
makes this test FAIL (the default adapter receives the delivery).
"""
db_path = tmp_path / "profile-no-fallback.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
conn = kb.connect()
try:
tid = kb.create_task(conn, title="owned by beta", assignee="worker")
# Subscription is owned by profile "beta".
kb.add_notify_sub(
conn, task_id=tid, platform="telegram", chat_id="chat-beta",
notifier_profile="beta",
)
kb.complete_task(conn, tid, summary="done")
finally:
conn.close()
default_adapter = RecordingAdapter()
other_adapter = RecordingAdapter()
runner = GatewayRunner.__new__(GatewayRunner)
runner._running = True
# Default profile has a telegram adapter …
runner.adapters = {Platform.TELEGRAM: default_adapter}
# … and profile "beta" HAS a non-empty registry entry (so it passes the
# notifier's upstream skip-filter, which only skips owning profiles with NO
# adapter at all), but that entry does NOT contain a telegram adapter — beta
# connected a different platform (discord). The telegram sub owned by beta
# must therefore resolve to NO adapter, not silently borrow the default
# profile's telegram bot.
runner._profile_adapters = {"beta": {Platform.DISCORD: other_adapter}}
runner._kanban_sub_fail_counts = {}
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
# The default profile's adapter must never receive beta's notification.
assert default_adapter.sent == [], (
"Owning-profile subscription must not fall back to the default "
f"profile's adapter; got {default_adapter.sent!r}"
)
assert other_adapter.sent == [], (
f"beta's discord adapter must not receive a telegram sub; got {other_adapter.sent!r}"
)
# The claim is rewound (adapter resolved to None → treated as disconnected),
# so the event is still unseen and will deliver once beta's adapter connects.
assert [ev.kind for ev in _unseen_terminal_events_for(tid, "chat-beta")] == ["completed"]
def test_notifier_claims_platform_only_a_secondary_profile_owns(tmp_path, monkeypatch):
"""A subscription owned by a secondary profile on a platform the DEFAULT
profile never connected must still be claimed and delivered.
Regression: the ``_collect()`` pre-filter built ``active_platforms``
solely from ``self.adapters`` (the default profile). A sub owned by
profile "beta" on "discord", where beta genuinely has a live discord
adapter but the default profile has no discord adapter at all, was
dropped by that pre-filter (``platform not in active_platforms``)
before ``claim_unseen_events_for_sub`` ever ran — unlike the
disconnected-adapter path, an unclaimed event is never rewound, so this
was a permanent, silent notification loss, not a retryable one. This
directly contradicts the feature's own purpose (routing notifications
via the owning profile), and is the same cross-profile-adapter-lookup
class the delivery-side chokepoint in
``test_notifier_owning_profile_adapter_no_default_fallback`` already
guards — just one gate earlier.
"""
db_path = tmp_path / "secondary-only-platform.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
conn = kb.connect()
try:
tid = kb.create_task(conn, title="owned by beta on discord", assignee="worker")
kb.add_notify_sub(
conn, task_id=tid, platform="discord", chat_id="chat-beta",
notifier_profile="beta",
)
kb.complete_task(conn, tid, summary="done")
finally:
conn.close()
beta_adapter = RecordingAdapter()
runner = GatewayRunner.__new__(GatewayRunner)
runner._running = True
# Default profile has NO discord adapter at all.
runner.adapters = {Platform.TELEGRAM: RecordingAdapter()}
# Secondary profile "beta" has a live discord adapter.
runner._profile_adapters = {"beta": {Platform.DISCORD: beta_adapter}}
runner._kanban_sub_fail_counts = {}
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
assert len(beta_adapter.sent) == 1, (
f"beta's discord adapter should have received the notification; got {beta_adapter.sent!r}"
)
def test_notifier_wakeup_uses_subscription_chat_type(tmp_path, monkeypatch):
db_path = tmp_path / "chat-type-wakeup.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
conn = kb.connect()
try:
tid = kb.create_task(
conn,
title="dm requester",
assignee="worker",
session_id="origin-session",
)
kb.add_notify_sub(
conn,
task_id=tid,
platform="telegram",
chat_id="chat-dm",
chat_type="dm",
)
kb.complete_task(conn, tid, summary="done")
finally:
conn.close()
adapter = RecordingAdapter()
asyncio.run(_run_one_notifier_tick(monkeypatch, _make_runner(adapter)))
assert len(adapter.sent) == 1
assert len(adapter.handled) == 1
assert adapter.handled[0].source.chat_type == "dm"
# The wake must resume the creator's real DM session key — the whole bug
# was that a hardcoded chat_type="group" made build_session_key() produce
# a group-scoped key (a NEW session) instead of the ":dm:<chat_id>" shape
# the original conversation runs under (#56580 / #68874).
from gateway.session import build_session_key
wake_key = build_session_key(adapter.handled[0].source)
assert wake_key == "agent:main:telegram:dm:chat-dm"
assert ":group:" not in wake_key
def test_auto_subscribe_persists_session_chat_type(tmp_path, monkeypatch):
db_path = tmp_path / "auto-sub-chat-type.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
from gateway.session_context import clear_session_vars, set_session_vars
from tools import kanban_tools
monkeypatch.setattr(
kanban_tools,
"load_config",
lambda: {"kanban": {"auto_subscribe_on_create": True}},
)
tokens = set_session_vars(
platform="telegram",
chat_id="chat-dm",
chat_type="dm",
)
conn = kb.connect()
try:
tid = kb.create_task(conn, title="auto sub", assignee="worker")
assert kanban_tools._maybe_auto_subscribe(conn, tid) is True
[sub] = kb.list_notify_subs(conn, task_id=tid)
assert sub["chat_type"] == "dm"
finally:
conn.close()
clear_session_vars(tokens)
def test_notify_sub_migration_adds_chat_type_to_legacy_table(tmp_path, monkeypatch):
db_path = tmp_path / "legacy-notify-sub.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
legacy = sqlite3.connect(db_path)
try:
legacy.execute(
"""
CREATE TABLE kanban_notify_subs (
task_id TEXT NOT NULL,
platform TEXT NOT NULL,
chat_id TEXT NOT NULL,
thread_id TEXT NOT NULL DEFAULT '',
user_id TEXT,
notifier_profile TEXT,
created_at INTEGER NOT NULL,
last_event_id INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (task_id, platform, chat_id, thread_id)
)
"""
)
legacy.commit()
finally:
legacy.close()
kb.init_db()
conn = kb.connect()
try:
cols = {
row["name"] for row in conn.execute("PRAGMA table_info(kanban_notify_subs)")
}
assert "chat_type" in cols
tid = kb.create_task(conn, title="legacy sub", assignee="worker")
kb.add_notify_sub(
conn,
task_id=tid,
platform="telegram",
chat_id="chat-dm",
chat_type="dm",
)
[sub] = kb.list_notify_subs(conn, task_id=tid)
assert sub["chat_type"] == "dm"
finally:
conn.close()
def _unseen_terminal_events_for(tid, chat_id):
conn = kb.connect()
try:
_, events = kb.unseen_events_for_sub(
conn,
task_id=tid,
platform="telegram",
chat_id=chat_id,
kinds=["completed", "blocked", "gave_up", "crashed", "timed_out"],
)
return events
finally:
conn.close()
def test_kanban_notifier_isolates_per_subscription_failure(tmp_path, monkeypatch):
"""One bad subscription must not block delivery for all others.
Regression for #59269: when claim_unseen_events_for_sub raises for one
subscription, the entire notifier tick used to abort — silently blocking
delivery for every other subscription.
"""
db_path = tmp_path / "isolation.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
# Create two tasks with subscriptions and complete both. The BAD task is
# created first: list_notify_subs() has no ORDER BY, so SQLite's natural
# scan returns insertion order — the failing subscription must be
# processed BEFORE the good one or this test passes even without the
# per-subscription isolation (the good delivery happens before the tick
# aborts). A deterministic-order shim below removes the reliance on the
# scan order entirely.
conn = kb.connect()
try:
tid_bad = kb.create_task(conn, title="bad task", assignee="worker")
kb.add_notify_sub(conn, task_id=tid_bad, platform="telegram", chat_id="chat-bad")
kb.complete_task(conn, tid_bad, summary="done")
tid_good = kb.create_task(conn, title="good task", assignee="worker")
kb.add_notify_sub(conn, task_id=tid_good, platform="telegram", chat_id="chat-good")
kb.complete_task(conn, tid_good, summary="done")
finally:
conn.close()
original_claim = kb.claim_unseen_events_for_sub
def selective_claim(conn, task_id, **kwargs):
if task_id == tid_bad:
raise RuntimeError("simulated DB corruption for bad task")
return original_claim(conn, task_id=task_id, **kwargs)
monkeypatch.setattr(kb, "claim_unseen_events_for_sub", selective_claim)
# Force the failing subscription to be iterated FIRST regardless of the
# unordered SELECT's scan order.
original_list = kb.list_notify_subs
def bad_first(conn, task_id=None):
subs = original_list(conn, task_id)
return sorted(subs, key=lambda s: 0 if s["task_id"] == tid_bad else 1)
monkeypatch.setattr(kb, "list_notify_subs", bad_first)
adapter = RecordingAdapter()
runner = _make_runner(adapter)
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
# The good task must still be delivered despite the bad task failing.
assert len(adapter.sent) == 1
assert tid_good in adapter.sent[0]["text"]
def test_notifier_delivers_block_loop_detected_triage_ping(tmp_path, monkeypatch):
"""A `block_loop_detected` event must reach the subscriber as a triage ping.
Regression for the silent-triage gap (PR #62712): kanban_db routes a task
to `triage` after BLOCK_RECURRENCE_LIMIT re-blocks for the same cause and
emits ONLY a `block_loop_detected` event — no `blocked`/`status` event.
Before `block_loop_detected` joined TERMINAL_KINDS with its own message
branch, that one transition (the whole point of which is to force human
attention) produced zero notification and the task stalled in triage
silently.
"""
db_path = tmp_path / "block-loop.db"
monkeypatch.setenv("HERMES_KANBAN_DB", str(db_path))
kb.init_db()
conn = kb.connect()
try:
tid = kb.create_task(conn, title="loops forever", assignee="worker")
kb.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="chat-1")
kb._append_event(
conn, tid, "block_loop_detected",
{"reason": "needs credentials", "kind": "needs_input",
"recurrences": 2, "limit": kb.BLOCK_RECURRENCE_LIMIT},
)
finally:
conn.close()
adapter = RecordingAdapter()
runner = _make_runner(adapter)
asyncio.run(_run_one_notifier_tick(monkeypatch, runner))
assert len(adapter.sent) == 1, "block_loop_detected must produce a notification"
text = adapter.sent[0]["text"]
assert "TRIAGE" in text
assert tid in text
assert "needs credentials" in text
# Cursor advanced: the event is claimed and not re-delivered.
conn = kb.connect()
try:
_, remaining = kb.unseen_events_for_sub(
conn, task_id=tid, platform="telegram", chat_id="chat-1",
kinds=["block_loop_detected"],
)
finally:
conn.close()
assert remaining == []