From 9b577742821f9a7ca4b9bd077826b1b628864023 Mon Sep 17 00:00:00 2001 From: Kimi Agent Date: Sat, 21 Mar 2026 13:53:11 +0000 Subject: [PATCH] [kimi] feat: pre-cycle state validation for stale cycle_result.json (#661) (#666) Co-authored-by: Kimi Agent Co-committed-by: Kimi Agent --- scripts/loop_guard.py | 90 ++++++++++++++ tests/loop/test_loop_guard_validate.py | 163 +++++++++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 tests/loop/test_loop_guard_validate.py diff --git a/scripts/loop_guard.py b/scripts/loop_guard.py index 76c9db2..b6bad13 100644 --- a/scripts/loop_guard.py +++ b/scripts/loop_guard.py @@ -27,11 +27,15 @@ 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" +CYCLE_RESULT_FILE = REPO_ROOT / ".loop" / "cycle_result.json" TOKEN_FILE = Path.home() / ".hermes" / "gitea_token" GITEA_API = os.environ.get("GITEA_API", "http://localhost:3000/api/v1") REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard") +# Default cycle duration in seconds (5 min); stale threshold = 2× this +CYCLE_DURATION = int(os.environ.get("CYCLE_DURATION", "300")) + # Backoff sequence: 60s, 120s, 240s, 600s max BACKOFF_BASE = 60 BACKOFF_MAX = 600 @@ -77,6 +81,89 @@ def _fetch_open_issue_numbers() -> set[int] | None: return None +def _load_cycle_result() -> dict: + """Read cycle_result.json, handling markdown-fenced JSON.""" + if not CYCLE_RESULT_FILE.exists(): + return {} + try: + raw = CYCLE_RESULT_FILE.read_text().strip() + if raw.startswith("```"): + lines = raw.splitlines() + lines = [ln for ln in lines if not ln.startswith("```")] + raw = "\n".join(lines) + return json.loads(raw) + except (json.JSONDecodeError, OSError): + return {} + + +def _is_issue_open(issue_number: int) -> bool | None: + """Check if a single issue is open. Returns None on API failure.""" + token = _get_token() + if not token: + return None + try: + url = f"{GITEA_API}/repos/{REPO_SLUG}/issues/{issue_number}" + req = urllib.request.Request( + url, + headers={ + "Authorization": f"token {token}", + "Accept": "application/json", + }, + ) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + return data.get("state") == "open" + except Exception: + return None + + +def validate_cycle_result() -> bool: + """Pre-cycle validation: remove stale or invalid cycle_result.json. + + Checks: + 1. Age — if older than 2× CYCLE_DURATION, delete it. + 2. Issue — if the referenced issue is closed, delete it. + + Returns True if the file was removed, False otherwise. + """ + if not CYCLE_RESULT_FILE.exists(): + return False + + # Age check + try: + age = time.time() - CYCLE_RESULT_FILE.stat().st_mtime + except OSError: + return False + stale_threshold = CYCLE_DURATION * 2 + if age > stale_threshold: + print( + f"[loop-guard] cycle_result.json is {int(age)}s old " + f"(threshold {stale_threshold}s) — removing stale file" + ) + CYCLE_RESULT_FILE.unlink(missing_ok=True) + return True + + # Issue check + cr = _load_cycle_result() + issue_num = cr.get("issue") + if issue_num is not None: + try: + issue_num = int(issue_num) + except (ValueError, TypeError): + return False + is_open = _is_issue_open(issue_num) + if is_open is False: + print( + f"[loop-guard] cycle_result.json references closed " + f"issue #{issue_num} — removing" + ) + CYCLE_RESULT_FILE.unlink(missing_ok=True) + return True + # is_open is None (API failure) or True — keep file + + return False + + def load_queue() -> list[dict]: """Load queue.json and return ready items, filtering out closed issues.""" if not QUEUE_FILE.exists(): @@ -150,6 +237,9 @@ def main() -> int: }, indent=2)) return 0 + # Pre-cycle validation: remove stale cycle_result.json + validate_cycle_result() + ready = load_queue() if ready: diff --git a/tests/loop/test_loop_guard_validate.py b/tests/loop/test_loop_guard_validate.py new file mode 100644 index 0000000..f015500 --- /dev/null +++ b/tests/loop/test_loop_guard_validate.py @@ -0,0 +1,163 @@ +"""Tests for cycle_result.json validation in loop_guard. + +Covers validate_cycle_result(), _load_cycle_result(), and _is_issue_open(). +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +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, "CYCLE_RESULT_FILE", tmp_path / "cycle_result.json") + monkeypatch.setattr(lg, "CYCLE_DURATION", 300) + monkeypatch.setattr(lg, "GITEA_API", "http://test:3000/api/v1") + monkeypatch.setattr(lg, "REPO_SLUG", "owner/repo") + + +def _write_cr(tmp_path, data: dict, age_seconds: float = 0) -> Path: + """Write a cycle_result.json and optionally backdate it.""" + p = tmp_path / "cycle_result.json" + p.write_text(json.dumps(data)) + if age_seconds: + mtime = time.time() - age_seconds + import os + + os.utime(p, (mtime, mtime)) + return p + + +# --- _load_cycle_result --- + + +def test_load_cycle_result_missing(tmp_path): + assert lg._load_cycle_result() == {} + + +def test_load_cycle_result_valid(tmp_path): + _write_cr(tmp_path, {"issue": 42, "type": "fix"}) + assert lg._load_cycle_result() == {"issue": 42, "type": "fix"} + + +def test_load_cycle_result_markdown_fenced(tmp_path): + p = tmp_path / "cycle_result.json" + p.write_text('```json\n{"issue": 99}\n```') + assert lg._load_cycle_result() == {"issue": 99} + + +def test_load_cycle_result_malformed(tmp_path): + p = tmp_path / "cycle_result.json" + p.write_text("not json at all") + assert lg._load_cycle_result() == {} + + +# --- _is_issue_open --- + + +def test_is_issue_open_true(monkeypatch): + monkeypatch.setattr(lg, "_get_token", lambda: "tok") + resp_data = json.dumps({"state": "open"}).encode() + + class FakeResp: + def read(self): + return resp_data + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + with patch("urllib.request.urlopen", return_value=FakeResp()): + assert lg._is_issue_open(42) is True + + +def test_is_issue_open_closed(monkeypatch): + monkeypatch.setattr(lg, "_get_token", lambda: "tok") + resp_data = json.dumps({"state": "closed"}).encode() + + class FakeResp: + def read(self): + return resp_data + + def __enter__(self): + return self + + def __exit__(self, *a): + pass + + with patch("urllib.request.urlopen", return_value=FakeResp()): + assert lg._is_issue_open(42) is False + + +def test_is_issue_open_no_token(monkeypatch): + monkeypatch.setattr(lg, "_get_token", lambda: "") + assert lg._is_issue_open(42) is None + + +def test_is_issue_open_api_error(monkeypatch): + monkeypatch.setattr(lg, "_get_token", lambda: "tok") + with patch("urllib.request.urlopen", side_effect=OSError("timeout")): + assert lg._is_issue_open(42) is None + + +# --- validate_cycle_result --- + + +def test_validate_no_file(tmp_path): + """No file → returns False, no crash.""" + assert lg.validate_cycle_result() is False + + +def test_validate_fresh_file_open_issue(tmp_path, monkeypatch): + """Fresh file with open issue → kept.""" + _write_cr(tmp_path, {"issue": 10}) + monkeypatch.setattr(lg, "_is_issue_open", lambda n: True) + assert lg.validate_cycle_result() is False + assert (tmp_path / "cycle_result.json").exists() + + +def test_validate_stale_file_removed(tmp_path): + """File older than 2× CYCLE_DURATION → removed.""" + _write_cr(tmp_path, {"issue": 10}, age_seconds=700) + assert lg.validate_cycle_result() is True + assert not (tmp_path / "cycle_result.json").exists() + + +def test_validate_fresh_file_closed_issue(tmp_path, monkeypatch): + """Fresh file referencing closed issue → removed.""" + _write_cr(tmp_path, {"issue": 10}) + monkeypatch.setattr(lg, "_is_issue_open", lambda n: False) + assert lg.validate_cycle_result() is True + assert not (tmp_path / "cycle_result.json").exists() + + +def test_validate_api_failure_keeps_file(tmp_path, monkeypatch): + """API failure → file kept (graceful degradation).""" + _write_cr(tmp_path, {"issue": 10}) + monkeypatch.setattr(lg, "_is_issue_open", lambda n: None) + assert lg.validate_cycle_result() is False + assert (tmp_path / "cycle_result.json").exists() + + +def test_validate_no_issue_field(tmp_path): + """File without issue field → kept (only age check applies).""" + _write_cr(tmp_path, {"type": "fix"}) + assert lg.validate_cycle_result() is False + assert (tmp_path / "cycle_result.json").exists() + + +def test_validate_stale_threshold_boundary(tmp_path, monkeypatch): + """File just under threshold → kept (not stale yet).""" + _write_cr(tmp_path, {"issue": 10}, age_seconds=599) + monkeypatch.setattr(lg, "_is_issue_open", lambda n: True) + assert lg.validate_cycle_result() is False + assert (tmp_path / "cycle_result.json").exists()