#!/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())