chore: harden Ralph session controls

This commit is contained in:
Dobromir Popov
2026-06-29 15:54:00 +03:00
parent 8895e5e8f1
commit c358798627

View File

@@ -17,6 +17,7 @@ import argparse
import datetime as dt import datetime as dt
import json import json
import os import os
import select
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -132,6 +133,20 @@ def _ralph_status_json() -> dict[str, Any] | None:
return {"status": "unknown", "raw": result.stdout.strip()} return {"status": "unknown", "raw": result.stdout.strip()}
def _positive_float(value: str) -> float:
parsed = float(value)
if parsed <= 0:
raise argparse.ArgumentTypeError("value must be > 0")
return parsed
def _non_negative_float(value: str) -> float:
parsed = float(value)
if parsed < 0:
raise argparse.ArgumentTypeError("value must be >= 0")
return parsed
def print_dashboard(prd_path: Path, *, include_git: bool = False, include_ralph: bool = True) -> None: def print_dashboard(prd_path: Path, *, include_git: bool = False, include_ralph: bool = True) -> None:
data = _load_prd(prd_path) data = _load_prd(prd_path)
stories = _stories(data) stories = _stories(data)
@@ -211,13 +226,21 @@ def _run_with_updates(cmd: list[str], prd_path: Path, *, interval: float) -> int
) )
next_update = time.monotonic() + interval next_update = time.monotonic() + interval
assert proc.stdout is not None assert proc.stdout is not None
for line in proc.stdout: stdout_fd = proc.stdout.fileno()
print(line, end="") while proc.poll() is None:
timeout = max(0.1, next_update - time.monotonic())
readable, _, _ = select.select([stdout_fd], [], [], timeout)
if readable:
line = proc.stdout.readline()
if line:
print(line, end="")
if time.monotonic() >= next_update: if time.monotonic() >= next_update:
print("\n--- Ralph progress update ---") print("\n--- Ralph progress update ---")
print_dashboard(prd_path, include_git=True, include_ralph=True) print_dashboard(prd_path, include_git=True, include_ralph=True)
print("--- end update ---\n") print("--- end update ---\n")
next_update = time.monotonic() + interval next_update = time.monotonic() + interval
for line in proc.stdout:
print(line, end="")
return proc.wait() return proc.wait()
@@ -230,7 +253,7 @@ def command_watch(args: argparse.Namespace) -> int:
while True: while True:
os.system("clear" if sys.stdout.isatty() else "true") os.system("clear" if sys.stdout.isatty() else "true")
print(dt.datetime.now().isoformat(timespec="seconds")) print(dt.datetime.now().isoformat(timespec="seconds"))
print_dashboard(args.prd, include_git=args.git, include_ralph=True) print_dashboard(args.prd, include_git=args.git, include_ralph=not args.no_ralph_status)
if args.once: if args.once:
return 0 return 0
time.sleep(args.interval) time.sleep(args.interval)
@@ -256,10 +279,15 @@ def command_run_next(args: argparse.Namespace) -> int:
def command_auto(args: argparse.Namespace) -> int: def command_auto(args: argparse.Namespace) -> int:
completed_this_run = 0 completed_this_run = 0
while True: while True:
data = _load_prd(args.prd) data_before = _load_prd(args.prd)
story = _next_ready(data) story = _next_ready(data_before)
if story is None: if story is None:
print("No ready tasks remain. Backlog is complete or blocked.") _, _, blocked = _story_sets(data_before)
if blocked:
print("No ready tasks remain; backlog is blocked, not complete.")
print_dashboard(args.prd, include_git=True, include_ralph=True)
return 2
print("No ready tasks remain. Backlog is complete.")
print_dashboard(args.prd, include_git=True, include_ralph=True) print_dashboard(args.prd, include_git=True, include_ralph=True)
return 0 return 0
if args.max_tasks is not None and completed_this_run >= args.max_tasks: if args.max_tasks is not None and completed_this_run >= args.max_tasks:
@@ -278,6 +306,14 @@ def command_auto(args: argparse.Namespace) -> int:
if code != 0: if code != 0:
return code return code
completed_this_run += 1 completed_this_run += 1
data_after = _load_prd(args.prd)
before_done = {str(story.get("id")) for story in _story_sets(data_before)[0]}
after_done = {str(story.get("id")) for story in _story_sets(data_after)[0]}
next_after = _next_ready(data_after)
if after_done == before_done and next_after and next_after.get("id") == story.get("id"):
raise SystemExit(
f"Ralph exited without advancing {story.get('id')}; stopping to avoid an infinite auto loop."
)
if _git_dirty() and not args.allow_dirty: if _git_dirty() and not args.allow_dirty:
print(_git_status_short()) print(_git_status_short())
raise SystemExit( raise SystemExit(
@@ -295,14 +331,31 @@ def _read_first_lines(path: Path, limit: int = 80) -> str:
return "\n".join(lines[:limit]) return "\n".join(lines[:limit])
IGNORED_REVIEW_PARTS = {".git", ".venv", "__pycache__", "build", "dist", ".mypy_cache", ".pytest_cache"}
def _review_path_allowed(path: Path) -> bool:
return not (IGNORED_REVIEW_PARTS & set(path.relative_to(REPO_ROOT).parts))
def _iter_review_files(pattern: str) -> list[Path]:
return [path for path in REPO_ROOT.glob(pattern) if path.is_file() and _review_path_allowed(path)]
def _iter_files(root: Path, pattern: str) -> list[Path]:
return [path for path in root.rglob(pattern) if path.is_file() and _review_path_allowed(path)]
def _review_report(prd_path: Path) -> str: def _review_report(prd_path: Path) -> str:
data = _load_prd(prd_path) data = _load_prd(prd_path)
stories = _stories(data) stories = _stories(data)
issue_files = sorted((prd_path.parent / "issues").glob("*.md")) issue_files = sorted((prd_path.parent / "issues").glob("*.md"))
adr_files = sorted((REPO_ROOT / "docs/adr").glob("*.md")) adr_files = sorted((REPO_ROOT / "docs/adr").glob("*.md"))
grill_files = sorted(REPO_ROOT.glob("**/*grill*.md")) doc_files = sorted((REPO_ROOT / "docs").rglob("*.md"))
code_roots = [REPO_ROOT / "packages", REPO_ROOT / "tests"] grill_files = sorted(_iter_review_files("**/*grill*.md"))
code_files = [p for root in code_roots if root.exists() for p in root.rglob("*.py")] code_roots = [REPO_ROOT / "packages", REPO_ROOT / "tests", REPO_ROOT / "scripts"]
code_files = sorted(path for root in code_roots if root.exists() for path in _iter_files(root, "*.py"))
config_files = [path for pattern in ("*.toml", "*.md") for path in _iter_review_files(pattern)]
lines = [ lines = [
"# Task / docs / code review brief", "# Task / docs / code review brief",
@@ -326,6 +379,8 @@ def _review_report(prd_path: Path) -> str:
lines.extend(f"- {_rel(path)}" for path in issue_files) lines.extend(f"- {_rel(path)}" for path in issue_files)
lines.extend(["", "### ADR / generated design docs", ""]) lines.extend(["", "### ADR / generated design docs", ""])
lines.extend(f"- {_rel(path)}" for path in adr_files) lines.extend(f"- {_rel(path)}" for path in adr_files)
lines.extend(["", "### Other docs", ""])
lines.extend(f"- {_rel(path)}" for path in doc_files if path not in adr_files)
if grill_files: if grill_files:
lines.extend(["", "### Grill-generated docs", ""]) lines.extend(["", "### Grill-generated docs", ""])
lines.extend(f"- {_rel(path)}" for path in grill_files) lines.extend(f"- {_rel(path)}" for path in grill_files)
@@ -333,6 +388,8 @@ def _review_report(prd_path: Path) -> str:
lines.extend(["", "### Grill-generated docs", "", "- No *grill*.md files found."]) lines.extend(["", "### Grill-generated docs", "", "- No *grill*.md files found."])
lines.extend(["", "### Code files", ""]) lines.extend(["", "### Code files", ""])
lines.extend(f"- {_rel(path)}" for path in sorted(code_files)[:200]) lines.extend(f"- {_rel(path)}" for path in sorted(code_files)[:200])
lines.extend(["", "### Config / top-level docs", ""])
lines.extend(f"- {_rel(path)}" for path in sorted(config_files)[:100])
lines.extend([ lines.extend([
"", "",
@@ -353,6 +410,9 @@ def _review_report(prd_path: Path) -> str:
def command_review(args: argparse.Namespace) -> int: def command_review(args: argparse.Namespace) -> int:
if args.run_ralph and _git_dirty() and not args.allow_dirty:
print(_git_status_short())
raise SystemExit("Working tree is dirty. Commit/review changes first, or pass --allow-dirty.")
report = _review_report(args.prd) report = _review_report(args.prd)
output = args.output output = args.output
if output is None: if output is None:
@@ -364,9 +424,6 @@ def command_review(args: argparse.Namespace) -> int:
print(f"Review brief written: {_rel(output)}") print(f"Review brief written: {_rel(output)}")
if not args.run_ralph: if not args.run_ralph:
return 0 return 0
if _git_dirty() and not args.allow_dirty:
print(_git_status_short())
raise SystemExit("Working tree is dirty. Commit/review changes first, or pass --allow-dirty.")
prompt = tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".md", prefix="ralph-review-", delete=False) prompt = tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".md", prefix="ralph-review-", delete=False)
prompt_path = Path(prompt.name) prompt_path = Path(prompt.name)
with prompt: with prompt:
@@ -401,15 +458,16 @@ def build_parser() -> argparse.ArgumentParser:
watch = subparsers.add_parser("watch", help="Refresh progress every N seconds") watch = subparsers.add_parser("watch", help="Refresh progress every N seconds")
watch.add_argument("--prd", type=Path, default=DEFAULT_PRD) watch.add_argument("--prd", type=Path, default=DEFAULT_PRD)
watch.add_argument("--interval", type=float, default=DEFAULT_INTERVAL) watch.add_argument("--interval", type=_positive_float, default=DEFAULT_INTERVAL)
watch.add_argument("--git", action="store_true") watch.add_argument("--git", action="store_true")
watch.add_argument("--no-ralph-status", action="store_true", help="Skip ralph-tui status lookup")
watch.add_argument("--once", action="store_true") watch.add_argument("--once", action="store_true")
watch.set_defaults(func=command_watch) watch.set_defaults(func=command_watch)
run_next = subparsers.add_parser("run-next", help="Start one fresh Ralph session for the next ready task") run_next = subparsers.add_parser("run-next", help="Start one fresh Ralph session for the next ready task")
run_next.add_argument("--prd", type=Path, default=DEFAULT_PRD) run_next.add_argument("--prd", type=Path, default=DEFAULT_PRD)
run_next.add_argument("--agent", default=DEFAULT_AGENT) run_next.add_argument("--agent", default=DEFAULT_AGENT)
run_next.add_argument("--interval", type=float, default=DEFAULT_INTERVAL, help="Progress update interval while Ralph runs") run_next.add_argument("--interval", type=_positive_float, default=DEFAULT_INTERVAL, help="Progress update interval while Ralph runs")
run_next.add_argument("--allow-dirty", action="store_true") run_next.add_argument("--allow-dirty", action="store_true")
run_next.add_argument("--dry-run", action="store_true") run_next.add_argument("--dry-run", action="store_true")
run_next.set_defaults(func=command_run_next) run_next.set_defaults(func=command_run_next)
@@ -417,8 +475,8 @@ def build_parser() -> argparse.ArgumentParser:
auto = subparsers.add_parser("auto", help="Run fresh Ralph sessions until complete/blocked") auto = subparsers.add_parser("auto", help="Run fresh Ralph sessions until complete/blocked")
auto.add_argument("--prd", type=Path, default=DEFAULT_PRD) auto.add_argument("--prd", type=Path, default=DEFAULT_PRD)
auto.add_argument("--agent", default=DEFAULT_AGENT) auto.add_argument("--agent", default=DEFAULT_AGENT)
auto.add_argument("--interval", type=float, default=DEFAULT_INTERVAL) auto.add_argument("--interval", type=_positive_float, default=DEFAULT_INTERVAL)
auto.add_argument("--delay", type=float, default=3.0, help="Delay between sessions") auto.add_argument("--delay", type=_non_negative_float, default=3.0, help="Delay between sessions")
auto.add_argument("--max-tasks", type=int, default=None) auto.add_argument("--max-tasks", type=int, default=None)
auto.add_argument("--allow-dirty", action="store_true", help="Do not stop between dirty-tree Ralph sessions") auto.add_argument("--allow-dirty", action="store_true", help="Do not stop between dirty-tree Ralph sessions")
auto.add_argument("--dry-run", action="store_true") auto.add_argument("--dry-run", action="store_true")
@@ -429,7 +487,7 @@ def build_parser() -> argparse.ArgumentParser:
review.add_argument("--output", type=Path, default=None) review.add_argument("--output", type=Path, default=None)
review.add_argument("--run-ralph", action="store_true", help="Start a fresh Ralph cleanup/review session with this brief") review.add_argument("--run-ralph", action="store_true", help="Start a fresh Ralph cleanup/review session with this brief")
review.add_argument("--agent", default=DEFAULT_AGENT) review.add_argument("--agent", default=DEFAULT_AGENT)
review.add_argument("--interval", type=float, default=DEFAULT_INTERVAL) review.add_argument("--interval", type=_positive_float, default=DEFAULT_INTERVAL)
review.add_argument("--allow-dirty", action="store_true") review.add_argument("--allow-dirty", action="store_true")
review.set_defaults(func=command_review) review.set_defaults(func=command_review)