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>
524 lines
21 KiB
Python
524 lines
21 KiB
Python
"""Headless Claude Code agents: launching, reaping, stopping, diffing.
|
|
|
|
Two kinds:
|
|
- work agents (start_agent) get an isolated git worktree + branch and may
|
|
edit and commit; the board moves their card in-progress → testing.
|
|
- review agents (start_review) are read-only, run in the main checkout, and
|
|
their relevance report is appended to the task file by the board.
|
|
|
|
Prompts live in .prompts/ and are read fresh on every launch.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import config
|
|
import events
|
|
import state
|
|
from taskfiles import find_stage_of, move_task, read_task
|
|
|
|
|
|
def _clean_log(text: str, cap: int = 3000) -> str:
|
|
"""An agent's -p output is its final report; strip the hook-failure
|
|
noise other tools may have interleaved."""
|
|
lines = [l for l in text.strip().splitlines()
|
|
if not ("hook" in l and "failed" in l)]
|
|
return "\n".join(lines).strip()[-cap:]
|
|
|
|
|
|
def _file_report(record: dict, heading: str, report: str) -> None:
|
|
"""The report travels with the task, like every review does."""
|
|
stage = find_stage_of(record["task"])
|
|
if not stage or not report:
|
|
return
|
|
path = config.TASKS / stage / record["task"]
|
|
stamp = time.strftime("%Y-%m-%d %H:%M")
|
|
try:
|
|
with path.open("a", encoding="utf-8") as fh:
|
|
fh.write(f"\n\n---\n\n## {heading} — {stamp} ({record.get('name') or 'agent'})\n\n{report}\n")
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _session_report(record: dict, report: str) -> None:
|
|
"""And it lands in the session's timeline as its closing entry."""
|
|
if not report or not record.get("session"):
|
|
return
|
|
events.ingest_event({
|
|
"v": 1, "session": record["session"], "kind": "report",
|
|
"summary": f"{record.get('name') or 'the agent'}'s report on {record['task']}",
|
|
"detail": report, "agent": record["id"], "task": record["task"],
|
|
})
|
|
|
|
|
|
# Short coastal names in the Bench spirit — one per running agent, so the
|
|
# board reads "Wren is on #09", not "agent 09-application-layer-091203".
|
|
NAMES = ["Wren", "Juno", "Basil", "Piper", "Sage", "Reed", "Olive", "Finch",
|
|
"Hazel", "Cleo", "Milo", "Fern", "Ada", "Otto", "Nell", "Skye"]
|
|
|
|
|
|
def _pick_name(stem: str) -> str:
|
|
"""Stable-ish per task (same task tends to get the same name back),
|
|
skipping names already worn by a running agent."""
|
|
with state.LOCK:
|
|
used = {r.get("name") for r in state.AGENTS.values() if r["status"] == "running"}
|
|
start = sum(ord(c) for c in stem) % len(NAMES)
|
|
for i in range(len(NAMES)):
|
|
name = NAMES[(start + i) % len(NAMES)]
|
|
if name not in used:
|
|
return name
|
|
return NAMES[start]
|
|
|
|
|
|
def _agent_public(record: dict) -> dict:
|
|
public = {k: record[k] for k in
|
|
("id", "task", "branch", "worktree", "status", "rc", "started", "session")}
|
|
public["mode"] = record.get("mode", "work")
|
|
public["name"] = record.get("name")
|
|
return public
|
|
|
|
|
|
def list_public() -> list[dict]:
|
|
with state.LOCK:
|
|
return [_agent_public(a) for a in state.AGENTS.values()]
|
|
|
|
|
|
def _assert_no_running_agent(filename: str) -> None:
|
|
with state.LOCK:
|
|
for record in state.AGENTS.values():
|
|
if record["task"] == filename and record["status"] == "running":
|
|
raise ValueError(f"an agent is already working on {filename}")
|
|
|
|
|
|
def _validate(filename: str, stage: str, allowed: set[str], why: str | None = None) -> None:
|
|
if Path(filename).name != filename or not filename.endswith(".md"):
|
|
raise ValueError("bad filename")
|
|
if stage not in allowed:
|
|
raise ValueError(why or f"agents cannot start from {stage}/")
|
|
if not (config.TASKS / stage / filename).is_file():
|
|
raise ValueError(f"{filename} is not in {stage}/ — refresh the board")
|
|
_assert_no_running_agent(filename)
|
|
|
|
|
|
def _launch(mode: str, prompt: str, cwd: Path, agent_id: str, filename: str, log_path: Path):
|
|
"""Run one headless job through the configured agent adapter.
|
|
|
|
The adapter contract: `run` gets AGENT_PROMPT and AGENT_MODE
|
|
(work = may mutate, review = read-only) plus the BOARD_* passthrough
|
|
for its event bridge; its stdout is the job log; exit 0 = completed.
|
|
"""
|
|
adapter = config.adapter_dir()
|
|
if adapter is None:
|
|
raise ValueError(
|
|
f"agent adapter '{config.ADAPTER}' not found — expected "
|
|
f"local/adapters/{config.ADAPTER}/run or core/adapters/{config.ADAPTER}/run")
|
|
env = config.child_env()
|
|
env.update({
|
|
"AGENT_PROMPT": prompt,
|
|
"AGENT_MODE": mode,
|
|
"AGENT_CWD": str(cwd),
|
|
"BOARD_AGENT_ID": agent_id,
|
|
"BOARD_TASK": filename,
|
|
"BOARD_PORT": str(state.serve_port),
|
|
})
|
|
log_file = log_path.open("wb")
|
|
try:
|
|
proc = subprocess.Popen(
|
|
[str(adapter / "run")], cwd=str(cwd), env=env,
|
|
stdout=log_file, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
|
|
except OSError as exc:
|
|
log_file.close()
|
|
raise ValueError(f"could not launch adapter {adapter}: {exc}")
|
|
return proc, log_file
|
|
|
|
|
|
def start_agent(filename: str, stage: str) -> dict:
|
|
# Moving a card to in-progress is the commitment; only then does work start.
|
|
_validate(filename, stage, {"in-progress"},
|
|
"work starts from in-progress/ — move the card there first")
|
|
|
|
stem = filename[:-3]
|
|
branch = f"task/{stem}"
|
|
worktree = config.WORKTREES / stem
|
|
|
|
def _git(*args: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(["git", "-C", str(config.REPO), *args],
|
|
capture_output=True, text=True)
|
|
|
|
branch_exists = _git("rev-parse", "--verify", "--quiet", branch).returncode == 0
|
|
continuing = worktree.exists()
|
|
if continuing:
|
|
# earlier work exists — the agent continues on it rather than refusing
|
|
current = subprocess.run(
|
|
["git", "-C", str(worktree), "branch", "--show-current"],
|
|
capture_output=True, text=True).stdout.strip()
|
|
if current != branch:
|
|
raise ValueError(
|
|
f"worktree {worktree} is on '{current}', not {branch} — fix it by hand")
|
|
base = _git("merge-base", "main", branch).stdout.strip() \
|
|
or _git("rev-parse", "HEAD").stdout.strip()
|
|
else:
|
|
config.WORKTREES.mkdir(exist_ok=True)
|
|
if branch_exists:
|
|
base = _git("merge-base", "main", branch).stdout.strip()
|
|
result = _git("worktree", "add", str(worktree), branch)
|
|
else:
|
|
base = _git("rev-parse", "HEAD").stdout.strip()
|
|
result = _git("worktree", "add", "-b", branch, str(worktree))
|
|
if result.returncode != 0:
|
|
raise ValueError(f"git worktree add failed: {result.stderr.strip()[:300]}")
|
|
|
|
task = read_task(config.TASKS / "in-progress" / filename, "in-progress")
|
|
|
|
agent_id = f"{stem}-{time.strftime('%H%M%S')}"
|
|
log_path = config.AGENT_DIR / "logs" / f"{agent_id}.log"
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
prompt = config.prompt("work.md").format(
|
|
branch=branch, filename=filename, body=task["body"])
|
|
proc, log_file = _launch("work", prompt, worktree, agent_id, filename, log_path)
|
|
|
|
name = _pick_name(stem)
|
|
record = {
|
|
"id": agent_id, "task": filename, "branch": branch,
|
|
"worktree": str(worktree), "base": base, "status": "running",
|
|
"rc": None, "started": time.time(), "session": None,
|
|
"log": str(log_path), "proc": proc, "origin": stage, "mode": "work",
|
|
"name": name,
|
|
}
|
|
with state.LOCK:
|
|
state.AGENTS[agent_id] = record
|
|
state.record_board_event({
|
|
"kind": "agent", "actor": "agent", "file": filename,
|
|
"summary": (f"{name} is back on {filename} — continuing branch {branch}"
|
|
if continuing else
|
|
f"{name} started on {filename} (branch {branch})"),
|
|
})
|
|
threading.Thread(target=_reap_agent, args=(agent_id, proc, log_file),
|
|
daemon=True).start()
|
|
return _agent_public(record)
|
|
|
|
|
|
def start_review(filename: str, stage: str) -> dict:
|
|
"""Fire a read-only agent that checks the task against the codebase."""
|
|
_validate(filename, stage, config.STAGE_DIRS)
|
|
|
|
task = read_task(config.TASKS / stage / filename, stage)
|
|
agent_id = f"review-{filename[:-3]}-{time.strftime('%H%M%S')}"
|
|
log_path = config.AGENT_DIR / "logs" / f"{agent_id}.log"
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
prompt = config.prompt("review.md").format(
|
|
stage=stage, filename=filename, body=task["body"])
|
|
proc, log_file = _launch("review", prompt, config.REPO, agent_id, filename, log_path)
|
|
|
|
name = _pick_name(filename)
|
|
record = {
|
|
"id": agent_id, "task": filename, "branch": None, "worktree": None,
|
|
"base": None, "status": "running", "rc": None, "started": time.time(),
|
|
"session": None, "log": str(log_path), "proc": proc,
|
|
"origin": stage, "mode": "review", "name": name,
|
|
}
|
|
with state.LOCK:
|
|
state.AGENTS[agent_id] = record
|
|
state.record_board_event({
|
|
"kind": "agent", "actor": "agent", "file": filename,
|
|
"summary": f"{name} is checking {filename} is still true of the codebase",
|
|
})
|
|
threading.Thread(target=_reap_review, args=(agent_id, proc, log_file),
|
|
daemon=True).start()
|
|
return _agent_public(record)
|
|
|
|
|
|
def _declined_reason(log_path: str) -> str | None:
|
|
"""First line of a `NOT READY:` marker in the agent's final output."""
|
|
try:
|
|
text = Path(log_path).read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return None
|
|
for match in re.finditer(r"^NOT READY:\s*(.*)$", text, re.MULTILINE):
|
|
reason = match.group(1).strip()
|
|
if reason.startswith("<"):
|
|
continue # the prompt's own template line, echoed into the log
|
|
return reason or "open questions"
|
|
return None
|
|
|
|
|
|
def _discard_untouched_worktree(record: dict) -> bool:
|
|
"""Remove worktree + branch, but only if the agent committed nothing."""
|
|
head = subprocess.run(
|
|
["git", "-C", record["worktree"], "rev-parse", "HEAD"],
|
|
capture_output=True, text=True)
|
|
if head.returncode != 0 or head.stdout.strip() != record["base"]:
|
|
return False
|
|
subprocess.run(["git", "-C", str(config.REPO), "worktree", "remove", "--force",
|
|
record["worktree"]], capture_output=True)
|
|
subprocess.run(["git", "-C", str(config.REPO), "branch", "-D", record["branch"]],
|
|
capture_output=True)
|
|
return True
|
|
|
|
|
|
def _finish(agent_id: str, proc: subprocess.Popen, log_file) -> tuple[dict, bool, int]:
|
|
rc = proc.wait()
|
|
log_file.close()
|
|
with state.LOCK:
|
|
record = state.AGENTS[agent_id]
|
|
stopped = record["status"] == "stopped"
|
|
record["status"] = "stopped" if stopped else ("done" if rc == 0 else "failed")
|
|
record["rc"] = rc
|
|
return record, stopped, rc
|
|
|
|
|
|
def _reap_agent(agent_id: str, proc: subprocess.Popen, log_file) -> None:
|
|
record, stopped, rc = _finish(agent_id, proc, log_file)
|
|
filename, branch = record["task"], record["branch"]
|
|
name = record.get("name") or "the agent"
|
|
|
|
declined = None if (stopped or rc != 0) else _declined_reason(record["log"])
|
|
if declined is not None:
|
|
with state.LOCK:
|
|
record["status"] = "declined"
|
|
# Send the card back for refinement and clear the way for a relaunch.
|
|
back_to = record["origin"] if record["origin"] in ("backlog", "to-do") else "to-do"
|
|
if find_stage_of(filename) == "in-progress":
|
|
try:
|
|
move_task(filename, "in-progress", back_to, actor="agent")
|
|
except ValueError:
|
|
pass
|
|
cleaned = _discard_untouched_worktree(record)
|
|
summary = (f"{name} declined {filename} — not ready: {declined}"
|
|
+ ("" if cleaned else f" (worktree {record['worktree']} kept: it has commits)"))
|
|
elif rc == 0 and not stopped:
|
|
if find_stage_of(filename) == "in-progress":
|
|
try:
|
|
move_task(filename, "in-progress", "review", actor="agent")
|
|
except ValueError:
|
|
pass
|
|
try:
|
|
report = _clean_log(Path(record["log"]).read_text(encoding="utf-8",
|
|
errors="replace"))
|
|
except OSError:
|
|
report = ""
|
|
_file_report(record, "Work report", report)
|
|
_session_report(record, report)
|
|
summary = f"{name} finished {filename} — review branch {branch}"
|
|
elif stopped:
|
|
summary = f"{name} was held on {filename} — nothing is lost"
|
|
else:
|
|
summary = f"{name} exited on {filename} rc={rc} — see its log"
|
|
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
|
|
"summary": summary})
|
|
state.broadcast({"type": "agents"})
|
|
|
|
|
|
def start_pr_review(filename: str, stage: str) -> dict:
|
|
"""Fire a read-only agent that reviews the task's PR and posts the
|
|
verdict to GitHub as well as back to the board."""
|
|
_validate(filename, stage, {"review"},
|
|
"PR reviews run on cards in review/")
|
|
task = read_task(config.TASKS / stage / filename, stage)
|
|
if not task.get("pr"):
|
|
raise ValueError(f"{filename} has no PR yet — nothing to review")
|
|
|
|
branch = f"task/{filename[:-3]}"
|
|
name = _pick_name(filename)
|
|
agent_id = f"review-pr-{filename[:-3]}-{time.strftime('%H%M%S')}"
|
|
log_path = config.AGENT_DIR / "logs" / f"{agent_id}.log"
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
prompt = config.prompt("review-pr.md").format(
|
|
filename=filename, pr=task["pr"], branch=branch, body=task["body"])
|
|
proc, log_file = _launch("review", prompt, config.REPO, agent_id, filename, log_path)
|
|
|
|
record = {
|
|
"id": agent_id, "task": filename, "branch": branch, "worktree": None,
|
|
"base": None, "status": "running", "rc": None, "started": time.time(),
|
|
"session": None, "log": str(log_path), "proc": proc,
|
|
"origin": stage, "mode": "review", "name": name,
|
|
}
|
|
with state.LOCK:
|
|
state.AGENTS[agent_id] = record
|
|
state.record_board_event({
|
|
"kind": "agent", "actor": "agent", "file": filename,
|
|
"summary": f"{name} is reviewing {filename}'s PR",
|
|
})
|
|
threading.Thread(target=_reap_pr_review, args=(agent_id, proc, log_file),
|
|
daemon=True).start()
|
|
return _agent_public(record)
|
|
|
|
|
|
def start_pr_fix(filename: str, stage: str) -> dict:
|
|
"""Fire a work agent that addresses the review feedback on the task's PR,
|
|
working in the task's existing worktree (recreated from the branch if it
|
|
was cleaned up), committing and pushing to update the PR."""
|
|
_validate(filename, stage, {"review"},
|
|
"acting on a PR happens from review/")
|
|
task = read_task(config.TASKS / stage / filename, stage)
|
|
if not task.get("pr"):
|
|
raise ValueError(f"{filename} has no PR to act on")
|
|
|
|
stem = filename[:-3]
|
|
branch = f"task/{stem}"
|
|
worktree = config.WORKTREES / stem
|
|
if not worktree.exists():
|
|
if subprocess.run(["git", "-C", str(config.REPO), "rev-parse", "--verify",
|
|
"--quiet", branch], capture_output=True).returncode != 0:
|
|
raise ValueError(f"branch {branch} does not exist locally — nothing to act in")
|
|
config.WORKTREES.mkdir(exist_ok=True)
|
|
result = subprocess.run(
|
|
["git", "-C", str(config.REPO), "worktree", "add", str(worktree), branch],
|
|
capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
raise ValueError(f"could not recreate the worktree: {result.stderr.strip()[:200]}")
|
|
|
|
name = _pick_name(stem)
|
|
agent_id = f"fix-pr-{stem}-{time.strftime('%H%M%S')}"
|
|
log_path = config.AGENT_DIR / "logs" / f"{agent_id}.log"
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
prompt = config.prompt("act-pr.md").format(
|
|
filename=filename, branch=branch, pr=task["pr"], body=task["body"])
|
|
proc, log_file = _launch("work", prompt, worktree, agent_id, filename, log_path)
|
|
|
|
record = {
|
|
"id": agent_id, "task": filename, "branch": branch,
|
|
"worktree": str(worktree), "base": None, "status": "running",
|
|
"rc": None, "started": time.time(), "session": None,
|
|
"log": str(log_path), "proc": proc, "origin": stage, "mode": "work",
|
|
"name": name,
|
|
}
|
|
with state.LOCK:
|
|
state.AGENTS[agent_id] = record
|
|
state.record_board_event({
|
|
"kind": "agent", "actor": "agent", "file": filename,
|
|
"summary": f"{name} is acting on the review of {filename}'s PR",
|
|
})
|
|
threading.Thread(target=_reap_pr_fix, args=(agent_id, proc, log_file),
|
|
daemon=True).start()
|
|
return _agent_public(record)
|
|
|
|
|
|
def _reap_pr_fix(agent_id: str, proc: subprocess.Popen, log_file) -> None:
|
|
record, stopped, rc = _finish(agent_id, proc, log_file)
|
|
filename = record["task"]
|
|
name = record.get("name") or "the agent"
|
|
|
|
if rc == 0 and not stopped:
|
|
try:
|
|
text = Path(record["log"]).read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
text = ""
|
|
idx = text.find("ADDRESSED:")
|
|
report = text[idx:].strip() if idx >= 0 else text.strip()[-1500:]
|
|
_file_report(record, "PR update", report)
|
|
_session_report(record, report)
|
|
summary = f"{name} acted on {filename}'s PR — re-review when ready"
|
|
elif stopped:
|
|
summary = f"{name} was held while acting on {filename}'s PR"
|
|
else:
|
|
summary = f"{name} failed acting on {filename}'s PR (rc={rc}) — see its log"
|
|
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
|
|
"summary": summary})
|
|
state.broadcast({"type": "board"})
|
|
state.broadcast({"type": "agents"})
|
|
|
|
|
|
def _reap_pr_review(agent_id: str, proc: subprocess.Popen, log_file) -> None:
|
|
record, stopped, rc = _finish(agent_id, proc, log_file)
|
|
filename = record["task"]
|
|
name = record.get("name") or "the reviewer"
|
|
|
|
verdict = None
|
|
if rc == 0 and not stopped:
|
|
try:
|
|
text = Path(record["log"]).read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
text = ""
|
|
idx = text.find("PR REVIEW:")
|
|
report = text[idx:].strip() if idx >= 0 else text.strip()[-1500:]
|
|
match = re.search(r"^PR REVIEW:\s*(APPROVE|REQUEST CHANGES)", report)
|
|
verdict = match.group(1) if match else None
|
|
_file_report(record, "PR review", report)
|
|
_session_report(record, report)
|
|
|
|
if stopped:
|
|
summary = f"{name}'s PR review of {filename} was held"
|
|
elif rc != 0 or verdict is None:
|
|
summary = f"{name}'s PR review of {filename} ended without a verdict — see its log"
|
|
else:
|
|
word = "approved it" if verdict == "APPROVE" else "asked for changes"
|
|
summary = f"{name} reviewed {filename}'s PR and {word}"
|
|
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
|
|
"summary": summary})
|
|
state.broadcast({"type": "board"})
|
|
state.broadcast({"type": "agents"})
|
|
|
|
|
|
def _reap_review(agent_id: str, proc: subprocess.Popen, log_file) -> None:
|
|
record, stopped, rc = _finish(agent_id, proc, log_file)
|
|
filename = record["task"]
|
|
|
|
verdict = None
|
|
if rc == 0 and not stopped:
|
|
try:
|
|
text = Path(record["log"]).read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
text = ""
|
|
idx = text.find("RELEVANCE REVIEW")
|
|
report = text[idx:].strip() if idx >= 0 else text.strip()[-1500:]
|
|
verdict = report.splitlines()[0] if report else None
|
|
_file_report(record, "Relevance review", report)
|
|
_session_report(record, report)
|
|
|
|
name = record.get("name") or "the review"
|
|
if stopped:
|
|
summary = f"{name}'s check of {filename} was held"
|
|
elif rc != 0:
|
|
summary = f"{name}'s check of {filename} exited rc={rc} — see its log"
|
|
else:
|
|
summary = f"{name} on {filename}: {verdict[:140] if verdict else 'report appended to the task'}"
|
|
state.record_board_event({"kind": "agent", "actor": "agent", "file": filename,
|
|
"summary": summary})
|
|
state.broadcast({"type": "board"})
|
|
state.broadcast({"type": "agents"})
|
|
|
|
|
|
def stop_agent(agent_id: str) -> dict:
|
|
with state.LOCK:
|
|
record = state.AGENTS.get(agent_id)
|
|
if record is None:
|
|
raise ValueError("unknown agent")
|
|
if record["status"] != "running":
|
|
raise ValueError("agent is not running")
|
|
record["status"] = "stopped"
|
|
proc = record["proc"]
|
|
proc.terminate()
|
|
return _agent_public(record)
|
|
|
|
|
|
def agent_diff(agent_id: str) -> dict:
|
|
with state.LOCK:
|
|
record = state.AGENTS.get(agent_id)
|
|
if record is None:
|
|
raise ValueError("unknown agent")
|
|
if not record.get("worktree"):
|
|
return {"agent": agent_id, "files": []}
|
|
result = subprocess.run(
|
|
["git", "-C", record["worktree"], "diff", "--numstat", record["base"]],
|
|
capture_output=True, text=True, timeout=10)
|
|
files = []
|
|
for line in result.stdout.splitlines():
|
|
parts = line.split("\t")
|
|
if len(parts) == 3:
|
|
plus, minus, name = parts
|
|
files.append({"file": name,
|
|
"plus": int(plus) if plus.isdigit() else 0,
|
|
"minus": int(minus) if minus.isdigit() else 0})
|
|
return {"agent": agent_id, "files": files}
|