From 1a47a36422ae3c42c23839b72cc24fb6a651aa28 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:27:26 -0700 Subject: [PATCH] feat(loop): first wakeup fires immediately by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip the salvaged --start-now behavior (PR #97958) into the unconditional default: /loop's first iteration is due the moment the loop is set, then recurs on the normal cadence. The flag is dropped — it was never released, so there is nothing to deprecate. - LoopManager.set(): next_due_at = now for both cadence modes - drop --start-now parsing, the persisted LoopState.start_now field, and the flag from help text; confirmation now always says the first wakeup fires now - tests updated to pin the new default (incl. the TUI not-due test, which now has to push next_due_at out explicitly) - docs: quick-start and command table describe the immediate first run --- hermes_cli/loops.py | 42 ++++-------- tests/hermes_cli/test_loops.py | 79 ++++------------------- tests/tui_gateway/test_loop_command.py | 7 +- website/docs/user-guide/features/loops.md | 19 +----- 4 files changed, 35 insertions(+), 112 deletions(-) diff --git a/hermes_cli/loops.py b/hermes_cli/loops.py index 9823888c19..92cdefdfe3 100644 --- a/hermes_cli/loops.py +++ b/hermes_cli/loops.py @@ -149,14 +149,11 @@ def parse_loop_args(text: str) -> Dict[str, Any]: /loop keep fixing the failing test until the suite passes /loop 2m poll CI --times 30 /loop 5m watch the queue --until queue depth reaches zero - /loop 1h --start-now check the deploy status Returns ``{"interval_seconds": int|None, "prompt": str, "times": int, - "until": str, "start_now": bool, "error": str|None}``. - ``interval_seconds`` None means self-paced. ``start_now`` is True when - the user passed ``--start-now`` (first wakeup fires immediately). - ``error`` is set for unusable input (empty prompt, interval-only, - bad --times). + "until": str, "error": str|None}``. ``interval_seconds`` None means + self-paced. ``error`` is set for unusable input (empty prompt, + interval-only, bad --times). """ raw = (text or "").strip() result: Dict[str, Any] = { @@ -164,7 +161,6 @@ def parse_loop_args(text: str) -> Dict[str, Any]: "prompt": "", "times": 0, "until": "", - "start_now": False, "error": None, } if not raw: @@ -174,16 +170,9 @@ def parse_loop_args(text: str) -> Dict[str, Any]: # Pull trailing flags first so an interval-looking token inside the # --until clause can't confuse the front parse. Flags may appear in # either order at the end of the line; --until consumes to end-of-line - # (or to a following --times). --start-now is a bare boolean flag. + # (or to a following --times). times = 0 until = "" - start_now = False - - # --start-now is a bare boolean flag; it may lead the args or trail. - m_start_now = re.search(r"(?:^|\s)--start-now\b", raw) - if m_start_now: - start_now = True - raw = (raw[: m_start_now.start()] + raw[m_start_now.end():]).strip() m_times = re.search(r"\s--times\s+(\S+)", raw) if m_times: @@ -222,7 +211,6 @@ def parse_loop_args(text: str) -> Dict[str, Any]: result["prompt"] = raw result["times"] = times result["until"] = until - result["start_now"] = start_now return result @@ -307,7 +295,6 @@ class LoopState: current_delay: float = 0.0 # live cadence (self-paced backoff) times: int = 0 # user cap (--times N); 0 = none until: str = "" # judged stop condition; "" = none - start_now: bool = False # --start-now: first wakeup fires immediately max_ticks: int = DEFAULT_MAX_TICKS # config backstop; 0 = unlimited ticks_fired: int = 0 created_at: float = 0.0 @@ -342,7 +329,6 @@ class LoopState: current_delay=float(data.get("current_delay", 0.0) or 0.0), times=int(data.get("times", 0) or 0), until=str(data.get("until", "") or ""), - start_now=bool(data.get("start_now", False)), max_ticks=int(data.get("max_ticks", DEFAULT_MAX_TICKS) or 0), ticks_fired=int(data.get("ticks_fired", 0) or 0), created_at=float(data.get("created_at", 0.0) or 0.0), @@ -611,13 +597,12 @@ class LoopManager: interval_seconds: Optional[int] = None, times: int = 0, until: str = "", - start_now: bool = False, route: Optional[Dict[str, str]] = None, ) -> LoopState: """Start a new loop (replaces any existing one for the session). - With ``start_now=True`` the first wakeup is due immediately (next - idle poll / gateway watcher scan) instead of one interval out. + The first wakeup is due immediately (next idle poll / gateway + watcher scan); subsequent wakeups follow the cadence. """ prompt = (prompt or "").strip() if not prompt: @@ -631,7 +616,7 @@ class LoopManager: mode="interval", interval_seconds=float(interval), current_delay=float(interval), - next_due_at=now if start_now else now + interval, + next_due_at=now, ) else: floor = self_paced_floor_seconds() @@ -640,11 +625,10 @@ class LoopManager: mode="self_paced", interval_seconds=0.0, current_delay=float(floor), - next_due_at=now if start_now else now + floor, + next_due_at=now, ) state.times = max(0, int(times or 0)) state.until = (until or "").strip() - state.start_now = bool(start_now) state.max_ticks = max_ticks_default() state.created_at = now state.route = dict(route or {}) @@ -910,13 +894,12 @@ def dispatch_loop_command( if lower in {"help", "--help", "-h"}: return { "output": ( - "Usage: /loop [interval] [--times N] [--until ] [--start-now]\n" - " /loop 5m check the deploy status — fixed cadence\n" + "Usage: /loop [interval] [--times N] [--until ]\n" + " /loop 5m check the deploy status — first run now, then every 5m\n" " /loop every 10m /recap — loop a slash command\n" " /loop keep fixing tests until green — self-paced (backs off while output is unchanged)\n" " /loop 2m poll CI --times 30 — stop after 30 runs\n" " /loop 5m watch the queue --until queue is empty\n" - " /loop 1h --start-now check deploy — first wakeup fires immediately\n" "Controls: /loop status · /loop pause · /loop resume · /loop stop\n" "The loop also stops itself when the agent replies with " f"{LOOP_COMPLETE_MARKER}." @@ -937,7 +920,6 @@ def dispatch_loop_command( interval_seconds=parsed["interval_seconds"], times=parsed["times"], until=parsed["until"], - start_now=parsed["start_now"], route=route, ) except ValueError as exc: @@ -960,8 +942,8 @@ def dispatch_loop_command( lines.append(f"Stops when: {state.until}") if not state.times and state.max_ticks: lines.append(f"Backstop budget: {state.max_ticks} ticks (loops.max_ticks; 0 = unlimited).") - if state.start_now and state.status == "active": - lines.append("First wakeup fires now (--start-now), then on the cadence above.") + if state.status == "active": + lines.append("First wakeup fires now, then on the cadence above. Controls: /loop status · pause · resume · stop.") else: lines.append(f"First wakeup {state.remaining_label()}. Controls: /loop status · pause · resume · stop.") if replacing: diff --git a/tests/hermes_cli/test_loops.py b/tests/hermes_cli/test_loops.py index c428e6e7e4..3a630c4206 100644 --- a/tests/hermes_cli/test_loops.py +++ b/tests/hermes_cli/test_loops.py @@ -133,51 +133,21 @@ class TestParseLoopArgs: p = parse_loop_args("2m poll --times zero") assert p["error"] is not None - def test_start_now_flag_trailing(self): + def test_no_start_now_flag_anymore(self): from hermes_cli.loops import parse_loop_args - p = parse_loop_args("1h --start-now check the deploy status") - assert p["start_now"] is True + # Immediate first wakeup is the default now; --start-now was never + # released, so the token is NOT parsed as a flag. + p = parse_loop_args("1h check the deploy status") + assert "start_now" not in p assert p["interval_seconds"] == 3600 assert p["prompt"] == "check the deploy status" assert p["error"] is None - def test_start_now_flag_leading(self): - from hermes_cli.loops import parse_loop_args - - p = parse_loop_args("--start-now 1h check the deploy status") - assert p["start_now"] is True - assert p["interval_seconds"] == 3600 - assert p["prompt"] == "check the deploy status" - - def test_start_now_flag_absent_by_default(self): - from hermes_cli.loops import parse_loop_args - - p = parse_loop_args("1h check the deploy status") - assert p["start_now"] is False - - def test_start_now_self_paced(self): - from hermes_cli.loops import parse_loop_args - - p = parse_loop_args("keep refining --start-now the tests") - assert p["start_now"] is True - assert p["interval_seconds"] is None - assert p["prompt"] == "keep refining the tests" - - def test_start_now_with_times_and_until(self): - from hermes_cli.loops import parse_loop_args - - p = parse_loop_args("2m poll CI --times 30 --start-now --until it is green") - assert p["start_now"] is True - assert p["times"] == 30 - assert p["until"] == "it is green" - assert p["prompt"] == "poll CI" - - def test_start_now_word_in_prompt_not_a_flag(self): + def test_start_now_word_in_prompt_kept_verbatim(self): from hermes_cli.loops import parse_loop_args p = parse_loop_args("1h read the file called start-now.md") - assert p["start_now"] is False assert p["prompt"] == "read the file called start-now.md" def test_interval_only_is_error(self): @@ -254,7 +224,6 @@ class TestLoopStateSerde: current_delay=300.0, times=5, until="ci is green", - start_now=True, ticks_fired=2, route={"platform": "telegram", "chat_id": "123"}, ) @@ -263,7 +232,6 @@ class TestLoopStateSerde: assert s2.interval_seconds == 300.0 assert s2.times == 5 assert s2.until == "ci is green" - assert s2.start_now is True assert s2.ticks_fired == 2 assert s2.route == {"platform": "telegram", "chat_id": "123"} @@ -273,7 +241,6 @@ class TestLoopStateSerde: s = LoopState.from_json('{"prompt": "p"}') assert s.prompt == "p" assert s.status == "active" - assert s.start_now is False assert s.route == {} @@ -340,12 +307,12 @@ class TestPersistence: class TestTickLifecycle: - def test_not_due_immediately(self, hermes_home): + def test_due_immediately_on_create(self, hermes_home): from hermes_cli.loops import LoopManager mgr = LoopManager(session_id="t1") mgr.set("poll", interval_seconds=300) - assert mgr.is_due() is False + assert mgr.is_due() is True def test_due_after_interval(self, hermes_home): from hermes_cli.loops import LoopManager @@ -355,29 +322,20 @@ class TestTickLifecycle: state.next_due_at = time.time() - 1 assert mgr.is_due() is True - def test_start_now_due_immediately(self, hermes_home): + def test_not_due_once_rescheduled(self, hermes_home): from hermes_cli.loops import LoopManager mgr = LoopManager(session_id="t2a") - state = mgr.set("poll", interval_seconds=300, start_now=True) - assert state.start_now is True - assert mgr.is_due() is True - - def test_start_now_default_not_due(self, hermes_home): - from hermes_cli.loops import LoopManager - - mgr = LoopManager(session_id="t2b") state = mgr.set("poll", interval_seconds=300) - assert state.start_now is False + state.next_due_at = time.time() + 300 assert mgr.is_due() is False - def test_start_now_self_paced_due_immediately(self, hermes_home): + def test_self_paced_due_immediately_on_create(self, hermes_home): from hermes_cli.loops import LoopManager mgr = LoopManager(session_id="t2c") - state = mgr.set("keep refining", start_now=True) + state = mgr.set("keep refining") assert state.mode == "self_paced" - assert state.start_now is True assert mgr.is_due() is True def test_fire_marks_awaiting_and_blocks_refire(self, hermes_home): @@ -654,25 +612,16 @@ class TestDispatchLoopCommand: assert result["created"] is True assert "Self-paced" in result["output"] - def test_create_start_now(self, hermes_home): + def test_create_fires_immediately(self, hermes_home): from hermes_cli.loops import LoopManager, dispatch_loop_command mgr = LoopManager(session_id="d2a") - result = dispatch_loop_command(mgr, "1h --start-now check the deploy") + result = dispatch_loop_command(mgr, "1h check the deploy") assert result["created"] is True assert "Loop set" in result["output"] assert "fires now" in result["output"] assert mgr.is_due() is True - def test_create_without_start_now_waits_interval(self, hermes_home): - from hermes_cli.loops import LoopManager, dispatch_loop_command - - mgr = LoopManager(session_id="d2b") - result = dispatch_loop_command(mgr, "1h check the deploy") - assert result["created"] is True - assert "First wakeup" in result["output"] - assert mgr.is_due() is False - def test_status_empty(self, hermes_home): from hermes_cli.loops import LoopManager, dispatch_loop_command diff --git a/tests/tui_gateway/test_loop_command.py b/tests/tui_gateway/test_loop_command.py index f437e26974..990947baa3 100644 --- a/tests/tui_gateway/test_loop_command.py +++ b/tests/tui_gateway/test_loop_command.py @@ -195,7 +195,12 @@ 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) + mgr = LoopManager(session_key) + mgr.set("poll", interval_seconds=300) + # New loops are due immediately; push the wakeup out to model "not due". + from hermes_cli.loops import save_loop + mgr.state.next_due_at = time.time() + 300 + save_loop(session_key, mgr.state) with patch.object(server, "_run_prompt_submit") as submit, \ patch.object(server, "_emit"): diff --git a/website/docs/user-guide/features/loops.md b/website/docs/user-guide/features/loops.md index 99e11aee9e..dfd0acb589 100644 --- a/website/docs/user-guide/features/loops.md +++ b/website/docs/user-guide/features/loops.md @@ -28,8 +28,8 @@ When the work should run **unattended** — overnight, on a real schedule, survi What you'll see: 1. **Loop accepted** — `↻ Loop set (every 5m): check the deploy status…` -2. **First wakeup in 5m** — while the session is idle, Hermes injects the wakeup and runs a normal turn against current state. -3. **Repeat** — every 5 minutes, until a stop condition fires or you stop it. +2. **First wakeup fires right away** — on the next idle poll (gateway: the next 15s watcher scan), Hermes injects the wakeup and runs a normal turn against current state. +3. **Repeat** — every 5 minutes after that, until a stop condition fires or you stop it. Loop a slash command just as easily: @@ -37,19 +37,6 @@ Loop a slash command just as easily: /loop 10m /recap ``` -### Run the first iteration immediately - -By default the first wakeup waits one full interval. Pass `--start-now` to run -the first iteration right away, then continue on the cadence: - -``` -/loop 1h --start-now check the deploy status and tell me if it's live yet -``` - -The first wakeup fires on the next idle poll (gateway: the next 15s watcher -scan); subsequent wakeups follow the interval as usual. Works in both cadence -modes and with `/proactive`. - ## The two cadence modes **Fixed interval — you set the clock.** Give an interval (`30s`, `5m`, `2h`, `1h30m`) and the loop fires on that schedule. Use it when the thing you're watching changes on its own timeline: @@ -89,7 +76,7 @@ Examples: | Command | What it does | |---|---| -| `/loop [interval] [--times N] [--until ] [--start-now]` | Start (or replace) the loop for this session. `--start-now` fires the first wakeup immediately. | +| `/loop [interval] [--times N] [--until ]` | Start (or replace) the loop for this session. The first wakeup fires immediately; later ones follow the cadence. | | `/loop` or `/loop status` | Show cadence, ticks fired, and time to the next wakeup. | | `/loop pause` | Stop firing without losing the loop. | | `/loop resume` | Pick it back up. |