feat(loop): add --start-now to fire the first wakeup immediately
/loop [interval] <prompt> currently schedules the first wakeup one full interval after the command runs (next_due_at = now + interval). When the user just told Hermes what to check, waiting the whole interval before any output feels like the command was ignored. Add an opt-in --start-now flag that keeps Claude Code parity as the default but lets the user run the first iteration immediately, then continue on the cadence: /loop 1h check the deploy status # first run in 1h (unchanged) /loop 1h --start-now check the deploy # first run now, then hourly - parse_loop_args(): parse and strip --start-now (leading or trailing) - LoopState: new persisted start_now field (default False, survives serialization round-trip and old rows missing the field) - LoopManager.set(): next_due_at = now when start_now, for both fixed interval and self-paced modes - dispatch_loop_command(): wire start_now through, update help text, and report "First wakeup fires now" in the confirmation - website/docs: document the flag in the /loop guide - tests: parse (trailing/leading/absent/self-paced/combo/prompt-word), tick lifecycle (due immediately vs after interval), serde round-trip, and dispatch-level confirmation
This commit is contained in:
+34
-9
@@ -149,11 +149,14 @@ 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, "error": str|None}``. ``interval_seconds`` None means
|
||||
self-paced. ``error`` is set for unusable input (empty prompt,
|
||||
interval-only, bad --times).
|
||||
"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).
|
||||
"""
|
||||
raw = (text or "").strip()
|
||||
result: Dict[str, Any] = {
|
||||
@@ -161,6 +164,7 @@ def parse_loop_args(text: str) -> Dict[str, Any]:
|
||||
"prompt": "",
|
||||
"times": 0,
|
||||
"until": "",
|
||||
"start_now": False,
|
||||
"error": None,
|
||||
}
|
||||
if not raw:
|
||||
@@ -170,9 +174,16 @@ 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).
|
||||
# (or to a following --times). --start-now is a bare boolean flag.
|
||||
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:
|
||||
@@ -211,6 +222,7 @@ 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
|
||||
|
||||
|
||||
@@ -295,6 +307,7 @@ 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
|
||||
@@ -329,6 +342,7 @@ 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),
|
||||
@@ -597,9 +611,14 @@ 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)."""
|
||||
"""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.
|
||||
"""
|
||||
prompt = (prompt or "").strip()
|
||||
if not prompt:
|
||||
raise ValueError("loop prompt is empty")
|
||||
@@ -612,7 +631,7 @@ class LoopManager:
|
||||
mode="interval",
|
||||
interval_seconds=float(interval),
|
||||
current_delay=float(interval),
|
||||
next_due_at=now + interval,
|
||||
next_due_at=now if start_now else now + interval,
|
||||
)
|
||||
else:
|
||||
floor = self_paced_floor_seconds()
|
||||
@@ -621,10 +640,11 @@ class LoopManager:
|
||||
mode="self_paced",
|
||||
interval_seconds=0.0,
|
||||
current_delay=float(floor),
|
||||
next_due_at=now + floor,
|
||||
next_due_at=now if start_now else now + floor,
|
||||
)
|
||||
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 {})
|
||||
@@ -890,12 +910,13 @@ def dispatch_loop_command(
|
||||
if lower in {"help", "--help", "-h"}:
|
||||
return {
|
||||
"output": (
|
||||
"Usage: /loop [interval] <prompt> [--times N] [--until <condition>]\n"
|
||||
"Usage: /loop [interval] <prompt> [--times N] [--until <condition>] [--start-now]\n"
|
||||
" /loop 5m check the deploy status — fixed cadence\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}."
|
||||
@@ -916,6 +937,7 @@ 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:
|
||||
@@ -938,7 +960,10 @@ 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).")
|
||||
lines.append(f"First wakeup {state.remaining_label()}. Controls: /loop status · pause · resume · stop.")
|
||||
if state.start_now and state.status == "active":
|
||||
lines.append("First wakeup fires now (--start-now), then on the cadence above.")
|
||||
else:
|
||||
lines.append(f"First wakeup {state.remaining_label()}. Controls: /loop status · pause · resume · stop.")
|
||||
if replacing:
|
||||
lines.insert(1, "(replaced the previous loop for this session)")
|
||||
return {"output": "\n".join(lines), "created": True}
|
||||
|
||||
@@ -133,6 +133,53 @@ class TestParseLoopArgs:
|
||||
p = parse_loop_args("2m poll --times zero")
|
||||
assert p["error"] is not None
|
||||
|
||||
def test_start_now_flag_trailing(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
|
||||
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):
|
||||
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):
|
||||
from hermes_cli.loops import parse_loop_args
|
||||
|
||||
@@ -207,6 +254,7 @@ class TestLoopStateSerde:
|
||||
current_delay=300.0,
|
||||
times=5,
|
||||
until="ci is green",
|
||||
start_now=True,
|
||||
ticks_fired=2,
|
||||
route={"platform": "telegram", "chat_id": "123"},
|
||||
)
|
||||
@@ -215,6 +263,7 @@ 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"}
|
||||
|
||||
@@ -224,6 +273,7 @@ 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 == {}
|
||||
|
||||
|
||||
@@ -305,6 +355,31 @@ class TestTickLifecycle:
|
||||
state.next_due_at = time.time() - 1
|
||||
assert mgr.is_due() is True
|
||||
|
||||
def test_start_now_due_immediately(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
|
||||
assert mgr.is_due() is False
|
||||
|
||||
def test_start_now_self_paced_due_immediately(self, hermes_home):
|
||||
from hermes_cli.loops import LoopManager
|
||||
|
||||
mgr = LoopManager(session_id="t2c")
|
||||
state = mgr.set("keep refining", start_now=True)
|
||||
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):
|
||||
from hermes_cli.loops import LoopManager
|
||||
|
||||
@@ -579,6 +654,25 @@ class TestDispatchLoopCommand:
|
||||
assert result["created"] is True
|
||||
assert "Self-paced" in result["output"]
|
||||
|
||||
def test_create_start_now(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")
|
||||
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
|
||||
|
||||
|
||||
@@ -37,6 +37,19 @@ 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:
|
||||
@@ -76,7 +89,7 @@ Examples:
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `/loop [interval] <prompt> [--times N] [--until <cond>]` | Start (or replace) the loop for this session. |
|
||||
| `/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` 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