diff --git a/scripts/loop_guard.py b/scripts/loop_guard.py index 71dd3717..73566dc8 100644 --- a/scripts/loop_guard.py +++ b/scripts/loop_guard.py @@ -240,9 +240,33 @@ def compute_backoff(consecutive_idle: int) -> int: return min(BACKOFF_BASE * (BACKOFF_MULTIPLIER ** consecutive_idle), BACKOFF_MAX) +def seed_cycle_result(item: dict) -> None: + """Pre-seed cycle_result.json with the top queue item. + + Only writes if cycle_result.json does not already exist — never overwrites + agent-written data. This ensures cycle_retro.py can always resolve the + issue number even when the dispatcher (claude-loop, gemini-loop, etc.) does + not write cycle_result.json itself. + """ + if CYCLE_RESULT_FILE.exists(): + return # Agent already wrote its own result — leave it alone + + seed = { + "issue": item.get("issue"), + "type": item.get("type", "unknown"), + } + try: + CYCLE_RESULT_FILE.parent.mkdir(parents=True, exist_ok=True) + CYCLE_RESULT_FILE.write_text(json.dumps(seed) + "\n") + print(f"[loop-guard] Seeded cycle_result.json with issue #{seed['issue']}") + except OSError as exc: + print(f"[loop-guard] WARNING: Could not seed cycle_result.json: {exc}") + + def main() -> int: wait_mode = "--wait" in sys.argv status_mode = "--status" in sys.argv + pick_mode = "--pick" in sys.argv state = load_idle_state() @@ -269,6 +293,17 @@ def main() -> int: state["consecutive_idle"] = 0 state["last_idle_at"] = 0 save_idle_state(state) + + # Pre-seed cycle_result.json so cycle_retro.py can resolve issue= + # even when the dispatcher doesn't write the file itself. + seed_cycle_result(ready[0]) + + if pick_mode: + # Emit the top issue number to stdout for shell script capture. + issue = ready[0].get("issue") + if issue is not None: + print(issue) + return 0 # Queue empty — apply backoff diff --git a/tests/loop/test_loop_guard_seed.py b/tests/loop/test_loop_guard_seed.py new file mode 100644 index 00000000..1dec8f2b --- /dev/null +++ b/tests/loop/test_loop_guard_seed.py @@ -0,0 +1,145 @@ +"""Tests for loop_guard.seed_cycle_result and --pick mode. + +The seed fixes the cycle-metrics dead-pipeline bug (#1250): +loop_guard pre-seeds cycle_result.json so cycle_retro.py can always +resolve issue= even when the dispatcher doesn't write the file. +""" + +from __future__ import annotations + +import json +import sys +from unittest.mock import patch + +import pytest +import scripts.loop_guard as lg + + +@pytest.fixture(autouse=True) +def _isolate(tmp_path, monkeypatch): + """Redirect loop_guard paths to tmp_path for isolation.""" + monkeypatch.setattr(lg, "QUEUE_FILE", tmp_path / "queue.json") + monkeypatch.setattr(lg, "IDLE_STATE_FILE", tmp_path / "idle_state.json") + monkeypatch.setattr(lg, "CYCLE_RESULT_FILE", tmp_path / "cycle_result.json") + monkeypatch.setattr(lg, "GITEA_API", "http://test:3000/api/v1") + monkeypatch.setattr(lg, "REPO_SLUG", "owner/repo") + + +# ── seed_cycle_result ────────────────────────────────────────────────── + + +def test_seed_writes_issue_and_type(tmp_path): + """seed_cycle_result writes issue + type to cycle_result.json.""" + item = {"issue": 42, "type": "bug", "title": "Fix the thing", "ready": True} + lg.seed_cycle_result(item) + + data = json.loads((tmp_path / "cycle_result.json").read_text()) + assert data == {"issue": 42, "type": "bug"} + + +def test_seed_does_not_overwrite_existing(tmp_path): + """If cycle_result.json already exists, seed_cycle_result leaves it alone.""" + existing = {"issue": 99, "type": "feature", "tests_passed": 123} + (tmp_path / "cycle_result.json").write_text(json.dumps(existing)) + + lg.seed_cycle_result({"issue": 1, "type": "bug"}) + + data = json.loads((tmp_path / "cycle_result.json").read_text()) + assert data["issue"] == 99, "Existing file must not be overwritten" + + +def test_seed_missing_issue_field(tmp_path): + """Item with no issue key — seed still writes without crashing.""" + lg.seed_cycle_result({"type": "unknown"}) + data = json.loads((tmp_path / "cycle_result.json").read_text()) + assert data["issue"] is None + + +def test_seed_default_type_when_absent(tmp_path): + """Item with no type key defaults to 'unknown'.""" + lg.seed_cycle_result({"issue": 7}) + data = json.loads((tmp_path / "cycle_result.json").read_text()) + assert data["type"] == "unknown" + + +def test_seed_oserror_is_graceful(tmp_path, monkeypatch, capsys): + """OSError during seed logs a warning but does not raise.""" + monkeypatch.setattr(lg, "CYCLE_RESULT_FILE", tmp_path / "no_dir" / "cycle_result.json") + + from pathlib import Path + original_mkdir = Path.mkdir + + def failing_mkdir(self, *args, **kwargs): + raise OSError("no space left") + + monkeypatch.setattr(Path, "mkdir", failing_mkdir) + + # Should not raise + lg.seed_cycle_result({"issue": 5, "type": "bug"}) + + captured = capsys.readouterr() + assert "WARNING" in captured.out + + +# ── main() integration ───────────────────────────────────────────────── + + +def _write_queue(tmp_path, items): + tmp_path.mkdir(parents=True, exist_ok=True) + lg.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True) + lg.QUEUE_FILE.write_text(json.dumps(items)) + + +def test_main_seeds_cycle_result_when_work_found(tmp_path, monkeypatch): + """main() seeds cycle_result.json with top queue item on ready queue.""" + _write_queue(tmp_path, [{"issue": 10, "type": "feature", "ready": True}]) + monkeypatch.setattr(lg, "_fetch_open_issue_numbers", lambda: None) + + with patch.object(sys, "argv", ["loop_guard"]): + rc = lg.main() + + assert rc == 0 + data = json.loads((tmp_path / "cycle_result.json").read_text()) + assert data["issue"] == 10 + + +def test_main_no_seed_when_queue_empty(tmp_path, monkeypatch): + """main() does not create cycle_result.json when queue is empty.""" + _write_queue(tmp_path, []) + monkeypatch.setattr(lg, "_fetch_open_issue_numbers", lambda: None) + + with patch.object(sys, "argv", ["loop_guard"]): + rc = lg.main() + + assert rc == 1 + assert not (tmp_path / "cycle_result.json").exists() + + +def test_main_pick_mode_prints_issue(tmp_path, monkeypatch, capsys): + """--pick flag prints the top issue number to stdout.""" + _write_queue(tmp_path, [{"issue": 55, "type": "bug", "ready": True}]) + monkeypatch.setattr(lg, "_fetch_open_issue_numbers", lambda: None) + + with patch.object(sys, "argv", ["loop_guard", "--pick"]): + rc = lg.main() + + assert rc == 0 + captured = capsys.readouterr() + # The issue number must appear as a line in stdout + lines = captured.out.strip().splitlines() + assert str(55) in lines + + +def test_main_pick_mode_empty_queue_no_output(tmp_path, monkeypatch, capsys): + """--pick with empty queue exits 1, doesn't print an issue number.""" + _write_queue(tmp_path, []) + monkeypatch.setattr(lg, "_fetch_open_issue_numbers", lambda: None) + + with patch.object(sys, "argv", ["loop_guard", "--pick"]): + rc = lg.main() + + assert rc == 1 + captured = capsys.readouterr() + # No bare integer line printed + for line in captured.out.strip().splitlines(): + assert not line.strip().isdigit(), f"Unexpected issue number in output: {line!r}"