mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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.
412 lines
14 KiB
Python
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 == []
|