Merge upstream b1f003e186 while preserving PM runtime ownership and
Python 3.14 worker startup, Windows signing, and macOS wait recovery.
Keep retired runtime modules deleted. Port upstream updater preflight
checks into the checkout strategy and preserve live build logging.
Carry checkpoint filename handling and process recovery into the current
module layout. Regenerate locks and adapt incoming platform test markers.
Focused Python and JavaScript tests, desktop and root-test typechecks,
conflict-path lint checks, lock validation, and retired-import checks pass.
The full test suite and packaged release builds were not run.
207 lines
7.2 KiB
Python
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.event 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 process 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 == {}
|