Bench 1: the task board extracted as its own distribution
Everything core from cicero-pas's .task-manager, with instance data reduced to skeleton: empty stage directories, the task template, empty local/ scaffolding, and a README covering install (clone into .task-manager/, vendored on purpose) and update (update.sh replaces core wholesale; local/ and tasks/ survive). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
"""GitHub plumbing: opening PRs from agent worktree branches, requesting
|
||||
Copilot reviews, and polling PR state for cards sitting in review/.
|
||||
|
||||
All of it is mechanical `git` + `gh` — no Claude involvement. The PR url is
|
||||
written into the task file (`**PR:** <url>`), keeping the file the single
|
||||
source of truth; only the volatile review/check state lives in memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import config
|
||||
import drive as drive_mod
|
||||
import state
|
||||
from taskfiles import STATUS_RE, find_stage_of, move_task, read_task
|
||||
|
||||
PR_STATE: dict[str, dict] = {} # filename -> {verdict, detail, url, ts}
|
||||
_OPENING: set[str] = set() # filenames with a PR-open in flight
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path | None = None, timeout: int = 60) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(cmd, cwd=str(cwd or config.REPO),
|
||||
capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def remote() -> str | None:
|
||||
if config.GIT_REMOTE:
|
||||
return config.GIT_REMOTE
|
||||
result = _run(["git", "remote"])
|
||||
names = result.stdout.split()
|
||||
return names[0] if names else None
|
||||
|
||||
|
||||
def gh_available() -> bool:
|
||||
return shutil.which(config.GH_BIN) is not None
|
||||
|
||||
|
||||
def _branch_exists(branch: str) -> bool:
|
||||
return _run(["git", "rev-parse", "--verify", "--quiet", branch]).returncode == 0
|
||||
|
||||
|
||||
def _write_pr_line(filename: str, url: str) -> None:
|
||||
stage = find_stage_of(filename)
|
||||
if not stage:
|
||||
return
|
||||
path = config.TASKS / stage / filename
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if re.search(r"^\*\*PR:\*\*", text, re.MULTILINE):
|
||||
return
|
||||
if STATUS_RE.search(text):
|
||||
text = STATUS_RE.sub(lambda m: f"{m.group(0)}\n**PR:** {url}", text, count=1)
|
||||
else:
|
||||
text = f"**PR:** {url}\n\n" + text
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def maybe_open_pr(filename: str) -> None:
|
||||
"""Card entered review/ — open a PR for its branch if one can be opened.
|
||||
|
||||
Quiet when there is simply no branch (hand-written tasks); loud in the
|
||||
ticker when a PR *should* be possible but something stands in the way.
|
||||
"""
|
||||
if filename in _OPENING:
|
||||
return
|
||||
_OPENING.add(filename)
|
||||
try:
|
||||
_open_pr(filename)
|
||||
finally:
|
||||
_OPENING.discard(filename)
|
||||
|
||||
|
||||
def _open_pr(filename: str) -> None:
|
||||
branch = f"task/{filename[:-3]}"
|
||||
if not _branch_exists(branch):
|
||||
return # nothing to publish — a hand-moved card without agent work
|
||||
stage = find_stage_of(filename)
|
||||
if stage != "review":
|
||||
return
|
||||
task = read_task(config.TASKS / stage / filename, stage)
|
||||
if task.get("pr"):
|
||||
return # already open
|
||||
|
||||
rname = remote()
|
||||
if rname is None or not gh_available():
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"no PR for {filename}: " +
|
||||
("no git remote configured" if rname is None else "gh is not installed")})
|
||||
return
|
||||
|
||||
# The PR's diff is computed against the remote main — refuse to open one
|
||||
# that would drag unpushed main commits along with it.
|
||||
_run(["git", "fetch", rname, "main"], timeout=120)
|
||||
ahead = _run(["git", "rev-list", "--count", f"{rname}/main..main"]).stdout.strip()
|
||||
if ahead.isdigit() and int(ahead) > 0:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"won't open a PR for {filename}: main is {ahead} commits "
|
||||
f"ahead of {rname} — push main first, then move the card again"})
|
||||
return
|
||||
|
||||
push = _run(["git", "push", "-u", rname, branch], timeout=180)
|
||||
if push.returncode != 0:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"push failed for {branch}: {push.stderr.strip()[:140]}"})
|
||||
return
|
||||
|
||||
body = (f"Task: `{filename}` — tracked in `.task-manager/tasks/review/`.\n\n"
|
||||
f"Opened by the board when the card moved to review.")
|
||||
log_tail = _agent_log_tail(filename)
|
||||
if log_tail:
|
||||
body += f"\n\n## Agent summary\n\n{log_tail}"
|
||||
result = _run([config.GH_BIN, "pr", "create", "--head", branch, "--base", "main",
|
||||
"--title", task["title"], "--body", body], timeout=120)
|
||||
if result.returncode != 0:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"PR creation failed for {branch}: {result.stderr.strip()[:140]}"})
|
||||
return
|
||||
url = next((l.strip() for l in result.stdout.splitlines() if "/pull/" in l), result.stdout.strip())
|
||||
_write_pr_line(filename, url)
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"PR opened for {filename}: {url}"})
|
||||
state.broadcast({"type": "board"})
|
||||
_poll_pr(filename, url) # first CI/review snapshot without waiting a cycle
|
||||
|
||||
|
||||
def _agent_log_tail(filename: str, cap: int = 1500) -> str:
|
||||
with state.LOCK:
|
||||
records = [r for r in state.AGENTS.values()
|
||||
if r["task"] == filename and r.get("mode") == "work"]
|
||||
if not records:
|
||||
return ""
|
||||
latest = max(records, key=lambda r: r["started"])
|
||||
try:
|
||||
text = Path(latest["log"]).read_text(encoding="utf-8", errors="replace").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
return text[-cap:]
|
||||
|
||||
|
||||
def request_copilot(filename: str) -> str:
|
||||
"""Ask GitHub Copilot to review the task's PR. Works iff Copilot code
|
||||
review is enabled for the repo — otherwise gh returns an error we relay."""
|
||||
stage = find_stage_of(filename)
|
||||
if stage is None:
|
||||
raise ValueError(f"{filename} is not on the board")
|
||||
task = read_task(config.TASKS / stage / filename, stage)
|
||||
url = task.get("pr")
|
||||
if not url:
|
||||
raise ValueError(f"{filename} has no PR yet")
|
||||
number = url.rstrip("/").rsplit("/", 1)[-1]
|
||||
result = _run([config.GH_BIN, "api", "-X", "POST",
|
||||
f"repos/{{owner}}/{{repo}}/pulls/{number}/requested_reviewers",
|
||||
"-f", "reviewers[]=copilot-pull-request-reviewer[bot]"], timeout=60)
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"Copilot said no: {result.stderr.strip()[:160]}")
|
||||
entry = PR_STATE.setdefault(filename, {"verdict": "pending", "ci": None,
|
||||
"url": url, "detail": "", "ts": 0})
|
||||
entry["copilot"] = "asked"
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"Copilot review requested on {filename}'s PR"})
|
||||
state.broadcast({"type": "board"})
|
||||
return url
|
||||
|
||||
|
||||
def _check_state(check: dict) -> str:
|
||||
"""One check-run or status-context → pass | fail | running."""
|
||||
conclusion = (check.get("conclusion") or check.get("state") or "").upper()
|
||||
status = (check.get("status") or "").upper()
|
||||
if conclusion in ("FAILURE", "TIMED_OUT", "CANCELLED", "ERROR", "STARTUP_FAILURE"):
|
||||
return "fail"
|
||||
if conclusion in ("SUCCESS", "NEUTRAL", "SKIPPED"):
|
||||
return "pass"
|
||||
if status in ("IN_PROGRESS", "QUEUED", "PENDING", "WAITING", "REQUESTED"):
|
||||
return "running"
|
||||
return "running"
|
||||
|
||||
|
||||
def _is_copilot(login) -> bool:
|
||||
return "copilot" in str(login or "").lower()
|
||||
|
||||
|
||||
def _poll_pr(filename: str, url: str) -> None:
|
||||
number = url.rstrip("/").rsplit("/", 1)[-1]
|
||||
result = _run([config.GH_BIN, "pr", "view", number,
|
||||
"--json", "reviews,reviewRequests,statusCheckRollup,state"], timeout=60)
|
||||
if result.returncode != 0:
|
||||
return
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return
|
||||
reviews = data.get("reviews") or []
|
||||
checks = data.get("statusCheckRollup") or []
|
||||
changes = any(r.get("state") == "CHANGES_REQUESTED" for r in reviews)
|
||||
approved = any(r.get("state") == "APPROVED" for r in reviews)
|
||||
|
||||
# Copilot: asked is a pending entry in reviewRequests; done is a review
|
||||
# authored by the copilot bot — the request entry disappears once it lands.
|
||||
cop_reviews = [r for r in reviews
|
||||
if _is_copilot((r.get("author") or {}).get("login"))]
|
||||
cop_requested = any(
|
||||
_is_copilot(rr.get("login") or rr.get("slug") or rr.get("name"))
|
||||
for rr in (data.get("reviewRequests") or []))
|
||||
if cop_reviews:
|
||||
copilot = {"APPROVED": "approved", "CHANGES_REQUESTED": "changes"}.get(
|
||||
cop_reviews[-1].get("state"), "commented")
|
||||
elif cop_requested:
|
||||
copilot = "asked"
|
||||
else:
|
||||
copilot = PR_STATE.get(filename, {}).get("copilot")
|
||||
copilot = "asked" if copilot == "asked" else None
|
||||
|
||||
states = [_check_state(c) for c in checks]
|
||||
ci = ("fail" if "fail" in states
|
||||
else "running" if "running" in states
|
||||
else "pass" if states else None)
|
||||
|
||||
verdict = ("red" if (changes or ci == "fail")
|
||||
else "green" if approved else "pending")
|
||||
detail_bits = []
|
||||
if reviews:
|
||||
detail_bits.append(f"{len(reviews)} review{'s' if len(reviews) > 1 else ''}")
|
||||
if ci:
|
||||
detail_bits.append({"fail": "checks failing", "running": "checks running",
|
||||
"pass": "checks ok"}[ci])
|
||||
if copilot:
|
||||
detail_bits.append("copilot " + {"asked": "asked", "approved": "approved",
|
||||
"changes": "asked for changes",
|
||||
"commented": "commented"}[copilot])
|
||||
prev = PR_STATE.get(filename, {})
|
||||
PR_STATE[filename] = {"verdict": verdict, "ci": ci, "copilot": copilot,
|
||||
"url": url, "detail": " · ".join(detail_bits),
|
||||
"ts": time.time()}
|
||||
if prev.get("copilot") in (None, "asked") and copilot in ("approved", "changes", "commented"):
|
||||
word = {"approved": "approved it", "changes": "asked for changes",
|
||||
"commented": "commented"}[copilot]
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"Copilot reviewed {filename}'s PR and {word}"})
|
||||
state.broadcast({"type": "board"})
|
||||
if prev.get("verdict") != verdict and verdict != "pending":
|
||||
word = "approved" if verdict == "green" else "changes asked"
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"GitHub on {filename}'s PR: {word}"})
|
||||
state.broadcast({"type": "board"})
|
||||
elif prev.get("ci") != ci and ci in ("fail", "pass") and prev.get("ci") is not None:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"CI on {filename}'s PR: "
|
||||
+ ("all checks pass" if ci == "pass" else "checks failing")})
|
||||
state.broadcast({"type": "board"})
|
||||
|
||||
|
||||
def poller() -> None:
|
||||
"""Watch PRs of cards sitting in review/. Silent when there are none."""
|
||||
while True:
|
||||
time.sleep(config.PR_POLL_INTERVAL)
|
||||
if not gh_available():
|
||||
continue
|
||||
directory = config.TASKS / "review"
|
||||
if not directory.is_dir():
|
||||
continue
|
||||
try:
|
||||
for path in directory.glob("*.md"):
|
||||
task = read_task(path, "review")
|
||||
if task.get("pr"):
|
||||
_poll_pr(task["file"], task["pr"])
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
def public_state() -> dict:
|
||||
return {f: {k: v.get(k) for k in ("verdict", "ci", "copilot", "detail", "url")}
|
||||
for f, v in PR_STATE.items()}
|
||||
|
||||
|
||||
def open_pr_async(filename: str) -> None:
|
||||
threading.Thread(target=maybe_open_pr, args=(filename,), daemon=True).start()
|
||||
|
||||
|
||||
def complete_task(filename: str, stage: str) -> dict:
|
||||
"""The user chose "merge & clean up" on a move to done: park the drive
|
||||
if it is this task's, merge the branch into main, push (which marks the
|
||||
PR merged), remove the worktree and branches, then move the card.
|
||||
Every step narrates; a conflict aborts cleanly and the card stays."""
|
||||
if stage not in config.STAGE_DIRS or stage == "done":
|
||||
raise ValueError("complete runs on a live-stage card")
|
||||
if not (config.TASKS / stage / filename).is_file():
|
||||
raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
|
||||
|
||||
stem = filename[:-3]
|
||||
branch = f"task/{stem}"
|
||||
|
||||
# 1. the app must not keep running code that is about to be merged away
|
||||
d = drive_mod.DRIVE
|
||||
if d and d.get("task") == filename and d.get("status") in ("starting", "up"):
|
||||
drive_mod.stop()
|
||||
for _ in range(40):
|
||||
if not drive_mod._alive(d):
|
||||
break
|
||||
time.sleep(0.5)
|
||||
|
||||
merged = False
|
||||
if _branch_exists(branch):
|
||||
current = _run(["git", "branch", "--show-current"]).stdout.strip()
|
||||
if current != "main":
|
||||
raise ValueError(f"the repo is on '{current}', not main — switch first")
|
||||
result = _run(["git", "merge", "--no-edit", branch], timeout=120)
|
||||
if result.returncode != 0:
|
||||
_run(["git", "merge", "--abort"])
|
||||
detail = (result.stdout.strip() or result.stderr.strip())[-160:]
|
||||
raise ValueError(f"merge conflict — resolve by hand ({detail})")
|
||||
merged = True
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"merged {branch} into main"})
|
||||
|
||||
rname = remote()
|
||||
if rname:
|
||||
push = _run(["git", "push", rname, "main"], timeout=180)
|
||||
if push.returncode != 0:
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"merged locally but the push failed — push main "
|
||||
f"yourself ({push.stderr.strip()[:100]})"})
|
||||
else:
|
||||
_run(["git", "push", rname, "--delete", branch], timeout=60)
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"pushed main (PR marked merged) and deleted {branch} on {rname}"})
|
||||
|
||||
worktree = config.WORKTREES / stem
|
||||
if worktree.exists():
|
||||
_run(["git", "worktree", "remove", "--force", str(worktree)])
|
||||
_run(["git", "branch", "-d", branch])
|
||||
PR_STATE.pop(filename, None)
|
||||
state.record_board_event({
|
||||
"kind": "agent", "actor": "board", "file": filename,
|
||||
"summary": f"cleaned up: worktree and local branch for {stem} removed"})
|
||||
|
||||
move_task(filename, stage, "done", actor="you")
|
||||
state.broadcast({"type": "board"})
|
||||
return {"merged": merged}
|
||||
|
||||
|
||||
def task_branches() -> list[str]:
|
||||
"""Stems of all task/* branches — the UI uses this to say honestly
|
||||
whether a review card has work attached."""
|
||||
result = _run(["git", "for-each-ref", "--format=%(refname:short)",
|
||||
"refs/heads/task/"])
|
||||
return [ref[len("task/"):] for ref in result.stdout.split() if ref.startswith("task/")]
|
||||
|
||||
|
||||
def reconcile() -> None:
|
||||
"""Catch up on moves the watcher never saw (board was down): any card
|
||||
already sitting in review/ with a branch but no PR gets its PR opened
|
||||
now. Runs once at startup."""
|
||||
time.sleep(3) # let the server settle first
|
||||
directory = config.TASKS / "review"
|
||||
if not directory.is_dir():
|
||||
return
|
||||
branches = set(task_branches())
|
||||
for path in sorted(directory.glob("*.md")):
|
||||
try:
|
||||
task = read_task(path, "review")
|
||||
except OSError:
|
||||
continue
|
||||
if not task.get("pr") and path.stem in branches:
|
||||
maybe_open_pr(path.name)
|
||||
Reference in New Issue
Block a user