diff --git a/scripts/cycle_retro.py b/scripts/cycle_retro.py index ae34cd36..45ac320b 100644 --- a/scripts/cycle_retro.py +++ b/scripts/cycle_retro.py @@ -149,6 +149,11 @@ def update_summary() -> None: def main() -> None: args = parse_args() + # Reject idle cycles — no issue and no duration means nothing happened + if not args.issue and args.duration == 0: + print(f"[retro] Cycle {args.cycle} skipped — idle (no issue, no duration)") + return + # A cycle is only truly successful if hermes exited clean AND main is green truly_success = args.success and args.main_green diff --git a/scripts/loop_guard.py b/scripts/loop_guard.py new file mode 100644 index 00000000..7ef44493 --- /dev/null +++ b/scripts/loop_guard.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Loop guard — idle detection + exponential backoff for the dev loop. + +Checks .loop/queue.json for ready items before spawning hermes. +When the queue is empty, applies exponential backoff (60s → 600s max) +instead of burning empty cycles every 3 seconds. + +Usage (called by the dev loop before each cycle): + python3 scripts/loop_guard.py # exits 0 if ready, 1 if idle + python3 scripts/loop_guard.py --wait # same, but sleeps the backoff first + python3 scripts/loop_guard.py --status # print current idle state + +Exit codes: + 0 — queue has work, proceed with cycle + 1 — queue empty, idle backoff applied (skip cycle) +""" + +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +QUEUE_FILE = REPO_ROOT / ".loop" / "queue.json" +IDLE_STATE_FILE = REPO_ROOT / ".loop" / "idle_state.json" + +# Backoff sequence: 60s, 120s, 240s, 600s max +BACKOFF_BASE = 60 +BACKOFF_MAX = 600 +BACKOFF_MULTIPLIER = 2 + + +def load_queue() -> list[dict]: + """Load queue.json and return ready items.""" + if not QUEUE_FILE.exists(): + return [] + try: + data = json.loads(QUEUE_FILE.read_text()) + if isinstance(data, list): + return [item for item in data if item.get("ready")] + return [] + except (json.JSONDecodeError, OSError): + return [] + + +def load_idle_state() -> dict: + """Load persistent idle state.""" + if not IDLE_STATE_FILE.exists(): + return {"consecutive_idle": 0, "last_idle_at": 0} + try: + return json.loads(IDLE_STATE_FILE.read_text()) + except (json.JSONDecodeError, OSError): + return {"consecutive_idle": 0, "last_idle_at": 0} + + +def save_idle_state(state: dict) -> None: + """Persist idle state.""" + IDLE_STATE_FILE.parent.mkdir(parents=True, exist_ok=True) + IDLE_STATE_FILE.write_text(json.dumps(state, indent=2) + "\n") + + +def compute_backoff(consecutive_idle: int) -> int: + """Exponential backoff: 60, 120, 240, 600 (capped).""" + return min(BACKOFF_BASE * (BACKOFF_MULTIPLIER ** consecutive_idle), BACKOFF_MAX) + + +def main() -> int: + wait_mode = "--wait" in sys.argv + status_mode = "--status" in sys.argv + + state = load_idle_state() + + if status_mode: + ready = load_queue() + backoff = compute_backoff(state["consecutive_idle"]) + print(json.dumps({ + "queue_ready": len(ready), + "consecutive_idle": state["consecutive_idle"], + "next_backoff_seconds": backoff if not ready else 0, + }, indent=2)) + return 0 + + ready = load_queue() + + if ready: + # Queue has work — reset idle state, proceed + if state["consecutive_idle"] > 0: + print(f"[loop-guard] Queue active ({len(ready)} ready) — " + f"resuming after {state['consecutive_idle']} idle cycles") + state["consecutive_idle"] = 0 + state["last_idle_at"] = 0 + save_idle_state(state) + return 0 + + # Queue empty — apply backoff + backoff = compute_backoff(state["consecutive_idle"]) + state["consecutive_idle"] += 1 + state["last_idle_at"] = time.time() + save_idle_state(state) + + print(f"[loop-guard] Queue empty — idle #{state['consecutive_idle']}, " + f"backoff {backoff}s") + + if wait_mode: + time.sleep(backoff) + + return 1 + + +if __name__ == "__main__": + sys.exit(main())