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

+180
View File
@@ -0,0 +1,180 @@
"""Decisions behind the worktree helpers (Issue #21 phase 2, item 4).
worktree_prepare.sh / worktree_remove.sh keep the orchestration in shell and
ask this module the questions that need parsing: is a path a linked worktree
of the control root (and is it locked), which session rows ran under it, which
of their jobs are still open, and whether its submodules hold anything a
forced removal would destroy. Standard library only.
CLI (JSON on stdout, rc 0; the session state comes from MAM_STATE_JSON):
python -m lib_py.worktree is-linked <main> <path>
python -m lib_py.worktree is-locked <main> <path>
python -m lib_py.worktree rows <path>
python -m lib_py.worktree open-jobs <path> [--exclude id,id]
python -m lib_py.worktree submodules <worktree>
"""
from __future__ import annotations
import glob
import json
import os
import subprocess
import sys
from typing import Dict, Iterable, List, Optional
OPEN_STATUSES = ("pending", "running")
def _git(cwd: str, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True)
def _real(p: str) -> str:
return os.path.realpath(p)
def worktree_blocks(main: str) -> List[Dict[str, object]]:
"""`git worktree list --porcelain`, one dict per worktree."""
res = _git(main, "worktree", "list", "--porcelain")
blocks: List[Dict[str, object]] = []
cur: Dict[str, object] = {}
for line in res.stdout.splitlines():
if not line.strip():
if cur:
blocks.append(cur)
cur = {}
continue
key, _, val = line.partition(" ")
cur[key] = val if val else True
if cur:
blocks.append(cur)
return blocks
def _block(main: str, path: str) -> Optional[Dict[str, object]]:
rp, rm = _real(path), _real(main)
if rp == rm:
return None
for b in worktree_blocks(main):
w = b.get("worktree")
if isinstance(w, str) and _real(w) == rp:
return b
return None
def is_linked_worktree(main: str, path: str) -> bool:
return _block(main, path) is not None
def is_locked(main: str, path: str) -> bool:
b = _block(main, path)
return bool(b and "locked" in b)
def rows_under(state: dict, path: str) -> List[dict]:
"""Session rows whose pane.cwd is `path` or below it."""
rp = _real(path).rstrip(os.sep)
out = []
for s in state.get("herdr_sessions") or []:
cwd = (s.get("pane") or {}).get("cwd") or ""
if not cwd:
continue
rc = _real(cwd)
if rc == rp or rc.startswith(rp + os.sep):
out.append(s)
return out
def registry_dirs(rows: Iterable[dict], env=None) -> List[str]:
env = os.environ if env is None else env
cands = [env.get("MAM_JOBS_DIR") or ""]
if env.get("WORKSPACE_ROOT"):
cands.append(os.path.join(env["WORKSPACE_ROOT"], ".mam", "jobs"))
cands += [((s.get("control_plane") or {}).get("mam_jobs_dir") or "") for s in rows]
seen, out = set(), []
for c in cands:
if c and os.path.isdir(c) and _real(c) not in seen:
seen.add(_real(c))
out.append(c)
return out
def open_jobs(rows: List[dict], dirs: Iterable[str], exclude_ids: Iterable[str] = ()) -> List[dict]:
"""Pending/running jobs addressed to one of the rows (herdr:<name>)."""
names = {f"herdr:{s.get('name')}" for s in rows if s.get("name")}
skip = {j for j in exclude_ids if j}
out, seen = [], set()
for d in dirs:
for p in sorted(glob.glob(os.path.join(d, "*.json"))):
try:
with open(p, encoding="utf-8") as fh:
rec = json.load(fh)
except (OSError, ValueError):
continue
jid = rec.get("job_id") if isinstance(rec, dict) else None
if not jid or jid in skip or jid in seen:
continue
if rec.get("agent_session") in names and rec.get("status") in OPEN_STATUSES:
seen.add(jid)
out.append({"job_id": jid, "agent_session": rec.get("agent_session"),
"status": rec.get("status"), "registry_dir": d})
return out
def submodule_findings(wt: str) -> Dict[str, object]:
"""trace: the worktree git dir holds modules/ (git then refuses a plain
`worktree remove`, even after a failed init); dirty / unpushed: what a
forced removal would destroy. `HEAD` is part of the unpushed check: a
submodule is on a detached HEAD after `submodule update`, so commits made
there are on no branch and `--branches --not --remotes` alone misses them."""
gd = _git(wt, "rev-parse", "--absolute-git-dir")
if gd.returncode != 0 or not gd.stdout.strip():
return {"trace": False, "dirty": "", "unpushed": "", "error": gd.stderr.strip() or "rev-parse failed"}
res: Dict[str, object] = {"trace": os.path.isdir(os.path.join(gd.stdout.strip(), "modules")),
"dirty": "", "unpushed": "", "error": ""}
if not res["trace"]:
return res
checks = (("dirty", "git status --porcelain"),
("unpushed", "git log --oneline HEAD --branches --not --remotes"))
errors = []
for key, cmd in checks:
r = _git(wt, "submodule", "foreach", "--recursive", "--quiet", cmd)
if r.returncode != 0:
errors.append(r.stderr.strip() or f"{key} check failed (rc {r.returncode})")
else:
res[key] = r.stdout.strip()
res["error"] = "; ".join(errors)
return res
def _state() -> dict:
try:
return json.loads(os.environ.get("MAM_STATE_JSON") or "{}") or {}
except ValueError:
return {}
def main(argv=None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
cmd = argv[0] if argv else ""
if cmd == "is-linked" and len(argv) == 3:
print(json.dumps(is_linked_worktree(argv[1], argv[2])))
elif cmd == "is-locked" and len(argv) == 3:
print(json.dumps(is_locked(argv[1], argv[2])))
elif cmd == "rows" and len(argv) == 2:
print(json.dumps(rows_under(_state(), argv[1])))
elif cmd == "open-jobs" and len(argv) in (2, 4) and (len(argv) == 2 or argv[2] == "--exclude"):
rows = rows_under(_state(), argv[1])
exclude = argv[3].replace(",", " ").split() if len(argv) == 4 else []
print(json.dumps(open_jobs(rows, registry_dirs(rows), exclude)))
elif cmd == "submodules" and len(argv) == 2:
print(json.dumps(submodule_findings(argv[1])))
else:
print(__doc__, file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -576,8 +576,10 @@ Task: $SUBMIT_JOB_PROMPT"
delegate_publish_event "$DELEGATE_JOB_ID" started "multi-agent-mux session created and instructions injected" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
# Trigger immediate priority reconcile cycle asynchronously
(bash "$WORKSPACE/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh" --once >/dev/null 2>&1 &) || true
WD_PID=$(start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE")
# Issue #21 phase 2 (A8): the control root's reconcile/watchdog, never the
# (unreviewed) copy in a worktree --workspace; identical paths by default.
(bash "$SKILL_DIR/multi-agent-mux-monitor/scripts/reconcile.sh" --once >/dev/null 2>&1 &) || true
WD_PID=$(start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE_ROOT")
echo "watchdog PID: $WD_PID"
fi
# Clear cleanup trap as registration was fully successful
@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# worktree_prepare.sh — prepare an issue worktree next to the control root
# (Issue #21 item 4). Run it from the control root's skills tree: the control
# root is lib.sh's WORKSPACE_ROOT, the main checkout that owns .mam.
#
# Usage:
# worktree_prepare.sh --path <WT> --branch <name> [--base <ref>] [--submodules]
# [--creator-agent <agent> [--session <name>] [--role <role>]
# [--herdr-session <s>] [--herdr-workspace <w>] [--onboard]] [--dry-run]
#
# Steps: git worktree (idempotent) -> .venv link to the control root's ->
# /.venv and .pytest_cache/ in the shared info/exclude -> submodules only with
# --submodules (a failed init is rolled back: it would otherwise leave a
# modules/ trace that makes a plain `git worktree remove` refuse) -> optional
# creator session registered in the control root's .mam -> summary with BASE=.
#
# Exit codes: 0 ok, 1 git / submodule / create failure, 2 usage or path rule.
set -euo pipefail
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$(cd "$_script_dir/../.." && pwd)/lib.sh"
usage() {
sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
}
WT=""; BRANCH=""; BASE_REF=""; SUBMODULES=0; DRY_RUN=0
CREATOR_AGENT=""; SESSION=""; ROLE="creator"; HS=""; HW=""; ONBOARD=0
while [ $# -gt 0 ]; do
case "$1" in
--path) WT="${2:-}"; shift 2 ;;
--branch) BRANCH="${2:-}"; shift 2 ;;
--base) BASE_REF="${2:-}"; shift 2 ;;
--submodules) SUBMODULES=1; shift ;;
--creator-agent) CREATOR_AGENT="${2:-}"; shift 2 ;;
--session) SESSION="${2:-}"; shift 2 ;;
--role) ROLE="${2:-}"; shift 2 ;;
--herdr-session) HS="${2:-}"; shift 2 ;;
--herdr-workspace) HW="${2:-}"; shift 2 ;;
--onboard) ONBOARD=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; usage >&2; exit 2 ;;
esac
done
[ -n "$WT" ] && [ -n "$BRANCH" ] || { echo "ERROR: --path and --branch are required" >&2; usage >&2; exit 2; }
MAIN="$(mam_abs_workspace "$WORKSPACE_ROOT")"
top="$(git -C "$MAIN" rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "$top" ] || [ "$(mam_abs_workspace "$top")" != "$MAIN" ]; then
echo "ERROR: the control root '$MAIN' (WORKSPACE_ROOT) is not the top of a git checkout" >&2
exit 2
fi
case "$WT" in /*) ;; *) WT="$PWD/$WT" ;; esac
WT="${WT%/}"
if [ -d "$WT" ]; then
WT="$(mam_abs_workspace "$WT")"
elif [ -d "$(dirname "$WT")" ]; then
WT="$(mam_abs_workspace "$(dirname "$WT")")/$(basename "$WT")"
fi
case "$WT/" in
"$MAIN"/*)
echo "ERROR: '$WT' is inside the control root '$MAIN'; a worktree there shows up as untracked files in the main tree. Put it next to the control root instead." >&2
exit 2 ;;
esac
PY="$(_delegate_py_bin)"
wt_py() { PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$PY" -m lib_py.worktree "$@"; }
if [ -z "$SESSION" ] && [ -n "$CREATOR_AGENT" ]; then
slug="$(printf '%s' "$BRANCH" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9-' '-' | sed -E 's/-+/-/g; s/^-//; s/-$//')"
SESSION="creator-${CREATOR_AGENT}-${slug}"
SESSION="${SESSION:0:60}"
SESSION="${SESSION%-}"
fi
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] control root: $MAIN"
echo "[dry-run] worktree: $WT (branch $BRANCH${BASE_REF:+, base $BASE_REF})"
echo "[dry-run] .venv link and shared info/exclude entries; submodules: $([ "$SUBMODULES" = 1 ] && echo init || echo skipped)"
[ -n "$CREATOR_AGENT" ] && echo "[dry-run] creator: $CREATOR_AGENT as $SESSION (role $ROLE)"
exit 0
fi
# 1. The worktree (idempotent).
if [ "$(wt_py is-linked "$MAIN" "$WT")" = "true" ]; then
cur="$(git -C "$WT" branch --show-current)"
if [ "$cur" != "$BRANCH" ]; then
echo "ERROR: '$WT' is already a worktree, on branch '${cur:-(detached)}', not '$BRANCH'" >&2
exit 2
fi
echo "worktree already prepared: $WT ($BRANCH)"
else
if [ -e "$WT" ] && [ -n "$(ls -A "$WT" 2>/dev/null)" ]; then
echo "ERROR: '$WT' exists and is not a worktree of '$MAIN'" >&2
exit 2
fi
mkdir -p "$(dirname "$WT")"
if git -C "$MAIN" show-ref --verify --quiet "refs/heads/$BRANCH"; then
git -C "$MAIN" worktree add "$WT" "$BRANCH" || exit 1
else
git -C "$MAIN" worktree add -b "$BRANCH" "$WT" "${BASE_REF:-HEAD}" || exit 1
fi
fi
# 2. .venv: a link to the control root's, never a copy.
if [ -e "$MAIN/.venv" ]; then
if [ ! -e "$WT/.venv" ] && [ ! -L "$WT/.venv" ]; then
ln -s "$MAIN/.venv" "$WT/.venv"
elif [ "$(readlink "$WT/.venv" 2>/dev/null || true)" != "$MAIN/.venv" ]; then
echo "WARNING: '$WT/.venv' exists and is not a link to '$MAIN/.venv'; left as it is" >&2
fi
fi
# 3. Ignore rules. A linked worktree's info/exclude is the shared
# $GIT_COMMON_DIR/info/exclude, so these lines apply to the main checkout too
# (they name artefacts it ignores as well).
excl="$(cd "$WT" && git rev-parse --path-format=absolute --git-path info/exclude)"
mkdir -p "$(dirname "$excl")"
touch "$excl"
if [ -s "$excl" ] && [ -n "$(tail -c1 "$excl")" ]; then echo >> "$excl"; fi
for pat in "/.venv" ".pytest_cache/"; do
grep -Fxq -- "$pat" "$excl" || printf '%s\n' "$pat" >> "$excl"
done
# 4. Submodules: opt-in only.
SUB_STATE="skipped"
if [ -f "$WT/.gitmodules" ]; then
if [ "$SUBMODULES" = "1" ]; then
gd="$(git -C "$WT" rev-parse --absolute-git-dir)"
had_modules=0
[ -e "$gd/modules" ] && had_modules=1
new_keys=()
while read -r key _; do
[ -n "$key" ] || continue
name="${key#submodule.}"; name="${name%.path}"
for k in url active; do
git -C "$MAIN" config --get "submodule.$name.$k" >/dev/null 2>&1 || new_keys+=("submodule.$name.$k")
done
done < <(git config --file "$WT/.gitmodules" --get-regexp '^submodule\..*\.path$' 2>/dev/null || true)
if ! git -C "$WT" submodule update --init --recursive; then
[ "$had_modules" = "1" ] || rm -rf "$gd/modules"
for k in ${new_keys[@]+"${new_keys[@]}"}; do
git -C "$MAIN" config --unset-all "$k" 2>/dev/null || true
done
echo "ERROR: submodule initialisation failed; its traces (modules/, new submodule.* config keys) were removed so the worktree can still be removed without --force. Re-run without --submodules to finish preparing." >&2
exit 1
fi
SUB_STATE="initialised"
else
echo "NOTE: submodules were not initialised (pass --submodules to initialise them)"
fi
fi
# 5. Optional creator, registered in the control root's .mam.
if [ -n "$CREATOR_AGENT" ]; then
args=(--workspace "$WT" --agent "$CREATOR_AGENT" --role "$ROLE" --session "$SESSION")
if [ "$ONBOARD" = "1" ]; then args+=(--onboard); else args+=(--no-onboard); fi
if [ -n "$HS" ]; then args+=(--herdr-session "$HS"); fi
if [ -n "$HW" ]; then args+=(--herdr-workspace "$HW"); fi
env_add=(WORKSPACE_ROOT="$MAIN"
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$MAIN/.mam/agent-sessions.yaml}"
MAM_JOBS_DIR="${MAM_JOBS_DIR:-$MAIN/.mam/jobs}"
DELEGATE_JOB_LOGS_DIR="${DELEGATE_JOB_LOGS_DIR:-$MAIN/.mam/delegate_job_logs}"
MAM_NO_SUBSCRIBER_DAEMON=1)
if [ -z "${MAM_ENV_FILE:-}" ] && [ -f "$MAIN/.mam.env" ]; then env_add+=(MAM_ENV_FILE="$MAIN/.mam.env"); fi
rc=0
env "${env_add[@]}" bash "$SKILL_DIR/multi-agent-mux-create/scripts/create_session.sh" "${args[@]}" || rc=$?
if [ "$rc" -ne 0 ]; then
echo "ERROR: create_session.sh failed (rc=$rc); the worktree is kept - remove it with worktree_remove.sh --path '$WT'" >&2
exit "$rc"
fi
fi
BASE_SHA="$(git -C "$WT" rev-parse HEAD)"
echo "WORKTREE=$WT"
echo "BRANCH=$BRANCH"
echo "BASE=$BASE_SHA"
echo "SUBMODULES=$SUB_STATE"
[ -n "$CREATOR_AGENT" ] && echo "SESSION=$SESSION"
echo "next: bash $SKILL_DIR/multi-agent-mux-loop/scripts/run_loop.sh --worktree '$WT' --base $BASE_SHA --review-only --creator <creator> --reviewer <reviewers> --task '...'"
echo "then: bash $SKILL_DIR/multi-agent-mux-stop/scripts/worktree_remove.sh --path '$WT' --stop"
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# worktree_remove.sh — remove an issue worktree of the control root (Issue #21
# item 4). Every check runs first and changes nothing; only when all pass are
# sessions stopped and the worktree removed.
#
# Usage:
# worktree_remove.sh --path <WT> [--stop] [--dry-run]
#
# Checks: (1) a linked worktree of the control root, not locked; (2) no running
# session under it unless --stop; (3) no open job of those sessions (the
# delegate job of a session --stop will stop is cancelled by stop itself);
# (4) clean tree, submodules included; (5) submodules: nothing modified and no
# commit that is on no remote (HEAD included - a submodule is on a detached
# HEAD). Removal is plain, or - when the worktree holds submodule state, which
# git refuses to remove without it - a single --force justified by (4) and (5).
# Never run `git worktree remove --force` by hand on such a worktree.
#
# Exit codes: 0 removed, 1 stop/git failure, 2 not a worktree of the control
# root, 3 refused by a check (nothing changed).
set -euo pipefail
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$(cd "$_script_dir/../.." && pwd)/lib.sh"
usage() {
sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
}
WT=""; STOP=0; DRY_RUN=0
while [ $# -gt 0 ]; do
case "$1" in
--path) WT="${2:-}"; shift 2 ;;
--stop) STOP=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; usage >&2; exit 2 ;;
esac
done
[ -n "$WT" ] || { echo "ERROR: --path is required" >&2; usage >&2; exit 2; }
MAIN="$(mam_abs_workspace "$WORKSPACE_ROOT")"
case "$WT" in /*) ;; *) WT="$PWD/$WT" ;; esac
WT="$(mam_abs_workspace "${WT%/}")"
PY="$(_delegate_py_bin)"
wt_py() { PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$PY" -m lib_py.worktree "$@"; }
jget() { python3 -c 'import json,sys; d=json.load(sys.stdin); print(eval(sys.argv[1]))' "$1"; }
if [ "$(wt_py is-linked "$MAIN" "$WT")" != "true" ]; then
echo "ERROR: '$WT' is not a linked worktree of the control root '$MAIN'" >&2
exit 2
fi
REFUSED=0
refuse() { echo "REFUSED: $*" >&2; REFUSED=1; }
# (1) locked: even a single --force is refused by git
if [ "$(wt_py is-locked "$MAIN" "$WT")" = "true" ]; then
refuse "the worktree is locked (run: git worktree unlock '$WT')"
fi
# (2) sessions
STATE="$(load_state_json)"
ROWS="$(MAM_STATE_JSON="$STATE" wt_py rows "$WT")"
RUNNING="$(printf '%s' "$ROWS" | jget '" ".join(s["name"] for s in d if s.get("status") == "running")')"
RUNNING_AGENTS="$(printf '%s' "$ROWS" | jget '" ".join(s["name"] + ":" + ((s.get("pane") or {}).get("cmd") or "") for s in d if s.get("status") == "running")')"
STOP_JIDS="$(printf '%s' "$ROWS" | jget '",".join(str(s.get("delegate_job_id")) for s in d if s.get("status") == "running" and s.get("delegate_job_id"))')"
if [ -n "$RUNNING" ] && [ "$STOP" != "1" ]; then
refuse "running session(s) in the worktree: $RUNNING (pass --stop to stop them before removal)"
fi
# (3) open jobs (not the delegate jobs --stop will cancel)
EXCLUDE=""
[ "$STOP" = "1" ] && EXCLUDE="$STOP_JIDS"
JOBS="$(MAM_STATE_JSON="$STATE" wt_py open-jobs "$WT" --exclude "$EXCLUDE")"
if [ "$(printf '%s' "$JOBS" | jget 'len(d)')" != "0" ]; then
refuse "open job(s) of sessions in the worktree - not cancelled automatically, they may belong to a running loop:"
printf '%s' "$JOBS" | jget '"\n".join(" %s %s %s (%s)" % (j["job_id"], j["agent_session"], j["status"], j["registry_dir"]) for j in d)' >&2
echo " close one after checking it: python3 .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py --registry-dir <dir> close --job <id> --request cancelled" >&2
fi
# (4) clean, submodules included
DIRTY="$(git -C "$WT" status --porcelain --ignore-submodules=none)"
if [ -n "$DIRTY" ]; then
refuse "the worktree has uncommitted changes:"
printf '%s\n' "$DIRTY" | sed 's/^/ /' >&2
fi
# (5) submodules
SUB="$(wt_py submodules "$WT")"
TRACE="$(printf '%s' "$SUB" | jget 'd["trace"]')"
METHOD="plain"
if [ "$TRACE" = "True" ]; then
METHOD="single --force"
for key in error dirty unpushed; do
val="$(printf '%s' "$SUB" | jget "d['$key']")"
if [ -n "$val" ]; then
refuse "submodule check '$key' is not empty (a forced removal would destroy it):"
printf '%s\n' "$val" | sed 's/^/ /' >&2
fi
done
fi
echo "checks: locked/sessions/jobs/clean/submodules -> $([ "$REFUSED" = 1 ] && echo refused || echo passed); removal method: $METHOD"
if [ "$REFUSED" = "1" ]; then
echo "nothing was changed" >&2
exit 3
fi
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] would ${RUNNING:+stop: $RUNNING, then }remove '$WT' ($METHOD)"
exit 0
fi
# (6) stop, then re-check every open job
if [ "$STOP" = "1" ] && [ -n "$RUNNING_AGENTS" ]; then
for pair in $RUNNING_AGENTS; do
name="${pair%%:*}"; agent="${pair#*:}"
stop_args=(--session "$name")
[ -n "$agent" ] && stop_args+=(--agent "$agent")
if ! bash "$SKILL_DIR/multi-agent-mux-stop/scripts/stop_session.sh" "${stop_args[@]}"; then
echo "ERROR: stopping '$name' failed; the worktree was not removed" >&2
exit 1
fi
done
JOBS="$(MAM_STATE_JSON="$(load_state_json)" wt_py open-jobs "$WT")"
if [ "$(printf '%s' "$JOBS" | jget 'len(d)')" != "0" ]; then
echo "REFUSED: the sessions were stopped, but open job(s) appeared meanwhile; the worktree was not removed:" >&2
printf '%s' "$JOBS" | jget '"\n".join(" %s %s %s" % (j["job_id"], j["agent_session"], j["status"]) for j in d)' >&2
exit 3
fi
fi
# (7) remove; the branch is kept
if [ "$METHOD" = "plain" ]; then
git -C "$MAIN" worktree remove "$WT" || exit 1
else
git -C "$MAIN" worktree remove --force "$WT" || exit 1
echo "submodule checks passed; removed with a single --force"
fi
echo "removed: $WT ($METHOD)"
NAMES="$(printf '%s' "$ROWS" | jget '" ".join(s["name"] for s in d)')"
if [ -n "$NAMES" ]; then
echo "Sessions created in this worktree ($NAMES) cannot be resumed; their conversations are kept, and recreating a worktree at the same path makes them resumable again."
fi