Files
agent-orchestrator/agents.py
T
notplantsandClaude Fable 5 e185cec88c tangled_pr: verify against the pulls list, not a response header; and a tools test suite
THE BUG THAT PROMPTED THIS. tangled_pr.py judged success ONLY by an HX-Redirect header on the POST.
A create that SUCCEEDED but answered without that header read as a failure, so the caller retried
and Tangled grew duplicates — that is exactly how #397, #398 and #399 were filed for one branch. A
response header describes what the server meant to say; it is not the artifact.

Now it checks the artifact, in both directions:
  * BEFORE posting, refuse if an open pull already exists for this source branch, naming it. A
    retry cannot duplicate, whatever the response said. (--allow-duplicate to override.)
  * AFTER posting, confirm against the pulls list: a new pull number that did not exist before,
    whose page names this source branch, IS the success — with or without a redirect header.
  * Failure is reported only when no such pull appeared. A false failure is worse than a loud
    error here, because the caller's remedy is to retry.
Verified live: a dry-run against a branch that already has a pull refuses with rc=3, naming #417.

TWO REAL DEFECTS FOUND BY WRITING THE TESTS.

agents.py shelled out to `pgrep -P` and `ps -o comm=`. Neither is on the agent PATH on this host,
and a missing binary under shell=True returns rc=127 with EMPTY stdout — indistinguishable from
"this process has no children" and "no build is running". So _build_running was ALWAYS False and
the stall detector could reboot an agent mid-build. Both now read /proc directly: no PATH
dependency, and it cannot fail silently in that direction.

That shipped because the unit tests MOCKED pgrep and ps. The fakes stood in for the broken
dependency, so the suite passed on a host where neither tool was reachable and never exercised the
real path. The tests now patch _proc_descendants and _comms — the seams this repo owns. A test that
mocks a dependency proves the mock works.

Also fixed a monkeypatch leak those tests had: restoration used a name derivation that silently
matched nothing, so the patch escaped into another test class and failed an unrelated test — only
in a full run, never when that test ran alone. Now addCleanup, which cannot be ordered wrong.

NEW: tests/test_tools.py, 24 tests over tangled_pr, tangled_pr_close and gateway-domain, with every
HTTP boundary injected so they run offline. Mutation-checked: breaking classify(), the pull-number
regex, the branch match, or the scan bound each turns the suite red. Suite is 93 tests, green, and
order-stable across repeated runs.

README: a "PATH on a NixOS host" section. Every one of ps, pgrep, free, cmp, awk, curl, diff,
strings, nm, getent and ping is INSTALLED here and simply not on the agent PATH, so each reports
"command not found" and reads as a missing package. Documents how to check before concluding a tool
is absent, how to add the system profile, `nix shell` for what is genuinely missing, and the rule
that harness code should not shell out for what the kernel already exposes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3LdmEL7CvCYTNpoBq1kce
2026-08-21 03:50:11 +00:00

1437 lines
67 KiB
Python
Executable File

#!/usr/bin/env python3
"""agent-orchestrator — one driver, one config (agents.toml) for a fleet of agents.
A generic, reusable harness for running and supervising AI-agent sessions in tmux. Every
agent — a Builder/Adversary loop pair, a persistent supervisor, a one-shot task — is declared
in a single TOML config; the watchdog reads the SAME file, so there is no env-vs-file drift.
Nothing about any particular project lives in this code: paths, the loop kickoff preamble, the
handoff conventions, and the on-complete hook are all supplied by the project's config.
Usage:
agents.py up [name...] start enabled agents (or just the named ones); use-or-create
agents.py down [name...] stop agents (or all)
agents.py status one table: every agent — kind, backend, model, session, phase
agents.py watchdog the supervisor loop (reads the config every tick)
agents.py logs <name> tail an agent's session log
agents.py phase [set N|next|show] inspect / move the loop phase
agents.py tokens per-phase token + time report (needs [watchdog].log_tokens = true)
agents.py selftest backend activity-detector regression checks (no config needed)
agents.py init [dir] scaffold a starter agents.toml + prompts/ in a project dir
Options:
--config PATH config file (default: ./agents.toml, else <script dir>/agents.toml)
Config is authoritative. A one-off override env AGENT_MODEL_<name> / AGENT_BACKEND_<name>
affects a single invocation only; the persisted watchdog always re-reads the file.
"""
import hashlib, json, os, re, shlex, subprocess, sys, time, tomllib
from datetime import datetime, timedelta
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
# ── config loading ──────────────────────────────────────────────────────────────
def _cfg_path(argv):
if "--config" in argv:
return Path(argv[argv.index("--config") + 1])
cwd_cfg = Path.cwd() / "agents.toml"
return cwd_cfg if cwd_cfg.exists() else SCRIPT_DIR / "agents.toml"
def _resolve(base, p):
"""Resolve a possibly-relative config path against the project root."""
pp = Path(os.path.expanduser(str(p)))
return pp if pp.is_absolute() else (Path(base) / pp)
def load_config(path):
path = Path(path)
with open(path, "rb") as f:
raw = tomllib.load(f)
defaults = raw.get("defaults", {})
# The project root: everything project-supplied (prompts, templates, relative paths) is
# resolved against it. Defaults to the directory holding the config file; override with
# defaults.project_dir (useful when the config lives in a sandbox but prompts live elsewhere).
project_dir = _resolve(path.resolve().parent, defaults.get("project_dir", ".")).resolve()
session_prefix = defaults.get("session_prefix")
if not session_prefix:
die("config error: [defaults].session_prefix is required (e.g. session_prefix = \"myproj-\")")
log_dir_raw = defaults.get("log_dir")
if not log_dir_raw:
die("config error: [defaults].log_dir is required")
cfg = {
"watchdog": raw.get("watchdog", {}),
"backends": raw.get("backend", {}),
"defaults": defaults,
"loop": raw.get("loop", {}),
"pipeline": raw.get("pipeline", {}),
"project_dir": str(project_dir),
"log_dir": str(_resolve(project_dir, log_dir_raw)),
"session_prefix": session_prefix,
}
agents = {}
for a in raw.get("agent", []):
m = {**defaults, **a}
m["session"] = a.get("session", session_prefix + a["name"])
m["kind"] = a.get("kind", "persistent")
m["dir"] = str(_resolve(project_dir, a.get("dir", defaults.get("dir", "."))))
# one-off env override (single invocation; watchdog ignores via fresh load each tick)
env_model = os.environ.get(f"AGENT_MODEL_{a['name']}")
env_backend = os.environ.get(f"AGENT_BACKEND_{a['name']}")
if env_model: m["model"] = env_model
if env_backend: m["backend"] = env_backend
agents[a["name"]] = m
cfg["agents"] = agents
cfg["services"] = {s["name"]: {**defaults, **s,
"session": session_prefix + s["name"],
"dir": str(_resolve(project_dir, s.get("dir", ".")))}
for s in raw.get("service", [])}
cfg["state_dir"] = os.path.join(cfg["log_dir"], "state")
Path(cfg["state_dir"]).mkdir(parents=True, exist_ok=True)
return cfg
def backend_of(cfg, agent):
b = cfg["backends"].get(agent["backend"])
if not b:
die(f"agent {agent['name']}: unknown backend {agent['backend']!r}")
return b
# ── logging ───────────────────────────────────────────────────────────────────
def log(msg):
print(f"[agents {datetime.now():%H:%M:%S}] {msg}", flush=True)
def die(msg):
log(f"ERROR: {msg}")
sys.exit(1)
# ── tmux helpers ────────────────────────────────────────────────────────────────
# ALWAYS target sessions with an exact-match "=" prefix. tmux does prefix/fnmatch on bare
# targets, so "-t myproj-assistant" would match "myproj-assistant3" — capturing or killing the
# wrong session. "=name" forces an exact match.
def _run(cmd):
"""subprocess.run wrapper that never raises if the binary (e.g. tmux) is absent."""
try:
return subprocess.run(cmd, capture_output=True, text=True)
except FileNotFoundError:
return subprocess.CompletedProcess(cmd, 127, "", "")
def TS(name): # exact target-SESSION (has-session, kill-session)
return "=" + name
def TP(name): # exact target-PANE: "=session:" anchors the exact session, current window/pane
return "=" + name + ":"
def session_alive(name):
return _run(["tmux", "has-session", "-t", TS(name)]).returncode == 0
def session_command(name):
r = _run(["tmux", "display-message", "-p", "-t", TP(name), "#{pane_current_command}"])
return r.stdout.strip() if r.returncode == 0 else ""
def kill_session(name):
_run(["tmux", "kill-session", "-t", TS(name)])
def capture_pane(name, lines=40):
r = _run(["tmux", "capture-pane", "-p", "-t", TP(name)])
return "\n".join(r.stdout.splitlines()[-lines:]) if r.returncode == 0 else ""
def pipe_to_log(session, log_path):
_run(["tmux", "pipe-pane", "-o", "-t", TP(session), f"cat >> '{log_path}'"])
def new_session(session, cwd, cmd, log_path):
Path(cwd).mkdir(parents=True, exist_ok=True)
_run(["tmux", "new-session", "-d", "-s", session, "-c", cwd, cmd])
pipe_to_log(session, log_path)
def ping_session(session, msg, submit_key="Enter"):
"""Type a message into a session and submit it; retry submit until the prefix clears."""
if not session_alive(session):
return
prefix = msg[:28]
_run(["tmux", "send-keys", "-t", TP(session), "-l", "--", msg])
time.sleep(0.5)
for _ in range(10):
_run(["tmux", "send-keys", "-t", TP(session), submit_key])
time.sleep(1)
if prefix not in capture_pane(session, 20):
return
# ── activity / limit / fatal detection (per-backend regexes from config) ─────────
def _re(backend, key):
pat = backend.get(key)
return re.compile(pat, re.I) if pat else None
def _session_log_path(cfg, session):
return Path(cfg["log_dir"]) / f"{session}.log"
def _log_recently_touched(cfg, session, age_seconds):
try:
return (time.time() - _session_log_path(cfg, session).stat().st_mtime) <= age_seconds
except FileNotFoundError:
return False
def pane_active(cfg, agent, pane, *, use_log=True):
"""True when the pane shows the agent is working. A footer_ui backend (a TUI with a static
footer that lingers after a turn) only counts the bottom rows as activity, and falls back to
a recently-touched session log within a grace window."""
backend = backend_of(cfg, agent)
active = _re(backend, "active_re")
if backend.get("footer_ui"):
bottom = "\n".join(pane.splitlines()[-10:])
hit = bool(active and active.search(bottom))
grace = int(backend.get("log_grace", 180))
return hit or (use_log and _log_recently_touched(cfg, agent["session"], grace))
return bool(active and active.search(pane))
# ── prompt assembly ───────────────────────────────────────────────────────────
DONE_PLACEHOLDER_RE = re.compile(
r"^\s*(not yet|not done|not complete|incomplete|pending\b|tbd\b|n/?a\b|"
r"written here only|only when|to be (written|filled)|when all|<.*>)", re.I)
def phases(cfg): return cfg["loop"].get("phases", [])
def phase_idx_file(cfg): return os.path.join(cfg["state_dir"], cfg["loop"].get("state_file", "phase-idx"))
def cur_idx(cfg):
try:
v = Path(phase_idx_file(cfg)).read_text().strip()
return int(v) if v.lstrip("-").isdigit() else 0
except FileNotFoundError:
return 0
def cur_phase(cfg):
ps = phases(cfg)
return ps[cur_idx(cfg)] if ps else {}
def _state_subdir(cfg):
return (cfg["loop"].get("handoff") or {}).get("state_subdir", "machine-docs")
def handoff_repo(cfg):
h = cfg["loop"].get("handoff") or {}
repo = h.get("repo")
return str(_resolve(cfg["project_dir"], repo)) if repo else cfg["project_dir"]
def resolve_state_file(cfg, repo_dir, basename):
sub = _state_subdir(cfg)
p = Path(repo_dir) / sub / basename
return p if p.exists() else Path(repo_dir) / basename
def phase_done(cfg, status_basename):
repo = handoff_repo(cfg)
try:
lines = resolve_state_file(cfg, repo, status_basename).read_text().splitlines()
except FileNotFoundError:
return False
marker = cfg["loop"].get("done_marker", "## DONE")
for i, line in enumerate(lines):
if not line.startswith(marker):
continue
body = next((nxt for nxt in lines[i+1:] if nxt.strip()), "")
if DONE_PLACEHOLDER_RE.match(body):
continue
return True
return False
def role_model(cfg, agent):
"""Per-phase override (phases[idx].models[role]) wins, else the agent's configured model."""
role = agent.get("role")
if role:
ov = (cur_phase(cfg).get("models") or {}).get(role)
if ov:
return ov
return agent.get("model", "")
def _render_template(text, fields):
for k, v in fields.items():
text = text.replace("{" + k + "}", str(v))
return text
def build_loop_kickoff(cfg, agent):
"""A loop agent's kickoff = the project's kickoff template (slots filled from the current
phase) followed by the role prompt prompts/<role>.md. Both files are project-supplied; this
code holds no project text."""
ph = cur_phase(cfg)
fields = {
"phase_id": ph.get("id", ""),
"plan": ph.get("plan", ""),
"status": ph.get("status", ""),
"role": agent.get("role", ""),
}
pdir = Path(cfg["project_dir"])
preamble = ""
tmpl = cfg["loop"].get("kickoff_template")
if tmpl:
preamble = _render_template(_resolve(pdir, tmpl).read_text(), fields)
roles_dir = cfg["loop"].get("roles_dir", "prompts")
role_prompt = (_resolve(pdir, roles_dir) / f"{agent['role']}.md").read_text()
return preamble + role_prompt
def agent_prompt(cfg, agent):
pdir = Path(cfg["project_dir"])
if agent["kind"] == "loop":
return build_loop_kickoff(cfg, agent)
if agent.get("prompt_file"):
return _resolve(pdir, agent["prompt_file"]).read_text()
return agent.get("prompt", "")
# ── resume id ───────────────────────────────────────────────────────────────────
def resume_id(cfg, agent):
f = Path(cfg["state_dir"]) / f"{agent['name']}.id"
if f.exists():
v = f.read_text().strip()
if v:
return v
return None
# ── agent launch ────────────────────────────────────────────────────────────────
def _expected_proc(backend):
"""The process name a healthy session should be running; used for backend-mismatch healing.
Only backends that declare process_name participate (so a generic exec backend, or the
transient login shell during startup, is never mistaken for a mismatch)."""
return backend.get("process_name")
def start_agent(cfg, agent, *, force=False):
session = agent["session"]
if session_alive(session):
if not force:
log(f"{session} already running — leaving it")
return
kill_session(session)
backend = backend_of(cfg, agent)
model = role_model(cfg, agent)
prompt = agent_prompt(cfg, agent)
log_path = str(_session_log_path(cfg, session))
kf = Path(cfg["state_dir"]) / f"kickoff-{session}.txt"
kf.write_text(prompt)
cwd = agent.get("dir") or cfg["project_dir"]
pid = cur_phase(cfg).get("id", "-") if agent["kind"] == "loop" else "-"
delivery = backend.get("prompt_delivery", "arg")
if delivery == "ping":
# TUI backend: launch, wait for it to connect, then type the prompt in.
model_env = (f"OPENCODE_CONFIG_CONTENT={shlex.quote(json.dumps({'model': model}))} "
if model and backend.get("model_env") else "")
preamble = backend.get("preamble", "")
sep = "; " if preamble else ""
attach = _render_template(backend.get("attach", "{bin}"),
{"bin": backend["bin"], "server": backend.get("server", ""),
"dir": shlex.quote(cwd)})
cmd = f"{preamble}{sep}{model_env}{attach}"
log(f"starting {session} ({agent['backend']}, kind={agent['kind']}, phase={pid}, "
f"model={model or 'default'})")
new_session(session, cwd, cmd, log_path)
time.sleep(int(backend.get("connect_delay", 12)))
boot = (f"Your full kickoff prompt is in {kf} — read it now with: "
f"`cat '{kf}'` — then follow it exactly.")
ping_session(session, boot, submit_key=backend.get("submit_key", "C-m"))
elif delivery == "exec":
# Generic backend: run an arbitrary command. {kickoff} = path to the prompt file,
# {session} = the tmux session name, {model} = resolved model.
cmd = _render_template(backend["bin"],
{"kickoff": str(kf), "session": session, "model": model})
log(f"starting {session} ({agent['backend']}, kind={agent['kind']}, phase={pid})")
new_session(session, cwd, cmd, log_path)
else: # "arg": prompt passed as a CLI argument (claude-style)
rid = resume_id(cfg, agent) if agent.get("resume") and backend.get("supports_resume") else None
parts = [backend["bin"]]
if rid:
parts.append(_render_template(backend.get("resume_flag", "--resume '{id}'"), {"id": rid}))
if backend.get("remote_control"):
parts.append(_render_template(backend.get("remote_control_flag",
"--remote-control '{session}'"), {"session": session}))
if model:
parts.append(_render_template(backend.get("model_flag", "--model '{model}'"), {"model": model}))
if backend.get("flags"):
parts.append(backend["flags"])
parts.append(f"\"$(cat '{kf}')\"")
cmd = " ".join(p for p in parts if p)
log(f"starting {session} ({agent['backend']}, kind={agent['kind']}, phase={pid}, "
f"model={model or 'default'}{', resume' if rid else ''})")
new_session(session, cwd, cmd, log_path)
def start_service(cfg, svc):
session = svc["session"]
if session_alive(session):
log(f"{session} already running — leaving it")
return
log(f"starting service {session}")
new_session(session, svc.get("dir", cfg["project_dir"]), svc["command"],
str(_session_log_path(cfg, session)))
# ── usage-limit state machine ────────────────────────────────────────────────────
RESET_RE = re.compile(r"resets?\s*(?:at\s*)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?", re.I)
def _limit_state_path(cfg, session): return Path(cfg["state_dir"]) / f"limited-{session}.json"
def _load_limit_state(cfg, session):
try: return json.loads(_limit_state_path(cfg, session).read_text())
except Exception: return None
def _save_limit_state(cfg, session, st): _limit_state_path(cfg, session).write_text(json.dumps(st))
def _clear_limit_state(cfg, session):
try: _limit_state_path(cfg, session).unlink()
except FileNotFoundError: pass
def _parse_reset_epoch(pane):
matches = list(RESET_RE.finditer(pane))
if not matches:
return None
m = matches[-1]
try:
hour, minute = int(m.group(1)), int(m.group(2) or 0)
ampm = (m.group(3) or "").lower()
if ampm == "pm" and hour != 12: hour += 12
elif ampm == "am" and hour == 12: hour = 0
if hour > 23 or minute > 59: return None
cand = datetime.now().replace(hour=hour, minute=minute, second=0, microsecond=0)
if cand.timestamp() <= time.time(): cand += timedelta(days=1)
return cand.timestamp()
except Exception:
return None
def _next_limit_until(cfg, pane, now):
fallback = int(cfg["watchdog"].get("limit_probe_fallback", 300))
slack = int(cfg["watchdog"].get("limit_reset_slack", 45))
parsed = _parse_reset_epoch(pane)
if parsed is not None and parsed - now <= 6 * 3600:
return parsed + slack, True
return now + fallback, False
def _limit_nudge_msg(kind):
if kind in ("persistent", "orchestrator"):
return ("watchdog probe: if the quota window has reset, RESUME now — re-check status "
"and continue from where you stopped.")
return ("watchdog probe: if the quota window has reset, RESUME your loop now — pull latest, "
"re-read your phase STATUS/REVIEW files, and continue; re-arm your loop pacing.")
def limit_tick(cfg, agent, pane):
"""True while the agent is inside a usage-limit window — callers suppress all healing."""
session = agent["session"]
backend = backend_of(cfg, agent)
limit_re = _re(backend, "limit_re")
submit = backend.get("submit_key", "Enter")
fallback = int(cfg["watchdog"].get("limit_probe_fallback", 300))
state = _load_limit_state(cfg, session)
limited_now = bool(limit_re and limit_re.search(pane))
if state is None:
if not limited_now or pane_active(cfg, agent, pane, use_log=False):
return False
now = time.time()
until, parsed = _next_limit_until(cfg, pane, now)
if parsed:
log(f"limit hit on {agent['name']} — banner says reset "
f"{datetime.fromtimestamp(until):%a %H:%M}; holding (no reboots)")
else:
log(f"limit hit on {agent['name']} — reset unparsable; flat "
f"{fallback//60}-min probe loop (no reboots)")
_save_limit_state(cfg, session, {"until": until, "nudges": 0})
return True
if pane_active(cfg, agent, pane, use_log=False) or not limited_now:
log(f"limit lifted on {agent['name']} — clearing limit state")
_clear_limit_state(cfg, session)
return False
now = time.time()
if now < state.get("until", 0):
return True
msg = _limit_nudge_msg(agent["kind"])
if msg[:28] in "\n".join(pane.splitlines()[-8:]):
return True
nudges = state.get("nudges", 0) + 1
log(f"limit probe #{nudges} on {agent['name']} — nudging to resume")
ping_session(session, msg, submit_key=submit)
time.sleep(3)
pane2 = capture_pane(session, 40)
if pane_active(cfg, agent, pane2, use_log=False) and not (limit_re and limit_re.search(pane2)):
log(f"limit lifted on {agent['name']} — probe resumed it")
_clear_limit_state(cfg, session)
return True
until, _ = _next_limit_until(cfg, pane2, now)
if nudges == 3:
log(f"WARNING: {agent['name']} still limited after {nudges} probes — flat probes; never rebooting")
_save_limit_state(cfg, session, {"until": until, "nudges": nudges})
return True
# ── stall detection ──────────────────────────────────────────────────────────────
_idle_since: dict[str, float] = {}
_done_nudged: dict[str, bool] = {} # per-session: sent the one-time "write the done marker" nudge this phase
_build_deferred: set = set() # per-session: currently deferring a stall because a real build is running
# Default set of process names (comm) that mean "genuine long-running work is happening" — a compile,
# coverage run, mutation run, or a real e2e test driving the server/browser. If one of these is a
# descendant of the agent's tmux pane, a silent pane is a running build, NOT a stall. High-signal names
# only (no bare python/node/bash — those match the orchestrator's own engine + would false-positive).
DEFAULT_BUILD_PROCS_RE = (r"^(cargo|cargo-llvm-cov|cargo-mutants|cargo-nextest|nextest|rustc|rustdoc|"
r"cc1|cc1plus|collect2|lld|ld\.lld|llvm-cov|llvm-profdata|"
r"lichen-server|lichen-cms|lichen-shell|lichen-cli|chromium|chrome|playwright)$")
def _proc_descendants(roots):
"""All descendant PIDs of the given root PIDs (BFS via pgrep -P), EXCLUDING the roots themselves —
the pane root is the claude process whose args embed the prompt (which mentions cargo/rustc/…), so
matching it would false-positive; we only inspect its real child processes.
Reads /proc directly rather than shelling out to `pgrep -P`. WHY (2026-08-21): pgrep is not
installed on every host we run on, and a missing binary makes `subprocess.run(shell=True)`
return rc=127 with EMPTY stdout — indistinguishable from "this process has no children". That
made this function return an empty set on such a host, which made `_build_running` always False,
which let the stall detector reboot an agent in the middle of a build. Reading /proc removes the
dependency and cannot fail silently in that direction.
(Note the repo already bans `pgrep -f` for a DIFFERENT reason — it self-matches. Two distinct
traps, same tool.)"""
roots = [str(p) for p in roots if str(p).isdigit()]
children = {}
for entry in os.listdir("/proc"):
if not entry.isdigit():
continue
try:
with open(f"/proc/{entry}/stat") as fh:
fields = fh.read().rsplit(") ", 1)[-1].split() # rsplit: comm may contain spaces
ppid = fields[1]
except (OSError, IndexError):
continue # process exited mid-scan
children.setdefault(ppid, []).append(entry)
seen, stack = set(), list(roots)
while stack:
pid = stack.pop()
if pid in seen:
continue
seen.add(pid)
stack += children.get(pid, [])
return seen - set(roots)
def _build_running(cfg, agent):
"""True if a real build/coverage/test process is running under the agent's tmux session, so a long
silent pane isn't mistaken for a stall. Matches process comm (never the claude root's args). Bounded
by stall_idle_max upstream so a genuinely hung build still gets rebooted eventually."""
session = agent["session"]
r = subprocess.run(f"tmux list-panes -t {session!r} -F '#{{pane_pid}}'",
shell=True, capture_output=True, text=True)
kids = _proc_descendants(r.stdout.split())
if not kids:
return False
try:
rx = re.compile(cfg["watchdog"].get("build_procs_re", DEFAULT_BUILD_PROCS_RE))
except re.error:
rx = re.compile(DEFAULT_BUILD_PROCS_RE)
# Read /proc/<pid>/comm rather than `ps -o comm=`: ps is absent on some hosts we run on, and
# an absent binary yields EMPTY stdout, which this function cannot tell from "no build is
# running" — so the stall detector would reboot an agent mid-build. Same class as the pgrep
# dependency removed from _proc_descendants above; both were invisible because the unit tests
# MOCKED these subprocess calls, so the suite exercised the fake and never the dependency.
return any(rx.match(c) for c in _comms(kids))
def _comms(pids):
"""The comm (process name) of each pid, skipping any that exited mid-scan. No external tools."""
out = []
for pid in sorted(pids):
try:
with open(f"/proc/{pid}/comm") as fh:
c = fh.read().strip()
except OSError:
continue
if c:
out.append(c)
return out
def _done_nudge_msg(cfg, ph):
"""The DONE-nudge: prompts a stalled loop agent to finalize a built-but-unmarked phase."""
dm = cfg["loop"].get("done_marker", "## DONE")
pid = ph.get("id", "")
status = ph.get("status", f"STATUS-{pid}.md")
sub = _state_subdir(cfg)
return (f"watchdog nudge: you've stalled in phase '{pid}', which is NOT yet marked '{dm}'. Resume now "
f"— pull any pending review/inbox and continue. If (and ONLY if) every DoD item has a fresh "
f"PASS from BOTH adversaries with no standing veto, write '{dm}' to {sub}/{status} and push, "
f"so the phase settles and auto-advances. Do not stay idle.")
def _pane_last_active(session):
"""Unix timestamp of the tmux window's last activity (last output change), or None.
Seeds idle-duration from the agent's REAL last activity rather than `now`, so stalls are
detected regardless of when the watchdog process started — restarting the watchdog no longer
resets every agent's stall clock."""
r = subprocess.run(f"tmux display-message -p -t {session!r} '#{{window_activity}}'",
shell=True, capture_output=True, text=True)
try:
return float(r.stdout.strip())
except (ValueError, AttributeError):
return None
def _last_nonempty_line(text):
for line in reversed(text.splitlines()):
if line.strip():
return line.strip()
return ""
def _parse_waiting_until(cfg, agent, pane):
# Only consulted once the pane is already idle (see stall_check_one), so scanning the whole
# capture and taking the MOST-RECENT marker is safe — and it's the only thing that works for a
# footer_ui backend (claude/opencode), whose input-box footer always renders BELOW the agent's
# final message. The footer never contains the marker, so the last match is the agent's own
# signal, whether or not a status footer follows it.
matches = re.findall(r"WAITING-UNTIL:\s*(\S+)", pane)
if not matches:
return None
try:
return datetime.fromisoformat(matches[-1].replace("Z", "+00:00")).timestamp()
except Exception:
return None
def stall_check_one(cfg, agent):
session = agent["session"]
if not session_alive(session):
_idle_since[session] = 0.0
_clear_limit_state(cfg, session)
return
now = time.time()
pane = capture_pane(session, 40)
if limit_tick(cfg, agent, pane):
_idle_since[session] = 0.0
return
if pane_active(cfg, agent, pane):
_idle_since[session] = 0.0
_build_deferred.discard(session)
return
# Seed from the pane's real last-activity (not `now`), so a watchdog that just (re)started still
# sees an already-idle pane as idle-for-its-true-duration instead of resetting the clock.
since = _idle_since.get(session) or _pane_last_active(session) or now
_idle_since[session] = since
idle = now - since
grace = int(cfg["watchdog"].get("stall_grace", 180))
until = _parse_waiting_until(cfg, agent, pane)
if until is not None:
# An agent that starts a long remote/async run (remote cargo build, terraform apply, VM
# provision, long ssh) prints `WAITING-UNTIL: <ISO8601>` so the watchdog holds off instead
# of killing it mid-run. Cap how far out it can push its own reboot, so a runaway can't park
# itself forever ("some max no matter what").
wu_max = int(cfg["watchdog"].get("waiting_until_max", 7200))
building = _build_running(cfg, agent)
# A build still running under the session (cargo-mutants, a coverage run, a remote ssh) is
# PROOF OF LIFE, and it outranks the agent's own deadline: the deadline is only an estimate,
# and an agent blocked on a shell cannot re-emit a fresh marker to extend it — so killing a
# live build at its estimate throws the work away for nothing. `waiting_until_max` is the one
# absolute bound: past it we reboot even mid-build, which is what catches a genuinely HUNG
# build (the case the cap exists for).
if wu_max and idle > wu_max:
reason = (f"idle {int(idle)}s past the {wu_max}s WAITING-UNTIL cap "
f"({'build still running — treating it as hung' if building else 'no build running'}) "
f"— rebooting regardless")
elif building:
return
elif now <= until + grace:
return
else:
reason = f"past its WAITING-UNTIL by {int(now-until)}s — self-wake did not fire"
else:
stall_idle = int(backend_of(cfg, agent).get("stall_idle", 300))
if idle < stall_idle:
return
# Build-aware: a silent pane with a real compile/coverage/test process running is NOT a stall —
# defer the reboot until it finishes, but never past stall_idle_max (hard cap for a hung build).
stall_idle_max = int(backend_of(cfg, agent).get("stall_idle_max", 1800))
if idle < stall_idle_max and _build_running(cfg, agent):
if session not in _build_deferred:
log(f"stall-defer: {agent['name']} ({session}) idle {int(idle)}s but a build/test is "
f"running — waiting (hard cap {stall_idle_max}s)")
_build_deferred.add(session)
return
reason = (f"idle {int(idle)}s past build-aware hard cap {stall_idle_max}s — rebooting regardless"
if idle >= stall_idle_max else
f"idle {int(idle)}s with no WAITING-UNTIL marker and no build running")
# Ceremony-lag guard: a loop agent idling in a phase that's built but NOT marked done won't let the
# phase advance (the recurring "all gates PASS but no ## DONE written" stall). Nudge it ONCE per phase
# to finalize (write the done marker if the DoD is met) before falling back to the blunt kill+reboot.
if (cfg["loop"].get("done_nudge", True) and agent.get("kind") == "loop" and phases(cfg)
and not phase_done(cfg, cur_phase(cfg).get("status", "")) and not _done_nudged.get(session)):
log(f"stall: {agent['name']} ({session}) {reason} — DONE-nudge (phase built but not marked done)")
ping_session(session, _done_nudge_msg(cfg, cur_phase(cfg)),
submit_key=backend_of(cfg, agent).get("submit_key", "Enter"))
_done_nudged[session] = True
_idle_since[session] = now # fresh idle window to act on the nudge before reboot escalates
return
log(f"stall: {agent['name']} ({session}) {reason} — kill + reboot")
start_agent(cfg, agent, force=True)
_idle_since[session] = 0.0
_build_deferred.discard(session)
# ── healing ──────────────────────────────────────────────────────────────────────
def backend_mismatch(cfg, agent):
expected = _expected_proc(backend_of(cfg, agent))
if not expected:
return False
cmd = session_command(agent["session"])
known = {_expected_proc(b) for b in cfg["backends"].values() if _expected_proc(b)}
# only a definite OTHER declared backend is a mismatch; a transient login shell during
# startup (not a known backend process) is not.
if cmd not in known:
return False
return cmd != expected
def heal_one(cfg, agent):
session = agent["session"]
backend = backend_of(cfg, agent)
if not session_alive(session):
log(f"{agent['name']} ({session}) gone — restarting")
start_agent(cfg, agent)
return
if backend_mismatch(cfg, agent):
log(f"{agent['name']} ({session}) is {session_command(session)!r}, expected "
f"{_expected_proc(backend)} — kill + restart")
start_agent(cfg, agent, force=True)
return
pane = capture_pane(session, 25)
if pane_active(cfg, agent, pane):
return
if limit_tick(cfg, agent, pane):
return
fatal = _re(backend, "fatal_re")
if fatal and fatal.search(pane):
log(f"FATAL session-state error on {agent['name']} ({session}) — kill + restart")
start_agent(cfg, agent, force=True)
# ── wake (persistent agents with a wake schedule) ────────────────────────────────
def wake_agent(cfg, agent):
"""Returns True when the wake landed (or is moot), False to retry next tick."""
wake = agent.get("wake")
if not wake:
return True
session = agent["session"]
# A one-shot `task` is "woken" by RE-RUNNING it fresh — it has no persistent REPL to re-prompt — so
# scheduled work (e.g. a coverage audit) recurs autonomously on its interval, no operator needed.
# Skip only while its previous run is still going; otherwise kill + restart for a clean re-run.
if agent.get("kind") == "task":
if session_alive(session) and pane_active(cfg, agent, capture_pane(session, 25)):
return False
log(f"wake: re-running task {agent['name']} ({session})")
start_agent(cfg, agent, force=True)
return True
if not session_alive(session):
return False
backend = backend_of(cfg, agent)
if pane_active(cfg, agent, capture_pane(session, 25)):
return False
pf = wake.get("prompt_file")
try:
msg = " ".join((_resolve(cfg["project_dir"], pf)).read_text().split())
except (FileNotFoundError, TypeError):
log(f"wake skipped for {agent['name']} — prompt file missing: {pf}")
return True
if not msg:
return True
log(f"waking {agent['name']} ({session}) for scheduled supervision pass")
ping_session(session, msg, submit_key=backend.get("submit_key", "Enter"))
return True
# ── handoff signalling (loop pair) ───────────────────────────────────────────────
_hand = {"sha": "", "adv_inbox": "", "builder_inbox": ""}
def handoff_reset():
_hand["sha"] = _hand["adv_inbox"] = _hand["builder_inbox"] = ""
def _git(repo, args):
return subprocess.run(f"git -C {repo!r} {args}", shell=True, capture_output=True, text=True)
def _show_pushed(cfg, repo, path):
sub = _state_subdir(cfg)
for loc in (f"origin/main:{sub}/{path}", f"origin/main:{path}"):
r = _git(repo, f"show {loc!r}")
if r.returncode == 0:
return r.stdout
return ""
def _ping_agents(cfg, value, default, msg):
"""Ping one or more agents. `value` is an agent name, a LIST of names, or falsy (→ default).
Each target is pinged in its own session with its own backend's submit key — so a handoff can
notify multiple reviewers (e.g. claim_pings = ["correctness-adversary", "readability-adversary"])."""
names = value if isinstance(value, list) else [value or default]
for name in names:
agent = cfg["agents"].get(name)
session = agent["session"] if agent and agent.get("session") else (cfg["session_prefix"] + str(name))
submit = backend_of(cfg, agent).get("submit_key", "Enter") if agent else "Enter"
ping_session(session, msg, submit_key=submit)
def handoff_check(cfg):
h = cfg["loop"].get("handoff")
if not h:
return
repo = handoff_repo(cfg)
claim_pat = h.get("claim_pattern", "^claim")
review_pat = h.get("review_pattern", "^review")
_git(repo, "fetch -q origin")
head = _git(repo, "rev-parse origin/main").stdout.strip()
if head:
if not _hand["sha"]:
_hand["sha"] = head
elif head != _hand["sha"]:
subjects = _git(repo, f"log --format=%s {_hand['sha']}..origin/main").stdout
if re.search(claim_pat, subjects, re.M | re.I):
log("handoff: claim commit → pinging reviewer(s)")
_ping_agents(cfg, h.get("claim_pings", "adversary"), "adversary",
"watchdog ping: the other loop pushed a gate CLAIM commit. "
"Pull and verify the claimed gate now.")
if re.search(review_pat, subjects, re.M | re.I):
log("handoff: review commit → pinging builder")
_ping_agents(cfg, h.get("review_pings", "builder"), "builder",
"watchdog ping: the other loop pushed a verdict/finding commit. "
"Pull the review file and act.")
_hand["sha"] = head
inboxes = h.get("inboxes", [])
md5 = lambda s: hashlib.md5(s.encode()).hexdigest()
sub_dir = _state_subdir(cfg)
for fname, key, target in (
(inboxes[0] if len(inboxes) > 0 else None, "adv_inbox", h.get("claim_pings", "adversary")),
(inboxes[1] if len(inboxes) > 1 else None, "builder_inbox", h.get("review_pings", "builder")),
):
if not fname:
continue
content = _show_pushed(cfg, repo, fname)
if content:
hh = md5(content)
if hh != _hand[key]:
log(f"handoff: {fname} changed → pinging {target}")
_ping_agents(cfg, target, target,
f"watchdog ping: the other loop pushed {sub_dir}/{fname} — pull, read it, "
f"act, then delete the file (commit + push) to mark it consumed.")
_hand[key] = hh
else:
_hand[key] = ""
# ── phase advance (loop machine) ─────────────────────────────────────────────────
def loop_agents(cfg):
return [a for a in cfg["agents"].values() if a["kind"] == "loop" and a.get("enabled", True)]
def stop_loops(cfg):
for a in loop_agents(cfg):
if session_alive(a["session"]):
log(f"killing {a['session']}")
kill_session(a["session"])
def start_loops(cfg):
for a in loop_agents(cfg):
start_agent(cfg, a)
# ── optional per-phase token + time logging (log_tokens) ──────────────────────────
# When [watchdog].log_tokens (or [loop].log_tokens) is true, the watchdog records, for each phase,
# how many tokens each agent used and how long the phase took, appended to <log_dir>/token-log.jsonl.
# Tokens are summed from each agent's Claude Code session transcript, attributed by working dir — so
# give each agent its OWN dir for accurate per-agent numbers (the Builder/Adversary loop pair already
# uses separate clones). View with: agents.py tokens.
def log_tokens_enabled(cfg):
return bool(cfg.get("watchdog", {}).get("log_tokens") or cfg.get("loop", {}).get("log_tokens"))
def _transcript_dir(workdir):
name = str(workdir).rstrip("/").replace("/", "-").replace(".", "-")
return Path(os.path.expanduser("~/.claude/projects")) / name
def _sum_tokens(workdir):
t = {"input": 0, "output": 0, "cache_create": 0, "cache_read": 0}
d = _transcript_dir(workdir)
if d.is_dir():
for f in d.glob("*.jsonl"):
try:
for line in f.open(errors="ignore"):
try:
o = json.loads(line)
except Exception:
continue
if o.get("type") == "assistant":
u = (o.get("message", {}) or {}).get("usage", {}) or {}
t["input"] += u.get("input_tokens", 0) or 0
t["output"] += u.get("output_tokens", 0) or 0
t["cache_create"] += u.get("cache_creation_input_tokens", 0) or 0
t["cache_read"] += u.get("cache_read_input_tokens", 0) or 0
except OSError:
continue
t["total"] = t["input"] + t["output"] + t["cache_create"] + t["cache_read"]
return t
def _token_cumulative(cfg):
"""Cumulative tokens per agent so far, summed from each agent's transcript dir."""
return {a["name"]: _sum_tokens(a["dir"]) for a in cfg["agents"].values()}
_TOKEN_KEYS = ("input", "output", "cache_create", "cache_read", "total")
def _token_state_path(cfg): return Path(cfg["state_dir"]) / "token-phase.json"
def _token_log_path(cfg): return Path(cfg["log_dir"]) / "token-log.jsonl"
def _tok_delta(cur, base): return {k: cur.get(k, 0) - base.get(k, 0) for k in _TOKEN_KEYS}
def token_phase_begin(cfg, phase_id):
"""Set the baseline (cumulative tokens + start time) for the phase now starting. Idempotent
across watchdog restarts: keeps the original baseline if already tracking this phase."""
if not log_tokens_enabled(cfg):
return
sf = _token_state_path(cfg)
try:
if json.loads(sf.read_text()).get("phase_id") == phase_id:
return
except Exception:
pass
sf.write_text(json.dumps({"phase_id": phase_id,
"started": datetime.now().isoformat(timespec="seconds"),
"baseline": _token_cumulative(cfg)}))
def token_phase_flush(cfg, next_phase_id):
"""Close the current phase: append its per-agent + total token deltas and duration to the
token-log, then re-baseline for next_phase_id (or finalize tracking if None)."""
if not log_tokens_enabled(cfg):
return
sf = _token_state_path(cfg)
try:
st = json.loads(sf.read_text())
except Exception:
return
cur = _token_cumulative(cfg)
base = st.get("baseline", {})
started = st.get("started")
try:
dur = round((datetime.now() - datetime.fromisoformat(started)).total_seconds(), 1)
except Exception:
dur = None
per_agent = {n: _tok_delta(cur.get(n, {}), base.get(n, {})) for n in cur}
total = {k: sum(per_agent[n][k] for n in per_agent) for k in _TOKEN_KEYS}
rec = {"phase_id": st.get("phase_id"), "started": started,
"ended": datetime.now().isoformat(timespec="seconds"), "duration_s": dur,
"agents": per_agent, "total": total}
with _token_log_path(cfg).open("a") as fh:
fh.write(json.dumps(rec) + "\n")
parts = ", ".join(f"{n}={per_agent[n]['total']:,}" for n in per_agent)
log(f"[log_tokens] phase {rec['phase_id']}: {total['total']:,} tok in {dur}s ({parts})")
if next_phase_id is not None:
sf.write_text(json.dumps({"phase_id": next_phase_id,
"started": datetime.now().isoformat(timespec="seconds"),
"baseline": cur}))
else:
sf.unlink(missing_ok=True)
# ── token logging granularity: per phase, or also per GATE (log_tokens) ────────────
# Phases are tracked per phase (token_phase_begin/flush). With token_granularity="gate" (the default)
# tokens are ALSO attributed to each gate. A "gate" is a claimed unit — any `claim(<label>)` commit on
# the work repo's origin/main (e.g. claim(D1-D5), claim(feat:multi-file)); a leading "feat:" is
# stripped for readability. A change in the most-recently-claimed label is a boundary; on each
# boundary the previous gate's per-agent token delta + duration is appended to token-log.jsonl tagged
# phase_id="<phase>:<label>", so `agents.py tokens` lists it as its own row. The per-phase rollup
# record is written either way; "phase" granularity logs only that.
_gate_claim_re = re.compile(r"^claim\(\s*([^)]+?)\s*\)", re.I)
def token_granularity(cfg):
"""'gate' (default; per claimed gate, plus the per-phase rollup) or 'phase' (per phase only)."""
g = (cfg.get("watchdog", {}).get("token_granularity")
or cfg.get("loop", {}).get("token_granularity") or "gate")
return g if g in ("gate", "phase") else "gate"
def _token_gate_state_path(cfg):
return Path(cfg["state_dir"]) / "token-gate.json"
def _latest_claimed_gate(cfg):
"""Label of the most-recent `claim(<label>)` subject on the work repo's origin/main, or None.
A leading 'feat:' is stripped so feature gates read as their bare name."""
r = _git(handoff_repo(cfg), "log -1 --format=%s --grep 'claim(' origin/main")
m = _gate_claim_re.match((r.stdout or "").strip())
if not m:
return None
label = m.group(1).strip()
return label[5:].strip() if label.lower().startswith("feat:") else label
def _write_token_delta(cfg, phase_id, st):
"""Append one per-agent token delta record (vs the baseline in st) to token-log.jsonl."""
cur = _token_cumulative(cfg)
base = st.get("baseline", {})
started = st.get("started")
try:
dur = round((datetime.now() - datetime.fromisoformat(started)).total_seconds(), 1)
except Exception:
dur = None
per_agent = {n: _tok_delta(cur.get(n, {}), base.get(n, {})) for n in cur}
total = {k: sum(per_agent[n][k] for n in per_agent) for k in _TOKEN_KEYS}
rec = {"phase_id": phase_id, "started": started,
"ended": datetime.now().isoformat(timespec="seconds"), "duration_s": dur,
"agents": per_agent, "total": total}
with _token_log_path(cfg).open("a") as fh:
fh.write(json.dumps(rec) + "\n")
return rec, per_agent, total
def gate_token_flush(cfg):
"""Close out the currently-tracked gate (if any): write its token delta, then drop the state."""
sf = _token_gate_state_path(cfg)
try:
st = json.loads(sf.read_text())
except Exception:
return
gate = st.get("gate")
if not gate:
return
phase_id = f"{st['phase']}:{gate}" if st.get("phase") else gate
rec, per_agent, total = _write_token_delta(cfg, phase_id, st)
parts = ", ".join(f"{n}={per_agent[n]['total']:,}" for n in per_agent)
log(f"[log_tokens] gate {phase_id}: {total['total']:,} tok in {rec['duration_s']}s ({parts})")
try:
sf.unlink()
except Exception:
pass
def gate_token_check(cfg):
"""When token_granularity=='gate', detect gate boundaries and flush per-gate token deltas."""
if not log_tokens_enabled(cfg) or token_granularity(cfg) != "gate":
return
current = _latest_claimed_gate(cfg)
if not current:
return
sf = _token_gate_state_path(cfg)
try:
tracked = json.loads(sf.read_text()).get("gate")
except Exception:
tracked = None
if tracked == current:
return
if tracked:
gate_token_flush(cfg) # close out the previous gate before starting the next
sf.write_text(json.dumps({"gate": current, "phase": cur_phase(cfg).get("id"),
"started": datetime.now().isoformat(timespec="seconds"),
"baseline": _token_cumulative(cfg)}))
log(f"[log_tokens] tracking gate: {current}")
def phase_advance_check(cfg):
"""On heavy tick: if the current phase is DONE, advance (or finish the sequence).
Returns True only when it actually transitions/completes THIS tick (caller skips healing
that one tick). Once the sequence is already marked complete it is idempotent (returns
False, no re-log, no re-stop). Appending a phase after completion clears the stale marker
and resumes the loops on the new phase."""
ps = phases(cfg)
if not ps or not cfg["loop"].get("auto_advance", True):
return False
marker = Path(cfg["log_dir"]) / "SEQUENCE-COMPLETE"
idx = cur_idx(cfg)
ph = ps[idx]
if not phase_done(cfg, ph["status"]):
return False
if log_tokens_enabled(cfg) and token_granularity(cfg) == "gate":
gate_token_flush(cfg) # close out the last in-flight gate before leaving the phase
nxt = idx + 1
if nxt < len(ps):
log(f"PHASE {ph['id']} DONE — auto-transitioning to {ps[nxt]['id']}")
token_phase_flush(cfg, ps[nxt]["id"])
stop_loops(cfg)
Path(phase_idx_file(cfg)).write_text(str(nxt))
if marker.exists():
marker.unlink() # resuming into a (freshly-appended) phase — clear stale completion
handoff_reset()
_done_nudged.clear() # fresh DONE-nudge budget for the new phase
start_loops(cfg)
return True
# last phase is DONE → sequence complete
if marker.exists():
return False # already handled — idempotent (no re-log, no re-stop)
log(f"PHASE SEQUENCE COMPLETE (last phase {ph['id']} DONE) — stopping loops")
token_phase_flush(cfg, None)
stop_loops(cfg)
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
marker.write_text(f"phase sequence complete {ts}. Loops stopped; build finished.\n")
oc = cfg["loop"].get("on_complete")
if oc:
trig = Path(cfg["log_dir"]) / oc.get("trigger_file", ".run-on-complete")
if trig.exists():
trig.unlink()
runname = oc.get("run")
if runname and runname in cfg["agents"]:
log(f"on_complete: launching task agent {runname!r}")
start_agent(cfg, cfg["agents"][runname], force=True)
return True
# ── watchdog loop ────────────────────────────────────────────────────────────────
def watched(cfg):
return [a for a in cfg["agents"].values()
if a.get("enabled", True) and a.get("watch", "none") != "none"]
# ── standalone-agent pipeline (sequential phases via completion markers) ──────────
# A [pipeline] block runs a sequence of DISTINCT agents (different prompts / models / dirs) one at a
# time, advancing when each writes its completion marker — the standalone-agent analog of the loop
# phase machine. Stateless: the markers (which agents write in their OWN cwd, e.g. work-fork/.ao-state/)
# are the source of truth, so the watchdog reconciles the desired state every tick — exactly one stage
# runs, earlier (done) stages are retired, later stages wait. Declare pipeline agents enabled=false: the
# pipeline (not `up`/watched()) owns their lifecycle; the ACTIVE stage is still stall/heal-watched.
#
# [pipeline]
# enabled = true
# stages = [
# { agent = "fork-e2e", done = "work-fork/.ao-state/FORK-E2E-COMPLETE" },
# { agent = "fork-iroh", done = "work-fork/.ao-state/FORK-IROH-COMPLETE" },
# ]
def pipeline_stages(cfg):
pl = cfg.get("pipeline") or {}
if not pl.get("enabled", True):
return []
return pl.get("stages", [])
def _pipeline_marker(cfg, stage):
p = Path(stage["done"])
return p if p.is_absolute() else Path(cfg["project_dir"]) / stage["done"]
def _pipeline_push_on_retire(agent):
"""Best-effort `git push` in a completing stage's dir before it's retired, so its final commits
aren't stranded locally if it wrote its completion marker before its last push landed (a race:
the pipeline retires a stage the instant its marker appears). Never raises; logs the outcome."""
d = agent.get("dir")
if not d or not (Path(d) / ".git").exists():
return
try:
r = subprocess.run(["git", "-C", d, "push", "origin", "HEAD:main"],
env={**os.environ, "GIT_SSH_COMMAND": "ssh -o BatchMode=yes"},
capture_output=True, text=True, timeout=90)
if r.returncode == 0:
log(f"pipeline: pushed {agent['name']!r} before retiring (no stranded commits)")
elif "up-to-date" not in (r.stderr + r.stdout).lower():
log(f"pipeline: push-on-retire for {agent['name']!r} nonzero (non-fatal): "
f"{(r.stderr or r.stdout).strip().splitlines()[-1:] }")
except Exception as e:
log(f"pipeline: push-on-retire for {agent['name']!r} failed (non-fatal): {e}")
def pipeline_check(cfg):
"""Reconcile the [pipeline]: run the first stage whose completion marker is absent, retire every
other pipeline agent. Returns the ACTIVE stage's agent dict (so the watchdog stall/heal-watches it),
or None if there's no pipeline or it's fully complete."""
stages = pipeline_stages(cfg)
if not stages:
return None
active = None
for st in stages:
if not _pipeline_marker(cfg, st).exists():
active = cfg["agents"].get(st.get("agent"))
break
active_session = active["session"] if active else None
for st in stages:
ag = cfg["agents"].get(st.get("agent"))
if not ag:
continue
alive = session_alive(ag["session"])
if ag["session"] == active_session:
if not alive:
log(f"pipeline: advancing → start stage {ag['name']!r}")
start_agent(cfg, ag, force=True)
elif alive:
# Retiring a non-active stage. If it's a COMPLETED stage (its marker exists), push its repo
# first so final commits written just before the marker aren't stranded locally.
if _pipeline_marker(cfg, st).exists():
_pipeline_push_on_retire(ag)
log(f"pipeline: retire stage {ag['name']!r} (complete or not yet active)")
kill_session(ag["session"])
# PIPELINE-COMPLETE must MIRROR the current stage list, not latch on first completion. Recompute it
# from every stage's marker each tick: write it only when ALL current markers exist, and CLEAR a stale
# one the moment any stage is incomplete. Without the clear, appending new stages to a pipeline that
# had already completed leaves a lying "done" sentinel (the appended stages run, but the file says the
# pipeline finished). `active is None` is not a safe proxy here — it's also None when a stage names a
# missing agent — so gate on the markers directly.
marker = Path(cfg["log_dir"]) / "PIPELINE-COMPLETE"
all_complete = all(_pipeline_marker(cfg, st).exists() for st in stages)
if all_complete:
if not marker.exists():
log("pipeline: ALL STAGES COMPLETE")
marker.write_text("pipeline complete\n")
elif marker.exists():
log("pipeline: reopened — a stage is incomplete; clearing stale PIPELINE-COMPLETE")
marker.unlink()
return active
def watchdog_loop(cfg_path):
cfg = load_config(cfg_path)
sig = int(cfg["watchdog"].get("signal_interval", 30))
heavy = int(cfg["watchdog"].get("heavy_interval", 300))
ps = phases(cfg)
log(f"watchdog up — phase={cur_phase(cfg).get('id','-')} [{cur_idx(cfg)+1}/{len(ps)}] "
f"signal={sig}s heavy={heavy}s, watching: {[a['name'] for a in watched(cfg)]}")
elapsed = heavy # force a heavy check on first tick
wake_elapsed = {a["name"]: 0 for a in cfg["agents"].values() if a.get("wake")}
if log_tokens_enabled(cfg):
token_phase_begin(cfg, cur_phase(cfg).get("id"))
log(f"[log_tokens] enabled (granularity={token_granularity(cfg)}) — token-log.jsonl")
while True:
cfg = load_config(cfg_path) # re-read every tick: config is authoritative, no env drift
has_loops = bool(loop_agents(cfg))
seq_done = (Path(cfg["log_dir"]) / "SEQUENCE-COMPLETE").exists()
if has_loops and not seq_done:
handoff_check(cfg)
gate_token_check(cfg)
# Reconcile the standalone-agent pipeline (start/advance/retire stages by their markers), and
# fold its ACTIVE stage into the stall/heal watch list so it auto-recovers like any watched agent.
active_pipe = pipeline_check(cfg)
watch_list = list(watched(cfg))
if active_pipe and active_pipe["name"] not in {x["name"] for x in watch_list}:
watch_list.append(active_pipe)
for a in watch_list:
if a["watch"] == "heal+stall":
stall_check_one(cfg, a)
else:
if session_alive(a["session"]):
limit_tick(cfg, a, capture_pane(a["session"], 40))
# ...and ADD one that appeared. wake_elapsed was built once at startup, and only ever
# shrank (below) — so a `wake` written into agents.toml while the watchdog is running was
# invisible to it, silently and forever. Both flat-file agents were given 30-min wakes on
# 2026-08-16 against a watchdog up since 2026-08-01: zero wakes fired in the 2 days since,
# and with watch="heal" (no stall-reboot) each finished turn parked them until a human
# looked. Seed a new wake with a full interval so it fires on its own schedule, not at once.
for _n, _a in cfg["agents"].items():
if _a.get("wake") and _n not in wake_elapsed:
wake_elapsed[_n] = 0
log(f"wake registered mid-run for {_n} (interval={_a['wake'].get('interval', 3600)}s)")
for name, el in list(wake_elapsed.items()):
agent = cfg["agents"].get(name)
# Config is re-read every tick, but wake_elapsed was built once at startup. If an agent's
# `wake` was removed (or the agent deleted) mid-run — e.g. an operator winding down a wake —
# skip it instead of KeyError-crashing the whole watchdog. (This bug once killed the watchdog
# silently, stalling phase advancement.)
if not agent or "wake" not in agent:
continue
# After the phase sequence completes, quiet the loop-tied wakes (e.g. the on-demand
# auditor) — but a PERSISTENT agent (the operator-facing supervisor) keeps waking, so its
# hourly supervision survives SEQUENCE-COMPLETE and can drive follow-on work (a second build).
if seq_done and agent.get("kind") != "persistent":
continue
interval = int(agent["wake"].get("interval", 3600))
if el >= interval:
if wake_agent(cfg, agent):
wake_elapsed[name] = 0
# Auto-advance is checked EVERY tick (not just the heavy tick) so a completed phase advances
# within signal_interval of its `## DONE` landing, instead of idling up to heavy_interval.
advanced = phase_advance_check(cfg) if has_loops else False
if elapsed >= heavy:
elapsed = 0
if not advanced:
for a in watch_list:
if seq_done and a["kind"] == "loop":
continue
heal_one(cfg, a)
time.sleep(sig)
elapsed += sig
for k in wake_elapsed:
wake_elapsed[k] += sig
# ── CLI commands ──────────────────────────────────────────────────────────────
def start_watchdog(cfg, cfg_path):
session = cfg["session_prefix"] + "watchdog"
if session_alive(session):
log("watchdog already running")
return
log("starting watchdog")
script = Path(__file__).resolve()
new_session(session, cfg["project_dir"],
f"exec >>'{cfg['log_dir']}/{session}.log' 2>&1; "
f"python3 '{script}' watchdog --config '{Path(cfg_path).resolve()}'",
str(_session_log_path(cfg, session)))
def cmd_up(cfg, cfg_path, names):
if cfg["loop"].get("resume_phase") is False and not Path(phase_idx_file(cfg)).exists():
Path(phase_idx_file(cfg)).write_text("0")
targets = ([cfg["agents"][n] for n in names if n in cfg["agents"]]
if names else
[a for a in cfg["agents"].values() if a.get("enabled", True)])
for a in targets:
start_agent(cfg, a)
if not names:
for s in cfg["services"].values():
start_service(cfg, s)
start_watchdog(cfg, cfg_path)
else:
for n in names:
if n in cfg["services"]:
start_service(cfg, cfg["services"][n])
if n == "watchdog":
start_watchdog(cfg, cfg_path)
def cmd_down(cfg, names):
sessions = []
if names:
for n in names:
if n in cfg["agents"]: sessions.append(cfg["agents"][n]["session"])
elif n in cfg["services"]: sessions.append(cfg["services"][n]["session"])
elif n == "watchdog": sessions.append(cfg["session_prefix"] + "watchdog")
else:
sessions = [a["session"] for a in cfg["agents"].values()]
sessions += [s["session"] for s in cfg["services"].values()]
sessions.append(cfg["session_prefix"] + "watchdog")
for s in sessions:
if session_alive(s):
log(f"killing {s}")
kill_session(s)
def cmd_status(cfg):
idx, ps = cur_idx(cfg), phases(cfg)
if ps:
ph = ps[idx]
done = "## DONE" if phase_done(cfg, ph["status"]) else "in progress"
print(f" phase: {ph['id']} [{idx+1}/{len(ps)}] plan={ph.get('plan','-')} ({done})")
print(f" {'AGENT':<14} {'KIND':<11} {'BACKEND':<9} {'MODEL':<20} {'WATCH':<10} STATE")
for a in cfg["agents"].values():
st = "RUNNING" if session_alive(a["session"]) else "stopped"
en = "" if a.get("enabled", True) else " (disabled)"
rc = session_command(a["session"]) if st == "RUNNING" else ""
print(f" {a['name']:<14} {a['kind']:<11} {a['backend']:<9} "
f"{role_model(cfg,a) or 'default':<20} {a.get('watch','none'):<10} {st}{en}"
+ (f" [{rc}]" if rc else ""))
for s in cfg["services"].values():
st = "RUNNING" if session_alive(s["session"]) else "stopped"
print(f" {s['name']:<14} {'service':<11} {'-':<9} {'-':<20} {'-':<10} {st}")
wd = cfg["session_prefix"] + "watchdog"
print(f" {'watchdog':<14} {'service':<11} {'-':<9} {'-':<20} {'-':<10} "
f"{'RUNNING' if session_alive(wd) else 'stopped'}")
def cmd_phase(cfg, args):
ps = phases(cfg)
if not ps:
print("no [loop].phases configured"); return
if not args or args[0] == "show":
idx = cur_idx(cfg)
print(f"phase {ps[idx]['id']} [{idx+1}/{len(ps)}] seq: {' '.join(p['id'] for p in ps)}")
return
if args[0] == "next":
Path(phase_idx_file(cfg)).write_text(str(min(cur_idx(cfg)+1, len(ps)-1)))
elif args[0] == "set" and len(args) > 1:
Path(phase_idx_file(cfg)).write_text(str(int(args[1])))
print(f"phase idx now {cur_idx(cfg)} ({cur_phase(cfg).get('id')})")
def cmd_tokens(cfg):
"""Pretty-print <log_dir>/token-log.jsonl: per-phase tokens by agent + total + duration."""
p = _token_log_path(cfg)
if not p.exists():
print(f"no token log at {p}\n(set [watchdog].log_tokens = true and run the loop)"); return
recs = []
for line in p.read_text().splitlines():
try: recs.append(json.loads(line))
except Exception: pass
if not recs:
print("token log is empty"); return
names = []
for r in recs:
for n in r.get("agents", {}):
if n not in names: names.append(n)
w = max([7] + [len(n) for n in names])
hdr = f"{'phase':<10} {'dur(s)':>8} " + " ".join(f"{n:>{w}}" for n in names) + f" {'TOTAL':>13}"
print(hdr); print("-" * len(hdr))
grand = {n: 0 for n in names}
durtot = 0.0
for r in recs:
ag = r.get("agents", {})
cells = " ".join(f"{ag.get(n,{}).get('total',0):>{w},}" for n in names)
print(f"{str(r.get('phase_id')):<10} {str(r.get('duration_s')):>8} {cells} "
f"{r.get('total',{}).get('total',0):>13,}")
for n in names: grand[n] += ag.get(n,{}).get("total",0)
durtot += r.get("duration_s") or 0
print("-" * len(hdr))
cells = " ".join(f"{grand[n]:>{w},}" for n in names)
print(f"{'TOTAL':<10} {durtot:>8.0f} {cells} {sum(grand.values()):>13,}")
def cmd_selftest():
"""Self-contained regression checks for the footer-UI activity detector. Needs no config."""
backend = {
"active_re": "esc interrupt|thinking|inferring|running tool|preparing patch|reading|searching",
"footer_ui": True, "log_grace": 180,
}
cfg = {"backends": {"tui": backend}, "log_dir": "/tmp"}
a = {"name": "x", "backend": "tui", "session": "selftest-x", "kind": "loop"}
idle = "\n ▣ Build · GPT-5.4 · 2m 19s\n 178.4K (17%) ctrl+p commands\n"
active = "\n ~ Preparing patch...\n ⬝⬝⬝■■ esc interrupt 137.6K\n"
checks = [
("footer_ui idle footer is idle", not pane_active(cfg, a, idle, use_log=False)),
("footer_ui active footer is active", pane_active(cfg, a, active, use_log=False)),
("limit banner + idle footer is not active",
not pane_active(cfg, a, idle + "\nYou've hit your weekly limit · resets Jun 16, 10pm\n", use_log=False)),
]
bad = [n for n, ok in checks if not ok]
for n, ok in checks:
print(f" {'PASS' if ok else 'FAIL'}: {n}")
sys.exit(1 if bad else 0)
INIT_TOML = """\
# Starter agent-orchestrator config. See the README for the full schema.
[defaults]
session_prefix = "{prefix}-"
log_dir = ".ao-state"
backend = "claude"
model = "claude-sonnet-4-6"
watch = "heal"
[backend.claude]
bin = "claude"
flags = "--dangerously-skip-permissions"
prompt_delivery = "arg"
process_name = "claude"
submit_key = "Enter"
stall_idle = 300
active_re = "esc to interrupt|Running tool"
limit_re = "usage limit|limit reached|reached your .*limit"
[[agent]]
name = "worker"
kind = "persistent"
prompt = "You are a worker agent. Wait for instructions."
"""
def cmd_init(args):
target = Path(args[0]) if args else Path.cwd()
target.mkdir(parents=True, exist_ok=True)
cfg_file = target / "agents.toml"
if cfg_file.exists():
die(f"{cfg_file} already exists — refusing to overwrite")
cfg_file.write_text(INIT_TOML.format(prefix=target.resolve().name or "proj"))
(target / "prompts").mkdir(exist_ok=True)
log(f"scaffolded {cfg_file} and {target/'prompts'}/ — edit, then `agents.py up --config {cfg_file}`")
# ── main ──────────────────────────────────────────────────────────────────────
def main():
argv = sys.argv[1:]
if not argv or argv[0] in ("-h", "--help", "help"):
print(__doc__); return
cfg_path = _cfg_path(argv)
argv = [a for i, a in enumerate(argv)
if a != "--config" and (i == 0 or argv[i-1] != "--config")]
cmd = argv[0] if argv else "status"
rest = argv[1:]
if cmd == "selftest": cmd_selftest(); return
if cmd == "init": cmd_init(rest); return
cfg = load_config(cfg_path)
if cmd == "up": cmd_up(cfg, cfg_path, rest)
elif cmd == "down": cmd_down(cfg, rest)
elif cmd == "status": cmd_status(cfg)
elif cmd == "watchdog": watchdog_loop(cfg_path)
elif cmd == "phase": cmd_phase(cfg, rest)
elif cmd == "tokens": cmd_tokens(cfg)
elif cmd == "logs":
if not rest:
die("usage: agents.py logs <name>")
sess = cfg["agents"].get(rest[0], {}).get("session") or (cfg["session_prefix"] + rest[0])
os.execvp("tail", ["tail", "-f", str(_session_log_path(cfg, sess))])
else:
print(__doc__)
if __name__ == "__main__":
main()