feat(loop): first wakeup fires immediately by default

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
This commit is contained in:
Teknium
2026-08-29 08:27:26 -07:00
parent 796babaaed
commit 1a47a36422
4 changed files with 35 additions and 112 deletions
+12 -30
View File
@@ -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] <prompt> [--times N] [--until <condition>] [--start-now]\n"
" /loop 5m check the deploy status — fixed cadence\n"
"Usage: /loop [interval] <prompt> [--times N] [--until <condition>]\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:
+14 -65
View File
@@ -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
+6 -1
View File
@@ -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"):
+3 -16
View File
@@ -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] <prompt> [--times N] [--until <cond>] [--start-now]` | Start (or replace) the loop for this session. `--start-now` fires the first wakeup immediately. |
| `/loop [interval] <prompt> [--times N] [--until <cond>]` | 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. |