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:
+12
-30
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user