diff --git a/AGENTS.md b/AGENTS.md index 5755ccc..7136380 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,3 +14,7 @@ Read README.md and SPEC.md. Keep one maintained version; expanded features remai For adopting projects, merge templates/AGENTS.md into their own instructions. This contributor file is not a replacement for an application's operating contract. + +On the `autonomous-reconcile-loop` branch: supervision code, records and docs live here, not on main. +Fix generic version 1 ledger bugs on main first, then merge main into this branch (merge commit; no +rebase or squash). Keep edits to files shared with main to small, appended hook points. diff --git a/SPEC.md b/SPEC.md index d1b6071..c250752 100644 --- a/SPEC.md +++ b/SPEC.md @@ -98,3 +98,6 @@ repositories; they do not prove a live runner's recovery or external branch-prot Config uses TOML for project paths and verification argv. The CLI itself does not execute or require that config: the phase procedures use it for engineering decisions and checks. This keeps scaffolding independent of a particular test runner, hosting provider or agent vendor. + +The `autonomous-reconcile-loop` branch adds opt-in supervised (version 2) rounds; see +[SUPERVISION.md](SUPERVISION.md). diff --git a/SUPERVISION.md b/SUPERVISION.md new file mode 100644 index 0000000..0a24732 --- /dev/null +++ b/SUPERVISION.md @@ -0,0 +1,66 @@ +# Supervised rounds + +This branch, `autonomous-reconcile-loop`, extends [the protocol](SPEC.md) so a deterministic +supervisor can drive rounds unattended. Main remains the lean manual workflow; this branch is a +superset of it and receives main by merge. Everything here is additive: a round created without +`--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. + +## Authority + +- Autonomy never grants authority. Merge, reconciliation and new-round permissions come only from + committed project policy; a supervisor setting can lower them, never raise them. +- Agents never write the ledger, push, open PRs, publish verdicts or merge. Supervisor code does, after + verifying each claim (head SHA, worktree, PR state, required checks) itself. +- Unknown is never success: an unavailable API, a missing check or a timed-out process stops or holds + the round; it is not treated as a pass. + +## Version 2 ledger + +`round.py init ROUND --base REF --supervised` writes `"version": 2` with empty `sessions` and `halts` +records. Lean main's `round.py` refuses version 2 rounds, because it does not enforce their invariants. +Version 2 rounds keep the five assignment states; the additions are fields and append-only records. + +### Ownership + +`assign` on a supervised round requires one or more `--owns GLOB` relative path patterns and accepts +`--resource NAME` (repeatable) and `--kind work|reconcile`. Owned paths, resources and kind are written +into the packet, so the packet digest binds them. Patterns must be normalized, with no leading or +trailing `/` and no empty, `.` or `..` segments, so one path has one spelling. Two live work +assignments may not share a resource or have owned patterns whose literal prefixes (text before the +first `*`, `?` or `[`) overlap; the check is deliberately conservative. + +A `reconcile` assignment needs `--start REF` (recorded as `Start-SHA`), may be created only after every +work assignment is merged or cancelled, and ends the round's work: no further assignments are accepted +while it is not cancelled. + +### Reviews + +```sh +python3 scripts/round.py review ROUND A1 --head FULL_HEAD --verdict ready|needs-fix|blocked \ + --evidence REFERENCE --reviewer NAME +``` + +A review is recorded against the latest result and its exact head. A newer result supersedes it. On a +supervised round, `resolve --outcome merged` requires the latest review of the latest result to be +`ready` for the supplied `--head`. + +### Closure + +Supervised `close` requires `--delta changed|none`, the round's actual behavior change, recorded as +`behavior_delta`. Repeated `none` rounds are the supervisor's stall signal. + +### Concurrency + +Every command except `init`, including `status`, takes an exclusive lock on +`.agent-runs/ROUND/round.lock` for its duration, in version 1 and 2 rounds alike, so an operator's +command and the supervisor cannot lose each other's updates. This is a single-host lock, not +distributed coordination. + +## Planned + +Sessions and halts (`session start|ack|end`, `halt`, `clear-halt`), the supervisor +(`scripts/supervise.py`), role contracts, merge policy and reconciliation automation follow in later +changes on this branch. diff --git a/scripts/round.py b/scripts/round.py index 5555169..8c52ff9 100644 --- a/scripts/round.py +++ b/scripts/round.py @@ -1,11 +1,13 @@ """Small local round ledger. Run writes serially from one coordinator checkout.""" import argparse +import fcntl import hashlib import json import re import subprocess import sys +from datetime import datetime, timezone from pathlib import Path from urllib.parse import urlsplit @@ -108,16 +110,16 @@ def initialize(repo, root, args): read(other).get("state") == "closed", f"Unreconciled round: {other.parent.name}", ) - save( - root / "round.json", - { - "version": 1, - "id": args.round, - "base_sha": base, - "state": "open", - "assignments": {}, - }, - ) + manifest = { + "version": 1, + "id": args.round, + "base_sha": base, + "state": "open", + "assignments": {}, + } + if args.supervised: + manifest.update(version=2, sessions={}, halts=[]) + save(root / "round.json", manifest) def assign(repo, root, manifest, args): @@ -141,10 +143,13 @@ def assign(repo, root, manifest, args): require( not packet.exists(), "Packet already exists; inspect interrupted assignment" ) + supervision, fields = supervised_assignment(repo, manifest, args) packet.parent.mkdir(parents=True, exist_ok=True) content = ( f"# Assignment {manifest['id']}/{args.assignment}\n\n" - f"Base-SHA: {manifest['base_sha']}\nItem: {args.item}\nBranch: {args.branch}\n\n" + f"Base-SHA: {manifest['base_sha']}\nItem: {args.item}\nBranch: {args.branch}\n" + + supervision + + "\n" "Use an isolated checkout of this base. Return revision-bound evidence; do not merge.\n\n" + spec ) @@ -156,6 +161,7 @@ def assign(repo, root, manifest, args): "packet_sha256": hashlib.sha256(packet.read_bytes()).hexdigest(), "state": "assigned", "results": [], + **fields, } @@ -192,6 +198,8 @@ def resolve(repo, root, manifest, args): args.head == result["head_sha"], "Review does not match the current result head", ) + if supervised(manifest): + require_ready_review(assignment, args.head) require( all(c["status"] == "passed" for c in result["checks"]), "Not all checks passed", @@ -242,6 +250,7 @@ def close(repo, root, manifest, args): "Handoff is too long", ) require(text(args.evidence), "Closure needs final verification evidence") + supervised_close(manifest, args) manifest.update( state="closed", final_main_sha=final, @@ -282,6 +291,7 @@ def parser(): ) for field in ("main", "handoff", "evidence"): sub.add_argument(f"--{field}", required=True) + add_supervised_arguments(actions) return command @@ -297,9 +307,10 @@ def main(): if args.action == "init": initialize(repo, root, args) else: + hold_lock(root) manifest = read(root / "round.json") require( - manifest["version"] == 1 and manifest["id"] == args.round, + manifest["version"] in VERSIONS and manifest["id"] == args.round, "Unsupported or mismatched round record", ) if args.action == "status": @@ -314,6 +325,8 @@ def main(): resolve(repo, root, manifest, args) elif args.action == "close": close(repo, root, manifest, args) + elif args.action == "review": + review(root, manifest, args) save(root / "round.json", manifest) print(f"{args.round}: {args.action} recorded") return 0 @@ -329,5 +342,159 @@ def main(): return 1 +# Supervised (version 2) rounds. These records let an unattended supervisor drive a round without +# interpreting prose; see SUPERVISION.md. Version 1 rounds behave exactly as on main. + +VERSIONS = {1, 2} +LIVE = {"assigned", "blocked", "result-ready"} +TERMINAL = {"merged", "cancelled"} +_LOCK = [] + + +def hold_lock(root): + """Serialize ledger writers on this host until the process exits.""" + if root.is_dir(): + handle = (root / "round.lock").open("a") + fcntl.flock(handle, fcntl.LOCK_EX) + _LOCK.append(handle) + + +def supervised(manifest): + return manifest["version"] == 2 + + +def utc_now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def static_prefix(pattern): + return re.split(r"[*?\[]", pattern, maxsplit=1)[0] + + +def normalized_path(pattern): + """Relative and canonical, so equal paths compare equal: no empty, `.` or `..` segments.""" + return text(pattern) and all(s not in {"", ".", ".."} for s in pattern.split("/")) + + +def supervised_assignment(repo, manifest, args): + """Validate ownership options; return extra packet lines and manifest fields.""" + if not supervised(manifest): + require( + not (args.owns or args.resource or args.kind or args.start), + "Ownership options need a supervised round", + ) + return "", {} + kind = args.kind or "work" + owns = args.owns or [] + resources = args.resource or [] + require( + owns and all(normalized_path(p) for p in owns), + "Supervised assignment needs normalized relative --owns paths", + ) + require(all(text(r) for r in resources), "Resource names must not be empty") + others = manifest["assignments"].values() + require( + not any( + a["kind"] == "reconcile" and a["state"] != "cancelled" for a in others + ), + "Round is already reconciling", + ) + lines = f"Kind: {kind}\n" + start = None + if kind == "reconcile": + require( + all(a["state"] in TERMINAL for a in others), + "Work assignments must be terminal before reconciliation", + ) + require(text(args.start), "Reconcile assignment needs --start") + start = commit(repo, args.start) + lines += f"Start-SHA: {start}\n" + else: + require(args.start is None, "--start is only for reconcile assignments") + mine = [static_prefix(p) for p in owns] + for other in (a for a in others if a["state"] in LIVE): + for theirs in map(static_prefix, other["owned_paths"]): + require( + not any(p.startswith(theirs) or theirs.startswith(p) for p in mine), + "Owned paths overlap a live assignment", + ) + require( + not set(resources) & set(other["resources"]), + "Resource is held by a live assignment", + ) + lines += "".join(f"Owned-Path: {p}\n" for p in owns) + lines += "".join(f"Resource: {r}\n" for r in resources) + fields = { + "kind": kind, + "owned_paths": owns, + "resources": resources, + "start_sha": start, + "reviews": [], + } + return lines, fields + + +def latest_review(assignment): + reviews = [r for r in assignment["reviews"] if r["result"] == assignment["results"][-1]] + return reviews[-1] if reviews else None + + +def require_ready_review(assignment, head): + current = latest_review(assignment) + require( + current and current["verdict"] == "ready" and current["head_sha"] == head, + "Supervised merge needs a ready review of the latest result head", + ) + + +def review(root, manifest, args): + require(supervised(manifest), "Reviews need a supervised round") + assignment = manifest["assignments"][args.assignment] + require(assignment["state"] == "result-ready", "No candidate result to review") + latest = assignment["results"][-1] + result = read(inside(root, latest)) + require(args.head == result["head_sha"], "Review does not match the latest result head") + require(text(args.evidence), "Review needs an evidence reference") + require(text(args.reviewer), "Review needs a reviewer") + assignment["reviews"].append( + { + "result": latest, + "head_sha": args.head, + "verdict": args.verdict, + "evidence": args.evidence, + "reviewer": args.reviewer, + "session": None, + "utc": utc_now(), + } + ) + + +def supervised_close(manifest, args): + if not supervised(manifest): + require(args.delta is None, "--delta needs a supervised round") + return + require(args.delta is not None, "Supervised closure needs --delta changed|none") + manifest["behavior_delta"] = args.delta + + +def add_supervised_arguments(actions): + actions.choices["init"].add_argument("--supervised", action="store_true") + assign_parser = actions.choices["assign"] + assign_parser.add_argument("--owns", action="append", help="Owned path glob") + assign_parser.add_argument("--resource", action="append") + assign_parser.add_argument("--kind", choices=["work", "reconcile"]) + assign_parser.add_argument("--start", help="Reconcile start revision") + actions.choices["close"].add_argument("--delta", choices=["changed", "none"]) + sub = actions.add_parser("review") + sub.add_argument("round") + sub.add_argument("assignment") + sub.add_argument("--head", required=True) + sub.add_argument( + "--verdict", choices=["ready", "needs-fix", "blocked"], required=True + ) + sub.add_argument("--evidence", required=True) + sub.add_argument("--reviewer", required=True) + + if __name__ == "__main__": sys.exit(main()) diff --git a/tests/test_round_v2.py b/tests/test_round_v2.py new file mode 100644 index 0000000..34da1be --- /dev/null +++ b/tests/test_round_v2.py @@ -0,0 +1,285 @@ +"""Behavior checks for supervised (version 2) rounds on the autonomous branch.""" + +import fcntl +import json +import subprocess +import sys +import tempfile +import time +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "round.py" + + +class SupervisedRoundTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.repo = Path(self.tmp.name) + self.git("init", "-q", "-b", "main") + self.git("config", "user.name", "Test") + self.git("config", "user.email", "test@example.invalid") + (self.repo / ".gitignore").write_text(".agent-runs/\nHANDOFF.md\n") + (self.repo / "README.md").write_text("Example\n") + self.git("add", ".") + self.git("commit", "-qm", "base") + self.base = self.git("rev-parse", "HEAD") + self.spec = self.repo / ".agent-runs" / "input.md" + self.spec.parent.mkdir() + self.spec.write_text("Document the approved behavior.\n") + + def git(self, *args): + return subprocess.check_output( + ["git", "-C", str(self.repo), *args], text=True + ).strip() + + def cli(self, *args, ok=True): + result = subprocess.run( + [sys.executable, str(SCRIPT), "--repo", str(self.repo), *args], + text=True, + capture_output=True, + check=False, + ) + if ok: + self.assertEqual(result.returncode, 0, result.stderr) + else: + self.assertNotEqual(result.returncode, 0, result.stdout) + return result + + def manifest(self): + return json.loads((self.repo / ".agent-runs/R1/round.json").read_text()) + + def assign(self, assignment="A1", *extra, ok=True, item=None, branch=None): + return self.cli( + "assign", + "R1", + assignment, + "--item", + item or f"TASK-{assignment}", + "--branch", + branch or f"task/{assignment.lower()}", + "--spec", + str(self.spec), + *extra, + ok=ok, + ) + + def start(self): + self.cli("init", "R1", "--base", "main", "--supervised") + self.assign("A1", "--owns", "docs/**") + + def submit(self, assignment="A1", head=None): + data = { + "assignment": assignment, + "base_sha": self.base, + "packet_sha256": self.manifest()["assignments"][assignment]["packet_sha256"], + "head_sha": head or self.base, + "pr": "https://example.invalid/pull/1", + "checks": [ + {"command": ["git", "diff", "--check"], "status": "passed", "evidence": "ok"} + ], + } + path = self.repo / ".agent-runs" / "result.json" + path.write_text(json.dumps(data)) + self.cli("record", "R1", "--result", str(path)) + + def review(self, verdict, head=None, ok=True): + return self.cli( + "review", + "R1", + "A1", + "--head", + head or self.base, + "--verdict", + verdict, + "--evidence", + "https://example.invalid/pull/1#verdict", + "--reviewer", + "operator", + ok=ok, + ) + + def merge(self, ok=True): + return self.cli( + "resolve", + "R1", + "A1", + "--outcome", + "merged", + "--head", + self.base, + "--merge", + self.base, + "--evidence", + "Reviewed and merged", + ok=ok, + ) + + def handoff(self): + (self.repo / "HANDOFF.md").write_text( + "# Handoff\nGenerated-UTC: 2026-10-10T10:00:00Z\n" + f"Main-SHA: {self.base}\n\n" + "## Round delta\nTASK-A1 reconciled.\n\n" + "## Open work\nNone.\n\n" + "## Next action\nPlan the next round.\n" + ) + + def close(self, *extra, ok=True): + self.handoff() + return self.cli( + "close", + "R1", + "--main", + "main", + "--reconciliation", + "README.md", + "--handoff", + "HANDOFF.md", + "--evidence", + "Final checks passed", + *extra, + ok=ok, + ) + + def test_default_init_stays_version_one_and_refuses_supervised_options(self): + self.cli("init", "R1", "--base", "main") + self.assertEqual( + set(self.manifest()), {"version", "id", "base_sha", "state", "assignments"} + ) + self.assertEqual(self.manifest()["version"], 1) + self.assign("A1", "--owns", "docs/**", ok=False) + self.assign("A1") + packet = (self.repo / ".agent-runs/R1/prompts/A1.md").read_text() + self.assertIn("Branch: task/a1\n\nUse an isolated checkout", packet) + self.cli( + "review", "R1", "A1", "--head", self.base, "--verdict", "ready", + "--evidence", "x", "--reviewer", "operator", ok=False, + ) + + def test_supervised_init_writes_version_two_records(self): + self.cli("init", "R1", "--base", "main", "--supervised") + manifest = self.manifest() + self.assertEqual(manifest["version"], 2) + self.assertEqual(manifest["sessions"], {}) + self.assertEqual(manifest["halts"], []) + self.cli("status", "R1") + + def test_supervised_assignment_binds_ownership_into_packet(self): + self.cli("init", "R1", "--base", "main", "--supervised") + self.assign("A1", ok=False) + self.assign("A1", "--owns", "/etc/**", ok=False) + self.assign( + "A1", "--owns", "src/x/**", "--owns", "tests/test_x.py", "--resource", "db" + ) + packet = (self.repo / ".agent-runs/R1/prompts/A1.md").read_text() + self.assertIn("Kind: work\n", packet) + self.assertIn("Owned-Path: src/x/**\nOwned-Path: tests/test_x.py\n", packet) + self.assertIn("Resource: db\n", packet) + record = self.manifest()["assignments"]["A1"] + self.assertEqual(record["kind"], "work") + self.assertEqual(record["owned_paths"], ["src/x/**", "tests/test_x.py"]) + self.assertEqual(record["resources"], ["db"]) + self.assertEqual(record["reviews"], []) + + def test_live_assignments_cannot_share_paths_or_resources(self): + self.cli("init", "R1", "--base", "main", "--supervised") + self.assign("A1", "--owns", "src/x/**", "--resource", "db") + self.assign("A2", "--owns", "src/x/y.py", ok=False) + self.assign("A2", "--owns", "src/**", ok=False) + self.assign("A2", "--owns", "**", ok=False) + self.assign("A2", "--owns", "docs/**", "--resource", "db", ok=False) + self.assign("A2", "--owns", "docs/**", "--resource", "gpu") + self.cli("resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped") + self.assign("A3", "--owns", "src/x/**", "--resource", "db") + + def test_owned_paths_must_be_normalized_relative_paths(self): + self.cli("init", "R1", "--base", "main", "--supervised") + self.assign("A1", "--owns", "src/a") + for pattern in ("./src/a", "src//a", "src/x/../a", "src/a/", "../outside", "src/./a", ""): + with self.subTest(pattern=pattern): + rejected = self.assign("A2", "--owns", pattern, ok=False) + self.assertIn("--owns", rejected.stderr) + self.assertEqual(list(self.manifest()["assignments"]), ["A1"]) + + def test_reconcile_assignment_follows_terminal_work(self): + self.start() + self.assign("R", "--kind", "reconcile", "--owns", "backlog/**", + "--start", "main", ok=False) + self.cli("resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped") + self.assign("R", "--kind", "reconcile", "--owns", "backlog/**", ok=False) + self.assign("A2", "--owns", "src/**", "--start", "main", ok=False) + self.assign("R", "--kind", "reconcile", "--owns", "backlog/**", "--start", "main") + packet = (self.repo / ".agent-runs/R1/prompts/R.md").read_text() + self.assertIn(f"Kind: reconcile\nStart-SHA: {self.base}\n", packet) + self.assertEqual(self.manifest()["assignments"]["R"]["start_sha"], self.base) + self.assign("R2", "--kind", "reconcile", "--owns", "backlog/**", + "--start", "main", ok=False) + self.assign("A3", "--owns", "src/**", ok=False) + + def test_review_is_bound_to_the_latest_result_head(self): + self.start() + self.review("ready", ok=False) + self.submit() + self.review("ready", head="0" * 40, ok=False) + self.review("approved", ok=False) + self.review("needs-fix") + reviews = self.manifest()["assignments"]["A1"]["reviews"] + self.assertEqual(len(reviews), 1) + self.assertEqual(reviews[0]["result"], "results/A1-1.json") + self.assertEqual(reviews[0]["head_sha"], self.base) + self.assertEqual(reviews[0]["verdict"], "needs-fix") + self.assertEqual(reviews[0]["reviewer"], "operator") + self.assertRegex(reviews[0]["utc"], r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$") + + def test_supervised_merge_needs_a_ready_review_of_the_latest_result(self): + self.start() + self.submit() + self.merge(ok=False) + self.review("needs-fix") + self.merge(ok=False) + self.review("ready") + self.submit() + self.merge(ok=False) + self.review("ready") + self.merge() + self.assertEqual(self.manifest()["assignments"]["A1"]["state"], "merged") + + def test_supervised_close_records_behavior_delta(self): + self.start() + self.cli("resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped") + self.close(ok=False) + self.close("--delta", "sometimes", ok=False) + self.close("--delta", "none") + self.assertEqual(self.manifest()["behavior_delta"], "none") + self.assertEqual(self.manifest()["state"], "closed") + + def test_version_one_close_refuses_behavior_delta(self): + self.cli("init", "R1", "--base", "main") + self.assign("A1") + self.cli("resolve", "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped") + self.close("--delta", "none", ok=False) + self.close() + + def test_ledger_writes_wait_for_the_round_lock(self): + self.start() + with open(self.repo / ".agent-runs/R1/round.lock", "a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + writer = subprocess.Popen( + [ + sys.executable, str(SCRIPT), "--repo", str(self.repo), "resolve", + "R1", "A1", "--outcome", "cancelled", "--evidence", "Stopped", + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + time.sleep(0.5) + self.assertIsNone(writer.poll()) + self.assertEqual(self.manifest()["assignments"]["A1"]["state"], "assigned") + writer.communicate(timeout=10) + self.assertEqual(writer.returncode, 0) + self.assertEqual(self.manifest()["assignments"]["A1"]["state"], "cancelled") + + +if __name__ == "__main__": + unittest.main()