fix(goal): the TUI judge also sees only its own session's processes; a lifted barrier resumes the loop from the idle hook
Independent review found two gaps in the first fix. The TUI/Desktop/dashboard goal path (tui_gateway/prompt_turn.py) still passed every agent's processes to the judge; it now filters by the session's own key like the CLI and gateway loops. And the 30-minute barrier cap (and a pid/timer barrier lifting at all) was only checked lazily, on the next turn, so a parked session with nothing else arriving stayed parked. The CLI idle hook now checks the barrier every 5 s while idle and queues the goal's continuation the moment it lifts. Tests (2 new): an elapsed timed barrier queues the continuation from the idle hook and clears the barrier; an unparked or inactive goal is a no-op.
This commit is contained in:
@@ -3468,6 +3468,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMix
|
||||
self._check_termios_drift,
|
||||
lambda: self._drain_process_notifications("cli-idle"),
|
||||
self._maybe_fire_loop_tick,
|
||||
self._maybe_resume_parked_goal,
|
||||
):
|
||||
with suppress(Exception):
|
||||
step()
|
||||
|
||||
@@ -386,6 +386,35 @@ class CLILoopsMixin:
|
||||
self._heartbeat_watchdog_started = False
|
||||
threading.Thread(target=_loop, daemon=True, name="heartbeat-watchdog").start()
|
||||
|
||||
def _maybe_resume_parked_goal(self) -> None:
|
||||
"""Idle hook run from process_loop: when a parked /goal's barrier has lifted (the process
|
||||
exited, the timer elapsed, or the wait aged past its cap), queue the continuation so the
|
||||
loop resumes WITHOUT waiting for an unrelated turn to re-evaluate it. The barrier used to be
|
||||
checked only lazily, on the next turn; a session with nothing else arriving stayed parked
|
||||
indefinitely (one run: 3 h 22 min on a grandchild's poller)."""
|
||||
now = time.time()
|
||||
if now - getattr(self, "_last_goal_barrier_check", 0.0) < 5.0:
|
||||
return
|
||||
self._last_goal_barrier_check = now
|
||||
try:
|
||||
if not self._pending_input.empty():
|
||||
return
|
||||
mgr = self._get_goal_manager()
|
||||
state = getattr(mgr, "state", None) if mgr is not None else None
|
||||
if state is None or state.status != "active":
|
||||
return
|
||||
if not (state.waiting_on_pid is not None or state.waiting_on_session is not None or state.waiting_until):
|
||||
return # not parked
|
||||
if mgr.is_waiting():
|
||||
return # barrier still holds (is_waiting() also applies the age cap)
|
||||
prompt = mgr.next_continuation_prompt()
|
||||
if prompt:
|
||||
from cli import _DIM, _RST, _cprint
|
||||
_cprint(f" {_DIM}▶ Goal barrier lifted — resuming.{_RST}")
|
||||
self._pending_input.put(prompt)
|
||||
except Exception as exc:
|
||||
logging.debug("parked-goal resume check failed: %s", exc)
|
||||
|
||||
def _maybe_fire_loop_tick(self) -> None:
|
||||
"""Idle hook run from process_loop: fire a due /loop wakeup.
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""A parked /goal resumes from the idle hook once its barrier lifts, without waiting for another turn."""
|
||||
import queue
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import goals
|
||||
from hermes_cli.cli_loops_mixin import CLILoopsMixin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
from pathlib import Path
|
||||
home = tmp_path / ".hermes"; home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
goals._DB_CACHE.clear()
|
||||
yield home
|
||||
goals._DB_CACHE.clear()
|
||||
|
||||
|
||||
class _Cli(CLILoopsMixin):
|
||||
def __init__(self, mgr):
|
||||
self._pending_input = queue.Queue()
|
||||
self._mgr = mgr
|
||||
|
||||
def _get_goal_manager(self):
|
||||
return self._mgr
|
||||
|
||||
|
||||
def test_idle_hook_queues_the_continuation_when_a_timed_barrier_has_elapsed(hermes_home):
|
||||
mgr = goals.GoalManager(session_id="resume-idle")
|
||||
mgr.set("finish the thing")
|
||||
mgr.wait_for_seconds(1, reason="cooldown")
|
||||
cli = _Cli(mgr)
|
||||
with patch("cli._cprint"), patch("cli._DIM", ""), patch("cli._RST", ""):
|
||||
cli._maybe_resume_parked_goal()
|
||||
assert cli._pending_input.empty() # still parked
|
||||
mgr.state.waiting_until = time.time() - 1
|
||||
mgr._save()
|
||||
cli._last_goal_barrier_check = 0.0
|
||||
cli._maybe_resume_parked_goal()
|
||||
assert not cli._pending_input.empty() # continuation queued
|
||||
assert "finish the thing" in cli._pending_input.get()
|
||||
assert mgr.state.waiting_until == 0.0 # barrier cleared
|
||||
|
||||
|
||||
def test_idle_hook_is_a_no_op_for_an_unparked_or_inactive_goal(hermes_home):
|
||||
mgr = goals.GoalManager(session_id="resume-noop")
|
||||
mgr.set("g")
|
||||
cli = _Cli(mgr)
|
||||
cli._maybe_resume_parked_goal()
|
||||
assert cli._pending_input.empty()
|
||||
mgr.clear()
|
||||
cli._last_goal_barrier_check = 0.0
|
||||
cli._maybe_resume_parked_goal()
|
||||
assert cli._pending_input.empty()
|
||||
@@ -294,7 +294,9 @@ def _goal_followup_after_turn(
|
||||
if session.get("session_key") and (goal_mgr := _active_goal_manager(session)) is not None:
|
||||
try:
|
||||
from hermes_cli.goals import gather_background_processes as _gather_bg
|
||||
_bg_procs = _gather_bg()
|
||||
# Only THIS session's processes (TUI turns register under session_key): subagents'
|
||||
# pollers must not park the parent's goal. Same rule as the CLI and gateway loops.
|
||||
_bg_procs = _gather_bg(owner_task_id=session.get("session_key") or None)
|
||||
except Exception:
|
||||
_bg_procs = None
|
||||
decision = goal_mgr.evaluate_after_turn(
|
||||
|
||||
Reference in New Issue
Block a user