246477a809
A fresh state.db init (schema DDL, FTS tables, first config import) measures ~300ms warm on a fast machine. The gateway constructs GoalManager on the event-loop thread, and a cold cache ran that init behind a 0.25s bootstrap grace window: on a slow CI box the /goal set path's waits expired and save_goal silently no-oped — the reply said "Goal set (7-turn budget)..." but nothing persisted, and a fresh GoalManager read back no state (first assertion passes, second fails). Two changes, one per caller shape: - Async callers (_get_goal_manager_for_event, _get_heartbeat_manager_for_event, _post_turn_goal_continuation, and the heartbeat poller) warm the SessionDB cache off-loop through the context-preserving executor before constructing the manager (shared _warm_goals_session_db helper). The loop never blocks and the first write lands at any init duration. A bare to_thread would lose the per-turn profile home override under multiplex; the executor hop keeps it (same pattern as the goal judge path). - Sync callers (heartbeat persistence, _goal_still_active_for_session) cannot await, so the bootstrap windows stay: the call that starts the bootstrap waits a one-time init window (1.5s) instead of the short per-call window (0.25s), giving healthy cold inits room to land while a contended migration still degrades to None with only a bounded one-time stall. The bootstrap thread binds the caller's home as a contextvar override so a multiplexed worker cannot cache the default profile's DB under another profile's key. save_goal and heartbeat save_state now log at WARNING when they drop a write, because the reply has already told the user the state was set. Regression test pins the contract: init past the window, write persists, loop gap under 2s (the flake-policy floor for wall-clock bounds; the slow-init margin grew to match, so the test still tells on-loop from off-loop). Independent diagnosis + measurement by jackulau (#88965 review); the off-loop warm-up shape follows their harness table. Simplify-code review (4-agent) contributed the helper extraction and the poller warm-up.
206 lines
6.3 KiB
Python
206 lines
6.3 KiB
Python
"""Tests for /loop handling in tui_gateway.
|
|
|
|
The TUI routes ``/loop`` through ``command.dispatch`` (same rationale as
|
|
``/goal`` — the CLI handler drives ``_pending_input``, which the slash
|
|
worker has no reader for). State mutations go through the shared
|
|
``dispatch_loop_command``; the per-session notification poller fires due
|
|
wakeups via ``_maybe_fire_tui_loop_tick``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@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))
|
|
|
|
from hermes_cli import goals
|
|
|
|
goals._DB_CACHE.clear()
|
|
yield home
|
|
goals._DB_CACHE.clear()
|
|
|
|
|
|
@pytest.fixture()
|
|
def server(hermes_home):
|
|
with patch.dict(
|
|
"sys.modules",
|
|
{
|
|
"hermes_cli.env_loader": MagicMock(),
|
|
"hermes_cli.banner": MagicMock(),
|
|
},
|
|
):
|
|
mod = importlib.import_module("tui_gateway.server")
|
|
yield mod
|
|
mod._sessions.clear()
|
|
mod._pending.clear()
|
|
mod._answers.clear()
|
|
|
|
|
|
@pytest.fixture()
|
|
def session(server):
|
|
sid = "sid-loop-test"
|
|
session_key = "tui-loop-session-1"
|
|
s = {
|
|
"session_key": session_key,
|
|
"history": [],
|
|
"history_lock": threading.Lock(),
|
|
"history_version": 0,
|
|
"running": False,
|
|
"attached_images": [],
|
|
"cols": 120,
|
|
}
|
|
server._sessions[sid] = s
|
|
return sid, session_key, s
|
|
|
|
|
|
def _call(server, method, **params):
|
|
handler = server._methods[method]
|
|
return handler(1, params)
|
|
|
|
|
|
# ── command.dispatch /loop ────────────────────────────────────────────
|
|
|
|
|
|
def test_loop_bare_shows_status_when_none_set(server, session):
|
|
sid, _, _ = session
|
|
r = _call(server, "command.dispatch", name="loop", arg="", session_id=sid)
|
|
assert r["result"]["type"] == "exec"
|
|
assert "No loop set" in r["result"]["output"]
|
|
|
|
|
|
def test_loop_set_persists(server, session):
|
|
sid, session_key, _ = session
|
|
r = _call(server, "command.dispatch", name="loop", arg="5m check the deploy", session_id=sid)
|
|
result = r["result"]
|
|
assert result["type"] == "exec"
|
|
assert "Loop set" in result["output"]
|
|
|
|
from hermes_cli.loops import LoopManager
|
|
|
|
mgr = LoopManager(session_key)
|
|
assert mgr.state is not None
|
|
assert mgr.state.prompt == "check the deploy"
|
|
assert mgr.state.status == "active"
|
|
assert mgr.state.interval_seconds == 300.0
|
|
|
|
|
|
def test_loop_proactive_alias_resolves(server, session):
|
|
sid, _, _ = session
|
|
r = _call(server, "command.dispatch", name="proactive", arg="5m ping", session_id=sid)
|
|
assert "Loop set" in r["result"]["output"]
|
|
|
|
|
|
def test_loop_pause_resume_stop(server, session):
|
|
sid, session_key, _ = session
|
|
_call(server, "command.dispatch", name="loop", arg="5m poll CI", session_id=sid)
|
|
|
|
r = _call(server, "command.dispatch", name="loop", arg="pause", session_id=sid)
|
|
assert "paused" in r["result"]["output"].lower()
|
|
|
|
r = _call(server, "command.dispatch", name="loop", arg="resume", session_id=sid)
|
|
assert "resumed" in r["result"]["output"].lower()
|
|
|
|
r = _call(server, "command.dispatch", name="loop", arg="stop", session_id=sid)
|
|
assert "stopped" in r["result"]["output"].lower()
|
|
|
|
from hermes_cli.loops import LoopManager
|
|
|
|
assert not LoopManager(session_key).has_loop()
|
|
|
|
|
|
def test_loop_requires_session(server):
|
|
r = _call(server, "command.dispatch", name="loop", arg="5m x", session_id="unknown")
|
|
assert "error" in r
|
|
assert r["error"]["code"] == 4001
|
|
|
|
|
|
# ── idle wakeup driver ────────────────────────────────────────────────
|
|
|
|
|
|
def test_tui_tick_fires_when_idle_and_due(server, session):
|
|
sid, session_key, s = session
|
|
from hermes_cli.loops import LoopManager, save_loop
|
|
|
|
mgr = LoopManager(session_key)
|
|
mgr.set("poll the build", interval_seconds=60)
|
|
mgr.state.next_due_at = time.time() - 1
|
|
save_loop(session_key, mgr.state)
|
|
|
|
fired = {}
|
|
|
|
def fake_submit(rid, sid_, session_, text, **kwargs):
|
|
fired["text"] = text
|
|
|
|
with patch.object(server, "_run_prompt_submit", fake_submit), \
|
|
patch.object(server, "_emit"):
|
|
server._maybe_fire_tui_loop_tick(sid, s)
|
|
|
|
assert "poll the build" in fired.get("text", "")
|
|
assert "[/loop wakeup #1" in fired["text"]
|
|
# Session claimed for the wakeup turn.
|
|
assert s["running"] is True
|
|
|
|
|
|
def test_tui_tick_defers_when_running(server, session):
|
|
sid, session_key, s = session
|
|
from hermes_cli.loops import LoopManager, save_loop
|
|
|
|
mgr = LoopManager(session_key)
|
|
mgr.set("poll", interval_seconds=60)
|
|
mgr.state.next_due_at = time.time() - 1
|
|
save_loop(session_key, mgr.state)
|
|
s["running"] = True
|
|
|
|
with patch.object(server, "_run_prompt_submit") as submit, \
|
|
patch.object(server, "_emit"):
|
|
server._maybe_fire_tui_loop_tick(sid, s)
|
|
|
|
submit.assert_not_called()
|
|
# Tick not consumed — still due for the next poll.
|
|
assert LoopManager(session_key).state.ticks_fired == 0
|
|
|
|
|
|
def test_tui_tick_defers_to_active_goal(server, session):
|
|
sid, session_key, s = session
|
|
from hermes_cli.goals import GoalManager
|
|
from hermes_cli.loops import LoopManager, save_loop
|
|
|
|
GoalManager(session_id=session_key).set("finish the feature")
|
|
mgr = LoopManager(session_key)
|
|
mgr.set("poll", interval_seconds=60)
|
|
mgr.state.next_due_at = time.time() - 1
|
|
save_loop(session_key, mgr.state)
|
|
|
|
with patch.object(server, "_run_prompt_submit") as submit, \
|
|
patch.object(server, "_emit"):
|
|
server._maybe_fire_tui_loop_tick(sid, s)
|
|
|
|
submit.assert_not_called()
|
|
assert s["running"] is False
|
|
|
|
|
|
def test_tui_tick_noop_when_not_due(server, session):
|
|
sid, session_key, s = session
|
|
from hermes_cli.loops import LoopManager
|
|
|
|
LoopManager(session_key).set("poll", interval_seconds=300)
|
|
|
|
with patch.object(server, "_run_prompt_submit") as submit, \
|
|
patch.object(server, "_emit"):
|
|
server._maybe_fire_tui_loop_tick(sid, s)
|
|
|
|
submit.assert_not_called()
|
|
assert s["running"] is False
|