Files
Timmy-time-dashboard/scripts/cycle_retro.py
Kimi Agent 3cde310c78
All checks were successful
Tests / lint (push) Successful in 4s
Tests / test (push) Successful in 1m7s
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>
2026-03-19 10:36:39 -04:00

199 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""Cycle retrospective logger for the Timmy dev loop.
Called after each cycle completes (success or failure).
Appends a structured entry to .loop/retro/cycles.jsonl.
SUCCESS DEFINITION:
A cycle is only "success" if BOTH conditions are met:
1. The hermes process exited cleanly (exit code 0)
2. Main is green (smoke test passes on main after merge)
A cycle that merges a PR but leaves main red is a FAILURE.
The --main-green flag records the smoke test result.
Usage:
python3 scripts/cycle_retro.py --cycle 42 --success --main-green --issue 85 \
--type bug --duration 480 --tests-passed 1450 --tests-added 3 \
--files-changed 2 --lines-added 45 --lines-removed 12 \
--kimi-panes 2 --pr 155
python3 scripts/cycle_retro.py --cycle 43 --failure --issue 90 \
--type feature --duration 1200 --reason "tox failed: 3 errors"
python3 scripts/cycle_retro.py --cycle 44 --success --no-main-green \
--reason "PR merged but tests fail on main"
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
SUMMARY_FILE = REPO_ROOT / ".loop" / "retro" / "summary.json"
# How many recent entries to include in rolling summary
SUMMARY_WINDOW = 50
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Log a cycle retrospective")
p.add_argument("--cycle", type=int, required=True)
p.add_argument("--issue", type=int, default=None)
p.add_argument("--type", choices=["bug", "feature", "refactor", "philosophy", "unknown"],
default="unknown")
outcome = p.add_mutually_exclusive_group(required=True)
outcome.add_argument("--success", action="store_true")
outcome.add_argument("--failure", action="store_true")
p.add_argument("--duration", type=int, default=0, help="Cycle time in seconds")
p.add_argument("--tests-passed", type=int, default=0)
p.add_argument("--tests-added", type=int, default=0)
p.add_argument("--files-changed", type=int, default=0)
p.add_argument("--lines-added", type=int, default=0)
p.add_argument("--lines-removed", type=int, default=0)
p.add_argument("--kimi-panes", type=int, default=0)
p.add_argument("--pr", type=int, default=None, help="PR number if merged")
p.add_argument("--reason", type=str, default="", help="Failure reason")
p.add_argument("--notes", type=str, default="", help="Free-form observations")
p.add_argument("--main-green", action="store_true", default=False,
help="Smoke test passed on main after this cycle")
p.add_argument("--no-main-green", dest="main_green", action="store_false",
help="Smoke test failed or was not run")
return p.parse_args()
def update_summary() -> None:
"""Compute rolling summary statistics from recent cycles."""
if not RETRO_FILE.exists():
return
entries = []
for line in RETRO_FILE.read_text().strip().splitlines():
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
recent = entries[-SUMMARY_WINDOW:]
if not recent:
return
# Only count entries with real measured data for rates.
# Backfilled entries lack main_green/hermes_clean fields — exclude them.
measured = [e for e in recent if "main_green" in e]
successes = [e for e in measured if e.get("success")]
failures = [e for e in measured if not e.get("success")]
main_green_count = sum(1 for e in measured if e.get("main_green"))
hermes_clean_count = sum(1 for e in measured if e.get("hermes_clean"))
durations = [e["duration"] for e in recent if e.get("duration", 0) > 0]
# Per-type stats (only from measured entries for rates)
type_stats: dict[str, dict] = {}
for e in recent:
t = e.get("type", "unknown")
if t not in type_stats:
type_stats[t] = {"count": 0, "measured": 0, "success": 0, "total_duration": 0}
type_stats[t]["count"] += 1
type_stats[t]["total_duration"] += e.get("duration", 0)
if "main_green" in e:
type_stats[t]["measured"] += 1
if e.get("success"):
type_stats[t]["success"] += 1
for t, stats in type_stats.items():
if stats["measured"] > 0:
stats["success_rate"] = round(stats["success"] / stats["measured"], 2)
else:
stats["success_rate"] = -1
if stats["count"] > 0:
stats["avg_duration"] = round(stats["total_duration"] / stats["count"])
# Quarantine candidates (failed 2+ times)
issue_failures: dict[int, int] = {}
for e in recent:
if not e.get("success") and e.get("issue"):
issue_failures[e["issue"]] = issue_failures.get(e["issue"], 0) + 1
quarantine_candidates = {k: v for k, v in issue_failures.items() if v >= 2}
summary = {
"updated_at": datetime.now(timezone.utc).isoformat(),
"window": len(recent),
"measured_cycles": len(measured),
"total_cycles": len(entries),
"success_rate": round(len(successes) / len(measured), 2) if measured else -1,
"main_green_rate": round(main_green_count / len(measured), 2) if measured else -1,
"hermes_clean_rate": round(hermes_clean_count / len(measured), 2) if measured else -1,
"avg_duration_seconds": round(sum(durations) / len(durations)) if durations else 0,
"total_lines_added": sum(e.get("lines_added", 0) for e in recent),
"total_lines_removed": sum(e.get("lines_removed", 0) for e in recent),
"total_prs_merged": sum(1 for e in recent if e.get("pr")),
"by_type": type_stats,
"quarantine_candidates": quarantine_candidates,
"recent_failures": [
{"cycle": e["cycle"], "issue": e.get("issue"), "reason": e.get("reason", "")}
for e in failures[-5:]
],
}
SUMMARY_FILE.write_text(json.dumps(summary, indent=2) + "\n")
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
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"cycle": args.cycle,
"issue": args.issue,
"type": args.type,
"success": truly_success,
"hermes_clean": args.success,
"main_green": args.main_green,
"duration": args.duration,
"tests_passed": args.tests_passed,
"tests_added": args.tests_added,
"files_changed": args.files_changed,
"lines_added": args.lines_added,
"lines_removed": args.lines_removed,
"kimi_panes": args.kimi_panes,
"pr": args.pr,
"reason": args.reason if (args.failure or not args.main_green) else "",
"notes": args.notes,
}
RETRO_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(RETRO_FILE, "a") as f:
f.write(json.dumps(entry) + "\n")
update_summary()
status = "✓ SUCCESS" if args.success else "✗ FAILURE"
print(f"[retro] Cycle {args.cycle} {status}", end="")
if args.issue:
print(f" (#{args.issue} {args.type})", end="")
if args.duration:
print(f"{args.duration}s", end="")
if args.failure and args.reason:
print(f"{args.reason}", end="")
print()
if __name__ == "__main__":
main()