diff --git a/cli.py b/cli.py index ec6d2b51bc..63a6438ec3 100644 --- a/cli.py +++ b/cli.py @@ -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() diff --git a/hermes_cli/cli_loops_mixin.py b/hermes_cli/cli_loops_mixin.py index 4622a60561..1b7c3ce1b7 100644 --- a/hermes_cli/cli_loops_mixin.py +++ b/hermes_cli/cli_loops_mixin.py @@ -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. diff --git a/tests/cli/test_cli_goal_parked_resume.py b/tests/cli/test_cli_goal_parked_resume.py new file mode 100644 index 0000000000..a5a729f342 --- /dev/null +++ b/tests/cli/test_cli_goal_parked_resume.py @@ -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() diff --git a/tui_gateway/prompt_turn.py b/tui_gateway/prompt_turn.py index d098bfc7ed..d9f9b4f07b 100644 --- a/tui_gateway/prompt_turn.py +++ b/tui_gateway/prompt_turn.py @@ -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(