Files
hermes-agent/tests/gateway/test_platform_reconnect.py
kshitijk4poor 206102314a test(gateway): pin where an installed secondary's held inbound lands on cancel
The cancel-after-install row asserted only that the salvaged event sat in
predecessor + failed combined, which passes wherever it lands, so it pinned
neither half of the r1 High fix (fold2 finding: mutating the secondary
installed-guard to `if True:` stayed green). Assert the installed adapter
keeps its queue, the predecessor stays empty, and the forward link points
predecessor -> installed adapter with no reverse link.
2026-10-06 11:24:04 +05:30

1228 lines
51 KiB
Python

"""Tests for the gateway platform reconnection watcher."""
import asyncio
import contextlib
import threading
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, SendResult
from gateway.platforms.helpers import MessageDeduplicator
from gateway.run import GatewayRunner
class StubAdapter(BasePlatformAdapter):
"""Adapter whose connect() result can be controlled."""
def __init__(
self,
*,
platform=Platform.TELEGRAM,
succeed=True,
fatal_error=None,
fatal_retryable=True,
):
super().__init__(PlatformConfig(enabled=True, token="test"), platform)
self._succeed = succeed
self._fatal_error = fatal_error
self._fatal_retryable = fatal_retryable
# Records the is_reconnect value of every connect() call so tests can
# assert that the watcher distinguishes reconnect from cold boot (#46621).
self.connect_calls: list[bool] = []
async def connect(self, *, is_reconnect: bool = False):
self.connect_calls.append(is_reconnect)
if self._fatal_error:
self._set_fatal_error("test_error", self._fatal_error, retryable=self._fatal_retryable)
return False
return self._succeed
async def disconnect(self):
return None
async def send(self, chat_id, content, reply_to=None, metadata=None):
return SendResult(success=True, message_id="1")
async def send_typing(self, chat_id, metadata=None):
return None
async def get_chat_info(self, chat_id):
return {"id": chat_id}
def _make_runner():
"""Create a minimal GatewayRunner via object.__new__ to skip __init__."""
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="test")}
)
runner._running = True
runner._shutdown_event = asyncio.Event()
runner._exit_reason = None
runner._exit_with_failure = False
runner._exit_cleanly = False
runner._failed_platforms = {}
runner.adapters = {}
runner.delivery_router = MagicMock()
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._honcho_managers = {}
runner._honcho_configs = {}
runner._shutdown_all_gateway_honcho = lambda: None
runner.session_store = MagicMock()
return runner
# --- Startup queueing ---
class TestStartupPlatformIsolation:
"""Verify one blocked platform cannot prevent later platforms from starting."""
@pytest.mark.asyncio
async def test_start_continues_after_platform_connect_timeout(self, tmp_path, monkeypatch):
"""A timeout on Telegram should queue it and still connect Feishu."""
# Skip the boot warm-up and boot-path sends: unrelated to platform
# isolation, and with create_task stubbed below the bounded wait on
# the (never-started) send task would hold start() for 30 s.
monkeypatch.setenv("HERMES_STARTUP_WARMUP_TIMEOUT", "0")
runner = _make_runner()
runner._await_startup_boot_sends = AsyncMock()
runner.config = GatewayConfig(
platforms={
Platform.TELEGRAM: PlatformConfig(enabled=True, token="test"),
Platform.FEISHU: PlatformConfig(enabled=True, token="test"),
},
sessions_dir=tmp_path,
)
runner.hooks = MagicMock()
runner.hooks.loaded_hooks = []
runner.hooks.emit = AsyncMock()
runner._suspend_stuck_loop_sessions = MagicMock(return_value=0)
runner._update_runtime_status = MagicMock()
runner._update_platform_runtime_status = MagicMock()
runner._sync_voice_mode_state_to_adapter = MagicMock()
runner._send_update_notification = AsyncMock(return_value=True)
runner._send_restart_notification = AsyncMock()
adapters = {
Platform.TELEGRAM: StubAdapter(platform=Platform.TELEGRAM),
Platform.FEISHU: StubAdapter(platform=Platform.FEISHU),
}
runner._create_adapter = MagicMock(
side_effect=lambda platform, _config: adapters[platform]
)
runner._connect_adapter_with_timeout = AsyncMock(
side_effect=[
TimeoutError("telegram connect timed out after 30s"),
True,
]
)
def fake_create_task(coro):
coro.close()
return MagicMock()
with patch("gateway.status.publish_runtime_status"):
with patch("hermes_cli.plugins.discover_plugins"):
with patch("hermes_cli.config.load_config", return_value={}):
with patch("agent.shell_hooks.register_from_config"):
with patch(
"tools.process_registry.process_registry.recover_from_checkpoint",
return_value=0,
):
with patch(
"gateway.channel_directory.build_channel_directory",
new=AsyncMock(return_value={"platforms": {}}),
):
with patch("gateway.run.asyncio.create_task", side_effect=fake_create_task):
assert await runner.start() is True
assert Platform.TELEGRAM in runner._failed_platforms
assert Platform.FEISHU in runner.adapters
assert Platform.TELEGRAM not in runner.adapters
assert runner._create_adapter.call_count == 2
# --- Reconnect watcher ---
class TestPlatformReconnectWatcher:
"""Test the _platform_reconnect_watcher background task."""
@pytest.mark.asyncio
async def test_reconnect_passes_is_reconnect_true(self):
"""The watcher must connect with is_reconnect=True so adapters preserve
their server-side update queue across an outage (#46621). Without this,
bootstrap start_polling(drop_pending_updates=True) silently dropped every
message queued while the bot was offline."""
runner = _make_runner()
runner._sync_voice_mode_state_to_adapter = MagicMock()
runner._failed_platforms[Platform.TELEGRAM] = {
"config": PlatformConfig(enabled=True, token="test"),
"attempts": 1,
"next_retry": time.monotonic() - 1,
}
succeed_adapter = StubAdapter(succeed=True)
real_sleep = asyncio.sleep
with patch.object(runner, "_create_adapter", return_value=succeed_adapter):
with patch("gateway.run.build_channel_directory", create=True):
runner._running = True
call_count = 0
async def fake_sleep(n):
nonlocal call_count
call_count += 1
if call_count > 1:
runner._running = False
await real_sleep(0)
with patch("asyncio.sleep", side_effect=fake_sleep):
await runner._platform_reconnect_watcher()
assert succeed_adapter.connect_calls == [True], (
f"watcher must pass is_reconnect=True; got {succeed_adapter.connect_calls!r}"
)
assert Platform.TELEGRAM in runner.adapters
@pytest.mark.asyncio
@pytest.mark.parametrize("degraded", [False, True])
async def test_reconnect_stamp_honours_adapter_send_path_degraded(self, degraded):
"""connect() returning True is not proof the receive path is live:
Telegram's degraded reconnect returns True while its own ladder
retries. The watcher's status stamp must publish what the adapter
reports, not an unconditional "connected" (#101391)."""
runner = _make_runner()
runner._sync_voice_mode_state_to_adapter = MagicMock()
runner._update_platform_runtime_status = MagicMock()
runner._failed_platforms[Platform.TELEGRAM] = {
"config": PlatformConfig(enabled=True, token="test"),
"attempts": 1,
"next_retry": time.monotonic() - 1,
}
class _DegradableAdapter(StubAdapter):
@property
def send_path_degraded(self) -> bool:
return degraded
adapter = _DegradableAdapter(succeed=True)
real_sleep = asyncio.sleep
with patch.object(runner, "_create_adapter", return_value=adapter):
with patch("gateway.run.build_channel_directory", create=True):
runner._running = True
call_count = 0
async def fake_sleep(n):
nonlocal call_count
call_count += 1
if call_count > 1:
runner._running = False
await real_sleep(0)
with patch("asyncio.sleep", side_effect=fake_sleep):
await runner._platform_reconnect_watcher()
stamps = [
c.kwargs for c in runner._update_platform_runtime_status.call_args_list
if c.args and c.args[0] == Platform.TELEGRAM.value
]
assert stamps, "watcher never stamped telegram"
final = stamps[-1]
assert final["platform_state"] == ("retrying" if degraded else "connected")
assert final["error_message"] == (adapter.DEGRADED_STATUS_MESSAGE if degraded else None)
assert final["retrying_since"] is None
@pytest.mark.asyncio
async def test_cold_connect_defaults_to_is_reconnect_false(self):
"""The cold-start connect path (_connect_adapter_with_timeout with no
is_reconnect arg) must default to False so a first boot still drops any
stale queue (#46621)."""
runner = _make_runner()
adapter = StubAdapter(succeed=True)
success = await runner._connect_adapter_with_timeout(adapter, Platform.TELEGRAM)
assert success is True
assert adapter.connect_calls == [False], (
f"cold-start must default to is_reconnect=False; got {adapter.connect_calls!r}"
)
@pytest.mark.asyncio
async def test_reconnect_retries_resume_pending_for_platform(self):
"""A successful reconnect retries the startup auto-resume scoped to
that platform.
Regression: a platform offline at gateway startup had its
restart-interrupted sessions skipped by the one-shot startup pass and
never rescheduled, so the documented auto-resume silently dropped
until the user sent a fresh message. The watcher now re-runs the
platform-scoped auto-resume on reconnect.
"""
runner = _make_runner()
runner._sync_voice_mode_state_to_adapter = MagicMock()
runner._redeliver_failed_obligations_for_platform = AsyncMock(return_value=1)
runner._schedule_resume_pending_sessions = MagicMock(return_value=1)
platform_config = PlatformConfig(enabled=True, token="test")
runner._failed_platforms[Platform.TELEGRAM] = {
"config": platform_config,
"attempts": 1,
"next_retry": time.monotonic() - 1,
}
succeed_adapter = StubAdapter(succeed=True)
real_sleep = asyncio.sleep
with patch.object(runner, "_create_adapter", return_value=succeed_adapter):
with patch("gateway.run.build_channel_directory", create=True):
async def run_one_iteration():
runner._running = True
call_count = 0
async def fake_sleep(n):
nonlocal call_count
call_count += 1
if call_count > 1:
runner._running = False
await real_sleep(0)
with patch("asyncio.sleep", side_effect=fake_sleep):
await runner._platform_reconnect_watcher()
await run_one_iteration()
assert Platform.TELEGRAM in runner.adapters
runner._redeliver_failed_obligations_for_platform.assert_awaited_once_with(
Platform.TELEGRAM
)
runner._schedule_resume_pending_sessions.assert_called_once_with(
platform=Platform.TELEGRAM
)
@pytest.mark.asyncio
async def test_reconnect_never_auto_pauses_retryable_failures(self):
"""Retryable failures (network/DNS) must keep retrying indefinitely —
the watcher must NOT auto-pause them. Auto-pausing a transiently-failed
platform left bots silently dead after a DNS blip (#35284). The pause
circuit breaker remains available for manual /platform pause only.
"""
runner = _make_runner()
platform_config = PlatformConfig(enabled=True, token="test")
# Far past the old circuit-breaker threshold (10): even after many
# consecutive retryable failures the platform must stay unpaused.
runner._failed_platforms[Platform.TELEGRAM] = {
"config": platform_config,
"attempts": 25,
"next_retry": time.monotonic() - 1,
}
fail_adapter = StubAdapter(
succeed=False, fatal_error="DNS failure", fatal_retryable=True
)
real_sleep = asyncio.sleep
with patch.object(runner, "_create_adapter", return_value=fail_adapter):
async def run_one_iteration():
runner._running = True
call_count = 0
async def fake_sleep(n):
nonlocal call_count
call_count += 1
if call_count > 1:
runner._running = False
await real_sleep(0)
with patch("asyncio.sleep", side_effect=fake_sleep):
await runner._platform_reconnect_watcher()
await run_one_iteration()
# Platform stays in queue and keeps retrying — never auto-paused.
assert Platform.TELEGRAM in runner._failed_platforms
info = runner._failed_platforms[Platform.TELEGRAM]
assert info.get("paused") is not True
assert "pause_reason" not in info
assert info["attempts"] == 26
# next_retry is pushed out by the backoff (capped at 300s), not inf.
assert info["next_retry"] != float("inf")
assert info["next_retry"] > time.monotonic()
@pytest.mark.asyncio
async def test_failed_plugin_load_is_rearmed_by_the_watcher(self, monkeypatch):
"""An unregistered plugin platform (its deferred load failed at startup) heals on the next watcher
tick: the adapter_unavailable branch re-arms the failed load instead of waiting for a manual
reload-plugins or restart (#126356)."""
import hermes_cli.plugins as plugins_mod
runner = _make_runner()
runner._update_platform_runtime_status = MagicMock()
runner._install_reconnected_adapter = AsyncMock()
platform = Platform("irc") # bundled plugin platform (not a builtin adapter)
monkeypatch.setattr(runner, "_adapter_may_heal", lambda p, c: True)
runner._failed_platforms[platform] = {
"config": PlatformConfig(enabled=True), "attempts": 0, "next_retry": 0,
}
rearmed, healed, on_loop = [], [], []
manager = MagicMock()
manager.rearm_failed_platform.side_effect = lambda name: rearmed.append(name) or on_loop.append(
threading.current_thread() is threading.main_thread()) or True
monkeypatch.setattr(plugins_mod, "get_plugin_manager", lambda: manager)
adapter = StubAdapter(platform=platform)
monkeypatch.setattr(runner, "_create_adapter", lambda p, c: adapter if rearmed and healed else None)
monkeypatch.setattr("gateway.platform_registry.platform_registry.get",
lambda name: on_loop.append(threading.current_thread() is threading.main_thread()))
for tick in range(6):
if tick == 5:
healed.append(True)
runner._failed_platforms.get(platform, {})["next_retry"] = 0
await runner._reconnect_failed_platform(platform, time.monotonic())
assert rearmed == ["irc"] * 3 # a permanently broken plugin stops being re-imported after the cap
assert on_loop and not any(on_loop) # neither the re-arm nor the plugin load blocks the event loop
runner._install_reconnected_adapter.assert_awaited_once_with(platform, adapter)
# --- Runtime disconnection queueing ---
class TestRuntimeDisconnectQueuing:
"""Test that _handle_adapter_fatal_error queues retryable disconnections."""
@pytest.mark.asyncio
async def test_retryable_error_keeps_gateway_alive_when_all_down(self):
"""When all adapters fail at runtime with retryable errors, the
gateway should stay alive and let the reconnect watcher recover them
in the background. (Previously this exited-with-failure to trigger
a systemd restart — that converted transient outages into infinite
restart loops and killed in-process state.)
"""
runner = _make_runner()
runner.stop = AsyncMock()
adapter = StubAdapter(succeed=True)
adapter._set_fatal_error("network_error", "DNS failure", retryable=True)
runner.adapters[Platform.TELEGRAM] = adapter
await runner._handle_adapter_fatal_error(adapter)
# stop() should NOT be called — gateway stays alive for the watcher
runner.stop.assert_not_called()
assert runner._exit_with_failure is False
assert Platform.TELEGRAM in runner._failed_platforms
class TestReconnectKeepsInboundDedup:
@pytest.mark.asyncio
async def test_replayed_inbound_id_after_runner_reconnect_is_dropped(self):
"""The watcher builds a NEW adapter; an inbound ID the old one already admitted must still
read as a duplicate there, or a platform replay after the reconnect is answered twice."""
runner = _make_runner()
runner.stop = AsyncMock()
runner._sync_voice_mode_state_to_adapter = MagicMock()
old, new = StubAdapter(), StubAdapter()
for a in (old, new):
a._dedup = MessageDeduplicator()
runner.adapters[Platform.TELEGRAM] = old
assert old._dedup.is_duplicate("m1") is False # handled before the drop
old._set_fatal_error("network_error", "socket closed", retryable=True)
await runner._handle_adapter_fatal_error(old)
with patch.object(runner, "_create_adapter", return_value=new):
await runner._reconnect_failed_platform(Platform.TELEGRAM, time.monotonic() + 1)
assert runner.adapters[Platform.TELEGRAM] is new
assert new._dedup.is_duplicate("m1") is True
assert new._dedup.is_duplicate("m2") is False
@staticmethod
def _telegram_cls():
from plugins.platforms.telegram.adapter import TelegramAdapter
class _Telegram(TelegramAdapter):
def __init__(self, succeed, hold=None, salvage=None):
super().__init__(PlatformConfig(enabled=True, token="123:abc"))
self.succeed, self.hold, self.salvage, self.handle_message = succeed, hold, salvage, AsyncMock()
async def connect(self, *, is_reconnect=False):
if self.succeed:
self._mark_connected()
return True
# Real connect failure marks a retryable fatal; an update PTB acked meanwhile is held.
self._set_fatal_error("telegram_connect_error", "startup failed", retryable=True)
self._hold_inbound_event(self.hold, where="connect")
if self.succeed is None:
raise RuntimeError("connect blew up")
return False
async def disconnect(self):
self._mark_disconnected()
if self.salvage is not None: # disconnect() salvages pending text batches into the hold
self._hold_inbound_event(self.salvage, where="teardown-salvage")
return _Telegram
@staticmethod
def _event(text):
from gateway.platforms.event import MessageEvent
from gateway.session import SessionSource
return MessageEvent(text=text, source=SessionSource(platform=Platform.TELEGRAM, chat_id="1", chat_type="dm"))
@pytest.mark.asyncio
@pytest.mark.parametrize("failed_connect", [False, None], ids=["returns-false", "raises"])
async def test_held_telegram_inbound_reaches_published_replacement_once(self, failed_connect):
"""PTB already acked a held update, so the queue on the retired Telegram adapter -- plus what a
failed candidate held in connect or salvaged in disconnect -- must be delivered exactly once by
the replacement the watcher publishes, not die with the failed candidate (#132829, #133399)."""
_Telegram = self._telegram_cls()
runner = _make_runner()
runner.stop = AsyncMock()
runner._sync_voice_mode_state_to_adapter = MagicMock()
held, in_connect, salvaged = self._event("held"), self._event("candidate"), self._event("salvage")
old, new = _Telegram(True), _Telegram(True)
failed = _Telegram(failed_connect, hold=in_connect, salvage=salvaged)
runner.adapters[Platform.TELEGRAM] = old
old._set_fatal_error("telegram_network_error", "stall", retryable=True)
old._hold_inbound_event(held, where="text-enqueue")
await runner._handle_adapter_fatal_error(old)
for candidate in (failed, new):
runner._failed_platforms[Platform.TELEGRAM]["next_retry"] = 0
with patch.object(runner, "_create_adapter", return_value=candidate):
await runner._reconnect_failed_platform(Platform.TELEGRAM, time.monotonic() + 1)
await asyncio.sleep(0)
await new._held_inbound_redispatch_task
assert runner.adapters[Platform.TELEGRAM] is new
failed.handle_message.assert_not_called()
assert [c.args[0] for c in new.handle_message.await_args_list] == [held, in_connect, salvaged]
assert held.source._transport_adapter_ref() is new
assert old._held_inbound_events == failed._held_inbound_events == new._held_inbound_events == []
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["not-installed", "connect-raises", "cancel-after-install"])
async def test_failed_secondary_candidate_returns_held_inbound_to_predecessor(self, mode):
"""A secondary reconnect candidate that is not installed (or whose connect raised inside the
attempt) must hand its held inbound back to the retained predecessor instead of discarding it
(#133399); an INSTALLED one cancelled mid-redeliver keeps its queue -- handing back would
forward in a cycle forever."""
_Telegram = self._telegram_cls()
runner = _make_runner()
runner._profile_adapters, runner._profile_failed_platforms = {}, {}
runner._sync_voice_mode_state_to_adapter = MagicMock()
in_connect, salvaged = self._event("candidate"), self._event("salvage")
installed = mode == "cancel-after-install"
predecessor = _Telegram(True)
failed = _Telegram(installed or (None if mode == "connect-raises" else False), hold=in_connect, salvage=salvaged)
predecessor._set_fatal_error("telegram_network_error", "stall", retryable=True)
async def attempt(*_args):
success = await failed.connect(is_reconnect=True)
runner._running = installed # stop after this one failed attempt
return failed, success
runner._redeliver_failed_obligations_for_platform = AsyncMock(side_effect=asyncio.CancelledError)
runner._create_adapter = MagicMock(return_value=failed)
runner._configure_profile_adapter = MagicMock(side_effect=lambda *_a: setattr(runner, "_running", False))
if mode != "connect-raises": # stub the attempt; connect-raises runs the real one's own teardown
runner._secondary_reconnect_attempt = attempt
with patch("hermes_cli.profiles.get_profile_dir"), \
patch("hermes_cli.env_loader.hydrate_profile_secret_sources"), \
patch("gateway.run._profile_runtime_scope", MagicMock()), \
patch("gateway.run._platform_has_bot_credential", return_value=True), \
patch("gateway.config.load_gateway_config") as load, \
pytest.raises(asyncio.CancelledError) if installed else contextlib.nullcontext():
load.return_value.platforms = {Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}
await runner._run_secondary_profile_reconnect("coder", Platform.TELEGRAM, predecessor)
if installed:
assert failed._held_inbound_events == [salvaged] and not predecessor._held_inbound_events
assert getattr(failed, "_held_inbound_successor", None) is None
assert predecessor._held_inbound_successor() is failed
return
assert failed._held_inbound_events == []
assert predecessor._held_inbound_events == [in_connect, salvaged]
assert in_connect.source._transport_adapter_ref() is predecessor
# --- Pause / resume circuit breaker ---
class TestPauseResume:
"""Test the per-platform pause/resume helpers and slash command."""
def test_resume_clears_paused_and_resets_attempts(self):
runner = _make_runner()
runner._failed_platforms[Platform.TELEGRAM] = {
"config": PlatformConfig(enabled=True, token="t"),
"attempts": 10,
"next_retry": float("inf"),
"paused": True,
"pause_reason": "auto-paused",
}
assert runner._resume_paused_platform(Platform.TELEGRAM) is True
info = runner._failed_platforms[Platform.TELEGRAM]
assert info["paused"] is False
assert info["attempts"] == 0
assert info["next_retry"] != float("inf")
assert "pause_reason" not in info
class TestPlatformSlashCommand:
"""Test the /platform list|pause|resume slash command handler."""
def _make_event(self, content: str):
ev = MagicMock()
ev.content = content
return ev
@pytest.mark.asyncio
async def test_list_shows_connected_and_paused(self):
runner = _make_runner()
runner.adapters[Platform.DISCORD] = StubAdapter(platform=Platform.DISCORD)
runner._failed_platforms[Platform.WHATSAPP] = {
"config": PlatformConfig(enabled=True, token="t"),
"attempts": 10,
"next_retry": float("inf"),
"paused": True,
"pause_reason": "not paired",
}
out = await runner._handle_platform_command(self._make_event("/platform list"))
assert "discord" in out
assert "whatsapp" in out
assert "not paired" in out
@pytest.mark.asyncio
async def test_pause_command_pauses_queued_platform(self):
runner = _make_runner()
runner._failed_platforms[Platform.WHATSAPP] = {
"config": PlatformConfig(enabled=True, token="t"),
"attempts": 2,
"next_retry": time.monotonic() + 30,
}
await runner._handle_platform_command(
self._make_event("/platform pause whatsapp")
)
assert runner._failed_platforms[Platform.WHATSAPP]["paused"] is True
# --- Supervised task wrapper (_spawn_supervised) ---
class TestSpawnSupervised:
"""Verify the task-level supervision wrapper around watcher launches."""
@pytest.mark.asyncio
async def test_watcher_does_not_inherit_delegated_child_context(self):
"""Long-lived gateway services must not inherit one turn's child scope."""
from agent.delegation_context import (
delegated_child_context,
is_delegated_child_context,
)
runner = _make_runner()
observed = []
async def _watcher():
observed.append(is_delegated_child_context())
with delegated_child_context():
assert is_delegated_child_context() is True
task = runner._spawn_supervised(_watcher, "isolated_watcher")
await task
assert is_delegated_child_context() is True
assert observed == [False]
@pytest.mark.asyncio
async def test_dispatcher_to_thread_does_not_trip_kanban_guard(self):
"""Already-running ticks copy the caller task context into to_thread.
Issue #91958 is a dispatcher that was spawned at boot, then a later
``delegate_task`` leaves the worker copy marked as a child. Spawn
isolation does not rewrite that frozen task context; the offload
helper must scrub it at the tick boundary. The in-task child guard
stays closed.
"""
from agent.delegation_context import (
delegated_child_context,
is_delegated_child_context,
)
from gateway.kanban_watchers_common import _to_thread_process_service
from hermes_cli.kanban_db import _assert_not_delegated_child_mutation
with delegated_child_context():
assert is_delegated_child_context() is True
with pytest.raises(PermissionError, match="delegate_task child"):
_assert_not_delegated_child_mutation()
await _to_thread_process_service(_assert_not_delegated_child_mutation)
assert is_delegated_child_context() is True
@pytest.mark.asyncio
async def test_clean_synchronous_return_is_not_respawned(self):
# A supervised coro that returns immediately (clean exit) must be
# invoked EXACTLY ONCE — a clean return means deliberate shutdown or a
# gated no-op watcher; respawning it would busy-spin the event loop.
runner = _make_runner()
calls = {"n": 0}
async def _coro():
calls["n"] += 1
return
runner._spawn_supervised(lambda: _coro(), "clean_watcher")
# Drive the loop so the done-callback fires; if it (wrongly) respawned,
# the count would keep climbing across these ticks.
for _ in range(50):
await asyncio.sleep(0)
assert calls["n"] == 1
class TestFatalHandoffCancellationProof:
"""The fatal-error handoff must survive cancellation of the notifying
task, and a retryable platform must never be silently stranded."""
@pytest.mark.asyncio
async def test_stranded_retryable_platform_exits_for_supervisor_restart(self):
"""If a retryable platform ends up neither reconnected nor queued
(e.g. its config entry is gone so queueing is skipped), the gateway
must exit with failure so launchd/systemd KeepAlive restarts it,
instead of running indefinitely with a dead platform while healthy
peers mask the loss (#68693)."""
runner = _make_runner()
async def _stop():
runner._shutdown_event.set()
runner.stop = AsyncMock(side_effect=_stop)
runner.config = GatewayConfig(platforms={}) # queueing impossible
adapter = StubAdapter(succeed=True)
adapter._set_fatal_error("network_error", "DNS failure", retryable=True)
runner.adapters[Platform.TELEGRAM] = adapter
# A healthy peer keeps self.adapters non-empty, so the existing
# "no platforms remain" shutdown branches do not fire.
runner.adapters[Platform.FEISHU] = StubAdapter(platform=Platform.FEISHU)
await runner._handle_adapter_fatal_error(adapter)
assert runner._exit_with_failure is True
assert runner.stop.await_count == 1
# ── _ensure_reconnect_watcher_running ──────────────────────────────────
# ── _handle_adapter_fatal_error calls _ensure_reconnect_watcher ────────
class TestReconnectWatcherRaceGuard:
"""Regression: a platform removed from _failed_platforms concurrently
(e.g. a manual /platform resume racing with the watcher's own
snapshot-then-lookup) must not raise KeyError and kill the loop
iteration -- it should just be skipped for that pass."""
"""Verify _handle_adapter_fatal_error calls _ensure_reconnect_watcher_running."""
@pytest.mark.asyncio
async def test_nonretryable_fatal_error_does_not_call_ensure(self):
"""A non-retryable error must NOT queue the platform or call the watcher."""
runner = _make_runner()
runner._running = True
runner._background_tasks = set()
runner._failed_platforms = {}
runner._fatal_handler_tasks = set()
runner._reconnect_watcher_task = None
platform_config = PlatformConfig(enabled=True, token="test")
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: platform_config}
)
adapter = StubAdapter(
platform=Platform.TELEGRAM,
succeed=False,
fatal_error="bad token",
fatal_retryable=False,
)
# Pre-set fatal error attributes so the handler can read them
# without going through connect() (#70344).
adapter._set_fatal_error(
"AUTH_FAILED", "bad token", retryable=False
)
runner.adapters[Platform.TELEGRAM] = adapter
ensure_called = False
def noop_ensure():
nonlocal ensure_called
ensure_called = True
with patch.object(runner, "_ensure_reconnect_watcher_running", side_effect=noop_ensure):
await runner._handle_adapter_fatal_error(adapter)
assert Platform.TELEGRAM not in runner._failed_platforms
assert not ensure_called
# ── _connect_adapter_with_timeout detach-on-timeout ────────────────────
class TestConnectAdapterDetachOnTimeout:
"""Verify _connect_adapter_with_timeout uses the detach pattern."""
@pytest.mark.asyncio
async def test_connect_timed_out_raises_timeouterror(self):
"""A connect() that never finishes must raise TimeoutError."""
runner = _make_runner()
adapter = StubAdapter(succeed=True)
async def _slow_connect(**kwargs):
await asyncio.sleep(0.2) # never finishes
with patch.object(adapter, "connect", side_effect=_slow_connect):
with patch.object(
runner, "_platform_connect_timeout_secs", return_value=0.01
):
with pytest.raises(TimeoutError, match="timed out"):
await runner._connect_adapter_with_timeout(
adapter, Platform.TELEGRAM
)
# After the TimeoutError, the slow connect coroutine should have been
# cancelled and detached, so the event loop can move on.
await asyncio.sleep(0)
# --- Voice input callback wiring ---
class TestVoiceInputCallbackWiring:
"""Startup and reconnect must wire _voice_input_callback on Discord."""
@staticmethod
def _make_discord_voice_adapter():
"""A minimal Discord adapter stub with voice attributes."""
adapter = MagicMock()
adapter._voice_input_callback = None
adapter._voice_text_channels = {}
adapter._voice_sources = {}
adapter.connect = AsyncMock(return_value=True)
adapter.disconnect = AsyncMock()
return adapter
def _make_runner_with_discord(self):
runner = _make_runner()
runner.config = GatewayConfig(
platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="test")}
)
runner._update_runtime_status = MagicMock()
runner._update_platform_runtime_status = MagicMock()
runner._sync_voice_mode_state_to_adapter = MagicMock()
runner._send_update_notification = AsyncMock(return_value=True)
runner._send_restart_notification = AsyncMock()
runner._suspend_stuck_loop_sessions = MagicMock(return_value=0)
runner.hooks = MagicMock()
runner.hooks.loaded_hooks = []
runner.hooks.emit = AsyncMock()
return runner
@pytest.mark.asyncio
async def test_startup_wires_voice_input_callback(self, tmp_path, monkeypatch):
"""Cold-start connect must wire _voice_input_callback on Discord adapter."""
monkeypatch.setenv("HERMES_STARTUP_WARMUP_TIMEOUT", "0")
runner = self._make_runner_with_discord()
runner._await_startup_boot_sends = AsyncMock() # see TestStartupPlatformIsolation
adapter = self._make_discord_voice_adapter()
runner.config.sessions_dir = tmp_path
def fake_create_task(coro):
coro.close()
return MagicMock()
with patch.object(runner, "_create_adapter", return_value=adapter):
with patch("gateway.status.publish_runtime_status"):
with patch("hermes_cli.plugins.discover_plugins"):
with patch("hermes_cli.config.load_config", return_value={}):
with patch("agent.shell_hooks.register_from_config"):
with patch(
"tools.process_registry.process_registry.recover_from_checkpoint",
return_value=0,
):
with patch(
"gateway.channel_directory.build_channel_directory",
new=AsyncMock(return_value={"platforms": {}}),
):
with patch(
"gateway.run.asyncio.create_task",
side_effect=fake_create_task,
):
assert await runner.start() is True
assert adapter._voice_input_callback is not None, (
"startup must wire _voice_input_callback"
)
class TestRequeueHealsDeadReconnectWatcher:
"""Regression for #90386: a second retryable fatal error for a platform that
is ALREADY queued must still check that the reconnect watcher is alive.
``_ensure_reconnect_watcher_running()`` is the documented backstop for the
watcher exhausting ``_MAX_SUPERVISED_RESTARTS`` (#70344) -- ``_spawn_supervised``
stops respawning after that and logs "giving up restarts". Its only call site
sat behind the newly-queued branch of ``_queue_retryable_fatal_platform``,
so it could never fire for a platform already in ``_failed_platforms`` --
which is the only kind of platform the watcher can have been retrying long
enough to exhaust the budget on.
The observable result is a silent permanent outage: the queue holds the
platform, nothing retries it, and the stranded check in
``_handle_adapter_fatal_error_detached`` deliberately treats a queued
platform as safe, so the process is never restarted either.
"""
@staticmethod
def _runner_with_dead_watcher(spawned):
runner = _make_runner()
runner._running = True
runner._background_tasks = set()
def _fake_spawn(coro_factory, name, **kwargs):
spawned.append(name)
handle = MagicMock()
handle.done.return_value = False
on_spawn = kwargs.get("on_spawn")
if on_spawn is not None:
on_spawn(handle)
return handle
runner._spawn_supervised = _fake_spawn
return runner
@staticmethod
def _already_queued(runner, *, attempts=7):
runner._failed_platforms[Platform.TELEGRAM] = {
"config": runner.config.platforms[Platform.TELEGRAM],
"attempts": attempts,
"next_retry": time.monotonic() + 300,
"queued_at": time.monotonic() - 3600,
"credential_claim": None,
"listener_claim": None,
}
@staticmethod
def _fatal_adapter():
adapter = StubAdapter()
adapter._set_fatal_error(
"telegram_network_error",
"Telegram polling could not reconnect after 10 network error retries.",
retryable=True,
)
return adapter
@pytest.mark.asyncio
async def test_requeue_respawns_a_watcher_that_gave_up_restarting(self):
"""The core #90386 regression.
Telegram is queued and the watcher has exhausted its restart budget, so
the task is done and ``_spawn_supervised`` will never bring it back on
its own. A fresh retryable fatal error arrives for that same platform.
Nothing is enqueued (it is already there), but the watcher MUST be
respawned -- otherwise the queue entry is retried by nobody, forever.
"""
spawned: list[str] = []
runner = self._runner_with_dead_watcher(spawned)
self._already_queued(runner)
async def _died():
return None
dead = asyncio.create_task(_died())
await dead
runner._reconnect_watcher_task = dead
queued = runner._queue_retryable_fatal_platform(self._fatal_adapter())
assert queued is False, "already-queued platform must not be re-enqueued"
assert spawned == ["platform_reconnect_watcher"], (
"a re-fatal on an already-queued platform must still heal a dead "
"reconnect watcher -- it is the only remaining path back to the "
"queue once _spawn_supervised has given up restarting (#90386)"
)
assert not runner._reconnect_watcher_task.done(), (
"the tracked handle must point at the newly spawned watcher"
)
@pytest.mark.asyncio
async def test_requeue_does_not_disturb_the_existing_queue_entry(self):
"""Guardrail on the fix: healing the watcher must not become a re-enqueue.
Overwriting the entry would reset ``attempts`` and ``next_retry``, so a
platform that fails repeatedly would restart its backoff ladder on every
fatal error and hammer a provider that is already refusing it.
"""
spawned: list[str] = []
runner = self._runner_with_dead_watcher(spawned)
self._already_queued(runner, attempts=7)
before = dict(runner._failed_platforms[Platform.TELEGRAM])
async def _died():
return None
dead = asyncio.create_task(_died())
await dead
runner._reconnect_watcher_task = dead
assert runner._queue_retryable_fatal_platform(self._fatal_adapter()) is False
assert runner._failed_platforms[Platform.TELEGRAM] == before, (
"the existing queue entry, including its attempt count and backoff "
"deadline, must survive the watcher heal untouched"
)
@pytest.mark.asyncio
async def test_requeue_with_a_live_watcher_spawns_nothing(self):
"""Guardrail on the fix: a live watcher must not be duplicated.
Two concurrent watchers would double every reconnect attempt, which is
the failure ``_spawn_supervised``'s ``on_spawn`` handle-tracking exists
to prevent.
"""
spawned: list[str] = []
runner = self._runner_with_dead_watcher(spawned)
self._already_queued(runner)
async def _alive():
await asyncio.sleep(5)
live = asyncio.create_task(_alive())
runner._reconnect_watcher_task = live
try:
assert runner._queue_retryable_fatal_platform(self._fatal_adapter()) is False
assert spawned == [], "a live watcher must never be respawned"
assert runner._reconnect_watcher_task is live
finally:
live.cancel()
try:
await live
except asyncio.CancelledError:
pass
class TestSupervisionExhaustionHasAnOwner:
"""The witness @andrexibiza asked for on #90448: recovery with NO new event.
The predecessor architecture (#72366, salvage of #71867 by @ygd58)
established that a reconnect watcher which dies while work is queued must
be respawned by something *autonomous*, because the only other trigger --
a fresh fatal error from another platform -- may never arrive. Supervised
restart closed that, but supervision is finite: after
``_MAX_SUPERVISED_RESTARTS`` rapid crashes it logs "giving up restarts" and
stops.
Past that point the system is back in exactly the state #72366 described.
And #81036 (salvage of #80700, preserving @HexLab98) makes the missing
event less likely rather than more: it publishes the queue *before*
disconnect and drops the failed adapter from the live map, so there may be
no adapter left to emit the callback recovery was waiting on.
These tests exercise that state directly. They queue a platform, let the
watcher burn its whole budget, and then emit **no further fatal
callbacks at all** -- the thing the branch-level regression tests below
cannot prove, because they manufacture the event.
"""
@staticmethod
def _runner(monkeypatch, *, crashes):
"""A runner whose reconnect watcher crashes `crashes` times, then lives."""
runner = _make_runner()
runner._background_tasks = set()
runner._reconnect_watcher_task = None
runner.state = {"live": 0, "spawns": 0}
# Collapse every real-time delay: the point is the ORDER of events, not
# their spacing. Left as class attributes in production precisely so a
# test can do this without sleeping for half an hour.
monkeypatch.setattr(GatewayRunner, "_MAX_SUPERVISED_RESTARTS", 2)
monkeypatch.setattr(GatewayRunner, "_SUPERVISED_HEALTHY_SECS", 3600)
monkeypatch.setattr(GatewayRunner, "_RECONNECT_WATCHER_SLOW_RETRY_SECS", 0)
monkeypatch.setattr(GatewayRunner, "_supervised_backoff", staticmethod(lambda _a: 0))
remaining = {"n": crashes}
async def _watcher():
runner.state["spawns"] += 1
if remaining["n"] > 0:
remaining["n"] -= 1
raise RuntimeError("watcher crashed on contact")
# Healthy: stay alive so `task.done()` is False.
runner.state["live"] += 1
await asyncio.Event().wait()
runner._platform_reconnect_watcher = _watcher
return runner
@staticmethod
def _queue_telegram(runner):
runner._failed_platforms[Platform.TELEGRAM] = {
"config": runner.config.platforms[Platform.TELEGRAM],
"attempts": 9,
"next_retry": time.monotonic() + 300,
"queued_at": time.monotonic() - 7200,
"credential_claim": None,
"listener_claim": None,
}
@staticmethod
async def _settle(times=40):
"""Let the supervisor's done-callbacks and respawn tasks drain."""
for _ in range(times):
await asyncio.sleep(0)
@pytest.mark.asyncio
async def test_watcher_recovers_after_budget_exhaustion_with_no_new_event(
self, monkeypatch
):
"""The whole review gate in one test.
Queue a platform, crash the watcher past its supervised budget, then do
nothing at all -- no fatal callback, no enqueue, no external poke. The
watcher must come back on its own once it stops crashing.
"""
# One crash more than supervision will tolerate, so the give-up branch
# is genuinely reached and the slow tier is what recovers it.
runner = self._runner(monkeypatch, crashes=3)
self._queue_telegram(runner)
runner._spawn_reconnect_watcher()
await self._settle(300)
assert runner.state["live"] == 1, (
"with no new fatal event, only the slow tier can have brought the "
"watcher back after supervision gave up"
)
assert runner._reconnect_watcher_task is not None
assert not runner._reconnect_watcher_task.done()
runner._running = False
for task in list(runner._background_tasks):
task.cancel()
@pytest.mark.asyncio
async def test_the_slow_tier_is_bounded_not_a_restart_loop(self, monkeypatch):
"""A watcher that never recovers must stop being respawned, and say so."""
runner = self._runner(monkeypatch, crashes=10_000)
self._queue_telegram(runner)
monkeypatch.setattr(GatewayRunner, "_MAX_SLOW_WATCHER_RESPAWNS", 3)
runner._spawn_reconnect_watcher()
await self._settle(400)
assert runner.state["live"] == 0
# Bounded, and the arithmetic is worth stating because it is the
# whole safety argument. Each slow-tier attempt hands the watcher a
# FRESH supervised budget -- that is deliberate, since a slow retry is
# a new bet that conditions have changed -- so the ceiling is
# (1 + _MAX_SUPERVISED_RESTARTS) x (1 + _MAX_SLOW_WATCHER_RESPAWNS):
# here (1+2) x (1+3) = 12. With production values that is 6 x 7 = 42
# spawns spread across at least half an hour, which is a ladder, not
# a restart loop. A tight loop here would be worse than the outage it
# is healing.
assert runner.state["spawns"] == 12, (
f"expected a finite spawn ladder, got {runner.state['spawns']}"
)
assert runner._reconnect_watcher_task.done()
# And it stays stopped: no further spawns once the ladder is spent.
await self._settle(400)
assert runner.state["spawns"] == 12
runner._running = False
for task in list(runner._background_tasks):
task.cancel()
@pytest.mark.asyncio
async def test_an_empty_queue_leaves_the_watcher_down(self, monkeypatch):
"""No queued work, no invariant to own.
Respawning a crash-looping watcher that nothing depends on would burn
the process for no one. The enqueue path spawns a fresh one the moment
a platform is actually queued.
"""
runner = self._runner(monkeypatch, crashes=10_000)
assert not runner._failed_platforms
runner._spawn_reconnect_watcher()
await self._settle(200)
assert runner.state["live"] == 0
assert runner._reconnect_watcher_task.done()
runner._running = False
for task in list(runner._background_tasks):
task.cancel()
@pytest.mark.asyncio
async def test_the_slow_tier_stands_down_when_the_queue_drains(self, monkeypatch):
"""Something else healed the platform -- stop respawning for it."""
runner = self._runner(monkeypatch, crashes=10_000)
self._queue_telegram(runner)
runner._spawn_reconnect_watcher()
# Let supervision give up, then drain the queue before the slow tier
# gets its turn.
await self._settle(6)
runner._failed_platforms.clear()
await self._settle(200)
assert runner.state["live"] == 0, (
"with nothing queued, the slow tier has no invariant left to own"
)
runner._running = False
for task in list(runner._background_tasks):
task.cancel()
@pytest.mark.asyncio
async def test_give_up_does_nothing_once_the_gateway_is_shutting_down(
self, monkeypatch
):
runner = self._runner(monkeypatch, crashes=10_000)
self._queue_telegram(runner)
runner._running = False
runner._on_reconnect_watcher_gave_up("platform_reconnect_watcher")
await self._settle()
assert runner.state["live"] == 0
assert not runner._background_tasks