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:
1 parent
2679ddfffd
commit
657b4b70ed
5 files changed
+696
-2
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user