Files
hermes-agent/tests/gateway/test_hygiene_deferred_work_drain.py
teknium1 9d0ab880bc fix: attribute gateway lifecycle stops to their system issuer, not the user
The parent commit only stamped a tool_reason on the watchdog producers, so a
reason-less hard_cancel still minted "explicit stop requested" (a USER reason)
for gateway stop/restart, session eviction while a turn runs, abandoned SSE
runs and TUI/desk shutdown - the exact confusion class of #112647, failing open
toward "user". Pass a system tool_reason at those producers (run_shutdown,
run_agent_cache via the eviction caller only - /stop and /new stay user-owned -
and api_server) so only genuine surface stops book interrupted_by_user.

InterruptScope.cancel now takes tool_reason so the Hermes Console user-cancel
branch is attributed to the user; the console timeout keeps the host slug.

Also repairs the red CI: the cross-process lease tests stub agent.interrupt
with (message, hard_cancel) only, so the new tool_reason kwarg raised TypeError
inside the lease's fallback and the stub never fired. Widen the doubles, and
update the two shutdown mocks that asserted the exact interrupt() call.
2026-09-16 17:48:17 -07:00

151 lines
4.8 KiB
Python

"""Shutdown accounting for executor work detached by hygiene timeouts."""
import asyncio
import threading
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.run import run_codex_hygiene_compaction
from tests.gateway.restart_test_helpers import make_restart_runner
@pytest.mark.asyncio
async def test_deferred_worker_remains_active_until_executor_future_finishes():
runner, _adapter = make_restart_runner()
runner._cleanup_agent_resources_off_loop = AsyncMock()
worker = asyncio.get_running_loop().create_future()
agent = MagicMock()
runner._defer_agent_cleanup_until_future_done(
worker,
agent,
context="test hygiene timeout",
)
assert runner._active_deferred_agent_worker_count() == 1
assert runner._active_work_count() == 1
worker.set_result(([], None))
await asyncio.gather(*runner._deferred_agent_cleanup_tasks)
assert runner._active_deferred_agent_worker_count() == 0
runner._cleanup_agent_resources_off_loop.assert_awaited_once_with(
agent,
context="test hygiene timeout",
)
@pytest.mark.asyncio
async def test_timed_out_codex_hygiene_worker_remains_visible_to_shutdown():
runner, _adapter = make_restart_runner()
started = threading.Event()
release = threading.Event()
class BlockingCodexAgent:
_codex_session = object()
context_compressor = SimpleNamespace(compression_count=0)
def _compress_context(self, *_args, **_kwargs):
started.set()
release.wait(timeout=5.0)
agent = BlockingCodexAgent()
runner._agent_cache = {"tg:123": (agent, 0.0)}
runner._agent_cache_lock = None
outcome = await run_codex_hygiene_compaction(
runner,
"tg:123",
"sess-1",
auto_mode="hermes",
history=[{"role": "user", "content": "hello"}],
approx_tokens=100,
timeout_seconds=0.01,
failure_cooldown_seconds=-1.0,
)
assert started.is_set()
assert outcome == "failed:timeout"
assert runner._active_deferred_agent_worker_count() == 1
with patch("gateway.run.request_hard_interrupt") as interrupt:
runner._interrupt_running_agents("gateway shutdown")
interrupt.assert_called_once_with(agent, "gateway shutdown", tool_reason="gateway shutdown")
release.set()
for _ in range(100):
if runner._active_deferred_agent_worker_count() == 0:
break
await asyncio.sleep(0.01)
assert runner._active_deferred_agent_worker_count() == 0
@pytest.mark.asyncio
async def test_shutdown_drain_waits_for_deferred_hygiene_worker():
runner, _adapter = make_restart_runner()
worker = asyncio.get_running_loop().create_future()
runner._deferred_agent_workers = {worker: MagicMock()}
async def finish_worker():
await asyncio.sleep(0.12)
worker.set_result(([], None))
finisher = asyncio.create_task(finish_worker())
_snapshot, timed_out = await runner._drain_active_agents(2.0)
await finisher
assert timed_out is False
assert _snapshot == {}
@pytest.mark.asyncio
async def test_deferred_hygiene_worker_times_out_and_receives_interrupt():
runner, _adapter = make_restart_runner()
worker = asyncio.get_running_loop().create_future()
agent = MagicMock()
runner._deferred_agent_workers = {worker: agent}
_snapshot, timed_out = await runner._drain_active_agents(0.01)
assert timed_out is True
assert _snapshot == {}
with patch("gateway.run.request_hard_interrupt") as interrupt:
runner._interrupt_running_agents("gateway shutdown")
interrupt.assert_called_once_with(agent, "gateway shutdown", tool_reason="gateway shutdown")
worker.cancel()
@pytest.mark.asyncio
async def test_stop_interrupts_deferred_worker_before_teardown():
runner, adapter = make_restart_runner()
runner._restart_drain_timeout = 0.01
worker = asyncio.get_running_loop().create_future()
class DeferredAgent:
def __init__(self):
self.interrupts = []
def hard_interrupt(self, reason):
self.interrupts.append(reason)
if not worker.done():
worker.set_result(([], None))
agent = DeferredAgent()
runner._deferred_agent_workers = {worker: agent}
adapter.disconnect = AsyncMock()
with (
patch("gateway.status.remove_pid_file"),
patch("gateway.status.write_runtime_status"),
patch("cron.scheduler.mark_job_run"),
patch("tools.process_registry.process_registry.kill_all", return_value=0),
patch("tools.terminal_tool.cleanup_all_environments"),
patch("tools.browser_tool_lifecycle.cleanup_all_browsers"),
):
await runner.stop()
assert agent.interrupts == ["Gateway shutting down"]
assert worker.done()