Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .agent/supervisor.example.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Copy to .agent/supervisor.toml and commit it. The supervisor reads this file from the remote
# default branch, never from the working tree; see SUPERVISION.md. An ignored
# .agent/supervisor.local.toml may override [runner] and [budgets] keys only.

[supervisor]
# 1: plan, execute and review automatically; a person merges and reconciles.
# 2: also merge when [policy.merge] grants it. 3: also reconcile and start the next round.
autonomy = 1
remote = "origin"
# Agent worktrees live outside the repository. Empty means ../<repository>-worktrees.
worktree_root = ""
max_parallel = 1
max_assignments = 1
poll_seconds = 60
launch_grace_seconds = 120

[budgets]
round_usd = 10.0
sessions_per_round = 8
max_rounds_per_run = 1
ci_timeout_minutes = 60

[stop]
# Park after this many consecutive closed rounds with no behavior change.
stall_rounds = 2
max_repairs = 2

[notify]
# argv run when the supervisor's outcome changes; empty disables notification.
command = []

[policy.merge]
auto_merge = false
method = "squash"
# Check names that must succeed on the exact reviewed head before an automatic merge.
required_checks = []
protected_paths = [".github/**", ".agent/**", "AGENTS.md", "SPEC.md", "SUPERVISION.md"]
max_changed_lines = 400
publish_reviews = true

[policy.reconcile]
auto_reconcile = false
allow_reconcile_merge = false
# Tracked project records a reconcile assignment may change.
record_paths = []

[runner]
default = "claude"
# Credentials removed from every agent environment; the supervisor publishes, agents do not.
env_drop = ["GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN", "SSH_AUTH_SOCK"]

[runner.commands.claude]
# Placeholders are substituted per argv element; no shell is involved.
argv = [
"claude", "-p", "--output-format", "json", "--no-session-persistence",
"--session-id", "{session_uuid}", "--model", "{model}",
"--permission-mode", "{permission_mode}", "{prompt}",
]
# Envelope field names are confirmed against a recorded run before the runner is enabled.
output = { result = "result", is_error = "is_error", cost_usd = "total_cost_usd", session_id = "session_id" }

[runner.roles.planner]
model = "opus"
budget_usd = 3.0
timeout_minutes = 30
permission_mode = "default"

[runner.roles.executor]
model = "opus"
budget_usd = 5.0
timeout_minutes = 60
permission_mode = "acceptEdits"

[runner.roles.reviewer]
# Prefer a different model from the executor to reduce correlated review.
model = "sonnet"
budget_usd = 2.0
timeout_minutes = 30
permission_mode = "default"

[runner.roles.reconciler]
model = "opus"
budget_usd = 3.0
timeout_minutes = 30
permission_mode = "acceptEdits"
72 changes: 69 additions & 3 deletions SUPERVISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ superset of it and receives main by merge. Everything here is additive: a round
`--supervised` is a version 1 round with the same records and rules as on main; it also takes the
round lock described under Concurrency.

> Status: draft. The ledger records below exist; the supervisor that uses them is not built yet.
> Status: draft. The ledger records below exist. The supervisor observes and decides but performs
> no effects yet: it cannot launch agents, push, publish or merge.

## Authority

Expand Down Expand Up @@ -96,7 +97,72 @@ planner reporting no executable work or an owed decision. The inventory lists li
worktrees and resources at that moment. While any halt is uncleared, no session may start and the
round cannot close. Only an operator clears a halt, with evidence of what was inspected or resolved.

## Supervisor (read-only preview)

`scripts/supervise.py` is a stateless loop manager. Each tick it observes the ledger (through
`round.py status`), Git, PR and check state (through `gh`; any failure is Unknown) and session
processes, then a pure `decide()` picks exactly one next action. In this version it only reports:

```sh
python3 scripts/supervise.py next # one decision as JSON; writes nothing
python3 scripts/supervise.py status # round, assignments, sessions, halts, decision
python3 scripts/supervise.py run --dry-run # log decisions; poll while waiting
```

Add `--new-round` to let a level 1 or 2 supervisor propose one new round. `--autonomy N` can only
lower the level. Exit codes: 0 action, wait or routine park; 10 needs attention (halt, hold, stop,
stall or budget); 11 transient or unknown observation; 2 configuration; 3 another instance holds
`.agent-runs/supervisor/lock`. `run` appends each decision to `.agent-runs/supervisor/log.jsonl`
and keeps the latest in `last.json`; it stops at the first decision that needs an effect.

### Configuration

Copy [the example](.agent/supervisor.example.toml) to `.agent/supervisor.toml` and commit it. The
supervisor reads it from the remote default branch (`git show REMOTE/BRANCH:...`), never from the
working tree, so an agent cannot raise its own authority by editing it. An ignored
`.agent/supervisor.local.toml` may override `[runner]` and `[budgets]` keys only. Unknown keys fail.
Level 2 needs `policy.merge.auto_merge` and non-empty `required_checks`; level 3 also needs
`policy.reconcile.auto_reconcile` and `allow_reconcile_merge`. A missing grant exits 2 naming it.

### Decision rules

First match wins: more than one open round stops; a version 1 round is parked as manual; live
sessions are contained even while halted (kill past deadline, mark lost, acknowledge); an uncleared
halt parks; a dirty coordinator checkout or one with commits not on the remote default stops; an
unfetched remote head asks for a fetch; exited sessions are imported and in-flight merges verified.
Without an open round the supervisor parks on stall (`stop.stall_rounds` closed rounds with delta
`none`), run limit or autonomy below 3 unless `--new-round`, else proposes the next round at the
remote default head. A round without assignments launches one planner. Each idle assignment then
yields at most one candidate, chosen as Merge > LaunchReviewer > LaunchExecutor > Cleanup within
`max_parallel` and the round budget; a hold on one assignment never blocks another:

| Assignment | Decision |
| --- | --- |
| `assigned` | launch executor |
| `blocked` result | hold |
| result, PR missing, closed, merged externally or head moved | hold |
| result, no review of its head | launch reviewer |
| `needs-fix` | launch repair executor, or hold after `stop.max_repairs` |
| review `blocked` | hold |
| `ready`, level 1 | park awaiting a person's merge |
| `ready`, level 2+ | merge gate: merge, wait for checks, or hold |
| `merged`/`cancelled` with a worktree | clean up, or hold if the worktree is dirty |

When every assignment is terminal, levels 1 and 2 park awaiting reconciliation; level 3 creates a
reconcile assignment from the remote head, runs the final gate after it merges and closes the round.

### Merge gate

All must hold: the latest review is `ready` for the latest result head; the PR is open, not a
draft, at that head, from the assigned branch into the default branch, `MERGEABLE` and `CLEAN` or
`HAS_HOOKS`; every `required_checks` name succeeded on that exact SHA (newest run wins; missing,
skipped, neutral and cancelled fail; pending past `ci_timeout_minutes` after the review holds); every
changed path is inside the owned globs and outside `protected_paths`; the change is within
`max_changed_lines` and has no binary files; a reconcile assignment also needs
`allow_reconcile_merge`. A branch behind or conflicting with the default branch holds; the supervisor
never updates branches or bypasses protection.

## Planned

The supervisor (`scripts/supervise.py`), role contracts, merge policy and reconciliation automation
follow in later changes on this branch.
Launching agents, importing their output, publishing reviews, merging and reconciliation follow in
later changes on this branch.
163 changes: 163 additions & 0 deletions scripts/supervise.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""Deterministic Reconcile-loop supervisor (read-only preview). See SUPERVISION.md.

Each tick observes the ledger, Git, PRs/checks and session processes, then decides one next action.
This version performs no effects: `next` prints the decision, `status` explains it and
`run --dry-run` logs decisions while waiting. Exit codes: 0 action/wait/routine park,
10 needs attention, 11 transient or unknown observation, 2 configuration, 3 another instance runs.
"""

import argparse
import fcntl
import json
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from supervisor import config, observe # noqa: E402
from supervisor.decide import decide # noqa: E402

STATE = ".agent-runs/supervisor"
PASSIVE = {"Wait"}


def utc_now():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


def default_branch(repo, remote):
done = subprocess.run(
["git", "-C", str(repo), "symbolic-ref", "--short", f"refs/remotes/{remote}/HEAD"],
text=True, capture_output=True, check=False,
)
prefix = f"{remote}/"
name = done.stdout.strip()
return name[len(prefix):] if done.returncode == 0 and name.startswith(prefix) else "main"


def tick(repo, args, settings, autonomy, rounds_this_run=0):
value = observe.snapshot(
repo, settings, autonomy, args.branch,
new_round=args.new_round, rounds_this_run=rounds_this_run,
)
return value, decide(value)


def describe(value, chosen):
lines = [f"Autonomy: L{value['autonomy']} default: {value['default_branch']}"
f" @ {(value['remote']['default_sha'] or 'unknown')[:12]}"]
manifest = value["round"]
if manifest is None:
closed = ", ".join(f"{r['id']}({r['delta']})" for r in value["rounds"]["closed"]) or "none"
lines.append(f"Open round: none closed: {closed}")
else:
lines.append(f"Open round: {manifest['id']} v{manifest['version']} base {manifest['base_sha'][:12]}")
for aid, a in sorted(manifest["assignments"].items()):
reviews = a.get("reviews") or []
verdict = f" review={reviews[-1]['verdict']}" if reviews else ""
lines.append(f" {aid} {a['state']} {a['branch']} results={len(a['results'])}{verdict}")
for name, s in sorted(manifest.get("sessions", {}).items()):
lines.append(f" {name} {s['role']} {s['state']} {s['assignment'] or '-'}")
for h in manifest.get("halts", []):
state = "cleared" if h["cleared_utc"] else "UNCLEARED"
lines.append(f" {h['id']} {state} {h['code']}: {h['detail']}")
details = {k: v for k, v in chosen.items() if k not in {"kind", "exit"}}
lines.append(f"Next: {chosen['kind']} (exit {chosen['exit']}) {json.dumps(details, sort_keys=True)}")
return "\n".join(lines)


def hold_lock(repo):
state = repo / STATE
state.mkdir(parents=True, exist_ok=True)
handle = (state / "lock").open("a")
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return None
return handle


def record(repo, value, chosen, dry_run):
entry = {"utc": utc_now(), "dry_run": dry_run, "round": (value["round"] or {}).get("id"),
"action": chosen}
state = repo / STATE
with (state / "log.jsonl").open("a") as log:
log.write(json.dumps(entry, sort_keys=True) + "\n")
temporary = state / "last.json.tmp"
temporary.write_text(json.dumps(entry, indent=2, sort_keys=True) + "\n")
temporary.replace(state / "last.json")


def run(repo, args, settings, autonomy):
if not args.dry_run:
print("Effects are not available in this version; use run --dry-run", file=sys.stderr)
return 2
ignored = subprocess.run(
["git", "-C", str(repo), "check-ignore", "-q", f"{STATE}/lock"], check=False
)
if ignored.returncode:
print("Add .agent-runs/ to .gitignore before running the supervisor", file=sys.stderr)
return 2
lock = hold_lock(repo)
if lock is None:
print("Another supervisor instance holds the lock", file=sys.stderr)
return 3
while True:
value, chosen = tick(repo, args, settings, autonomy)
record(repo, value, chosen, dry_run=True)
print(json.dumps(chosen, sort_keys=True), flush=True)
if args.once or chosen["kind"] not in PASSIVE:
# A dry run cannot perform the chosen effect, so the state will not advance further.
return chosen["exit"]
time.sleep(settings["supervisor"]["poll_seconds"])


def parser():
command = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0])
command.add_argument("--repo", type=Path, default=Path.cwd())
command.add_argument("--remote", default="origin")
command.add_argument("--branch", help="Default branch (default: the remote's HEAD)")
command.add_argument("--autonomy", type=int, choices=[1, 2, 3], help="Lower the level")
actions = command.add_subparsers(dest="action", required=True)
for name in ("next", "status", "run"):
sub = actions.add_parser(name)
sub.add_argument("--new-round", action="store_true", help="Allow one new round below L3")
if name == "run":
sub.add_argument("--once", action="store_true")
sub.add_argument("--dry-run", action="store_true")
return command


def main(argv=None):
args = parser().parse_args(argv)
repo = args.repo.resolve()
args.branch = args.branch or default_branch(repo, args.remote)
try:
settings = config.load(repo, args.remote, args.branch)
if settings["supervisor"]["remote"] != args.remote:
raise config.ConfigError(
f"supervisor.remote is {settings['supervisor']['remote']!r}; pass --remote to match"
)
autonomy = config.effective_autonomy(settings, args.autonomy)
except config.ConfigError as error:
print(f"Configuration: {error}", file=sys.stderr)
return 2
try:
if args.action == "run":
return run(repo, args, settings, autonomy)
value, chosen = tick(repo, args, settings, autonomy)
except observe.ObservationError as error:
print(f"Cannot observe: {error}", file=sys.stderr)
return 10
if args.action == "next":
print(json.dumps(chosen, sort_keys=True))
else:
print(describe(value, chosen))
return chosen["exit"]


if __name__ == "__main__":
sys.exit(main())
Empty file added scripts/supervisor/__init__.py
Empty file.
Loading
Loading