Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
114 lines
3.3 KiB
Python
114 lines
3.3 KiB
Python
#!/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())
|