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:
Teknium
2026-09-05 06:23:55 -07:00
parent f5f54ab784
commit 1f3912d277
4 changed files with 91 additions and 1 deletions
+1
View File
@@ -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()
+29
View File
@@ -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.
+58
View File
@@ -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()
+3 -1
View File
@@ -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(