diff --git a/agents/conductors/batch/AGENTS.md b/agents/conductors/batch/AGENTS.md index 872ef55..1cdeee4 100644 --- a/agents/conductors/batch/AGENTS.md +++ b/agents/conductors/batch/AGENTS.md @@ -172,9 +172,25 @@ it → crashed (re-dispatched once). A finished worker's tokens (input + output cache creation) are summed from its local transcript, named by the full `sessionId`, and the brief's `token-budget:` refuses the next spawn once spent. `worker-mode:` defaults to `auto` (human, 2026-11-03); `dontAsk` and -`acceptEdits` are allowed, `bypassPermissions` never. **Until phase 3c lands, -real dispatch needs `sandbox: yes` in the brief** — one Mind writer is doctrine -and a real worker would otherwise commit to Mind itself. +`acceptEdits` are allowed, `bypassPermissions` never. + +**One Mind writer (phase 3c).** Workers never commit to PyAutoMind. Each Mind +change — the `active.md` claim, the `draft/ → active/` move, the close-out — is +queued as an op file in `/ledger/` by `bin/shift_ledger.py` +(`append | write | move | remove | drop-section`; every path must be *ledger* +by PyAutoMind's own `scripts/ledger_merge.py`, default deny). Each tick, after +the reap, `_mind_writer.commit_ledger` applies all pending ops in a temporary +detached worktree on `origin/main`, commits once and pushes; if `main` moved it +replays the ops on the new `origin/main` (up to three tries). It never resets, +never forces, and never touches the canonical checkout's working tree. +Committed op files move to `ledger/committed/`; an op that cannot apply stays +pending and the tick reports it. **Enforced, not asked:** a worker runs with +`PYAUTO_SHIFT_WORKER=1`, `PYAUTO_SHIFT_LEDGER_DIR`, and `core.hooksPath` (via +`GIT_CONFIG_*`) pointing at `bin/worker_git_hooks/`, whose `pre-commit` +refuses any commit in a PyAutoMind repo — `prompt_sync_push`, `lifecycle close` +or a raw `git commit` alike — and otherwise chains to the repo's own hook. +With that in place real dispatch no longer needs `sandbox: yes` (human, +2026-11-03); the key stays as a test-shift marker. **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. diff --git a/agents/conductors/batch/_mind_writer.py b/agents/conductors/batch/_mind_writer.py new file mode 100644 index 0000000..e087b83 --- /dev/null +++ b/agents/conductors/batch/_mind_writer.py @@ -0,0 +1,186 @@ +"""One Mind writer per shift (AUTONOMY.md, 2026-11-03) — the orchestrator's side. + +Workers never commit to PyAutoMind. They describe each change as an *op file* +in the shift's `ledger/` directory (`bin/shift_ledger.py` writes them); the +orchestrator applies every pending op in a **temporary detached worktree** on +`origin/main`, commits once, and pushes. A rejected push means someone else +moved `main` (another session, the dashboard self-heal bot): the ops are +replayed on the new `origin/main` — a rebase of operations, not of diffs — at +most `PUSH_TRIES` times. The canonical checkout's working tree is never read +or written, nothing is reset, and nothing is forced. + +Ops (one JSON object per file, applied in file-name order): + + {"op": "append", "path": "active.md", "text": "..."} + {"op": "write", "path": "complete/2026/11/x.md", "text": "..."} # must not exist + {"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"} + +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. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +import tempfile +import types +from pathlib import Path + +OPS = ("append", "write", "move", "remove", "drop-section") +PUSH_TRIES = 3 +LEDGER_DIR = "ledger" +COMMITTED_DIR = "committed" + + +class MindWriteError(RuntimeError): + pass + + +def _ledger_merge(mind_repo: Path): + """PyAutoMind's own `ledger_merge` module, from the checkout we write to. + + Compiled from source rather than imported: an import writes + `scripts/__pycache__/` into the canonical checkout, and this module's + promise is that the canonical working tree is never written.""" + path = Path(mind_repo) / "scripts" / "ledger_merge.py" + if not path.is_file(): + raise MindWriteError(f"no scripts/ledger_merge.py under {mind_repo}") + name = "_mind_ledger_merge" + mod = types.ModuleType(name) + mod.__file__ = str(path) + sys.modules[name] = mod # dataclasses and friends look the module up by name + try: + exec(compile(path.read_text(encoding="utf-8"), str(path), "exec"), mod.__dict__) + finally: + sys.modules.pop(name, None) + return mod + + +def validate_op(op: dict, lm) -> list[str]: + """Problems with one op; empty when it may be applied.""" + errs = [] + kind = op.get("op") + if kind not in OPS: + return [f"op: {kind!r} is not one of {', '.join(OPS)}"] + paths = [op.get("path")] + ([op.get("to")] if kind == "move" else []) + for p in paths: + if not isinstance(p, str) or not p: + errs.append(f"{kind}: a path is missing") + elif p.startswith("/") or not lm.is_ledger_path(p): + errs.append(f"{kind}: {p} is not a ledger path (scripts/ledger_merge.py)") + if kind in ("append", "write") and not isinstance(op.get("text"), str): + errs.append(f"{kind}: text is missing") + if kind == "drop-section": + if not op.get("section"): + 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") + return errs + + +def apply_op(tree: Path, op: dict, lm) -> None: + """Apply one validated op inside `tree`; raises MindWriteError if it cannot.""" + target = tree / op["path"] + kind = op["op"] + if kind == "append": + target.parent.mkdir(parents=True, exist_ok=True) + old = target.read_text(encoding="utf-8") if target.exists() else "" + sep = "" if not old or old.endswith("\n") else "\n" + target.write_text(old + sep + op["text"], encoding="utf-8") + elif kind == "write": + if target.exists(): + raise MindWriteError(f"write: {op['path']} already exists") + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(op["text"], encoding="utf-8") + elif kind == "move": + dest = tree / op["to"] + if not target.exists(): + raise MindWriteError(f"move: {op['path']} does not exist") + if dest.exists(): + raise MindWriteError(f"move: {op['to']} already exists") + dest.parent.mkdir(parents=True, exist_ok=True) + _git(tree, "mv", op["path"], op["to"]) + elif kind == "remove": + if not target.exists(): + raise MindWriteError(f"remove: {op['path']} does not exist") + _git(tree, "rm", "-q", op["path"]) + elif kind == "drop-section": + preamble, entries = lm.split_entries(target.read_text(encoding="utf-8")) + kept = [(s, b) for s, b in entries if s != f"## {op['section']}"] + 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") + + +def _git(cwd: Path, *args, check=True) -> subprocess.CompletedProcess: + res = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True) + if check and res.returncode != 0: + raise MindWriteError(f"git {' '.join(args)}: {(res.stderr or res.stdout).strip()}") + return res + + +def pending_ops(slot: Path) -> list[Path]: + d = Path(slot) / LEDGER_DIR + return sorted(f for f in d.glob("*.json")) if d.is_dir() else [] + + +def commit_ledger(slot: Path, mind_repo: Path, message: str, + remote: str = "origin", branch: str = "main") -> dict: + """Commit every pending op in `/ledger/` to PyAutoMind as one commit. + + Returns {"committed": sha | None, "ops": n, "tries": k}. Ops that fail + validation are refused before anything is touched; an op that cannot apply + on the current `origin/main` stops the run and stays pending. + """ + mind_repo = Path(mind_repo) + files = pending_ops(slot) + if not files: + return {"committed": None, "ops": 0, "tries": 0} + lm = _ledger_merge(mind_repo) + ops = [] + for f in files: + try: + op = json.loads(f.read_text(encoding="utf-8")) + except ValueError as e: + raise MindWriteError(f"{f.name}: not JSON ({e})") from e + errs = validate_op(op, lm) + if errs: + raise MindWriteError(f"{f.name}: " + "; ".join(errs)) + ops.append(op) + + for attempt in range(1, PUSH_TRIES + 1): + _git(mind_repo, "fetch", "-q", remote, branch) + tmp = Path(tempfile.mkdtemp(prefix="mind-writer-")) + tree = tmp / "tree" + try: + _git(mind_repo, "worktree", "add", "-q", "--detach", str(tree), + f"{remote}/{branch}") + for op in ops: + apply_op(tree, op, lm) + _git(tree, "add", "-A") + if not _git(tree, "status", "--porcelain").stdout.strip(): + sha = None # every op was already true on main + else: + _git(tree, "commit", "-q", "-m", message) + sha = _git(tree, "rev-parse", "HEAD").stdout.strip() + pushed = _git(tree, "push", "-q", remote, f"HEAD:{branch}", check=False) + if pushed.returncode != 0: + continue # main moved: replay the ops on the new origin/main + done = Path(slot) / LEDGER_DIR / COMMITTED_DIR + done.mkdir(parents=True, exist_ok=True) + for f in files: + f.replace(done / f.name) + return {"committed": sha, "ops": len(ops), "tries": attempt} + finally: + # Our own throwaway tree. A failed apply can leave it dirty, so + # delete the directory and prune the registration, no force flag. + if _git(mind_repo, "worktree", "remove", str(tree), check=False).returncode: + shutil.rmtree(tmp, ignore_errors=True) + _git(mind_repo, "worktree", "prune", check=False) + shutil.rmtree(tmp, ignore_errors=True) + raise MindWriteError(f"push rejected {PUSH_TRIES} times; ops left pending") diff --git a/agents/conductors/batch/_orchestrator.py b/agents/conductors/batch/_orchestrator.py index 037c5b7..a6e2a4b 100644 --- a/agents/conductors/batch/_orchestrator.py +++ b/agents/conductors/batch/_orchestrator.py @@ -16,8 +16,10 @@ Workers are fake stubs (`PYAUTO_ORCHESTRATOR_FAKE=1`, phase 3a) or real `claude --bg` sessions from one session template (phase 3b). A real worker's liveness is its `claude agents --json` state; one left `blocked` past a grace -is parked for a human — never killed, never re-dispatched. Until phase 3c (one -Mind writer) lands, real workers run only under a `sandbox: yes` brief. +is parked for a human — never killed, never re-dispatched. Workers never +commit to PyAutoMind (phase 3c): they queue ledger ops (`bin/shift_ledger.py`) +and each tick commits them from a temporary worktree (`_mind_writer.py`); a +git hook injected through the worker's environment refuses a direct commit. Nothing here commits, merges, posts or arms a timer: the systemd timer that calls `bin/orchestrator_watch.py` is installed by the human. """ @@ -33,6 +35,7 @@ from pathlib import Path import _brief # noqa: E402 — the batch conductor puts this directory on sys.path +import _mind_writer # noqa: E402 STOP_FILE = "STOP" LOCK_FILE = "tick.lock" @@ -50,6 +53,10 @@ 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") +#: 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" +SHIFT_LEDGER = Path(__file__).resolve().parents[3] / "bin" / "shift_ledger.py" FAKE_ENV = "PYAUTO_ORCHESTRATOR_FAKE" HOME_ENV = "PYAUTO_ORCHESTRATOR_HOME" @@ -173,7 +180,23 @@ def spawn(self, task: str, prompt_path: str, lease_dir: Path) -> dict: def worker_prompt(task: str, prompt_path: str, brief_path: Path, lease_dir: Path) -> str: """The session template filled in for one task.""" return WORKER_TEMPLATE.read_text(encoding="utf-8").format( - task=task, task_path=prompt_path, brief_path=brief_path, lease_dir=lease_dir) + task=task, 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 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 + PyAutoMind is refused whatever tool makes it.""" + env = dict(os.environ if base is None else base) + n = int(env.get("GIT_CONFIG_COUNT", "0") or 0) + env.update({"PYAUTO_SHIFT_WORKER": "1", + "PYAUTO_SHIFT_LEDGER_DIR": str(Path(lease_dir).parent / _mind_writer.LEDGER_DIR), + "GIT_CONFIG_COUNT": str(n + 1), + f"GIT_CONFIG_KEY_{n}": "core.hooksPath", + f"GIT_CONFIG_VALUE_{n}": str(WORKER_HOOKS)}) + return env def session_command(task: str, prompt_path: str, brief_path: Path, lease_dir: Path, @@ -204,7 +227,8 @@ def __init__(self, brief_path: Path, mode: str, cwd: Path | None = None, run=Non def spawn(self, task: str, prompt_path: str, lease_dir: Path) -> dict: argv = session_command(task, prompt_path, self.brief_path, lease_dir, self.mode) - res = self.run(argv, cwd=self.cwd, capture_output=True, text=True, timeout=120) + res = self.run(argv, cwd=self.cwd, capture_output=True, text=True, timeout=120, + env=worker_env(lease_dir)) # An untrusted folder is refused here, fast ("Workspace not trusted") — # never a silent stall. Workers start from a folder the human trusted. if res.returncode != 0: @@ -443,13 +467,16 @@ 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: counters = read_counters(slot) - # One Mind writer (AUTONOMY.md, 2026-11-03) is phase 3c. Until it lands a - # real worker would commit to PyAutoMind itself, so real dispatch is fenced - # to briefs that declare themselves a sandbox. - if getattr(spawner, "real", False) and not _brief.is_sandbox(brief): - raise OrchestratorError("real dispatch needs `sandbox: yes` in the brief " - "until phase 3c (one Mind writer) lands") reaped = _reap(slot, counters, now, grace_s, agents, tokens) + # One Mind writer: whatever the workers queued is committed here, by the + # orchestrator alone, from a temporary worktree — never by a worker. + ledger = {"committed": None, "ops": 0} + if _mind_writer.pending_ops(slot): + try: + ledger = _mind_writer.commit_ledger( + slot, mind, f"shift {slot.name}: ledger from workers") + 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") @@ -497,7 +524,7 @@ 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)} + "refused": refused, "live": sorted(live), "ledger": ledger} def main(argv=None) -> int: diff --git a/agents/conductors/batch/worker_template.md b/agents/conductors/batch/worker_template.md index 922ff65..fc22004 100644 --- a/agents/conductors/batch/worker_template.md +++ b/agents/conductors/batch/worker_template.md @@ -10,8 +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. -- Never commit to PyAutoMind yourself: write any ledger line you would have - committed as a file in {lease_dir}/ and the orchestrator commits it. +- 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 + your `active.md` claim, the `draft/ → active/` move and your close-out. - When you finish, write {lease_dir}/{task}.done containing the URLs of the PRs you opened (or the issue you parked on), one per line. Without that file the watcher counts your session as crashed. diff --git a/bin/shift_ledger.py b/bin/shift_ledger.py new file mode 100755 index 0000000..b8cad8f --- /dev/null +++ b/bin/shift_ledger.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Hand a PyAutoMind change to the shift orchestrator instead of committing it. + +A shift worker never commits to PyAutoMind (AUTONOMY.md "One Mind writer per +shift"); it writes one op file per change here and the orchestrator commits +them all from a temporary worktree. Paths are PyAutoMind-relative and must be +ledger paths (`draft/`, `active/`, `complete/`, `batches/`, the registries). + + shift_ledger.py append active.md --text-file entry.md + shift_ledger.py write complete/2026/11/x.md --text-file record.md + shift_ledger.py move draft/feature/x/y.md active/y.md + shift_ledger.py remove active/y.md + shift_ledger.py drop-section active.md my-task + +The ledger directory is `$PYAUTO_SHIFT_LEDGER_DIR` (set for every worker). +""" +import argparse +import json +import os +import sys +import time +from pathlib import Path + +BATCH = Path(__file__).resolve().parents[1] / "agents" / "conductors" / "batch" +sys.path.insert(0, str(BATCH)) +import _mind_writer as mw # noqa: E402 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(prog="shift_ledger", description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("op", choices=mw.OPS) + ap.add_argument("path") + ap.add_argument("arg", nargs="?", help="move: destination; drop-section: section") + ap.add_argument("--text-file", type=Path) + ap.add_argument("--text") + ap.add_argument("--mind", type=Path, default=os.environ.get("PYAUTO_MIND"), + help="PyAutoMind checkout to validate paths against (read only)") + a = ap.parse_args(argv) + ledger = os.environ.get("PYAUTO_SHIFT_LEDGER_DIR") + if not ledger: + print("shift_ledger: PYAUTO_SHIFT_LEDGER_DIR is not set — this is for " + "shift workers only", file=sys.stderr) + return 2 + op = {"op": a.op, "path": a.path} + if a.op == "move": + op["to"] = a.arg + if a.op == "drop-section": + op["section"] = a.arg + if a.op in ("append", "write"): + op["text"] = a.text_file.read_text(encoding="utf-8") if a.text_file else (a.text or "") + if a.mind: + errs = mw.validate_op(op, mw._ledger_merge(a.mind)) + if errs: + print("shift_ledger: refused — " + "; ".join(errs), file=sys.stderr) + return 1 + d = Path(ledger) + d.mkdir(parents=True, exist_ok=True) + name = f"{time.time_ns():020d}-{os.getpid()}-{a.op}.json" + tmp = d / (name + ".tmp") + tmp.write_text(json.dumps(op, indent=2), encoding="utf-8") + tmp.replace(d / name) + print(f"shift_ledger: queued {a.op} {a.path} → {d / name}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bin/worker_git_hooks/pre-commit b/bin/worker_git_hooks/pre-commit new file mode 100755 index 0000000..b3efc6c --- /dev/null +++ b/bin/worker_git_hooks/pre-commit @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Shift workers' pre-commit hook (AUTONOMY.md "One Mind writer per shift"). +# A worker's environment points core.hooksPath here (GIT_CONFIG_* set by the +# orchestrator's spawner), so this runs for every commit the worker makes, in +# every repo. In PyAutoMind it refuses; anywhere else it hands over to the +# repo's own pre-commit hook, if it has one. +set -u +if [ "${PYAUTO_SHIFT_WORKER:-}" = "1" ]; then + origin="$(git config --get remote.origin.url 2>/dev/null || true)" + case "$origin" in + */PyAutoMind|*/PyAutoMind.git|*/PyAutoMind/) + echo "pre-commit: shift workers never commit to PyAutoMind — hand the change to the orchestrator with" >&2 + echo " bin/shift_ledger.py append|write|move|remove|drop-section ... (ledger: \$PYAUTO_SHIFT_LEDGER_DIR)" >&2 + exit 1 ;; + esac +fi +# The repo's OWN hook: `--git-path hooks/...` would resolve to this overridden +# hooks dir and re-run this script, so read the real one from the git dir. +own="$(git rev-parse --git-common-dir 2>/dev/null || true)/hooks/pre-commit" +if [ -n "$own" ] && [ -x "$own" ]; then + exec "$own" "$@" +fi +exit 0 diff --git a/tests/test_orchestrator_dispatch.py b/tests/test_orchestrator_dispatch.py index 14dfc93..4e5a426 100644 --- a/tests/test_orchestrator_dispatch.py +++ b/tests/test_orchestrator_dispatch.py @@ -161,11 +161,12 @@ def test_a_failed_spawn_leaves_no_lease(tmp_path): assert orch.leases(orch.slot_dir(brief, home)) == {} -def test_real_dispatch_outside_a_sandbox_brief_is_refused(tmp_path): +def test_real_dispatch_no_longer_needs_a_sandbox_brief(tmp_path): + # 3c (one Mind writer) lifted 3b's fence — human decision 2026-11-03. mind, brief, home = _shift(tmp_path) brief.write_text(brief.read_text().replace(" - sandbox: yes\n", "")) - with pytest.raises(orch.OrchestratorError, match="sandbox: yes"): - _tick(brief, mind, home, NOW, FakeClaude()) + r = _tick(brief, mind, home, NOW, FakeClaude()) + assert [s["task"] for s in r["spawned"]] == ["t1"] # --- liveness: running, blocked → parked, done, gone --------------------------------- diff --git a/tests/test_orchestrator_mind_writer.py b/tests/test_orchestrator_mind_writer.py new file mode 100644 index 0000000..6cadab9 --- /dev/null +++ b/tests/test_orchestrator_mind_writer.py @@ -0,0 +1,254 @@ +"""Autonomous orchestrators phase 3c (PyAutoBrain#559): one Mind writer per shift. + +Real git throughout: a bare `PyAutoMind.git` remote, a "canonical" clone the +orchestrator commits through, and a second clone that races it. Workers queue +ops with `bin/shift_ledger.py`; only `commit_ledger` writes to the remote, from +a temporary worktree, and a hook in the worker's environment refuses a direct +Mind commit. +""" + +from __future__ import annotations + +import datetime as _dt +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +BRAIN = Path(__file__).resolve().parents[1] +BATCH = BRAIN / "agents" / "conductors" / "batch" +sys.path.insert(0, str(BRAIN / "agents" / "faculties" / "sizing")) +sys.path.insert(0, str(BATCH)) +import _mind_writer as mw # noqa: E402 +import _orchestrator as orch # noqa: E402 + +LEDGER_MERGE = BRAIN.parent / "PyAutoMind" / "scripts" / "ledger_merge.py" +if not LEDGER_MERGE.is_file(): + LEDGER_MERGE = Path(os.environ.get("PYAUTO_MIND", "/nonexistent")) / "scripts" / "ledger_merge.py" +pytestmark = pytest.mark.skipif( + not LEDGER_MERGE.is_file() or shutil.which("git") is None, + reason="needs git and a PyAutoMind checkout beside this Brain (scripts/ledger_merge.py)") + +ACTIVE = """# Active tasks + +## t2 +- issue: https://example.invalid/issues/2 +- prompt: active/t2.md +""" + + +def git(cwd, *args, env=None, check=True): + res = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, env=env) + if check: + assert res.returncode == 0, (args, res.stderr) + return res + + +def _identity(repo): + git(repo, "config", "user.name", "Test") + git(repo, "config", "user.email", "test@example.invalid") + + +@pytest.fixture +def mind(tmp_path): + """(bare remote, canonical clone, second clone).""" + bare = tmp_path / "remote" / "PyAutoMind.git" + git(tmp_path, "init", "-q", "--bare", "-b", "main", str(bare)) + seed = tmp_path / "seed" + git(tmp_path, "clone", "-q", str(bare), str(seed)) + _identity(seed) + (seed / "scripts").mkdir() + shutil.copy(LEDGER_MERGE, seed / "scripts" / "ledger_merge.py") + (seed / "active.md").write_text(ACTIVE) + (seed / "draft" / "feature" / "x").mkdir(parents=True) + (seed / "draft" / "feature" / "x" / "t1.md").write_text("# t1\n") + (seed / "active").mkdir() + (seed / "active" / "t2.md").write_text("# t2\n") + git(seed, "add", "-A") + git(seed, "commit", "-q", "-m", "seed") + git(seed, "push", "-q", "origin", "HEAD:main") + canon = tmp_path / "canon" + other = tmp_path / "other" + for c in (canon, other): + git(tmp_path, "clone", "-q", str(bare), str(c)) + _identity(c) + # The canonical checkout is shared and may be dirty with someone else's work. + (canon / "someone_elses_notes.md").write_text("untracked, not ours\n") + return bare, canon, other + + +def _queue(slot, *argv, mind_repo): + env = {**os.environ, "PYAUTO_SHIFT_LEDGER_DIR": str(slot / "ledger"), + "PYAUTO_MIND": str(mind_repo)} + return subprocess.run([sys.executable, str(BRAIN / "bin" / "shift_ledger.py"), *argv], + capture_output=True, text=True, env=env) + + +def _claim_and_close(tmp_path, slot, canon): + """Worker t1 claims its task; worker t2 closes its out.""" + entry = tmp_path / "entry.md" + entry.write_text("\n## t1\n- issue: https://example.invalid/issues/1\n") + record = tmp_path / "record.md" + record.write_text("## t2\n- completed: 2026-11-04\n") + for argv in (["move", "draft/feature/x/t1.md", "active/t1.md"], + ["append", "active.md", "--text-file", str(entry)], + ["write", "complete/2026/11/t2.md", "--text-file", str(record)], + ["drop-section", "active.md", "t2"], + ["remove", "active/t2.md"]): + res = _queue(slot, *argv, mind_repo=canon) + assert res.returncode == 0, res.stderr + + +def _remote_tree(bare, path): + return git(bare, "show", f"main:{path}", check=False) + + +def test_two_workers_land_in_one_orchestrator_commit(tmp_path, mind, monkeypatch): + # Bytecode writing ON, whatever the shell says: CI writes it, and an import + # of Mind's scripts once left scripts/__pycache__/ in the canonical tree. + monkeypatch.setattr(sys, "dont_write_bytecode", False) + bare, canon, _ = mind + slot = tmp_path / "slot" + before = (git(canon, "status", "--porcelain").stdout, git(canon, "rev-parse", "HEAD").stdout) + _claim_and_close(tmp_path, slot, canon) + res = mw.commit_ledger(slot, canon, "shift test: ledger from workers") + assert res["ops"] == 5 and res["tries"] == 1 and res["committed"] + log = git(bare, "log", "--format=%s", "main").stdout.splitlines() + assert log == ["shift test: ledger from workers", "seed"] # one commit + assert _remote_tree(bare, "active/t1.md").returncode == 0 + assert _remote_tree(bare, "draft/feature/x/t1.md").returncode != 0 + assert _remote_tree(bare, "active/t2.md").returncode != 0 + assert "completed: 2026-11-04" in _remote_tree(bare, "complete/2026/11/t2.md").stdout + active = _remote_tree(bare, "active.md").stdout + assert "## t1" in active and "## t2" not in active + # The canonical checkout was never touched: same status, same HEAD, and + # no temporary worktree left registered. + after = (git(canon, "status", "--porcelain").stdout, git(canon, "rev-parse", "HEAD").stdout) + assert after == before + assert len(git(canon, "worktree", "list").stdout.splitlines()) == 1 + assert mw.pending_ops(slot) == [] + assert len(list((slot / "ledger" / "committed").glob("*.json"))) == 5 + + +def test_a_concurrent_commit_is_replayed_over_never_forced(tmp_path, mind, monkeypatch): + bare, canon, other = mind + slot = tmp_path / "slot" + _claim_and_close(tmp_path, slot, canon) + real_git, raced = mw._git, [] + + def racing_git(cwd, *args, check=True): + if args[:1] == ("push",) and not raced: + # Someone else moves main between our fetch and our push. + (other / "planned.md").write_text("# Planned\n") + git(other, "add", "planned.md") + git(other, "commit", "-q", "-m", "someone else") + git(other, "push", "-q", "origin", "HEAD:main") + raced.append(True) + assert "--force" not in args and "-f" not in args and "reset" not in args + return real_git(cwd, *args, check=check) + + monkeypatch.setattr(mw, "_git", racing_git) + res = mw.commit_ledger(slot, canon, "shift test: ledger from workers") + assert res["tries"] == 2 + log = git(bare, "log", "--format=%s", "main").stdout.splitlines() + assert log == ["shift test: ledger from workers", "someone else", "seed"] + + +def test_a_non_ledger_path_is_refused_before_anything_moves(tmp_path, mind): + bare, canon, _ = mind + slot = tmp_path / "slot" + res = _queue(slot, "write", "scripts/evil.py", "--text", "x", mind_repo=canon) + assert res.returncode == 1 and "not a ledger path" in res.stderr + (slot / "ledger").mkdir(parents=True, exist_ok=True) + (slot / "ledger" / "0-x.json").write_text(json.dumps( + {"op": "write", "path": ".github/workflows/x.yml", "text": "x"})) + head = git(bare, "rev-parse", "main").stdout + with pytest.raises(mw.MindWriteError, match="not a ledger path"): + mw.commit_ledger(slot, canon, "nope") + assert git(bare, "rev-parse", "main").stdout == head + + +def test_an_op_that_cannot_apply_stays_pending(tmp_path, mind): + bare, canon, _ = mind + slot = tmp_path / "slot" + assert _queue(slot, "remove", "active/missing.md", mind_repo=canon).returncode == 0 + head = git(bare, "rev-parse", "main").stdout + with pytest.raises(mw.MindWriteError, match="does not exist"): + mw.commit_ledger(slot, canon, "nope") + assert git(bare, "rev-parse", "main").stdout == head + assert len(mw.pending_ops(slot)) == 1 + assert len(git(canon, "worktree", "list").stdout.splitlines()) == 1 + + +def test_the_worker_hook_refuses_a_direct_mind_commit(tmp_path, mind): + _, canon, _ = mind + env = orch.worker_env(tmp_path / "slot" / "leases", base=dict(os.environ)) + res = git(canon, "commit", "--allow-empty", "-q", "-m", "direct", env=env, check=False) + assert res.returncode != 0 + assert "never commit to PyAutoMind" in res.stderr and "shift_ledger.py" in res.stderr + + +def test_the_worker_hook_allows_other_repos_and_chains_their_own_hook(tmp_path): + repo = tmp_path / "OtherRepo" + git(tmp_path, "init", "-q", "-b", "main", str(repo)) + _identity(repo) + git(repo, "remote", "add", "origin", "https://example.invalid/OtherRepo.git") + env = orch.worker_env(tmp_path / "slot" / "leases", base=dict(os.environ)) + assert git(repo, "commit", "--allow-empty", "-q", "-m", "ok", env=env, + check=False).returncode == 0 + own = repo / ".git" / "hooks" / "pre-commit" + own.write_text("#!/bin/sh\necho repo-own-hook-ran >&2\nexit 1\n") + own.chmod(0o755) + res = git(repo, "commit", "--allow-empty", "-q", "-m", "x", env=env, check=False) + assert res.returncode != 0 and "repo-own-hook-ran" in res.stderr + + +def test_worker_env_marks_points_and_hooks(): + env = orch.worker_env(Path("/s/leases"), base={"GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "a.b", + "GIT_CONFIG_VALUE_0": "c"}) + assert env["PYAUTO_SHIFT_WORKER"] == "1" + assert env["PYAUTO_SHIFT_LEDGER_DIR"] == "/s/ledger" + assert env["GIT_CONFIG_COUNT"] == "2" and env["GIT_CONFIG_KEY_0"] == "a.b" + assert env["GIT_CONFIG_KEY_1"] == "core.hooksPath" + assert env["GIT_CONFIG_VALUE_1"] == str(orch.WORKER_HOOKS) + + +def test_the_module_never_resets_or_forces(): + src = (BATCH / "_mind_writer.py").read_text() + code = "\n".join(l.split("#")[0] for l in src.splitlines()) + assert '"reset"' not in code and "--force" not in code and '"-f"' not in code + + +def test_a_tick_commits_what_the_workers_queued(tmp_path, mind): + bare, canon, _ = mind + (canon / "draft" / "feature" / "pyautobrain").mkdir(parents=True) + brief = tmp_path / "shift.md" + brief.write_text("""\ +# Batch 2026-11-04 am +- dispatched: 2026-11-04T08:30Z +- review-at: 2026-11-04T11:30Z +- brief: + - goals: + - 1. ledger + - backlog: none + - review-minutes: 45 + - lanes: any + - merge-cap: 0 + - token-budget: 1000 +""") + home = tmp_path / "home" + slot = orch.slot_dir(brief, home) + entry = tmp_path / "e.md" + entry.write_text("\n## t9\n- issue: x\n") + assert _queue(slot, "append", "active.md", "--text-file", str(entry), + mind_repo=canon).returncode == 0 + r = orch.tick(brief, canon, home=home, + now=_dt.datetime(2026, 11, 4, 9, 0, tzinfo=_dt.timezone.utc), + spawner=orch.FakeSpawner(), records=[]) + assert r["ledger"]["ops"] == 1 and r["ledger"]["committed"] + assert "## t9" in _remote_tree(bare, "active.md").stdout diff --git a/tests/test_orchestrator_watcher.py b/tests/test_orchestrator_watcher.py index c58b24f..eaa70fc 100644 --- a/tests/test_orchestrator_watcher.py +++ b/tests/test_orchestrator_watcher.py @@ -238,15 +238,14 @@ def test_a_refused_brief_dispatches_nothing(tmp_path): # --- fake mode only, and the entry -------------------------------------------------- -def test_real_dispatch_needs_a_sandbox_brief_until_3c(tmp_path, monkeypatch): - # 3b replaced 3a's blanket refusal: real workers exist, but only a - # `sandbox: yes` brief may start one until the one-Mind-writer path (3c). +def test_without_the_fake_flag_the_spawner_is_real(tmp_path, monkeypatch): + # No tick here: since 3c lifted the sandbox fence a tick would start a real + # `claude --bg`. The choice of spawner is what this pins. monkeypatch.delenv(orch.FAKE_ENV, raising=False) - mind, brief, home = _shift(tmp_path, names=("t1",)) - with pytest.raises(orch.OrchestratorError, match="sandbox: yes"): - orch.tick(brief, mind, home=home, now=NOW) - # The lease it could not fill is not left behind to block the task. - assert orch.leases(orch.slot_dir(brief, home)) == {} + sp = orch.default_spawner(tmp_path / "shift.md", {"worker-mode": {"value": "dontAsk"}}) + assert isinstance(sp, orch.RealSpawner) and sp.mode == "dontAsk" + monkeypatch.setenv(orch.FAKE_ENV, "1") + assert isinstance(orch.default_spawner(tmp_path / "shift.md", {}), orch.FakeSpawner) def test_home_comes_from_the_environment(tmp_path, monkeypatch):