Review cleanups on the drain back-off: - `_drain_after` now requires `guard`: a None default silently meant "release whatever guard is current", which is the guard-swap bug the parameter fixes. - The identity rule is stated once (docstring, wrapped), and the redundant `pending_event is dispatched_event` disjunct is dropped: the same object always has an equal message_id, and when that id is empty its timestamp equals itself, so the remaining comparison already covers it. - Tests drop the `_Adapter` alias and cut the hot-loop matrix from 6 to 4 explicit cases (plain, rewrite with id, rewrite without id, steer). The demotion route does not interact with the identity axis, and each case spends a fixed 1s measuring window.
177 lines
8.4 KiB
Python
177 lines
8.4 KiB
Python
"""Regression: a busy follow-up the runner re-queues must not hot-loop through the adapter drain.
|
|
|
|
Field failure (Discord, Windows): the Discord websocket fell behind and the adapter was replaced
|
|
by a reconnect while the old adapter's task was still running a long agent turn. The NEW adapter
|
|
has no ``_active_sessions`` guard for that session, but the runner still has the running agent.
|
|
A message / watch-notification wake arriving while context compression was in flight then:
|
|
|
|
adapter.handle_message -> no guard -> _process_message_background -> runner._handle_message
|
|
-> running agent + compression in flight -> "PRIORITY interrupt demoted to queue"
|
|
-> event put back in adapter._pending_messages, handler returns None
|
|
-> adapter in-band drain pops the SAME event and immediately spawns a new drain task -> ...
|
|
|
|
~240-300 iterations/s for the whole compression window (88k identical INFO lines flooded the 3x5 MB
|
|
gateway.log rotation in ~6 minutes), and every iteration started and cancelled a typing refresh:
|
|
one aborted HTTPS request to discord.com per iteration, each on a fresh TCP connection. The home
|
|
router's IPS classified the burst as a DDoS from this host and blocked discord.com for hours.
|
|
"""
|
|
|
|
import asyncio
|
|
import dataclasses
|
|
import time
|
|
from datetime import datetime
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from gateway.config import GatewayConfig, Platform, PlatformConfig
|
|
from gateway.platforms.event import MessageEvent
|
|
from gateway.session import SessionEntry, SessionSource, build_session_key
|
|
from tests.gateway.restart_test_helpers import RestartTestAdapter
|
|
|
|
|
|
def _source() -> SessionSource:
|
|
return SessionSource(platform=Platform.TELEGRAM, user_id="u1", chat_id="c1",
|
|
user_name="tester", chat_type="dm")
|
|
|
|
|
|
def _runner_with_running_agent(adapter, *, compression_in_flight):
|
|
"""Runner whose agent for the session is still running (owned by a task the adapter no
|
|
longer tracks) — the post-reconnect split-brain."""
|
|
from gateway.run import GatewayRunner
|
|
|
|
runner = object.__new__(GatewayRunner)
|
|
runner.config = GatewayConfig(
|
|
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")})
|
|
runner.adapters = {Platform.TELEGRAM: adapter}
|
|
runner._voice_mode = {}
|
|
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
|
|
sk = build_session_key(_source())
|
|
entry = SessionEntry(session_key=sk, session_id="sess-1", created_at=datetime.now(),
|
|
updated_at=datetime.now(), platform=Platform.TELEGRAM, chat_type="dm")
|
|
store = MagicMock()
|
|
store.get_or_create_session.return_value = entry
|
|
store.load_transcript.return_value = []
|
|
store.has_any_sessions.return_value = True
|
|
runner.session_store = store
|
|
runner._running_agents = {}
|
|
runner._running_agents_ts = {}
|
|
runner._pending_messages = {}
|
|
runner._pending_approvals = {}
|
|
runner._session_db = None
|
|
runner._reasoning_config = None
|
|
runner._provider_routing = {}
|
|
runner._fallback_model = None
|
|
runner._show_reasoning = False
|
|
runner._service_tier = None
|
|
runner._is_user_authorized = lambda _source: True
|
|
runner._set_session_env = lambda _context: None
|
|
runner._should_send_voice_reply = lambda *_a, **_k: False
|
|
runner._send_voice_reply = AsyncMock()
|
|
runner._capture_gateway_honcho_if_configured = lambda *a, **k: None
|
|
runner._emit_gateway_run_progress = AsyncMock()
|
|
runner._draining = False
|
|
runner._busy_input_mode = "interrupt"
|
|
runner._agent_has_active_subagents = lambda _agent: False
|
|
runner._session_has_compression_in_flight = AsyncMock(return_value=compression_in_flight)
|
|
agent = MagicMock()
|
|
agent.get_activity_summary.return_value = {
|
|
"seconds_since_activity": 0.0, "last_activity_desc": "api_call",
|
|
"api_call_count": 1, "max_iterations": 60}
|
|
runner._running_agents[sk] = agent
|
|
runner._running_agents_ts[sk] = time.time() - 120
|
|
return runner, agent, sk
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("rewrite_hook,message_id,busy_mode", [
|
|
(False, "m1", "interrupt"),
|
|
(True, "m1", "interrupt"),
|
|
(True, None, "interrupt"), # id-less rewrite copy: only the copied timestamp ties it back
|
|
# The demotion route is orthogonal to the identity axis; steer covers the other route.
|
|
(False, "m1", "steer"),
|
|
])
|
|
async def test_requeued_busy_event_does_not_hot_loop(rewrite_hook, message_id, busy_mode):
|
|
adapter = RestartTestAdapter()
|
|
runner, agent, sk = _runner_with_running_agent(adapter, compression_in_flight=True)
|
|
# interrupt: demoted to queue (compression in flight); steer: agent refuses -> queue fallback.
|
|
runner._busy_input_mode = busy_mode
|
|
agent.steer.return_value = False
|
|
busy_calls = []
|
|
|
|
async def handler(event):
|
|
busy_calls.append(time.monotonic())
|
|
# A pre_gateway_dispatch "rewrite" hands the runner a dataclasses.replace copy, so the
|
|
# event it re-queues is a different object than the one the adapter dispatched.
|
|
if rewrite_hook:
|
|
event = dataclasses.replace(event, text=event.text)
|
|
return await runner._handle_message(event)
|
|
|
|
adapter.set_message_handler(handler)
|
|
|
|
# The new adapter holds no guard for the session (the reconnect replaced it mid-turn).
|
|
assert sk not in adapter._active_sessions
|
|
await adapter.handle_message(
|
|
MessageEvent(text="still there?", source=_source(), message_id=message_id))
|
|
# Measure after the first dispatch so a cold first handler call can't eat the window.
|
|
for _ in range(1000):
|
|
if busy_calls:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
base_dispatches = len(busy_calls)
|
|
await asyncio.sleep(1.0)
|
|
dispatches = len(busy_calls) - base_dispatches
|
|
await adapter.cancel_background_tasks()
|
|
|
|
agent.interrupt.assert_not_called()
|
|
# Unpatched: hundreds of dispatches per second. Patched: a handful.
|
|
assert dispatches <= 8, f"hot loop: {dispatches} re-dispatches in 1s"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_requeued_event_runs_once_the_agent_finishes():
|
|
"""The back-off must defer, not drop: once the running agent is gone the event is processed.
|
|
And it must key on the runner's demotion, not on ``None``: every streamed turn returns None,
|
|
so chained genuine follow-ups after it must each dispatch immediately."""
|
|
adapter = RestartTestAdapter()
|
|
runner, agent, sk = _runner_with_running_agent(adapter, compression_in_flight=True)
|
|
handled, starts, ends = [], [], []
|
|
real_handle = runner._handle_message
|
|
|
|
async def handler(event):
|
|
if sk not in runner._running_agents:
|
|
handled.append(event.text)
|
|
starts.append(time.monotonic())
|
|
runner._running_agents[sk] = agent # a real turn owned by this adapter
|
|
await asyncio.sleep(0.02)
|
|
nxt = {"queued msg": "f1", "f1": "f2", "f2": "f3"}.get(event.text)
|
|
if nxt: # a genuine follow-up reaches the runner mid-turn and is queued behind it
|
|
await real_handle(MessageEvent(text=nxt, source=_source(), message_id=nxt))
|
|
await asyncio.sleep(0.03)
|
|
runner._running_agents.pop(sk, None)
|
|
ends.append(time.monotonic())
|
|
return None # streamed turn: the body was already delivered
|
|
return await real_handle(event)
|
|
|
|
adapter.set_message_handler(handler)
|
|
await adapter.handle_message(MessageEvent(text="queued msg", source=_source(), message_id="m2"))
|
|
# Wait for a real back-off (not a fixed sleep: a cold first dispatch can take >1s under load).
|
|
for _ in range(2000):
|
|
if adapter._requeue_counts.get(sk, 0) >= 2 and sk in adapter._pending_messages:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
# A cancel during the back-off (e.g. /stop) must not lose the queued event: it stays pending.
|
|
await adapter.cancel_session_processing(sk, discard_pending=False)
|
|
assert adapter._pending_messages[sk].text == "queued msg"
|
|
runner._running_agents.pop(sk) # the long turn finishes
|
|
await adapter._drain_pending_after_session_command(sk, asyncio.Event()) # /stop tail replays it
|
|
for _ in range(2000):
|
|
if len(ends) >= 4:
|
|
break
|
|
await asyncio.sleep(0.01)
|
|
await adapter.cancel_background_tasks()
|
|
assert handled == ["queued msg", "f1", "f2", "f3"]
|
|
gaps = [starts[i] - ends[i - 1] for i in range(1, len(starts))]
|
|
assert all(g < 0.1 for g in gaps), f"genuine follow-ups backed off: {gaps}"
|