fix: idle detection + exponential backoff for dev loop (#435)
Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit was merged in pull request #435.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
113
scripts/loop_guard.py
Normal file
113
scripts/loop_guard.py
Normal file
@@ -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())
|
||||
Reference in New Issue
Block a user