Files
bench/manager/core/agents.py
T
istosandClaude Opus 5 755d40dd33 etiquette: the actor's board acts, every other replica renders
Task 19 gave every board the same truth; this makes exactly one of them
react to it. State syncs; reactions don't.

- watch.py: attribution is now the trigger gate. _actor returns (who,
  remote), and a move a pull applied — the arrivals sync files — renders
  and narrates but opens no PR. A plain mv on this disk still acts: inert
  means "happened elsewhere", not "unattributed".
- github.py: the file-carried gates behind that rule, so the rare double
  is harmless rather than loud. The **PR:** line commits itself in team
  mode (taskfiles.commit_edit, sharing the move's pathspec-scoped commit),
  so it reaches the other boards instead of sitting in one working tree —
  where it would also stall sync outright; and a `gh pr create` that races
  anyway adopts the PR GitHub already holds.
- No board finishes the actor's half-done side effect on its own: the
  startup reconcile stands down in team mode and a review card with a
  branch and no PR carries ↑ open PR (POST /api/pr/open), which is a
  person deciding rather than N boards guessing. _open_pr raises its
  reasons now, so the automatic path narrates them and the explicit one
  toasts them.
- agents.py: the claim gates work launches. A card someone else holds
  refuses, naming them; ▸ take over is the deliberate second path (armed
  like everything that costs tokens) and reassigns via taskfiles'
  set_assignee; an unheld card claims itself on launch. Only in team mode
  — with BOARD_COMMIT_MOVES off nothing writes an assignee, so nothing
  reads one as a lock.
- github.complete_task: with BOARD_SYNC on, merge & clean up runs
  `gh pr merge` and lets the beat deliver the result, so local main only
  ever fast-forwards and no board makes a merge commit of its own. A
  branch without a PR is refused with a pointer to ↑ open PR. Sync off
  keeps the local merge path exactly as it was.

Verified with tests/test_actor_acts.py: two real clones of a real bare
upstream and a stub gh — the replica that only renders, the PR line that
travels, the double that adopts, the takeover that reassigns, and both
merge paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:18:17 +02:00

636 lines
27 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 actor_name, find_stage_of, move_task, read_task, set_assignee
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")
# The model the launch was actually given; None = inherited the
# vendor's own default. Honesty for the Sessions/Focus views.
public["model"] = record.get("model")
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, AGENT_MODE (the intent:
work = mutate and commit, act-pr = work + push, review = read-only +
post PR verdicts), AGENT_COMMANDS (the project's runnable command
prefixes) and AGENT_MODEL (the configured model, when there is one)
plus the BOARD_* passthrough for its event bridge; its stdout is the
job log; exit 0 = completed. Returns (proc, log_file, model) with
model = '' when the launch inherits the vendor default.
"""
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_COMMANDS": config.AGENT_COMMANDS,
"AGENT_CWD": str(cwd),
"BOARD_AGENT_ID": agent_id,
"BOARD_TASK": filename,
"BOARD_PORT": str(state.serve_port),
})
model = config.agent_model(mode)
if model:
env["AGENT_MODEL"] = model
else:
# Inherit = the variable is simply absent. Popping also stops a
# stray AGENT_MODEL in the board's own environment leaking through.
env.pop("AGENT_MODEL", None)
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, model
def _fresh_branch_point() -> tuple[str | None, str | None]:
"""Where a brand-new task branch should start: the newest main that
exists. With an `origin` remote, fetch its main (bounded by
FETCH_TIMEOUT) and branch from origin/main — never touching the main
checkout itself, the fetched ref is only the branch point. No remote,
a failed fetch or a timeout all mean today's behaviour: branch from
HEAD, because launching must never be blocked by network weather.
Returns (start point, ticker note); (None, …) means HEAD. The note is
non-None whenever the branch point deserves a mention — origin/main
ahead of this checkout, or a fetch that had to be skipped.
"""
def _git(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(config.REPO), *args],
capture_output=True, text=True)
if "origin" not in _git("remote").stdout.split():
return None, None
try:
fetched = subprocess.run(
["git", "-C", str(config.REPO), "fetch", "origin", "main"],
capture_output=True, text=True, timeout=config.FETCH_TIMEOUT)
except subprocess.TimeoutExpired:
return None, "fetch of origin/main timed out; branched from local HEAD"
if fetched.returncode != 0 or \
_git("rev-parse", "--verify", "--quiet", "origin/main").returncode != 0:
return None, "fetch of origin/main failed; branched from local HEAD"
# Counted against HEAD, not main: HEAD is the fallback base, so this is
# exactly what launching would have missed — accurate even when the
# board checkout sits on another branch.
ahead = _git("rev-list", "--count", "HEAD..origin/main").stdout.strip()
if ahead.isdigit() and int(ahead) > 0:
return "origin/main", (f"branched from origin/main, "
f"{ahead} ahead of this checkout")
return "origin/main", None
def _claim_for_launch(filename: str, stage: str, takeover: bool) -> None:
"""One agent per task is a board-memory rule; across machines the card
file is the only thing every board can see, so the claim is what gates
a launch here.
Someone else's card refuses — naming who holds it — unless this is the
deliberate takeover, which reassigns the card to whoever asked. An
unclaimed card claims itself on launch: starting work is as much a
commitment as the move that usually writes the line.
Only in team mode. With `BOARD_COMMIT_MOVES` off nothing writes the
assignee, so nothing may refuse on it either — the launch is exactly
what it was before.
"""
if not config.COMMIT_MOVES:
return
path = config.TASKS / stage / filename
holder = read_task(path, stage).get("assignee")
me = actor_name()
if holder and me and holder != me and not takeover:
raise ValueError(f"{holder} holds {filename} — take it over deliberately "
f"(the card's ▸ take over), or clear the Assignee line")
if not me:
return # no identity to write; git has no name here
if holder == me:
return
set_assignee(filename, stage, me)
state.record_board_event({
"kind": "agent", "actor": "board", "file": filename,
"summary": (f"{me} took {filename} over from {holder}" if holder
else f"{me} claimed {filename} by starting work on it")})
def start_agent(filename: str, stage: str, takeover: bool = False) -> 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")
_claim_for_launch(filename, stage, takeover)
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()
base_note = None
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:
point, base_note = _fresh_branch_point()
if point:
base = _git("rev-parse", point).stdout.strip()
result = _git("worktree", "add", "--no-track", "-b", branch,
str(worktree), point)
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, model = _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, "model": model or None,
}
with state.LOCK:
state.AGENTS[agent_id] = record
summary = (f"{name} is back on {filename} — continuing branch {branch}"
if continuing else
f"{name} started on {filename} (branch {branch})")
if base_note:
summary += f" — {base_note}"
state.record_board_event({
"kind": "agent", "actor": "agent", "file": filename,
"summary": summary,
})
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, model = _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, "model": model or None,
}
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 _no_new_commits(record: dict) -> bool:
"""True iff the worktree's HEAD is still the commit the agent started
from — i.e. the run produced no commits on the branch."""
if not record.get("worktree") or not record.get("base"):
return False
head = subprocess.run(
["git", "-C", record["worktree"], "rev-parse", "HEAD"],
capture_output=True, text=True)
return head.returncode == 0 and head.stdout.strip() == record["base"]
def _discard_untouched_worktree(record: dict) -> bool:
"""Remove worktree + branch, but only if the agent committed nothing."""
if not _no_new_commits(record):
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:
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)
if _no_new_commits(record):
# A "clean" exit with an empty branch is how permission bugs
# hide: nothing reaches review/ silently.
summary = (f"{name} exited cleanly on {filename} but committed "
f"NOTHING to {branch} — card stays in in-progress; "
f"read the report before relaunching")
else:
if find_stage_of(filename) == "in-progress":
try:
move_task(filename, "in-progress", "review", actor="agent")
except ValueError:
pass
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, model = _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, "model": model or None,
}
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"])
# act-pr is the one intent allowed to push: the PR must update.
proc, log_file, model = _launch("act-pr", 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, "model": model or None,
}
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}