From 3bbbebc0a9123e6050cf6eda0c11eb158d29259b Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 4 Nov 2026 10:25:39 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20autonomous=20orchestrators=20p4b=20?= =?UTF-8?q?=E2=80=94=20unattended=20glance=20close-out=20+=20stage-2=20row?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A glance worker under a brief ends at PR-open. Once gh pr checks is green, the recorded tier is glance, merges + in-flight close-outs are under merge-cap and the shift is not shadow, the watcher starts one independent close-out session (closeout_template.md, --model opus) that runs the witness itself, checks the label and Heart, and merges via /prm or refuses. A merge bumps glance_auto_merges and lands one stage-2 row in autonomy_log.md through a new table-append Mind writer op; every refusal is final and digested. AUTONOMY.md: Stage-2 adversary amendment — 2026-11-04 (same model allowed for a fresh independent session; human). Co-Authored-By: Claude Opus 5.5 --- AUTONOMY.md | 32 ++- agents/conductors/batch/AGENTS.md | 18 ++ agents/conductors/batch/_digest.py | 12 +- agents/conductors/batch/_mind_writer.py | 29 ++- agents/conductors/batch/_orchestrator.py | 163 +++++++++++- agents/conductors/batch/closeout_template.md | 37 +++ agents/conductors/batch/worker_template.md | 4 + skills/batch/batch.md | 4 +- tests/test_autonomy_doctrine.py | 40 +++ tests/test_orchestrator_closeout.py | 257 +++++++++++++++++++ tests/test_orchestrator_digest.py | 21 +- 11 files changed, 607 insertions(+), 10 deletions(-) create mode 100644 agents/conductors/batch/closeout_template.md create mode 100644 tests/test_orchestrator_closeout.py diff --git a/AUTONOMY.md b/AUTONOMY.md index bf05a20..c7ee7c9 100644 --- a/AUTONOMY.md +++ b/AUTONOMY.md @@ -574,6 +574,32 @@ the human did not recall typing that `/prm`. *Revert condition:* any `reverted` row demotes `glance` back to a human `/prm` immediately, by dated doctrine edit citing the row — stage 1 or stage 2 alike. +## Stage-2 adversary amendment — 2026-11-04 + +Amends "The `glance` Shadow-window ruling — 2026-11-04" above, the same day, +by the human, at the phase-4b plan of the autonomous-orchestrators epic. This +section is that dated doctrine edit. + +**The adversary leg of an unattended `glance` merge may be the same model as +the author** — Opus 5.5 for both, on cost: the alternatives were a more +expensive model (Fable) or a weaker one (Sonnet). What makes it the leg is +**independence, not the model**: a **fresh close-out session** the watcher +starts after CI lands, which did not write the PR, shares none of the worker's +context, **runs the `Witness:` itself** and reviews the diff adversarially +before it merges or refuses (`agents/conductors/batch/closeout_template.md`). +It applies to the first 10 rows of the stage-2 sub-window exactly as the ruling +says; only "different-model" is replaced. + +**Why the ruling asked for a different model, and what is given up.** Two +runs of one model share blind spots, so a subtle error the author made is more +likely to pass a same-model reader. Independence of context still catches what +the leg is mostly for — a skipped or overclaimed witness, an unread label, an +ignored Heart state. The stage-2 window is how this is checked. + +*Revert condition:* any stage-2 row `reverted`, or amended to +`merged-after-substantive-change`, restores the different-model requirement for +unattended merges at once, by dated doctrine edit citing the row. + ## Shift briefs and unattended `glance` — 2026-11-03 Approved by the human 2026-11-03 as the rulings of the autonomous-orchestrators @@ -617,7 +643,9 @@ CI lands**, only when every one of these holds: Every `/prm` guard applies to that close-out verbatim. These merges count in their own **stage-2 sub-window**, and each of the first 10 needs the adversary -leg actually run ("The `glance` Shadow-window ruling — 2026-11-04"). +leg actually run ("The `glance` Shadow-window ruling — 2026-11-04"). The +leg may be same-model when it is a fresh independent close-out session ("Stage-2 +adversary amendment — 2026-11-04"). *Revert condition:* a `reverted` row demotes the tier exactly as the 2026-10-02 section says; one merge past a brief's cap retires this sub-rule. @@ -977,6 +1005,8 @@ Codex hook list. Shadow-window ruling" (2026-11-04): a witness that did not PASS is a human `/prm`; unattended merges count in a stage-2 sub-window whose first 10 each need the adversary leg. +- `batch` (the close-out session) — "Stage-2 adversary amendment" (2026-11-04): + the independent close-out session is the adversary leg, same model allowed. - `batch` (the watcher) — "The watcher's network surface" (2026-11-03): what the watcher may reach, and that it is opt-in, laptop-only and never writes. diff --git a/agents/conductors/batch/AGENTS.md b/agents/conductors/batch/AGENTS.md index af98970..01f6089 100644 --- a/agents/conductors/batch/AGENTS.md +++ b/agents/conductors/batch/AGENTS.md @@ -225,6 +225,24 @@ counters plus slot and mode; `review-minutes-actual` and `overrules` land as `?` for the human) in the same Mind commit as the digest. A shift whose `digest.posted` exists writes nothing more — no second append, row or comment. +**Unattended `glance` close-out (phase 4b).** A `glance` worker under a brief +ends at PR-open (`worker_template.md`). Each tick, `_closeouts` first reaps +running close-outs — `.closeout` (`merged` | `refused`, `why`, `gate`, +`model`), or a session gone without one, which is recorded refused — then, for +each PR a worker named, judges the guards it can see with its own reads: the +recorded tier is `glance`, `gh pr checks` is green (pending waits a tick, red is +refused), merges plus in-flight close-outs are under `merge-cap:`, and the +shift is not shadow. Then it starts **one** close-out session +(`closeout_template.md`; `claude --bg --name shift-close: --model opus`). +That session is the independent leg: it runs the `Witness:` itself, reviews the +diff adversarially, checks the `decision-taken` label and Heart, and only then +runs the `/prm` close-out — or refuses. A merge bumps `glance_auto_merges`, +marks the PR merged, and queues a `table-append` op that lands one row in +`autonomy_log.md` "Unattended `glance` sub-window (stage 2)" through the one Mind +writer. Every refusal is final for the shift and named in the digest +(`awaiting /prm — close-out refused: `). Same-model close-out: AUTONOMY +"Stage-2 adversary amendment — 2026-11-04". + **Workers start in a trusted folder.** `claude --bg` refuses an untrusted one at once ("Workspace not trusted", exit 1) — the spawn fails and leaves no lease. The watcher launches from the workspace root; task worktrees under the diff --git a/agents/conductors/batch/_digest.py b/agents/conductors/batch/_digest.py index a1fe8b9..881b36a 100644 --- a/agents/conductors/batch/_digest.py +++ b/agents/conductors/batch/_digest.py @@ -103,8 +103,18 @@ def _line(task: str, counters: dict, tier: str, health) -> str: if not out["prs"]: return f"- {task} — finished, no PR or issue named in its .done" parts = [] + closeouts = counters.get("closeouts", {}) for url in out["prs"]: - state = "merged" if url in out["merged"] else health(url) + co = closeouts.get(url, {}) + if url in out["merged"]: + state = ("merged unattended (close-out, " + co.get("model", "?") + ")" + if co.get("state") == "merged" else "merged") + elif co.get("state") == "refused": + state = f"awaiting /prm — close-out refused: {co.get('why', '?')}" + elif co.get("state") == "running": + state = "close-out running" + else: + state = health(url) parts.append(f"{url} — {state}") return f"- {task} — " + "; ".join(parts) + f" — {tier}" diff --git a/agents/conductors/batch/_mind_writer.py b/agents/conductors/batch/_mind_writer.py index 04d0ebb..bf9ffbd 100644 --- a/agents/conductors/batch/_mind_writer.py +++ b/agents/conductors/batch/_mind_writer.py @@ -16,6 +16,7 @@ {"op": "move", "path": "draft/a/b/x.md", "to": "active/x.md"} {"op": "remove", "path": "active/x.md"} {"op": "drop-section", "path": "active.md", "section": "task-name"} + {"op": "table-append", "path": "autonomy_log.md", "heading": "## Unattended …", "row": "| … |"} Every path must be *ledger* by PyAutoMind's own rule (`scripts/ledger_merge.py` `is_ledger_path`, default deny), so a worker cannot route code through here. @@ -31,7 +32,7 @@ import types from pathlib import Path -OPS = ("append", "write", "move", "remove", "drop-section") +OPS = ("append", "write", "move", "remove", "drop-section", "table-append") PUSH_TRIES = 3 LEDGER_DIR = "ledger" COMMITTED_DIR = "committed" @@ -80,6 +81,12 @@ def validate_op(op: dict, lm) -> list[str]: errs.append("drop-section: section is missing") elif op.get("path") not in lm.ENTRY_MERGED_FILES: errs.append(f"drop-section: {op.get('path')} is not a `## slug` registry") + if kind == "table-append": + if not str(op.get("heading", "")).startswith("## "): + errs.append("table-append: heading must be a `## ` line") + row = op.get("row") + if not isinstance(row, str) or not row.startswith("|") or "\n" in row: + errs.append("table-append: row must be one `| … |` line") return errs @@ -115,6 +122,26 @@ def apply_op(tree: Path, op: dict, lm) -> None: if len(kept) == len(entries): raise MindWriteError(f"drop-section: no `## {op['section']}` in {op['path']}") target.write_text(lm.join_entries(preamble, kept), encoding="utf-8") + elif kind == "table-append": + target.write_text(table_append(target.read_text(encoding="utf-8") + if target.exists() else "", + op["heading"], op["row"]), encoding="utf-8") + + +def table_append(text: str, heading: str, row: str) -> str: + """`row` inserted after the last table line under the `## ` heading that + starts with `heading` — whatever follows that section stays where it is.""" + lines = text.splitlines() + start = next((i for i, ln in enumerate(lines) if ln.startswith(heading)), None) + if start is None: + raise MindWriteError(f"table-append: no heading {heading!r}") + end = next((j for j in range(start + 1, len(lines)) if lines[j].startswith("## ")), + len(lines)) + rows = [i for i in range(start + 1, end) if lines[i].startswith("|")] + if not rows: + raise MindWriteError(f"table-append: no table under {heading!r}") + lines.insert(rows[-1] + 1, row) + return "\n".join(lines) + "\n" def _git(cwd: Path, *args, check=True) -> subprocess.CompletedProcess: diff --git a/agents/conductors/batch/_orchestrator.py b/agents/conductors/batch/_orchestrator.py index 884ceec..80f919c 100644 --- a/agents/conductors/batch/_orchestrator.py +++ b/agents/conductors/batch/_orchestrator.py @@ -57,6 +57,13 @@ STALL_GRACE_S = 900 #: The worker prompt every real worker starts from (never a bare session). WORKER_TEMPLATE = Path(__file__).with_name("worker_template.md") +CLOSEOUT_TEMPLATE = Path(__file__).with_name("closeout_template.md") +#: The close-out session's model (phase 4b). The same model as the workers — +#: the human's call 2026-11-04 (AUTONOMY.md "Stage-2 adversary amendment"): a +#: fresh, independent session that runs the witness itself is the leg. +CLOSEOUT_MODEL = "opus" +STAGE2_HEADING = "## Unattended `glance` sub-window (stage 2)" +STAGE2_LOG = "autonomy_log.md" #: Hooks every worker's git runs with (core.hooksPath, set through GIT_CONFIG_*): #: its pre-commit refuses a commit in PyAutoMind and chains to the repo's own. WORKER_HOOKS = Path(__file__).resolve().parents[3] / "bin" / "worker_git_hooks" @@ -117,6 +124,8 @@ def read_counters(slot: Path) -> dict: c.setdefault("tokens", 0) c.setdefault("picks", {}) # task → {prompt, why, tier}, at dispatch (3d) c.setdefault("outcomes", {}) # task → {prs, merged}, from its `.done` (3d) + c.setdefault("closeouts", {}) # PR url → {task, state, why, …} (4b) + c.setdefault("glance_auto_merges", 0) return c @@ -182,6 +191,14 @@ def spawn(self, task: str, prompt_path: str, lease_dir: Path) -> dict: stderr=subprocess.DEVNULL, start_new_session=True) return {"pid": proc.pid, "session_id": f"fake-{proc.pid}", "worktree": None} + def spawn_closeout(self, task: str, pr_url: str, prompt_path: str, lease_dir: Path) -> dict: + """A dry close-out: it refuses, because a fake session merged nothing.""" + verdict = {"result": "refused", "pr": pr_url, + "why": "fake close-out (dry shift) — nothing merged", "gate": "", + "model": "fake"} + (Path(lease_dir) / f"{task}.closeout").write_text(json.dumps(verdict)) + return {"pid": 0, "agent_id": None} + def worker_prompt(task: str, prompt_path: str, brief_path: Path, lease_dir: Path) -> str: """The session template filled in for one task.""" @@ -191,6 +208,27 @@ def worker_prompt(task: str, prompt_path: str, brief_path: Path, lease_dir: Path shift_ledger=SHIFT_LEDGER) +def closeout_prompt(task: str, pr_url: str, prompt_path: str, brief_path: Path, + lease_dir: Path) -> str: + """The close-out session template filled in for one PR (phase 4b).""" + return CLOSEOUT_TEMPLATE.read_text(encoding="utf-8").format( + task=task, pr_url=pr_url, task_path=prompt_path, brief_path=brief_path, + lease_dir=lease_dir, ledger_dir=Path(lease_dir).parent / _mind_writer.LEDGER_DIR, + shift_ledger=SHIFT_LEDGER) + + +def closeout_command(task: str, pr_url: str, prompt_path: str, brief_path: Path, + lease_dir: Path, mode: str) -> list[str]: + """The argv that starts one close-out session: its own name, the close-out + model, the brief's worker mode.""" + if mode not in _brief.WORKER_MODES: + raise OrchestratorError(f"worker mode {mode!r} is not one of " + + ", ".join(_brief.WORKER_MODES)) + return ["claude", "--bg", "--name", f"shift-close:{task}", "--model", CLOSEOUT_MODEL, + "--permission-mode", mode, + closeout_prompt(task, pr_url, prompt_path, brief_path, lease_dir)] + + def worker_env(lease_dir: Path, base: dict | None = None) -> dict: """The environment a worker runs in: marked as a shift worker, pointed at the shift's ledger, and with git's hooks path overridden so a commit in @@ -246,6 +284,19 @@ def spawn(self, task: str, prompt_path: str, lease_dir: Path) -> dict: + res.stdout.strip()[:200]) return {"pid": 0, "agent_id": m.group(1), "session_id": None, "mode": self.mode} + def spawn_closeout(self, task: str, pr_url: str, prompt_path: str, lease_dir: Path) -> dict: + argv = closeout_command(task, pr_url, prompt_path, self.brief_path, lease_dir, + self.mode) + res = self.run(argv, cwd=self.cwd, capture_output=True, text=True, timeout=120, + env=worker_env(lease_dir)) + if res.returncode != 0: + raise OrchestratorError(f"claude --bg close-out failed for {task}: " + + (res.stderr or res.stdout).strip()[:300]) + m = _BG_ID_RE.search(res.stdout) + if not m: + raise OrchestratorError(f"claude --bg printed no session id for {task}'s close-out") + return {"pid": 0, "agent_id": m.group(1)} + def default_spawner(brief_path: Path, brief: dict): if os.environ.get(FAKE_ENV) == "1": @@ -484,7 +535,7 @@ def tick(brief_path: Path, mind: Path, *, home: Path | None = None, try: result = _tick_locked(slot, brief, mind, now, spawner or default_spawner(brief_path, brief), - grace_s, records, agents, tokens) + grace_s, records, agents, tokens, health) finally: _drop_lock(slot) _log(slot, now, result) @@ -492,7 +543,7 @@ def tick(brief_path: Path, mind: Path, *, home: Path | None = None, def _tick_locked(slot, brief, mind, now, spawner, grace_s, records, agents, - tokens) -> dict: + tokens, health=None) -> dict: counters = read_counters(slot) reaped = _reap(slot, counters, now, grace_s, agents, tokens) # One Mind writer: whatever the workers queued is committed here, by the @@ -504,6 +555,13 @@ def _tick_locked(slot, brief, mind, now, spawner, grace_s, records, agents, slot, mind, f"shift {slot.name}: ledger from workers") except _mind_writer.MindWriteError as e: ledger = {"committed": None, "error": str(e)} + closeouts = _closeouts(slot, brief, counters, spawner, now, agents, health) + if _mind_writer.pending_ops(slot): # a stage-2 row queued by a merged close-out + try: + ledger = _mind_writer.commit_ledger( + slot, mind, f"shift {slot.name}: unattended close-out") + except _mind_writer.MindWriteError as e: + ledger = {"committed": None, "error": str(e)} live = leases(slot) max_workers = _brief.cap_of(brief, "max-workers") max_sessions = _brief.cap_of(brief, "max-sessions") @@ -560,7 +618,8 @@ def _tick_locked(slot, brief, mind, now, spawner, grace_s, records, agents, counters["refusals"] += [dict(r, at=_iso(now)) for r in refused] _write_counters(slot, counters) return {"action": "ticked", "reaped": reaped, "spawned": spawned, - "refused": refused, "live": sorted(live), "ledger": ledger} + "refused": refused, "live": sorted(live), "ledger": ledger, + "closeouts": closeouts} # --- the digest (phase 3d) ---------------------------------------------------------- @@ -650,6 +709,104 @@ def _write_packet(slot: Path, digest: str) -> Path: return out +# --- unattended `glance` close-out (phase 4b) ------------------------------------ + +def _closeouts(slot: Path, brief: dict, counters: dict, spawner, now: _dt.datetime, + agents: AgentsView | None, health) -> dict: + """Reap finished close-out sessions, then start one per newly green PR. + + The watcher's guards are the ones it can judge from its own reads (AUTONOMY + "Unattended `glance` merges"): the recorded tier, green CI by `gh pr checks`, + the brief's `merge-cap:`, and never in a shadow shift. The label, Heart and + the witness are the close-out session's to check — it is the independent + session, and it refuses rather than merges when one fails. + """ + events = {"started": [], "merged": [], "refused": []} + lease_dir = slot / "leases" + agents = agents or AgentsView() + health = health or _digest.pr_health + + for pr, co in counters["closeouts"].items(): + if co["state"] != "running": + continue + verdict_file = lease_dir / f"{co['task']}.closeout" + if verdict_file.exists(): + try: + v = json.loads(verdict_file.read_text(encoding="utf-8")) + except ValueError: + v = {"result": "refused", "why": "unreadable .closeout file"} + verdict_file.unlink(missing_ok=True) + if v.get("result") == "merged": + co.update(state="merged", gate=v.get("gate", ""), model=v.get("model", "")) + counters["glance_auto_merges"] += 1 + out = counters["outcomes"].setdefault(co["task"], {"prs": [pr], "merged": []}) + if pr not in out["merged"]: + out["merged"].append(pr) + _mind_writer.queue_op(slot / _mind_writer.LEDGER_DIR, { + "op": "table-append", "path": STAGE2_LOG, "heading": STAGE2_HEADING, + "row": stage2_row(now, co["task"], pr, slot.name, co["gate"], co["model"])}) + events["merged"].append(pr) + else: + co.update(state="refused", why=v.get("why") or "refused, no reason given") + events["refused"].append(pr) + continue + real = bool(co.get("agent_id")) + state = agents.state(co["agent_id"]) if real else None + gone = (state in (None, "done")) if real else not _alive(int(co.get("pid") or 0)) + if gone: + # Never retried: a close-out that ended without a verdict is the + # human's to look at, not a second unattended attempt. + co.update(state="refused", why="the close-out session ended without a verdict") + events["refused"].append(pr) + + if _brief.is_shadow(brief): + return events + cap = _brief._nonneg_int(brief.get("merge-cap", {}).get("value", "")) or 0 + for task, out in counters["outcomes"].items(): + for pr in out["prs"]: + if "/pull/" not in pr or pr in out["merged"] or pr in counters["closeouts"]: + continue + tier = counters["picks"].get(task, {}).get("tier", "?") + if tier != "glance": + counters["closeouts"][pr] = {"task": task, "state": "refused", + "why": f"tier {tier} — only glance merges unattended"} + events["refused"].append(pr) + continue + verdict = health(pr) + if verdict == "RUNNING": + continue # CI has not landed yet; look again next tick + if verdict != "HEALTHY": + counters["closeouts"][pr] = {"task": task, "state": "refused", + "why": f"CI {verdict} — never merged unattended"} + events["refused"].append(pr) + continue + in_flight = sum(1 for c in counters["closeouts"].values() if c["state"] == "running") + if counters["glance_auto_merges"] + in_flight >= cap: + # Final for this shift, like every refusal: the PR waits for a + # human `/prm`, and the digest says why. + counters["closeouts"][pr] = {"task": task, "state": "refused", + "why": f"merge-cap {cap} reached this shift"} + events["refused"].append(pr) + continue + prompt = counters["picks"].get(task, {}).get("prompt", "") + co = {"task": task, "state": "running", "started": _iso(now)} + co.update(spawner.spawn_closeout(task, pr, prompt, lease_dir)) + counters["closeouts"][pr] = co + events["started"].append(pr) + return events + + +def stage2_row(now: _dt.datetime, task: str, pr: str, slot_name: str, gate: str, + model: str) -> str: + """One row of autonomy_log.md's stage-2 table (its columns: date, task, + brief, gate with the adversary leg, human action — `merged-unchanged` at + merge, amended by the human later).""" + cells = (now.strftime("%Y-%m-%d"), f"{task} ({pr})", slot_name, + f"{gate} / adversary: independent close-out session ({model})", + "merged-unchanged") + return "| " + " | ".join(" ".join(str(c).replace("|", "/").split()) for c in cells) + " |" + + def _propose(members: list[dict], brief: dict, counters: dict, max_sessions: int) -> list[str]: """A shadow shift's tick (phase 4a): record what a live shift would have dispatched — no lease, no worker — up to the brief's `max-sessions:`.""" diff --git a/agents/conductors/batch/closeout_template.md b/agents/conductors/batch/closeout_template.md new file mode 100644 index 0000000..f96d7c1 --- /dev/null +++ b/agents/conductors/batch/closeout_template.md @@ -0,0 +1,37 @@ +You are the close-out session for one pull request in a shift the human +launched by writing a shift brief: +{brief_path} + +The PR: {pr_url} +Its task: {task_path} (declared `Consequence: glance`) + +You are the **independent session** AUTONOMY.md "Unattended `glance` merges" +requires: you did not write this PR, and you trust nothing its author claimed. +Merge only if every check below passes; otherwise refuse. A refusal is a good +outcome — the human merges by `/prm` at check-in. + +1. Read the task's `Witness:` header and **run it yourself** against the PR's + head. Record what decided it. +2. Review the diff adversarially: look for a changed default, a user-visible + error message, a removed or weakened test, a renamed public thing, or a + wrong docs claim. Any one of them → refuse. +3. Refuse if the PR carries the `decision-taken` label, if its tier is not + `glance`, or if Heart is RED (`python3 $PYAUTO_BRAIN/bin/heart_feed.py`, + exit 2). +4. If everything passed, use the prm skill on {pr_url}: the full close-out, + judged exactly as a human-typed `/prm`. Never commit to PyAutoMind yourself + — a hook refuses it; queue each Mind change with + `{shift_ledger} append|write|move|remove|drop-section ...` (ops land in + {ledger_dir}/) and the orchestrator commits them. Do **not** append the + Shadow-window row: the watcher writes the stage-2 row from your verdict. +5. End at your deliverable. Never arm anything that outlives your turn. + +Last, write {lease_dir}/{task}.closeout as one JSON object: + + {{"result": "merged" | "refused", "pr": "{pr_url}", + "why": "", + "gate": "tests …/witness …/review …/heart …", + "model": ""}} + +Without that file the watcher records the close-out as ended without a +verdict and never retries it. diff --git a/agents/conductors/batch/worker_template.md b/agents/conductors/batch/worker_template.md index c9f6097..e745bd7 100644 --- a/agents/conductors/batch/worker_template.md +++ b/agents/conductors/batch/worker_template.md @@ -10,6 +10,10 @@ The terms of this shift (AUTONOMY.md "Shift briefs and unattended `glance`"): - End at your deliverable: a PR, a posted verdict, or a park with a question on the issue. Never arm anything that outlives your turn (no send_later, ScheduleWakeup, CronCreate, RemoteTrigger or /loop) — the watcher keeps time. +- If the task is `Consequence: glance`, end at PR-open: do not wait for CI + and do not merge. Once CI lands, the watcher starts an independent close-out + session that runs the witness and merges, or refuses (AUTONOMY.md + "Unattended `glance` merges"). A `notify` task merges in-turn as usual. - Never commit to PyAutoMind yourself — a hook refuses it. Queue each Mind change with `{shift_ledger} append|write|move|remove|drop-section ...` (ops land in {ledger_dir}/) and the orchestrator commits them. This covers diff --git a/skills/batch/batch.md b/skills/batch/batch.md index 38a1664..e609451 100644 --- a/skills/batch/batch.md +++ b/skills/batch/batch.md @@ -137,7 +137,9 @@ One deterministic tick per timer firing; the timer is installed by the human, ne After `review-at:` its one remaining act is the digest: written into the record and posted once on the brief's `issue:`. A `shadow: yes` brief plans and digests but spawns nothing — the pilot's baseline; every shift adds one row to -`PyAutoMind/batches/pilot/pilot.md`, whose `?` cells are the human's at check-in. +`PyAutoMind/batches/pilot/pilot.md`, whose `?` cells are the human's at check-in. Once a worker's `glance` PR is green, the +watcher starts one independent close-out session that merges it or refuses; +each merge adds a row to the stage-2 table in `PyAutoMind/autonomy_log.md`. What a tick does, its state, caps and the install commands: `agents/conductors/batch/AGENTS.md` "The watcher". ## When they come back — collect, review, close (2026-08-31) diff --git a/tests/test_autonomy_doctrine.py b/tests/test_autonomy_doctrine.py index c58d0e1..d515547 100644 --- a/tests/test_autonomy_doctrine.py +++ b/tests/test_autonomy_doctrine.py @@ -220,3 +220,43 @@ def test_ruling_keeps_the_revert_condition_and_is_a_consumer_entry(): assert "any reverted row demotes glance" in sec consumers = AUTONOMY[AUTONOMY.index("## Consumers"):] assert "Shadow-window ruling" in consumers + + +# --- Stage-2 adversary amendment (2026-11-04, PyAutoBrain#572) ---------------- + +AMEND_HEADER = "## Stage-2 adversary amendment — 2026-11-04" + + +def _amend_section(): + assert AUTONOMY.count(AMEND_HEADER) == 1 + rest = AUTONOMY[AUTONOMY.index(AMEND_HEADER) + len(AMEND_HEADER):] + return _norm(rest[:rest.find("\n## ")]) + + +def test_amendment_follows_the_ruling_it_amends(): + assert AUTONOMY.index(RULING_HEADER) < AUTONOMY.index(AMEND_HEADER) \ + < AUTONOMY.index(SHIFT_HEADER) + + +def test_amendment_allows_same_model_only_for_an_independent_session(): + sec = _amend_section() + assert "may be the same model as the author" in sec + assert "fresh close-out session" in sec + assert "runs the witness: itself" in sec + assert "only \"different-model\" is replaced" in sec + + +def test_amendment_names_its_revert_condition(): + sec = _amend_section() + assert sec.count("revert condition:") == 1 + assert "restores the different-model requirement" in sec + for trigger in ("reverted", "merged-after-substantive-change"): + assert trigger in sec + + +def test_unattended_rule_and_consumers_point_at_the_amendment(): + shift = AUTONOMY[AUTONOMY.index(SHIFT_HEADER):] + assert "Stage-2\nadversary amendment — 2026-11-04" in shift \ + or "Stage-2 adversary amendment — 2026-11-04" in shift + consumers = AUTONOMY[AUTONOMY.index("## Consumers"):] + assert "Stage-2 adversary amendment" in consumers diff --git a/tests/test_orchestrator_closeout.py b/tests/test_orchestrator_closeout.py new file mode 100644 index 0000000..ce3cf9b --- /dev/null +++ b/tests/test_orchestrator_closeout.py @@ -0,0 +1,257 @@ +"""Autonomous orchestrators phase 4b (PyAutoBrain#572): the unattended `glance` close-out. + +After CI lands green on a declared-`glance` PR a worker opened, the watcher +starts one independent close-out session; its verdict file decides the rest. A +merge lands one row in autonomy_log.md's stage-2 table through the one Mind +writer; every guard that fails starts nothing and is digested; nothing is ever +retried. +""" + +from __future__ import annotations + +import datetime as _dt +import json +import shutil +import sys +from pathlib import Path + +import pytest + +BRAIN = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BRAIN / "tests")) +import test_orchestrator_digest as t # noqa: E402 — the 3d fixtures +from test_orchestrator_digest import _brief, _digest, orch # noqa: E402 +import _mind_writer as mw # noqa: E402 + +PR1 = "https://github.com/o/r/pull/1" +PR2 = "https://github.com/o/r/pull/2" + + +class Spawner(t.FilesSpawner): + """Workers that finished at once (PRs open, nothing merged in-turn) and a + close-out whose verdict the test writes when it chooses.""" + + def __init__(self): + super().__init__(done={"named": PR1 + "\n", "picked": PR2 + "\n"}, proofs={}) + self.closeouts = [] + + def spawn_closeout(self, task, pr_url, prompt_path, lease_dir): + self.closeouts.append((task, pr_url)) + return {"pid": 0, "agent_id": f"co{len(self.closeouts):06d}"} + + +class Agents: + """`claude agents --json`: every close-out still working unless told.""" + + def __init__(self, states=None): + self.states = states or {} + + def state(self, agent_id): + return self.states.get(agent_id, "working") + + def session_of(self, agent_id): + return None + + +def _health(verdicts): + return lambda url: verdicts.get(url, "HEALTHY") + + +def _shift(tmp_path, cap=1, extra=""): + mind = t._mind(tmp_path / "mind") + brief = tmp_path / "shift.md" + brief.write_text(t.BRIEF.format(issue=t.ISSUE, packet="") + .replace("merge-cap: 1", f"merge-cap: {cap}") + extra) + home = tmp_path / "home" + sp = Spawner() + orch.tick(brief, mind, home=home, now=t.NOW, spawner=sp, agents=Agents(), + health=_health({})) + return mind, brief, home, sp + + +def _tick(brief, mind, home, sp, minutes, health=None, agents=None): + return orch.tick(brief, mind, home=home, now=t.NOW + _dt.timedelta(minutes=minutes), + spawner=sp, agents=agents or Agents(), health=health or _health({})) + + +def _verdict(home, brief, task, **v): + slot = orch.slot_dir(brief, home) + (slot / "leases" / f"{task}.closeout").write_text(json.dumps(v)) + + +# --- green + guards → exactly one close-out, merged → one stage-2 row ------------- + +def test_a_green_glance_pr_gets_exactly_one_closeout(tmp_path): + mind, brief, home, sp = _shift(tmp_path, cap=2) + r = _tick(brief, mind, home, sp, 1) # reaps the workers, starts close-outs + assert sorted(r["closeouts"]["started"]) == [PR1, PR2] + _tick(brief, mind, home, sp, 2) + _tick(brief, mind, home, sp, 3) + assert sorted(sp.closeouts) == [("named", PR1), ("picked", PR2)] # never twice + + +def test_a_merged_verdict_counts_and_queues_one_stage2_row(tmp_path): + mind, brief, home, sp = _shift(tmp_path) + _tick(brief, mind, home, sp, 1) + _verdict(home, brief, "named", result="merged", pr=PR1, gate="tests PASS / witness PASS", + model="claude-opus-5-5") + r = _tick(brief, mind, home, sp, 2) + assert r["closeouts"]["merged"] == [PR1] + slot = orch.slot_dir(brief, home) + c = orch.read_counters(slot) + assert c["glance_auto_merges"] == 1 + assert c["outcomes"]["named"]["merged"] == [PR1] + ops = [json.loads(f.read_text()) for f in mw.pending_ops(slot)] + rows = [o for o in ops if o["op"] == "table-append"] + assert len(rows) == 1 and rows[0]["heading"] == orch.STAGE2_HEADING + assert rows[0]["row"].startswith("| 2026-11-04 | named (" + PR1 + ") | shift |") + assert "adversary: independent close-out session (claude-opus-5-5)" in rows[0]["row"] + assert rows[0]["row"].endswith("| merged-unchanged |") + d = _digest.build(_brief.parse(brief.read_text())["brief"], slot, c, health=t._health) + assert "merged unattended (close-out, claude-opus-5-5)" in d + assert "- glance-auto-merges: 1" in d and "- merges: 1" in d + + +# --- each guard failing on its own → nothing started, the reason digested ------- + +def test_a_non_glance_task_is_never_closed_out(tmp_path): + mind, brief, home, sp = _shift(tmp_path, cap=2) + slot = orch.slot_dir(brief, home) + c = orch.read_counters(slot) + c["picks"]["named"]["tier"] = "notify" + orch._write_counters(slot, c) + _tick(brief, mind, home, sp, 1) + assert ("named", PR1) not in sp.closeouts + co = orch.read_counters(slot)["closeouts"][PR1] + assert co["state"] == "refused" and "only glance merges unattended" in co["why"] + + +def test_red_ci_is_refused_and_pending_ci_waits(tmp_path): + mind, brief, home, sp = _shift(tmp_path, cap=2) + h = _health({PR1: "FAILED", PR2: "RUNNING"}) + _tick(brief, mind, home, sp, 1, health=h) + assert sp.closeouts == [] + c = orch.read_counters(orch.slot_dir(brief, home)) + assert c["closeouts"][PR1]["why"] == "CI FAILED — never merged unattended" + assert PR2 not in c["closeouts"] # waiting, not refused + _tick(brief, mind, home, sp, 2, health=_health({PR1: "HEALTHY"})) + assert sp.closeouts == [("picked", PR2)] # PR1's refusal is final + + +def test_merge_cap_counts_merges_and_in_flight_closeouts(tmp_path): + mind, brief, home, sp = _shift(tmp_path, cap=1) + r = _tick(brief, mind, home, sp, 1) + assert len(r["closeouts"]["started"]) == 1 and len(r["closeouts"]["refused"]) == 1 + c = orch.read_counters(orch.slot_dir(brief, home)) + refused = [co for co in c["closeouts"].values() if co["state"] == "refused"] + assert refused[0]["why"] == "merge-cap 1 reached this shift" + + +def test_merge_cap_zero_starts_nothing(tmp_path): + mind, brief, home, sp = _shift(tmp_path, cap=0) + _tick(brief, mind, home, sp, 1) + assert sp.closeouts == [] + + +def test_a_shadow_shift_never_closes_out(tmp_path): + mind, brief, home, sp = _shift(tmp_path, cap=2) + slot = orch.slot_dir(brief, home) + counters = orch.read_counters(slot) + events = orch._closeouts(slot, {"shadow": {"value": "yes", "children": []}, + "merge-cap": {"value": "2", "children": []}}, + counters, sp, t.NOW, Agents(), _health({})) + assert events["started"] == [] and sp.closeouts == [] + + +# --- refusals are final and digested ------------------------------------------ + +def test_a_refused_closeout_is_digested_and_never_retried(tmp_path): + mind, brief, home, sp = _shift(tmp_path) + _tick(brief, mind, home, sp, 1) + _verdict(home, brief, "named", result="refused", pr=PR1, + why="decision-taken label", gate="", model="claude-opus-5-5") + _tick(brief, mind, home, sp, 2) + _tick(brief, mind, home, sp, 3) + assert [c for c in sp.closeouts if c[1] == PR1] == [("named", PR1)] + slot = orch.slot_dir(brief, home) + c = orch.read_counters(slot) + assert c["glance_auto_merges"] == 0 and c["outcomes"]["named"]["merged"] == [] + d = _digest.build(_brief.parse(brief.read_text())["brief"], slot, c, health=t._health) + assert f"{PR1} — awaiting /prm — close-out refused: decision-taken label" in d + + +def test_a_closeout_that_ends_without_a_verdict_is_refused_not_retried(tmp_path): + mind, brief, home, sp = _shift(tmp_path) + _tick(brief, mind, home, sp, 1) + _tick(brief, mind, home, sp, 2, agents=Agents({"co000001": "done"})) + _tick(brief, mind, home, sp, 3) + c = orch.read_counters(orch.slot_dir(brief, home)) + assert c["closeouts"][PR1]["why"] == "the close-out session ended without a verdict" + assert len(sp.closeouts) == 1 + + +# --- the session and the Mind writer ------------------------------------------- + +def test_closeout_command_is_named_moded_and_on_the_closeout_model(tmp_path): + argv = orch.closeout_command("t1", PR1, "draft/x/t1.md", tmp_path / "b.md", + tmp_path / "leases", "auto") + assert argv[:8] == ["claude", "--bg", "--name", "shift-close:t1", "--model", "opus", + "--permission-mode", "auto"] + prompt = argv[-1] + assert PR1 in prompt and "run it yourself" in prompt + assert str(tmp_path / "leases" / "t1.closeout") in prompt + with pytest.raises(orch.OrchestratorError): + orch.closeout_command("t1", PR1, "x", tmp_path, tmp_path, "bypassPermissions") + + +def test_the_worker_template_hands_glance_merges_to_the_closeout(): + tpl = (t.BATCH / "worker_template.md").read_text() + assert "end at PR-open" in tpl and "independent close-out" in tpl + + +def test_table_append_keeps_what_follows_the_section(): + text = ("## A\n| x |\n|---|\n| 1 |\n\n" + orch.STAGE2_HEADING + " — opened\n\n" + "| d |\n|---|\n\n## After\ntail\n") + out = mw.table_append(text, orch.STAGE2_HEADING, "| 2 |") + assert out.index("| 2 |") < out.index("## After") and out.count("| 2 |") == 1 + assert out.index("| 2 |") > out.index(orch.STAGE2_HEADING) + with pytest.raises(mw.MindWriteError): + mw.table_append("## Other\n", orch.STAGE2_HEADING, "| 2 |") + + +@pytest.mark.skipif(not t.LEDGER_MERGE.is_file() or shutil.which("git") is None, + reason="needs git and PyAutoMind's scripts/ledger_merge.py") +def test_a_merged_closeout_lands_one_stage2_row_on_mind_main(tmp_path, monkeypatch): + monkeypatch.setattr(sys, "dont_write_bytecode", False) + bare = tmp_path / "remote.git" + t.git(tmp_path, "init", "-q", "--bare", "-b", "main", str(bare)) + canon = tmp_path / "canon" + t.git(tmp_path, "clone", "-q", str(bare), str(canon)) + t.git(canon, "config", "user.name", "Test") + t.git(canon, "config", "user.email", "test@example.invalid") + t._mind(canon) + (canon / "scripts").mkdir() + shutil.copy(t.LEDGER_MERGE, canon / "scripts" / "ledger_merge.py") + (canon / "autonomy_log.md").write_text( + "# Log\n\n## Shadow window\n\n| a |\n|---|\n\n" + orch.STAGE2_HEADING + + " — opened 2026-11-04\n\n| date | task | brief | gate | human action |\n" + "|---|---|---|---|---|\n\n## Later section\nkept\n") + t.git(canon, "add", "-A") + t.git(canon, "commit", "-q", "-m", "seed") + t.git(canon, "push", "-q", "origin", "HEAD:main") + + brief = tmp_path / "shift.md" + brief.write_text(t.BRIEF.format(issue=t.ISSUE, packet="")) + home, sp = tmp_path / "home", Spawner() + orch.tick(brief, canon, home=home, now=t.NOW, spawner=sp, agents=Agents(), + health=_health({})) + _tick(brief, canon, home, sp, 1) + _verdict(home, brief, "named", result="merged", pr=PR1, gate="tests PASS", + model="claude-opus-5-5") + r = _tick(brief, canon, home, sp, 2) + assert r["ledger"]["committed"], r + log = t.git(bare, "show", "main:autonomy_log.md").stdout + section = log[log.index(orch.STAGE2_HEADING):log.index("## Later section")] + rows = [ln for ln in section.splitlines() if ln.startswith("| 2026-11-04 ")] + assert len(rows) == 1 and "named (" + PR1 + ")" in rows[0] + assert log.rstrip().endswith("kept") diff --git a/tests/test_orchestrator_digest.py b/tests/test_orchestrator_digest.py index 9011822..41e5ba8 100644 --- a/tests/test_orchestrator_digest.py +++ b/tests/test_orchestrator_digest.py @@ -93,6 +93,18 @@ def spawn(self, task, prompt_path, lease_dir): (Path(lease_dir) / f"{task}.done").write_text(self.done.get(task, "")) return {"pid": 0, "session_id": None, "worktree": None} + def spawn_closeout(self, task, pr_url, prompt_path, lease_dir): + # Phase 4b: a green glance PR gets a close-out; this one never reports. + return {"pid": 0, "agent_id": "c0ffee00"} + + +class _Working: + def state(self, agent_id): + return "working" + + def session_of(self, agent_id): + return None + def _mind(root: Path) -> Path: folder = root / "draft" / "feature" / "pyautobrain" @@ -109,9 +121,11 @@ def _run_shift(tmp_path: Path, brief: Path, mind: Path, home: Path): done={"named": "merged https://github.com/o/r/pull/1\n", "picked": "https://github.com/o/r/pull/2\n"}, proofs={"named": PROOF}) - r = orch.tick(brief, mind, home=home, now=NOW, spawner=sp) + r = orch.tick(brief, mind, home=home, now=NOW, spawner=sp, health=_health, + agents=_Working()) assert sorted(s["task"] for s in r["spawned"]) == ["named", "picked"], r - orch.tick(brief, mind, home=home, now=NOW + _dt.timedelta(minutes=1), spawner=sp) + orch.tick(brief, mind, home=home, now=NOW + _dt.timedelta(minutes=1), spawner=sp, + health=_health, agents=_Working()) def _shift(tmp_path: Path, packet=""): @@ -152,7 +166,8 @@ def test_digest_separates_brief_work_from_backlog_picks_with_reasons(tmp_path): picks = d.split("### Backlog picks")[1].split("### Proof packets")[0] assert "- named — https://github.com/o/r/pull/1 — merged — glance" in work assert "- never — not started" in work - assert "- picked — https://github.com/o/r/pull/2 — HEALTHY — glance — why: scoped epic e1" in picks + # Green and glance: since phase 4b its close-out is running at review time. + assert "- picked — https://github.com/o/r/pull/2 — close-out running — glance — why: scoped epic e1" in picks assert "named" not in picks