Files
hermes-agent/tests/gateway/test_goal_resume_restart.py
T
Teknium f8f4d056f5 test(gateway): pre-warm the goals SessionDB cache in async goal tests
GoalManager.set() on an event-loop thread only waits the bounded
_DB_BOOTSTRAP_INIT_WAIT_S window for the background SessionDB bootstrap
(deliberate: an unbounded init starved the gateway loop watchdog). On a
loaded CI runner the cold init overruns that window, the goal write is
silently dropped by design, and the test flakes downstream: /loop showed
no active-goal note and the goal continuation was never enqueued (both
FLAKY on main run 33455779041).

Fix the class: every async goal test fixture that clears goals._DB_CACHE
now pre-warms it via _get_session_db() from sync context (unbounded init
path), so the bounded-window degradation can never fire mid-test. Applied
to all four gateway goal/loop test files; sync-only goal tests are
unaffected by construction.

Live repro: slowing SessionDB.__init__ past the window reproduces the
dropped write deterministically without the pre-warm and never with it.
2026-09-01 10:52:58 -07:00

207 lines
7.2 KiB
Python

"""#75362 — /goal resume must restart work, not just flip persisted state.
After a goal auto-pauses on turn-budget exhaustion, each surface's
``resume`` handler must schedule exactly one canonical
``GoalManager.next_continuation_prompt()`` turn through that surface's
existing input path:
- classic CLI → ``self._pending_input``
- messaging gateway → the adapter FIFO (``_enqueue_fifo``)
The TUI/Desktop ``command.dispatch`` boundary is covered in
``tests/tui_gateway/test_goal_command.py``.
"""
from __future__ import annotations
import queue
import uuid
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import MessageEvent, MessageType
from gateway.run import GatewayRunner
from gateway.session import SessionSource
from hermes_cli import goals
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
# get_hermes_home() prefers the context-local override over the env
# var, so a set_hermes_home_override() leaked by ANY earlier test in
# this xdist worker would silently point the goals DB at a dead tmp
# dir and make resume enqueue nothing (CI-only flake). Pin the
# override to THIS home so the fixture is immune to leaks.
from hermes_constants import (
reset_hermes_home_override,
set_hermes_home_override,
)
token = set_hermes_home_override(str(home))
goals._DB_CACHE.clear()
# Pre-warm the SessionDB cache from sync context so async GoalManager
# writes never race the bounded loop-thread bootstrap window on loaded
# CI runners (goal silently unpersisted; main run 33455779041).
goals._get_session_db()
yield home
try:
reset_hermes_home_override(token)
except Exception:
pass
goals._DB_CACHE.clear()
def _exhaust_budget(session_id: str, goal_text: str = "ship the benchmark"):
"""Set a 1-turn goal and drive it to budget-exhaustion auto-pause."""
mgr = goals.GoalManager(session_id)
mgr.set(goal_text, max_turns=1)
with patch(
"hermes_cli.goals.judge_goal",
return_value=("continue", "needs more steps", False, None, False),
):
decision = mgr.evaluate_after_turn("worked a bit")
assert decision["status"] == "paused"
assert decision["should_continue"] is False
assert "turn budget exhausted" in (mgr.state.paused_reason or "")
return mgr
# ──────────────────────────────────────────────────────────────────────
# Classic CLI
# ──────────────────────────────────────────────────────────────────────
def _make_cli(session_id: str):
from cli import HermesCLI
cli = HermesCLI.__new__(HermesCLI)
cli._pending_input = queue.Queue()
cli.session_id = session_id
cli.agent = MagicMock()
cli.agent.session_id = session_id
return cli
class TestCliResumeRestartsWork:
def test_resume_after_budget_exhaustion_queues_continuation(self, hermes_home):
sid = f"sid-cli-resume-{uuid.uuid4().hex}"
cli = _make_cli(sid)
_exhaust_budget(sid)
cli._handle_goal_command("goal resume")
assert not cli._pending_input.empty(), (
"/goal resume must queue the continuation prompt on _pending_input "
"— otherwise the goal sits idle until the user types something"
)
queued = cli._pending_input.get_nowait()
assert queued.startswith("[Continuing toward your standing goal]")
state = goals.GoalManager(sid).state
assert state.status == "active"
assert state.turns_used == 0
def test_resume_without_goal_queues_nothing(self, hermes_home):
sid = f"sid-cli-noresume-{uuid.uuid4().hex}"
cli = _make_cli(sid)
cli._handle_goal_command("goal resume")
assert cli._pending_input.empty()
# ──────────────────────────────────────────────────────────────────────
# Messaging gateway
# ──────────────────────────────────────────────────────────────────────
_GW_SID = "sid-gateway-goal-resume"
_GW_KEY = "agent:main:discord:channel:goal-resume"
class _FakeSessionEntry:
session_id = _GW_SID
class _FakeSessionStore:
def __init__(self):
self.entry = _FakeSessionEntry()
def get_or_create_session(self, source, **_kwargs):
return self.entry
def _generate_session_key(self, source):
return _GW_KEY
class _FakeAdapter:
def __init__(self):
self._pending_messages = {}
def _make_runner() -> tuple[GatewayRunner, _FakeAdapter]:
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="token")}
)
runner.session_store = _FakeSessionStore()
adapter = _FakeAdapter()
runner.adapters = {Platform.DISCORD: adapter}
runner._queued_events = {}
return runner, adapter
def _resume_event() -> MessageEvent:
return MessageEvent(
text="/goal resume",
message_type=MessageType.TEXT,
source=SessionSource(
platform=Platform.DISCORD,
chat_id="chat-goal-resume",
chat_type="channel",
user_id="user-goal-resume",
),
message_id="msg-goal-resume",
)
class TestGatewayResumeRestartsWork:
@pytest.mark.asyncio
async def test_resume_after_budget_exhaustion_enqueues_continuation(
self, hermes_home
):
runner, adapter = _make_runner()
_exhaust_budget(_GW_SID)
response = await GatewayRunner._handle_goal_command(runner, _resume_event())
assert "resume" in response.lower() or "Goal" in response
pending = adapter._pending_messages.get(_GW_KEY)
assert pending is not None, (
"/goal resume must enqueue the continuation on the adapter FIFO "
"— otherwise the goal sits idle until the next real user message"
)
assert pending.text.startswith("[Continuing toward your standing goal]")
# The pause/clear stale-work guard must recognize the queued turn as
# a synthetic goal continuation so it can be cleaned up on /goal pause.
assert GatewayRunner._is_goal_continuation_event(pending)
state = goals.GoalManager(_GW_SID).state
assert state.status == "active"
assert state.turns_used == 0
@pytest.mark.asyncio
async def test_resume_without_goal_enqueues_nothing(self, hermes_home):
runner, adapter = _make_runner()
response = await GatewayRunner._handle_goal_command(runner, _resume_event())
assert "No goal to resume" in response
assert adapter._pending_messages == {}