Files
hermes-agent/tests/gateway/test_restart_drain.py
T
ethernet 30da5d0a89 test: run os-specific tests on their real host, not a faked one
many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.

this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.

each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
  dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has

bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.

running on real hosts found real errors: a chrome-sandbox failure in
test_gui_command that main hides, and two windows failures fixed here.
the agents.md testing section now documents the policy.
2026-08-09 22:09:49 -04:00

378 lines
15 KiB
Python

import asyncio
import shutil
import subprocess
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import pytest
import gateway.run as gateway_run
from agent.i18n import t
from gateway.platforms.base import MessageEvent, MessageType
from gateway.restart import DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT
from gateway.session import SessionEntry, build_session_key
from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source
@pytest.mark.asyncio
async def test_restart_command_while_busy_requests_drain_without_interrupt(monkeypatch):
# Ensure INVOCATION_ID is NOT set — systemd sets this in service mode,
# which changes the restart call signature.
monkeypatch.delenv("INVOCATION_ID", raising=False)
monkeypatch.delenv("XPC_SERVICE_NAME", raising=False)
monkeypatch.delenv("HERMES_S6_SUPERVISED_CHILD", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_EXTERNAL_SUPERVISOR", raising=False)
# Hermeticity: neutralize the real container probe (see
# test_restart_service_detection.py) — /.dockerenv on a containerized CI
# runner would otherwise route via_service=True under this test.
monkeypatch.setattr(
"gateway.restart.is_container_restart_context", lambda: False
)
runner, _adapter = make_restart_runner()
runner.request_restart = MagicMock(return_value=True)
event = MessageEvent(
text="/restart",
message_type=MessageType.TEXT,
source=make_restart_source(),
message_id="m1",
)
session_key = build_session_key(event.source)
running_agent = MagicMock()
runner._running_agents[session_key] = running_agent
result = await runner._handle_message(event)
expected = t("gateway.draining", count=1)
assert result == expected
# Guard against the silent-degradation regression in #22266: if the i18n
# catalog cannot be resolved (e.g. xdist workers losing the locales path)
# then ``t("gateway.draining", count=1)`` returns the bare key
# ``"gateway.draining"`` instead of the formatted English string, and both
# sides of the equality above would still match. Assert on the catalog
# output explicitly so a broken locale resolution fails loudly here.
assert expected != "gateway.draining"
assert "Draining" in expected and "1" in expected
running_agent.interrupt.assert_not_called()
runner.request_restart.assert_called_once_with(detached=True, via_service=False)
def test_load_busy_text_mode_follows_input_mode_and_honors_legacy(tmp_path, monkeypatch):
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.delenv("HERMES_GATEWAY_BUSY_TEXT_MODE", raising=False)
monkeypatch.delenv("HERMES_GATEWAY_BUSY_INPUT_MODE", raising=False)
# No knobs set → follows busy_input_mode, which defaults to interrupt.
assert gateway_run.GatewayRunner._load_busy_text_mode() == "interrupt"
# busy_input_mode=queue propagates to text handling (single source of truth).
(tmp_path / "config.yaml").write_text(
"display:\n busy_input_mode: queue\n", encoding="utf-8"
)
assert gateway_run.GatewayRunner._load_busy_text_mode() == "queue"
# Legacy explicit busy_text_mode still wins for backward compat.
(tmp_path / "config.yaml").write_text(
"display:\n busy_input_mode: interrupt\n busy_text_mode: queue\n",
encoding="utf-8",
)
assert gateway_run.GatewayRunner._load_busy_text_mode() == "queue"
# Legacy env override wins too.
(tmp_path / "config.yaml").write_text(
"display:\n busy_input_mode: interrupt\n", encoding="utf-8"
)
monkeypatch.setenv("HERMES_GATEWAY_BUSY_TEXT_MODE", "queue")
assert gateway_run.GatewayRunner._load_busy_text_mode() == "queue"
# Bogus legacy value is ignored → falls through to busy_input_mode (interrupt).
monkeypatch.setenv("HERMES_GATEWAY_BUSY_TEXT_MODE", "bogus")
assert gateway_run.GatewayRunner._load_busy_text_mode() == "interrupt"
@pytest.mark.asyncio
async def test_request_restart_is_idempotent():
runner, _adapter = make_restart_runner()
runner.stop = AsyncMock()
runner._launch_detached_restart_command = AsyncMock()
# _run_restart is held on self._restart_task and is intentionally NOT in
# _background_tasks, so _stop_impl's cancel loop can't abort it mid-await
# (see #12875).
assert runner.request_restart(detached=True, via_service=False) is True
assert runner._restart_task is not None
assert runner._restart_task not in runner._background_tasks
assert runner.request_restart(detached=True, via_service=False) is False
# In-band restart marks draining immediately so new turns are refused
# while any after-turn wait runs (#77184).
assert runner._draining is True
await runner._restart_task
runner._launch_detached_restart_command.assert_awaited_once_with()
runner.stop.assert_awaited_once_with(
restart=True, detached_restart=True, service_restart=False
)
@pytest.mark.asyncio
async def test_request_restart_defers_stop_until_active_turn_finishes():
"""Regression for #77184: requesting turn must not enter the drain set."""
runner, _adapter = make_restart_runner()
runner.stop = AsyncMock()
runner._launch_detached_restart_command = AsyncMock()
runner._restart_after_turn_timeout = 5.0
session_key = "agent:main:telegram:dm:123"
runner._running_agents[session_key] = MagicMock()
assert runner.request_restart(detached=False, via_service=True) is True
assert runner._draining is True
# While the requesting turn is still active, stop() must not run.
await asyncio.sleep(0.25)
runner.stop.assert_not_awaited()
assert session_key in runner._running_agents
# Turn finishes → restart proceeds immediately (drain set empty).
del runner._running_agents[session_key]
await runner._restart_task
runner.stop.assert_awaited_once_with(
restart=True, detached_restart=False, service_restart=True
)
# Detached helper is only for the non-service path.
runner._launch_detached_restart_command.assert_not_awaited()
@pytest.mark.asyncio
async def test_request_restart_after_turn_timeout_zero_enters_stop_immediately():
"""restart_after_turn_timeout=0 preserves legacy immediate drain."""
runner, _adapter = make_restart_runner()
runner.stop = AsyncMock()
runner._restart_after_turn_timeout = 0.0
runner._running_agents["agent:main:telegram:dm:1"] = MagicMock()
assert runner.request_restart(detached=False, via_service=True) is True
await runner._restart_task
runner.stop.assert_awaited_once_with(
restart=True, detached_restart=False, service_restart=True
)
@pytest.mark.asyncio
async def test_request_restart_after_turn_cap_elapsed_still_calls_stop():
"""Safety valve: wedged turns cannot pin the gateway forever."""
runner, _adapter = make_restart_runner()
runner.stop = AsyncMock()
runner._restart_after_turn_timeout = 0.2
runner._running_agents["agent:main:telegram:dm:1"] = MagicMock()
assert runner.request_restart(detached=False, via_service=True) is True
await runner._restart_task
runner.stop.assert_awaited_once_with(
restart=True, detached_restart=False, service_restart=True
)
# Agent was still present — stop() owns the interrupt path from here.
assert runner._running_agents
@pytest.mark.asyncio
async def test_run_restart_excluded_from_stop_cancel_loop():
"""Regression for #12875: _run_restart is held on self._restart_task and
kept OUT of _background_tasks, and the _stop_impl cancel loop explicitly
skips it. If it were in _background_tasks, the cancel loop (which fires
while _run_restart is awaiting _stop_task) would propagate CancelledError
into _stop_impl and skip _shutdown_event.set() / _exit_code = 75."""
runner, _adapter = make_restart_runner()
runner.stop = AsyncMock()
# A decoy background task that SHOULD be cancelled, plus the restart task
# that must NOT be.
async def _decoy():
await asyncio.sleep(0.2)
decoy = asyncio.create_task(_decoy())
runner._background_tasks.add(decoy)
decoy.add_done_callback(runner._background_tasks.discard)
assert runner.request_restart(detached=False, via_service=True) is True
restart_task = runner._restart_task
assert restart_task is not None
assert restart_task not in runner._background_tasks
# Run the real cancel loop body in isolation (mirrors _stop_impl:7234).
runner._stop_task = None
for _task in list(runner._background_tasks):
if _task is runner._stop_task:
continue
if _task is runner._restart_task:
continue
_task.cancel()
await asyncio.sleep(0) # let cancellation settle
assert decoy.cancelled()
assert not restart_task.cancelled()
await restart_task
runner.stop.assert_awaited_once_with(
restart=True, detached_restart=False, service_restart=True
)
@pytest.mark.windows_only
@pytest.mark.asyncio
async def test_windows_detached_restart_scrubs_gateway_marker(monkeypatch, tmp_path):
"""Faking sys.platform="win32" on Linux could not reach the real Windows
detach branch (msvcrt/creationflags spawn, Lib/site-packages venv layout);
this runs on the Windows CI job instead."""
runner, _adapter = make_restart_runner()
popen_calls = []
venv_dir = tmp_path / "venv"
site_packages = venv_dir / "Lib" / "site-packages"
site_packages.mkdir(parents=True)
monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["hermes"])
monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321)
monkeypatch.setenv("_HERMES_GATEWAY", "1")
monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir))
import hermes_cli._subprocess_compat as subprocess_compat
monkeypatch.setattr(
subprocess_compat,
"windows_detach_popen_kwargs",
lambda: {},
)
def fake_popen(cmd, **kwargs):
popen_calls.append((cmd, kwargs))
return MagicMock()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
await runner._launch_detached_restart_command()
assert len(popen_calls) == 1
cmd, kwargs = popen_calls[0]
assert cmd[-3:] == ["hermes", "gateway", "restart"]
assert kwargs["env"].get("_HERMES_GATEWAY") is None
assert kwargs["env"]["VIRTUAL_ENV"] == str(venv_dir)
assert str(site_packages) in kwargs["env"]["PYTHONPATH"].split(gateway_run.os.pathsep)
assert kwargs["stdout"] is subprocess.DEVNULL
assert kwargs["stderr"] is subprocess.DEVNULL
@pytest.mark.windows_only
@pytest.mark.asyncio
async def test_windows_detached_restart_watcher_keeps_console_python(monkeypatch, tmp_path):
"""The restart watcher must run sys.executable (console python) under the
hidden-console detach kwargs — NOT swap in GUI-subsystem pythonw.exe,
which would leave the watcher console-less and make its descendants
flash visible conhosts (#54220/#56747).
Faking sys.platform on Linux could not enter the Windows-only watcher
spawn branch this asserts on, so it runs on the Windows CI job.
"""
runner, _adapter = make_restart_runner()
popen_calls = []
venv_dir = tmp_path / "venv"
site_packages = venv_dir / "Lib" / "site-packages"
site_packages.mkdir(parents=True)
monkeypatch.setattr(gateway_run.sys, "executable", r"C:\venv\Scripts\python.exe")
monkeypatch.setattr(gateway_run, "_resolve_hermes_bin", lambda: ["hermes"])
monkeypatch.setattr(gateway_run.os, "getpid", lambda: 321)
monkeypatch.setenv("VIRTUAL_ENV", str(venv_dir))
import hermes_cli._subprocess_compat as subprocess_compat
monkeypatch.setattr(
subprocess_compat,
"windows_detach_popen_kwargs",
lambda: {"creationflags": 0x08000200},
)
def fake_popen(cmd, **kwargs):
popen_calls.append((cmd, kwargs))
return MagicMock()
monkeypatch.setattr(subprocess, "Popen", fake_popen)
await runner._launch_detached_restart_command()
assert len(popen_calls) == 1
cmd, kwargs = popen_calls[0]
assert cmd[0] == r"C:\venv\Scripts\python.exe"
assert cmd[-3:] == ["hermes", "gateway", "restart"]
assert kwargs["creationflags"] == 0x08000200
# ── Shutdown notification tests ──────────────────────────────────────
@pytest.mark.asyncio
async def test_shutdown_notification_uses_persisted_origin_for_colon_ids():
"""Shutdown notifications should route from persisted origin, not reparsed keys."""
runner, adapter = make_restart_runner()
adapter.send = AsyncMock()
source = make_restart_source(chat_id="!room123:example.org", chat_type="group")
source.platform = gateway_run.Platform.MATRIX
session_key = build_session_key(source)
runner._running_agents[session_key] = MagicMock()
runner.session_store._entries = {
session_key: SessionEntry(
session_key=session_key,
session_id="sess-1",
created_at=datetime.now(),
updated_at=datetime.now(),
origin=source,
platform=source.platform,
chat_type=source.chat_type,
)
}
runner.adapters = {gateway_run.Platform.MATRIX: adapter}
await runner._notify_active_sessions_of_shutdown()
assert adapter.send.await_count == 1
@pytest.mark.asyncio
async def test_drain_suppress_skips_home_channel_keeps_session_ping(tmp_path, monkeypatch):
"""A suppress_notification drain marker mutes ONLY the home-channel broadcast.
The per-active-session interrupt ping MUST still fire (it carries the
"your task was interrupted, message me to resume" hint). This is the core
drain-notification-suppression contract.
"""
from gateway.config import HomeChannel, Platform
import gateway.drain_control as dc
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
runner, adapter = make_restart_runner()
# A home channel distinct from the active session's chat.
runner.config.platforms[Platform.TELEGRAM].home_channel = HomeChannel(
platform=Platform.TELEGRAM,
chat_id="home-42",
name="Ops Home",
)
# One active session in a different chat.
runner._running_agents["agent:main:telegram:dm:999"] = MagicMock()
# NAS auto-update drain: marker present with suppress_notification=True.
dc.write_drain_request(principal="nas", suppress_notification=True)
await runner._notify_active_sessions_of_shutdown()
# Exactly one send — the active-session ping to chat 999. The home-channel
# broadcast to home-42 was suppressed.
assert len(adapter.sent_calls) == 1
sent_chat_ids = {chat_id for chat_id, _content, _meta in adapter.sent_calls}
assert "999" in sent_chat_ids
assert "home-42" not in sent_chat_ids
assert "shutting down" in adapter.sent[0]