hermes-agent/tests/gateway/test_handoff_watcher_async_db.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

134 lines
4.4 KiB
Python

"""Regression test for #40695 (salvage of keystone PR #40782).
The Discord gateway heartbeat was stalling because the handoff watcher
(``GatewayRunner._handoff_watcher``) polled the synchronous, blocking
SQLite-backed ``SessionDB`` directly on the asyncio event loop every 2s
('Shard ID None heartbeat blocked for more than N seconds').
The fix routes every blocking ``SessionDB`` call in the watcher through the
``AsyncSessionDB`` facade, which offloads each call via ``asyncio.to_thread`` so
the SQLite I/O runs on a worker thread and never blocks the event loop / Discord
heartbeat.
These tests assert that behaviour contract. They are mutation-survivable:
reverting any ``await self._session_db.<call>(...)`` back to a direct synchronous
call on the loop makes the relevant assertion fail.
"""
import asyncio
import types
import pytest
import gateway.run as run
class _RecordingSessionDB:
"""SessionDB stand-in that records the thread each method runs on.
If the watcher calls these methods directly on the event loop (the bug),
they run on the loop thread. If they are wrapped in ``asyncio.to_thread``
(the fix), they run on a *different* worker thread.
"""
def __init__(self, loop_thread_ident):
self._loop_thread_ident = loop_thread_ident
self.threads = {}
self.calls = []
def _record(self, name):
import threading
self.threads.setdefault(name, []).append(threading.get_ident())
self.calls.append(name)
def ran_off_loop(self, name):
"""True iff every call to ``name`` ran on a non-loop thread."""
idents = self.threads.get(name, [])
return bool(idents) and all(i != self._loop_thread_ident for i in idents)
def list_pending_handoffs(self):
self._record("list_pending_handoffs")
return [{"id": "sess-1"}]
def claim_handoff(self, session_id):
self._record("claim_handoff")
return True
def complete_handoff(self, session_id):
self._record("complete_handoff")
def fail_handoff(self, session_id, error):
self._record("fail_handoff")
def _make_fake_runner(session_db, *, fail_process=False):
"""Build a minimal object that exposes exactly what the loop body touches.
The watcher now talks to the SessionDB through the AsyncSessionDB facade,
so wrap the recording stand-in the same way the gateway does.
"""
from hermes_state import AsyncSessionDB
fake = types.SimpleNamespace()
fake._session_db = AsyncSessionDB(session_db)
# _running yields True for the first loop check, then False so the loop
# exits after a single tick.
states = iter([True, False])
class _Running:
def __bool__(_self):
try:
return next(states)
except StopIteration:
return False
fake._running = _Running()
async def _process_handoff(row):
if fail_process:
raise RuntimeError("boom")
fake._process_handoff = _process_handoff
return fake
async def _run_one_tick(fake, monkeypatch):
"""Run the watcher for a single tick with sleeps neutralised."""
async def _no_sleep(_seconds):
return None
monkeypatch.setattr(run.asyncio, "sleep", _no_sleep)
# Bind the real (patched) method onto our minimal stand-in.
coro = run.GatewayRunner._handoff_watcher(fake, interval=0.0)
await asyncio.wait_for(coro, timeout=5)
@pytest.mark.asyncio
async def test_watcher_wraps_calls_via_asyncio_to_thread(monkeypatch):
"""Explicitly assert the offload goes through asyncio.to_thread.
Patches the AsyncSessionDB facade's ``asyncio.to_thread`` (it lives in
hermes_state) and records which SessionDB callables were handed to it.
Mutation-survivable: dropping any await removes its callable from the set.
"""
import hermes_state
db = _RecordingSessionDB(loop_thread_ident=-1)
fake = _make_fake_runner(db, fail_process=False)
wrapped = []
real_to_thread = hermes_state.asyncio.to_thread
async def _spy_to_thread(func, *args, **kwargs):
wrapped.append(getattr(func, "__name__", repr(func)))
return await real_to_thread(func, *args, **kwargs)
monkeypatch.setattr(hermes_state.asyncio, "to_thread", _spy_to_thread)
await _run_one_tick(fake, monkeypatch)
assert "list_pending_handoffs" in wrapped
assert "claim_handoff" in wrapped
assert "complete_handoff" in wrapped