chore: harden Ralph session controls
This commit is contained in:
@@ -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()
|
||||||
|
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="")
|
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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user