Skip to content
Merged
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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
66 changes: 66 additions & 0 deletions SUPERVISION.md
Original file line number Diff line number Diff line change
@@ -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.
191 changes: 179 additions & 12 deletions scripts/round.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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
)
Expand All @@ -156,6 +161,7 @@ def assign(repo, root, manifest, args):
"packet_sha256": hashlib.sha256(packet.read_bytes()).hexdigest(),
"state": "assigned",
"results": [],
**fields,
}


Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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


Expand All @@ -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":
Expand All @@ -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
Expand All @@ -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())
Loading
Loading