aogavrilov's picture
Add files using upload-large-folder tool
dc7b96e verified
Raw
History Blame Contribute Delete
10.5 kB
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import os
import shlex
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Any
DG_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SUPERVISOR = DG_ROOT / "scripts" / "run_supervisor_agent.sh"
def run_cmd(command: list[str], cwd: Path, timeout: int) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
command,
cwd=str(cwd),
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired as exc:
return subprocess.CompletedProcess(
command,
124,
stdout=exc.stdout if isinstance(exc.stdout, str) else "",
stderr=f"task step timed out after {timeout}s",
)
def git(repo: Path, *args: str) -> subprocess.CompletedProcess[str]:
return run_cmd(["git", *args], repo, timeout=60)
def safe_repo_file(repo: Path, value: str) -> str:
raw = Path(value)
if raw.is_absolute() or ".." in raw.parts:
raise ValueError(f"file must be a repo-relative path: {value}")
resolved = (repo / raw).resolve()
if resolved != repo and repo not in resolved.parents:
raise ValueError(f"file escapes repo: {value}")
return raw.as_posix()
def load_plan(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"cannot read plan: {exc}") from exc
if not isinstance(payload, dict) or not isinstance(payload.get("steps"), list) or not payload["steps"]:
raise ValueError("plan must contain a non-empty steps array")
return payload
def step_config(step: Any, defaults: dict[str, Any], repo: Path) -> dict[str, Any]:
if not isinstance(step, dict):
raise ValueError("each plan step must be an object")
task = str(step.get("task") or "").strip()
if not task:
raise ValueError("each plan step requires task")
raw_files = step.get("files", [])
if isinstance(raw_files, str):
raw_files = [raw_files]
if not isinstance(raw_files, list):
raise ValueError("step files must be an array")
files = [safe_repo_file(repo, str(item)) for item in raw_files if str(item).strip()]
return {
"name": str(step.get("name") or "task"),
"task": task,
"files": files,
"max_files": int(step.get("max_files", defaults.get("max_files", max(1, len(files) or 1)))),
"aider_timeout": int(step.get("aider_timeout", defaults.get("aider_timeout", 420))),
"repair_attempts": int(step.get("repair_attempts", defaults.get("repair_attempts", 1))),
"test_timeout": int(step.get("test_timeout", defaults.get("test_timeout", 120))),
"test_cmd": str(step.get("test_cmd") or ""),
"no_deterministic_first": bool(step.get("no_deterministic_first", False)),
}
def write_json(path: Path | None, payload: dict[str, Any]) -> None:
if path is None:
return
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def read_json(path: Path) -> dict[str, Any] | None:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return payload if isinstance(payload, dict) else None
def command_text(command: list[str]) -> str:
return " ".join(shlex.quote(part) for part in command)
def step_report_name(index: int, name: str) -> str:
slug = "".join(char if char.isalnum() or char in {"-", "_"} else "-" for char in name).strip("-_")
return f"{index:02d}-{slug or 'task'}.json"
def rollback_clean_start(repo: Path, before_diff: str) -> dict[str, Any]:
if not before_diff:
patch = git(repo, "diff", "--binary").stdout
if not patch.strip():
return {"attempted": False, "status": "not-needed"}
proc = subprocess.run(
["git", "apply", "--reverse", "--whitespace=nowarn"],
cwd=str(repo),
input=patch,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
return {
"attempted": True,
"status": "success" if proc.returncode == 0 else "failed",
"method": "reverse-working-tree-diff-from-clean-start",
"stderr": proc.stderr[-2000:],
}
return {"attempted": False, "status": "skipped", "reason": "repo was dirty before task"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Execute an artifacted, bounded DG task plan.")
parser.add_argument("--repo", required=True, type=Path)
parser.add_argument("--plan", required=True, type=Path)
parser.add_argument("--report", type=Path, default=None)
parser.add_argument("--supervisor", default="")
parser.add_argument("--step-report-dir", type=Path, default=None)
parser.add_argument("--allow-dirty", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--rollback-on-failure", action="store_true")
parser.add_argument("--continue-on-failure", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
repo = args.repo.resolve()
plan_path = args.plan.resolve()
report_path = args.report.resolve() if args.report else None
started = time.time()
if not repo.is_dir():
print(f"repo does not exist: {repo}", file=sys.stderr)
return 2
if git(repo, "rev-parse", "--is-inside-work-tree").stdout.strip() != "true":
print(f"repo is not a git repository: {repo}", file=sys.stderr)
return 2
try:
plan = load_plan(plan_path)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 2
defaults = plan.get("defaults") if isinstance(plan.get("defaults"), dict) else {}
try:
steps = [step_config(step, defaults, repo) for step in plan["steps"]]
except (TypeError, ValueError) as exc:
print(f"invalid plan: {exc}", file=sys.stderr)
return 2
status_before = git(repo, "status", "--short").stdout
if status_before.strip() and not args.allow_dirty:
print("Refusing to start with a dirty repo. Use --allow-dirty if this is intentional.", file=sys.stderr)
return 3
supervisor = Path(args.supervisor).resolve() if args.supervisor else DEFAULT_SUPERVISOR
if not args.dry_run and not supervisor.exists():
print(f"supervisor runner missing: {supervisor}", file=sys.stderr)
return 2
if args.step_report_dir:
step_dir = args.step_report_dir.resolve()
elif report_path:
step_dir = report_path.parent / "steps"
else:
step_dir = Path(tempfile.mkdtemp(prefix="dg-task-steps."))
if not args.dry_run:
step_dir.mkdir(parents=True, exist_ok=True)
aggregate: dict[str, Any] = {
"repo": str(repo),
"plan": str(plan_path),
"dry_run": args.dry_run,
"started_at": started,
"status_before": status_before,
"steps": [],
"rollback": {"attempted": False, "status": "not-needed"},
}
failed = False
for index, step in enumerate(steps, start=1):
step_report = step_dir / step_report_name(index, step["name"])
command = [
str(supervisor),
"--repo",
str(repo),
"--task",
step["task"],
"--max-files",
str(max(1, step["max_files"])),
"--aider-timeout",
str(max(1, step["aider_timeout"])),
"--repair-attempts",
str(max(0, step["repair_attempts"])),
"--test-timeout",
str(max(1, step["test_timeout"])),
"--report",
str(step_report),
]
for file_name in step["files"]:
command.extend(["--file", file_name])
if step["test_cmd"]:
command.extend(["--test-cmd", step["test_cmd"]])
if step["no_deterministic_first"]:
command.append("--no-deterministic-first")
if args.allow_dirty:
command.append("--allow-dirty")
if args.dry_run:
step_result = {
"index": index,
"name": step["name"],
"status": "dry-run",
"command": command,
"step_report": str(step_report),
}
print(f"DRY RUN step {index}: {command_text(command)}")
else:
timeout = max(30, step["aider_timeout"] + step["test_timeout"] + 60)
proc = run_cmd(command, repo, timeout=timeout)
step_result = {
"index": index,
"name": step["name"],
"status": "success" if proc.returncode == 0 else "failed",
"returncode": proc.returncode,
"command": command,
"step_report": str(step_report),
"stdout_tail": proc.stdout[-4000:],
"stderr_tail": proc.stderr[-4000:],
"supervisor_report": read_json(step_report),
}
if proc.stdout:
print(proc.stdout, end="")
if proc.stderr:
print(proc.stderr, end="", file=sys.stderr)
aggregate["steps"].append(step_result)
if step_result["status"] == "failed":
failed = True
if not args.continue_on_failure and bool(plan.get("stop_on_failure", True)):
break
if failed and args.rollback_on_failure:
aggregate["rollback"] = rollback_clean_start(repo, status_before)
aggregate["finished_at"] = time.time()
aggregate["elapsed_sec"] = round(aggregate["finished_at"] - started, 3)
aggregate["status"] = "failed" if failed else "success"
aggregate["status_after"] = git(repo, "status", "--short").stdout
write_json(report_path, aggregate)
print(f"DG task runner finished: {aggregate['status']}")
if report_path:
print(f"Task report: {report_path}")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())