mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-21 05:11:26 +00:00
Two follow-up improvements to the previous commit's notifier dedup work. 1. Add a regression test for the send-exception rewind path. The contributor's PR included a test for the adapter-disconnect path (test_kanban_notifier_rewinds_claim_if_adapter_disconnects, where adapter is None at delivery time), but not for the "adapter is connected, send() raises" path that fires inside the inner try/except at gateway/run.py:4314. The new test (test_kanban_notifier_rewinds_claim_on_send_exception) uses a FailingAdapter that always raises and confirms (a) send was actually attempted, (b) the claim was rewound, (c) the next call to unseen_events_for_sub still returns the event for retry. 2. Drop the per-delivery success log from INFO to DEBUG. A busy board on a multi-platform gateway can produce hundreds of these per day; that's gateway.log noise that obscures real warnings. Failure paths stay at WARNING (where you'd want to look when something's wrong) so we don't lose visibility into transient send issues.
174 lines
5.5 KiB
Python
174 lines
5.5 KiB
Python
import asyncio
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
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 = []
|
|
|
|
async def send(self, chat_id, text, metadata=None):
|
|
self.sent.append({"chat_id": chat_id, "text": text, "metadata": metadata or {}})
|
|
|
|
|
|
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_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_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"]
|