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

153 lines
5.9 KiB
Python

"""Invariant test: a completed webhook delivery closes its session.
Regression guard for the ghost-session leak. Webhook deliveries create a
unique one-shot session (``delivery_id`` baked into the session key), but the
adapter historically fired ``handle_message`` without ever ending the session.
``SessionDB.prune_sessions`` only reaps rows where ``ended_at IS NOT NULL``, so
every webhook session stayed unprunable and state.db grew without bound (this
was the primary driver of the SQLite lock-contention gateway outage).
The invariant asserted here is a *behavior contract*, not a snapshot: once a
webhook delivery's agent run completes, the session row for that delivery must
have ``ended_at`` set — mirroring how a cron run closes its session with
``end_session(..., "cron_complete")``.
CRITICAL: these tests go through the REAL ``handle_message`` →
``_process_message_background`` → ``on_processing_complete`` pipeline (only the
runner-side ``_message_handler`` is stubbed, exactly the seam the live gateway
injects). ``handle_message`` is fire-and-forget — it spawns the background
task and returns before the run starts — so any close bolted around
``handle_message`` itself runs BEFORE the session row exists and silently
no-ops. A test that fakes ``handle_message`` to create the row synchronously
masks exactly that bug (the first version of this fix shipped that way).
"""
import asyncio
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from gateway.platforms.webhook import WebhookAdapter, _INSECURE_NO_AUTH
from gateway.session import SessionSource, SessionStore
def _make_adapter(routes, **extra_kw) -> WebhookAdapter:
extra = {"host": "127.0.0.1", "port": 0, "routes": routes}
extra.update(extra_kw)
config = PlatformConfig(enabled=True, extra=extra)
return WebhookAdapter(config)
class _FakeRunner:
"""Minimal gateway runner surface the webhook close path depends on.
Wires a real ``SessionStore`` (which owns a real ``SessionDB``) and reuses
that same ``SessionDB`` as ``_session_db`` so the row created at routing
time is the row the close path ends — exactly the wiring the live gateway
has (``self.session_store`` + ``self._session_db``).
"""
def __init__(self, store: SessionStore):
self.session_store = store
self._session_db = store._db
def _session_key_for_source(self, source: SessionSource) -> str:
return self.session_store._generate_session_key(source)
def _make_store(tmp_path) -> SessionStore:
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
config = GatewayConfig(
platforms={Platform.WEBHOOK: PlatformConfig(enabled=True)}
)
store = SessionStore(sessions_dir=sessions_dir, config=config)
assert store._db is not None, "test requires a real SessionDB"
return store
def _make_event(adapter: WebhookAdapter, delivery_id: str, text: str) -> MessageEvent:
session_chat_id = f"webhook:alerts:{delivery_id}"
source = adapter.build_source(
chat_id=session_chat_id,
chat_name="webhook/alerts",
chat_type="webhook",
user_id="webhook:alerts",
user_name="alerts",
)
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
raw_message={"message": text},
message_id=delivery_id,
)
async def _drain_background_tasks(adapter: WebhookAdapter, timeout: float = 5.0) -> None:
"""Wait for the adapter's spawned processing task(s) to finish."""
deadline = asyncio.get_event_loop().time() + timeout
while adapter._background_tasks and asyncio.get_event_loop().time() < deadline:
await asyncio.sleep(0.02)
# One extra tick for done-callbacks to run.
await asyncio.sleep(0.05)
@pytest.mark.asyncio
async def test_completed_webhook_delivery_closes_its_session(tmp_path):
"""After a webhook run finishes (REAL dispatch path), ended_at is set."""
store = _make_store(tmp_path)
runner = _FakeRunner(store)
adapter = _make_adapter(
{
"alerts": {
"secret": _INSECURE_NO_AUTH,
"prompt": "Alert: {message}",
"deliver": "log",
}
}
)
adapter.gateway_runner = runner
# Stub the RUNNER-side handler (the seam the live gateway injects) — the
# adapter's own handle_message / _process_message_background pipeline runs
# for real, including the fire-and-forget task spawn and the
# on_processing_complete hook. The handler creates the session row, just
# like GatewayRunner._handle_message does at routing time.
created = {}
async def _message_handler(event: MessageEvent):
entry = store.get_or_create_session(event.source)
created["session_id"] = entry.session_id
return "" # webhook deliver=log — nothing to send back
adapter._message_handler = _message_handler
event = _make_event(adapter, "alert-close-001", "Alert: server on fire")
# Exactly what _handle_webhook schedules.
await adapter.handle_message(event)
# handle_message is fire-and-forget: the session must NOT be expected to
# exist yet. (Guards against reintroducing a close wrapped around
# handle_message itself, which ran before the row existed and no-op'd.)
await _drain_background_tasks(adapter)
session_id = created["session_id"]
row = store._db.get_session(session_id)
assert row is not None
# INVARIANT: a completed webhook session must be closed so prune can reap it.
assert row["ended_at"] is not None, (
"webhook session was never closed — ended_at is NULL, so "
"prune_sessions can never reap it (the ghost-session leak)"
)
assert row["end_reason"] == "webhook_complete"
# And the closed row is actually prunable, unlike the pre-fix leak.
pruned = store._db.prune_sessions(older_than_days=0, source="webhook")
assert pruned >= 1
store._db.close()