"""Issue #21 phase 2, loop A: worktree helpers (item 4), resume only at the original cwd, loud control-plane binding failures (recommendation 1), the binding persisted on resume's already-running path (recommendation 2), and the installer's `/.venv` ignore rule (#27). Every git repository lives under tmp_path / mam_sandbox; local submodules are allowed through GIT_CONFIG_* environment only (never global config), and every subprocess gets WORKSPACE_ROOT so lib.sh's .mam/shim lands in the sandbox. """ from __future__ import annotations import json import os import shutil import subprocess import sys import time from pathlib import Path import pytest REPO_ROOT = Path(__file__).resolve().parent.parent SKILLS = REPO_ROOT / ".agents" / "skills" if str(SKILLS) not in sys.path: sys.path.insert(0, str(SKILLS)) GIT_ID = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid", "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid"} FILE_OK = {"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "protocol.file.allow", "GIT_CONFIG_VALUE_0": "always"} FILE_NO = {"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "protocol.file.allow", "GIT_CONFIG_VALUE_0": "never"} PREP = ("multi-agent-mux-create", "scripts", "worktree_prepare.sh") REM = ("multi-agent-mux-stop", "scripts", "worktree_remove.sh") CP_KEYS = ("workspace_root", "agent_sessions_yaml", "mam_jobs_dir", "delegate_job_logs_dir") def _git(cwd, *args, env=None, check=True): return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True, check=check, env={**os.environ, **GIT_ID, **(env or {})}) def _skill(root: Path, *parts) -> str: return str(root / ".agents" / "skills" / Path(*parts)) def _run(root: Path, parts, *args, env=None, timeout=180): return subprocess.run(["bash", _skill(root, *parts), *args], capture_output=True, text=True, cwd=str(root), env={**os.environ, "WORKSPACE_ROOT": str(root), **(env or {})}, timeout=timeout) def _rows(root: Path) -> list: import yaml return (yaml.safe_load((root / ".mam" / "agent-sessions.yaml").read_text()) or {}).get("herdr_sessions") or [] def _mutate(root: Path, code: str) -> None: res = subprocess.run(["bash", "-c", f"source {_skill(root, 'lib.sh')} && atomic_dump_yaml {root / '.mam' / 'agent-sessions.yaml'}"], input=code, capture_output=True, text=True, env={**os.environ, "WORKSPACE_ROOT": str(root)}) assert res.returncode == 0, res.stderr def _calls(mock_state_file, since=0) -> list: calls = json.loads(Path(mock_state_file).read_text()).get("calls", [])[since:] return [c[2:] if c[:1] == ["--session"] else c for c in calls] def _spawn_calls(mock_state_file, since=0) -> list: return [c for c in _calls(mock_state_file, since) if c[:2] in (["pane", "split"], ["workspace", "create"], ["agent", "start"])] def _worktrees(main: Path) -> str: return _git(main, "worktree", "list", "--porcelain").stdout @pytest.fixture def ctl(mam_sandbox, monkeypatch): """The sandbox is the control root: a git checkout with one commit and a .venv.""" for k, v in GIT_ID.items(): monkeypatch.setenv(k, v) main = mam_sandbox _git(main, "init", "-q") (main / "README").write_text("r\n") _git(main, "add", "README") _git(main, "commit", "-q", "-m", "init") (main / ".venv").mkdir(exist_ok=True) return main @pytest.fixture def ctl_sub(ctl): """Control root with a submodule (bare local remote) left uninitialised in the main checkout, like nats-docker.""" main = ctl src, bare = main.parent / f"{main.name}-subsrc", main.parent / f"{main.name}-sub.git" src.mkdir() _git(src, "init", "-q") (src / "s.txt").write_text("s\n") _git(src, "add", "s.txt") _git(src, "commit", "-q", "-m", "s") subprocess.run(["git", "clone", "-q", "--bare", str(src), str(bare)], check=True, capture_output=True) _git(main, "submodule", "add", "-q", str(bare), "sm", env=FILE_OK) _git(main, "commit", "-q", "-m", "add sm") _git(main, "submodule", "deinit", "-q", "-f", "sm") assert _git(main, "config", "--get", "submodule.sm.url", check=False).returncode != 0 return main def _wt(main: Path, name: str = "wt") -> Path: return main.parent / f"{main.name}-{name}" def _prepare(main, wt, *extra, env=None): return _run(main, PREP, "--path", str(wt), "--branch", f"b-{wt.name}", *extra, env=env) # --------------------------------------------------------------------------- # #27: the managed .gitignore block ignores .venv as a file, directory or link # --------------------------------------------------------------------------- def _install_env(): env = dict(os.environ) env["MAM_REPO_URL"] = str(REPO_ROOT) env["MAM_SKIP_VENV"] = "1" env["MAM_INSTALLER_URL"] = "file://" + str(REPO_ROOT / "deploy" / "install.sh") return env GI_START = "# >>> MAM managed block (managed by install.sh — do not edit) >>>" GI_END = "# <<< MAM managed block <<<" def _block(gi: str) -> list: lines = gi.splitlines() return lines[lines.index(GI_START) + 1:lines.index(GI_END)] @pytest.mark.parametrize("installer", ["install.sh", "install_mam.sh"]) def test_w10_managed_block_ignores_venv_in_every_form(tmp_path, installer): tgt = tmp_path / "tgt" tgt.mkdir() cmd = ["bash", str(REPO_ROOT / "deploy" / installer)] + ([str(tgt)] if installer == "install.sh" else ["--target", str(tgt)]) res = subprocess.run(cmd, capture_output=True, text=True, env=_install_env(), cwd=str(tmp_path), timeout=300) assert res.returncode == 0, res.stdout[-2000:] + res.stderr[-2000:] block = _block((tgt / ".gitignore").read_text()) assert "/.venv" in block and "/.venv/" not in block _git(tgt, "init", "-q") v = tgt / ".venv" def clear(): if v.is_symlink() or v.is_file(): v.unlink() elif v.exists(): shutil.rmtree(v) if v.exists() or v.is_symlink(): # whatever the installer itself left there is ignored too assert _git(tgt, "check-ignore", "-q", ".venv", check=False).returncode == 0 for make in (lambda p: p.symlink_to(tmp_path), lambda p: p.write_text("x"), lambda p: p.mkdir()): clear() make(v) assert _git(tgt, "check-ignore", "-q", ".venv", check=False).returncode == 0, make clear() def test_w11_reinstall_replaces_the_old_rule_and_keeps_user_lines(tmp_path): tgt = tmp_path / "tgt" tgt.mkdir() # an installation made before #27: the same markers, the old directory-only rule (tgt / ".gitignore").write_text(f"user-one\n{GI_START}\n/.venv/\n/.mam/\n{GI_END}\nuser-two\n") res = subprocess.run(["bash", str(REPO_ROOT / "deploy" / "install.sh"), str(tgt)], capture_output=True, text=True, env=_install_env(), cwd=str(tmp_path), timeout=300) assert res.returncode == 0, res.stdout[-2000:] + res.stderr[-2000:] gi = (tgt / ".gitignore").read_text() assert gi.count(GI_START) == 1 and gi.count(GI_END) == 1 assert "/.venv" in _block(gi) and "/.venv/" not in _block(gi) assert "user-one" in gi.splitlines() and "user-two" in gi.splitlines() for ledger in list(tgt.rglob("asset_hashes.txt")) + list(tgt.rglob("install_manifest.txt")): assert ".gitignore" not in ledger.read_text().split(), ledger # --------------------------------------------------------------------------- # resume: original cwd only (A11), binding failures (A12), already running (A13) # --------------------------------------------------------------------------- def _resume(root, ws, name, agent="claude", env=None): return _run(root, ("multi-agent-mux-resume", "scripts", "resume_session.sh"), "--workspace", str(ws), "--agent", agent, "--session", name, env=env) def test_w7_resume_only_at_the_original_cwd(mam_sandbox, mock_herdr, mock_agents): root = mam_sandbox here, there, gone = root / "here", root / "there", root / "gone" here.mkdir() there.mkdir() _mutate(root, f"""d['herdr_sessions'] = [ {{'name': 'x-creator-claude', 'status': 'stopped', 'pane': {{'cwd': {str(here)!r}, 'cmd': 'claude'}}, 'claude_session_id_own': '11111111-2222-4333-8444-555555555555', 'session_id_source': 'assigned'}}, {{'name': 'x-creator-agy', 'status': 'stopped', 'pane': {{'cwd': {str(here)!r}, 'cmd': 'agy'}}}}, {{'name': 'y-creator-claude', 'status': 'stopped', 'pane': {{'cwd': {str(gone)!r}, 'cmd': 'claude'}}}}]""") since = len(_calls(mock_herdr)) res = _resume(root, there, "x-creator-claude") assert res.returncode == 2 and f"--workspace '{here}'" in res.stderr res = _resume(root, there, "x-creator-agy", agent="agy") assert res.returncode == 2 res = _resume(root, gone, "y-creator-claude") assert res.returncode == 2 and "no longer exists" in res.stderr assert _spawn_calls(mock_herdr, since) == [], "nothing was spawned in the wrong directory" def test_w8_binding_failure_refused_for_a_worktree_warned_for_the_control_root(mam_sandbox, mock_herdr, mock_agents): root = mam_sandbox Path(_skill(root, "lib_py", "control_plane.py")).write_text("raise SystemExit(1)\n") wt = root.parent / f"{root.name}-wt8" wt.mkdir() since = len(_calls(mock_herdr)) create = ("multi-agent-mux-create", "scripts", "create_session.sh") res = _run(root, create, "--workspace", str(wt), "--agent", "claude", "--role", "creator", "--no-onboard") assert res.returncode == 2 and "cannot compute the control-plane binding" in res.stderr assert _rows(root) == [] and _spawn_calls(mock_herdr, since) == [] res = _run(root, create, "--workspace", str(root), "--agent", "claude", "--role", "creator", "--no-onboard") assert res.returncode == 0 and "WARNING: control-plane binding unavailable" in res.stderr, res.stderr (row,) = _rows(root) assert "control_plane" not in row _mutate(root, f"""d['herdr_sessions'].append({{'name': 'w8-creator-claude', 'status': 'stopped', 'pane': {{'cwd': {str(wt)!r}, 'cmd': 'claude'}}, 'claude_session_id_own': '11111111-2222-4333-8444-555555555555', 'session_id_source': 'assigned'}})""") res = _resume(root, wt, "w8-creator-claude") assert res.returncode == 2 and "cannot compute the control-plane binding" in res.stderr def test_w9_already_running_resume_persists_the_binding(mam_sandbox, mock_herdr, mock_agents): root = mam_sandbox create = ("multi-agent-mux-create", "scripts", "create_session.sh") res = _run(root, create, "--workspace", str(root), "--agent", "claude", "--role", "creator", "--no-onboard") assert res.returncode == 0, res.stdout + res.stderr name = _rows(root)[0]["name"] _mutate(root, "d['herdr_sessions'][0].pop('control_plane', None)") res = _resume(root, root, name) assert res.returncode == 0 and "already running" in res.stdout, res.stdout + res.stderr row = _rows(root)[0] assert set(row["control_plane"]) == set(CP_KEYS) assert row["control_plane"]["workspace_root"] == os.path.realpath(root) custom = {**row["control_plane"], "mam_jobs_dir": str(root / "custom-jobs")} _mutate(root, f"d['herdr_sessions'][0]['control_plane'] = {custom!r}") assert _resume(root, root, name).returncode == 0 assert _rows(root)[0]["control_plane"] == custom, "an existing row binding is kept" # --------------------------------------------------------------------------- # prepare # --------------------------------------------------------------------------- def test_w1_prepare_worktree_venv_exclude_base(ctl_sub): main = ctl_sub wt = _wt(main) for _ in range(2): res = _prepare(main, wt) assert res.returncode == 0, res.stdout + res.stderr assert os.path.realpath(wt) in _worktrees(main) assert os.readlink(wt / ".venv") == str(main / ".venv") excl = (main / ".git" / "info" / "exclude").read_text().splitlines() assert excl.count("/.venv") == 1 and excl.count(".pytest_cache/") == 1 assert _git(wt, "status", "--porcelain").stdout == "" assert f"BASE={_git(wt, 'rev-parse', 'HEAD').stdout.strip()}" in res.stdout assert "SUBMODULES=skipped" in res.stdout gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip() assert not os.path.exists(os.path.join(gd, "modules")), "no submodule trace without --submodules" assert _git(main, "config", "--get", "submodule.sm.url", check=False).returncode != 0 def test_w2_prepare_refusals_and_dry_run(ctl): main = ctl res = _run(main, PREP, "--path", str(main / "inside"), "--branch", "x") assert res.returncode == 2 and "inside the control root" in res.stderr wt = _wt(main) assert _prepare(main, wt).returncode == 0 res = _run(main, PREP, "--path", str(_wt(main, "other")), "--branch", f"b-{wt.name}") assert res.returncode == 1, "a branch checked out in another worktree: git's refusal is passed on" dry = _wt(main, "dry") res = _run(main, PREP, "--path", str(dry), "--branch", "dry", "--dry-run") assert res.returncode == 0 and "[dry-run]" in res.stdout assert not dry.exists() and _git(main, "show-ref", "--verify", "--quiet", "refs/heads/dry", check=False).returncode != 0 def test_w3_w4_prepare_creator_binding_no_daemon_control_root_reconcile(ctl, mock_herdr, mock_agents, monkeypatch): """The creator row points at the worktree but belongs to the control plane; prepare suppresses the subscriber daemon; create's immediate reconcile runs the control root's reconcile.sh, not the worktree's copy (A8).""" from conftest import _subscriber_daemon_pids main = ctl wt = _wt(main) marker_a, marker_b = main / "marker-control-root", main / "marker-worktree" ctl_reconcile = Path(_skill(main, "multi-agent-mux-monitor", "scripts", "reconcile.sh")) ctl_reconcile.write_text(f"#!/usr/bin/env bash\ntouch '{marker_a}'\n") monkeypatch.delenv("MAM_NO_SUBSCRIBER_DAEMON", raising=False) assert _prepare(main, wt).returncode == 0 wt_reconcile = wt / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" wt_reconcile.parent.mkdir(parents=True) wt_reconcile.write_text(f"#!/usr/bin/env bash\ntouch '{marker_b}'\n") _git(wt, "add", ".agents") _git(wt, "commit", "-q", "-m", "worktree copy of reconcile") res = _prepare(main, wt, "--creator-agent", "claude", "--onboard") assert res.returncode == 0, res.stdout + res.stderr (row,) = _rows(main) assert os.path.realpath(row["pane"]["cwd"]) == os.path.realpath(wt) assert row["control_plane"]["workspace_root"] == os.path.realpath(main) deadline = time.time() + 10 while time.time() < deadline and not marker_a.exists(): time.sleep(0.2) assert marker_a.exists(), "the control root's reconcile.sh ran" assert not marker_b.exists(), "the worktree's reconcile.sh must not run" assert _subscriber_daemon_pids(main) == [], "prepare passes MAM_NO_SUBSCRIBER_DAEMON=1" def test_w6c_submodule_init_failure_rolls_back_and_prepare_recovers(ctl_sub): main = ctl_sub wt = _wt(main) res = _prepare(main, wt, "--submodules", "--creator-agent", "claude", env=FILE_NO) assert res.returncode == 1 and "submodule initialisation failed" in res.stderr gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip() assert not os.path.exists(os.path.join(gd, "modules")) assert _git(main, "config", "--get", "submodule.sm.url", check=False).returncode != 0 assert _rows(main) == [], "no creator after a failed step" assert _prepare(main, wt).returncode == 0, "re-running without --submodules finishes the preparation" res = _run(main, REM, "--path", str(wt)) assert res.returncode == 0 and "removal method: plain" in res.stdout, res.stdout + res.stderr # --------------------------------------------------------------------------- # remove # --------------------------------------------------------------------------- def _row(main, name, wt, status="running", job=None): return (f"{{'name': {name!r}, 'status': {status!r}, 'role': 'creator', 'delegate_job_id': {job!r}, " f"'pane': {{'cwd': {str(wt)!r}, 'cmd': 'claude'}}}}") def test_w5_remove_refusals_change_nothing(ctl, mock_herdr, mock_agents): from lib_py.delegation import registry main = ctl wt = _wt(main) assert _prepare(main, wt).returncode == 0 jobs = main / ".mam" / "jobs" own = registry.register_job("own", registry_dir=str(jobs), agent_session="herdr:wt-creator-claude") _mutate(main, f"d['herdr_sessions'] = [{_row(main, 'wt-creator-claude', wt, job=own)}]") res = _run(main, REM, "--path", str(wt)) # (a) running, no --stop assert res.returncode == 3 and "pass --stop" in res.stderr res = _run(main, REM, "--path", str(wt), "--stop", "--dry-run") assert res.returncode == 0, "with --stop the row's own delegate job is not a refusal" other = registry.register_job("other", registry_dir=str(jobs), agent_session="herdr:wt-creator-claude") res = _run(main, REM, "--path", str(wt), "--stop") # (b) another open job assert res.returncode == 3 and other in res.stderr rec = json.loads((jobs / f"{other}.json").read_text()) rec["status"] = "completed" (jobs / f"{other}.json").write_text(json.dumps(rec)) (wt / "dirty.txt").write_text("x\n") # (c) dirty res = _run(main, REM, "--path", str(wt), "--stop") assert res.returncode == 3 and "uncommitted changes" in res.stderr (wt / "dirty.txt").unlink() _git(main, "worktree", "lock", str(wt)) # (d) locked res = _run(main, REM, "--path", str(wt), "--stop") assert res.returncode == 3 and "locked" in res.stderr _git(main, "worktree", "unlock", str(wt)) assert os.path.realpath(wt) in _worktrees(main) (row,) = _rows(main) assert row["status"] == "running", "no refusal may stop a session first" assert json.loads((jobs / f"{own}.json").read_text())["status"] == "pending" def test_w6_remove_plain_and_with_stop(ctl, mock_herdr, mock_agents): main = ctl wt1, wt2 = _wt(main, "one"), _wt(main, "two") assert _prepare(main, wt1).returncode == 0 _mutate(main, f"d['herdr_sessions'] = [{_row(main, 'one-creator-claude', wt1, status='stopped')}]") res = _run(main, REM, "--path", str(wt1)) assert res.returncode == 0 and "removal method: plain" in res.stdout, res.stdout + res.stderr assert os.path.realpath(wt1) not in _worktrees(main) assert _git(main, "show-ref", "--verify", "--quiet", f"refs/heads/b-{wt1.name}", check=False).returncode == 0 res = _prepare(main, wt2, "--creator-agent", "claude") assert res.returncode == 0, res.stdout + res.stderr name = [r for r in _rows(main) if os.path.realpath(r["pane"]["cwd"]) == os.path.realpath(wt2)][0]["name"] res = _run(main, REM, "--path", str(wt2), "--stop") assert res.returncode == 0, res.stdout + res.stderr assert [r for r in _rows(main) if r["name"] == name][0]["status"] == "stopped" assert os.path.realpath(wt2) not in _worktrees(main) def test_w6b_submodule_worktree_single_force_after_checks(ctl_sub): main = ctl_sub wt = _wt(main) res = _prepare(main, wt, "--submodules", env=FILE_OK) assert res.returncode == 0 and "SUBMODULES=initialised" in res.stdout, res.stdout + res.stderr (wt / "sm" / "junk.txt").write_text("u\n") # (i) untracked in the submodule res = _run(main, REM, "--path", str(wt)) assert res.returncode == 3 and os.path.realpath(wt) in _worktrees(main) (wt / "sm" / "junk.txt").unlink() (wt / "sm" / "new.txt").write_text("n\n") # (ii) detached commit, gitlink committed above _git(wt / "sm", "add", "new.txt") _git(wt / "sm", "commit", "-q", "-m", "local only") local = _git(wt / "sm", "rev-parse", "HEAD").stdout.strip() _git(wt, "add", "sm") _git(wt, "commit", "-q", "-m", "bump sm") assert _git(wt, "status", "--porcelain", "--ignore-submodules=none").stdout == "" res = _run(main, REM, "--path", str(wt)) assert res.returncode == 3 and "unpushed" in res.stderr, res.stdout + res.stderr assert _git(wt / "sm", "cat-file", "-e", local, check=False).returncode == 0 _git(wt / "sm", "push", "-q", "origin", "HEAD:refs/heads/pushed", env=FILE_OK) _git(wt / "sm", "fetch", "-q", "origin", env=FILE_OK) res = _run(main, REM, "--path", str(wt)) assert res.returncode == 0, res.stdout + res.stderr assert "single --force" in res.stdout and os.path.realpath(wt) not in _worktrees(main) def test_w6d_failed_init_trace_outside_prepare_is_removable(ctl_sub): main = ctl_sub wt = _wt(main) assert _prepare(main, wt).returncode == 0 assert _git(wt, "submodule", "update", "--init", env=FILE_NO, check=False).returncode != 0 gd = _git(wt, "rev-parse", "--absolute-git-dir").stdout.strip() assert os.path.isdir(os.path.join(gd, "modules")), "git leaves modules/ behind after a failed init" res = _run(main, REM, "--path", str(wt)) assert res.returncode == 0 and "single --force" in res.stdout, res.stdout + res.stderr