Every test double that stood in for `_adapter_for_source` was a delivery stand-in. Part of #88715 (phase 4).
192 lines
7.9 KiB
Python
192 lines
7.9 KiB
Python
"""Push admission and durable notifier retries use real adapter/SQLite lifecycles."""
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from evals.heartbeat_idle_wire import WireAdapter
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.kanban_watchers_notifier import _KanbanNotification, _notifier_collect
|
|
from gateway.platforms.event import MessageEvent
|
|
from gateway.run import GatewayRunner
|
|
from gateway.session import SessionSource, build_session_key
|
|
from gateway.wake import admit_internal_event, deliver_wake
|
|
from hermes_cli import kanban_db as kb
|
|
from hermes_cli import kanban_db_connect as kbc
|
|
from hermes_cli import kanban_db_notify as kbn
|
|
|
|
|
|
def setup_route(raft=False):
|
|
if raft:
|
|
from plugins.platforms.raft.adapter import RaftAdapter
|
|
adapter = RaftAdapter(PlatformConfig(enabled=True, typing_indicator=False,
|
|
extra={"bridge_token": "owned-test-token", "port": 0}))
|
|
else:
|
|
adapter = WireAdapter(PlatformConfig(enabled=True, typing_indicator=False), Platform.TELEGRAM)
|
|
adapter.wire = []
|
|
runner = object.__new__(GatewayRunner)
|
|
runner._running_agents = {}
|
|
runner.adapters = {adapter.platform: adapter}
|
|
runner._delivery_adapter_for = lambda source: adapter
|
|
runner._kanban_dispatcher_lock_handle = object()
|
|
source = SessionSource(platform=adapter.platform, chat_id="42", user_id="42", chat_type="dm")
|
|
return runner, adapter, source, build_session_key(source)
|
|
|
|
|
|
async def drain(adapter):
|
|
while adapter._background_tasks:
|
|
await asyncio.gather(*list(adapter._background_tasks))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("raft", [False, True])
|
|
async def test_push_receipt_requires_real_admission_without_displacing_user(raft, monkeypatch):
|
|
runner, adapter, source, key = setup_route(raft)
|
|
if raft:
|
|
monkeypatch.setattr(adapter, "_spawn_bridge", lambda port: None)
|
|
release, started = asyncio.Event(), asyncio.Event()
|
|
received = []
|
|
|
|
async def handler(event):
|
|
received.append(event.text)
|
|
started.set()
|
|
await release.wait()
|
|
# Real runner FIFO promotion at the fake model boundary.
|
|
if key not in adapter._pending_messages:
|
|
pending = runner._promote_queued_event(key, adapter, None)
|
|
if pending is not None:
|
|
adapter._pending_messages[key] = pending
|
|
|
|
adapter.set_message_handler(handler)
|
|
await adapter.connect()
|
|
try:
|
|
await deliver_wake(adapter, text="idle", source=source)
|
|
await asyncio.wait_for(started.wait(), 2)
|
|
assert await adapter.handle_message(MessageEvent(text="human", source=source)) is None
|
|
with pytest.raises(RuntimeError, match="not accepted"):
|
|
await deliver_wake(adapter, text="no-fifo", source=source)
|
|
assert adapter._pending_messages[key].text == "human"
|
|
adapter.set_busy_session_handler(runner._handle_active_session_busy_message)
|
|
runner._BUSY_QUEUE_MAX_PENDING = 1
|
|
with pytest.raises(RuntimeError, match="not accepted"):
|
|
await deliver_wake(adapter, text="at-cap", source=source)
|
|
assert adapter._pending_messages[key].text == "human"
|
|
runner._BUSY_QUEUE_MAX_PENDING = 3
|
|
await deliver_wake(adapter, text="busy", source=source)
|
|
assert runner._queue_depth(key, adapter=adapter) == 2
|
|
rejected = MessageEvent(text="wrong-key", source=source, internal=True,
|
|
metadata={"gateway_session_key": "agent:wrong"})
|
|
with pytest.raises(RuntimeError, match="not accepted"):
|
|
await admit_internal_event(adapter, rejected)
|
|
assert rejected._gateway_accepted is False
|
|
release.set()
|
|
await drain(adapter)
|
|
assert received == ["idle", "human", "busy"]
|
|
adapter.set_message_handler(None)
|
|
with pytest.raises(RuntimeError, match="not accepted"):
|
|
await deliver_wake(adapter, text="no-handler", source=source)
|
|
finally:
|
|
release.set()
|
|
await drain(adapter)
|
|
await adapter.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notifier_retries_unaccepted_wake_without_repeating_pings(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "board.db"))
|
|
runner, adapter, source, key = setup_route()
|
|
conn = kbc.connect()
|
|
tids = {}
|
|
try:
|
|
for mode in ("notify+wake", "wake", "notify"):
|
|
tid = kb.create_task(conn, title=mode, assignee="worker", session_id=key)
|
|
kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="42",
|
|
user_id="42", chat_type="dm", delivery_mode=mode)
|
|
kb.complete_task(conn, tid, summary="handoff")
|
|
tids[mode] = tid
|
|
finally:
|
|
conn.close()
|
|
|
|
failures = {}
|
|
|
|
async def tick():
|
|
deliveries = await asyncio.to_thread(_notifier_collect, runner, kb,
|
|
notifier_profile=None, gc_due=False, gc_retention_days=30)
|
|
for delivery in deliveries:
|
|
await _KanbanNotification(runner, delivery, platform_cls=Platform,
|
|
sub_fail_counts=failures).deliver()
|
|
|
|
def unseen(mode):
|
|
conn = kbc.connect()
|
|
try:
|
|
return kbn.unseen_events_for_sub(conn, task_id=tids[mode], platform="telegram",
|
|
chat_id="42", kinds=["completed"])[1]
|
|
finally:
|
|
conn.close()
|
|
|
|
await adapter.connect()
|
|
await tick() # send works, but no message handler has been installed yet
|
|
assert len(adapter.wire) == 2
|
|
assert unseen("notify+wake") and unseen("wake")
|
|
assert not unseen("notify")
|
|
# New notifier instances and DB connections replay the durable claim, not the ping.
|
|
for _ in range(13):
|
|
await tick()
|
|
assert len(adapter.wire) == 2
|
|
assert unseen("notify+wake") and unseen("wake")
|
|
assert failures == {}
|
|
received = []
|
|
|
|
async def handler(event):
|
|
received.append(event.text)
|
|
|
|
adapter.set_message_handler(handler)
|
|
await tick()
|
|
await drain(adapter)
|
|
await tick()
|
|
assert len(received) == 2
|
|
assert all("handoff" in text for text in received)
|
|
assert len(adapter.wire) == 2
|
|
assert not any(unseen(mode) for mode in tids)
|
|
await adapter.disconnect()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_suppressed_ping_has_no_sent_receipt_but_wake_executes(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.setenv("HERMES_KANBAN_DB", str(tmp_path / "board.db"))
|
|
(tmp_path / "config.yaml").write_text("display: {suppress_warning_notifications: true}")
|
|
runner, adapter, source, key = setup_route()
|
|
conn = kbc.connect()
|
|
try:
|
|
tid = kb.create_task(conn, title="failure", assignee="worker", session_id=key)
|
|
kbn.add_notify_sub(conn, task_id=tid, platform="telegram", chat_id="42",
|
|
user_id="42", chat_type="dm", delivery_mode="notify+wake")
|
|
kb.block_task(conn, tid, reason="worker failure", kind="transient")
|
|
finally:
|
|
conn.close()
|
|
work = []
|
|
async def handler(event):
|
|
work.append(event)
|
|
return "technical diagnostic echo"
|
|
adapter.set_message_handler(handler)
|
|
await adapter.connect()
|
|
try:
|
|
deliveries = await asyncio.to_thread(_notifier_collect, runner, kb,
|
|
notifier_profile=None, gc_due=False, gc_retention_days=30)
|
|
for delivery in deliveries:
|
|
await _KanbanNotification(runner, delivery, platform_cls=Platform,
|
|
sub_fail_counts={}).deliver()
|
|
await drain(adapter)
|
|
assert len(work) == 1
|
|
assert work[0].metadata["notification_category"] == "diagnostic"
|
|
assert adapter.wire == []
|
|
conn = kbc.connect()
|
|
try:
|
|
row = conn.execute("SELECT last_ping_event_id, last_event_id FROM kanban_notify_subs WHERE task_id=?", (tid,)).fetchone()
|
|
assert row["last_ping_event_id"] == 0
|
|
assert row["last_event_id"] > 0
|
|
finally:
|
|
conn.close()
|
|
finally:
|
|
await adapter.disconnect()
|