feat(issue-21): worktree_prepare.sh / worktree_remove.sh (opt-in submodules; judge before any change, single verified --force for submodule worktrees); create starts its reconcile and watchdog from the control root

- worktree_prepare.sh (create skill): idempotent `git worktree add` next to the
  control root (a path inside it is refused), .venv linked to the control
  root's, /.venv and .pytest_cache/ in the shared info/exclude, submodules only
  with --submodules - a failed init removes its own traces (modules/, new
  submodule.* keys in the shared config) and exits 1, since git refuses a plain
  `worktree remove` once modules/ exists - and an optional creator registered
  in the control root's .mam with MAM_NO_SUBSCRIBER_DAEMON=1. Prints BASE= for
  `run_loop.sh --worktree ... --base ... --review-only`.
- worktree_remove.sh (stop skill): every check first, nothing changed on a
  refusal (exit 3): linked and not locked, running sessions only with --stop,
  no open job of those sessions except the delegate jobs stop cancels, clean
  tree including submodules, and inside submodules no modification and no
  commit on no remote (HEAD included - submodules sit on a detached HEAD).
  Then stop, re-check jobs, and remove: plain, or a single --force when the
  worktree holds submodule state.
- lib_py/worktree.py: the parsing behind both (stdlib only).
- A8: create's immediate reconcile and watchdog use the control root's code
  and WORKSPACE_ROOT, not the worktree --workspace's copy.

Tests W1-W6d (local submodules allowed through GIT_CONFIG_* env only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
This commit is contained in:
GodopuandClaude Opus 5 committed 2026-09-14 15:24:10 +00:00
1 parent 2679ddfffd
commit 657b4b70ed
5 files changed
+696 -2

No files matched your search

+185
View File
@@ -246,3 +246,188 @@ def test_w9_already_running_resume_persists_the_binding(mam_sandbox, mock_herdr,
_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