hermes-agent/tests/gateway/test_kanban_notifier.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

412 lines
14 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_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_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
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")
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_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_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 _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 == []