diff --git a/.agents/skills/lib_py/worktree.py b/.agents/skills/lib_py/worktree.py new file mode 100644 index 0000000..d935e73 --- /dev/null +++ b/.agents/skills/lib_py/worktree.py @@ -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
+ python -m lib_py.worktree is-locked
+ python -m lib_py.worktree rows + python -m lib_py.worktree open-jobs [--exclude id,id] + python -m lib_py.worktree submodules +""" + +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:).""" + 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()) diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 05cbabd..c8f8d81 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -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 diff --git a/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh b/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh new file mode 100755 index 0000000..105fa82 --- /dev/null +++ b/.agents/skills/multi-agent-mux-create/scripts/worktree_prepare.sh @@ -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 --branch [--base ] [--submodules] +# [--creator-agent [--session ] [--role ] +# [--herdr-session ] [--herdr-workspace ] [--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 --reviewer --task '...'" +echo "then: bash $SKILL_DIR/multi-agent-mux-stop/scripts/worktree_remove.sh --path '$WT' --stop" diff --git a/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh b/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh new file mode 100755 index 0000000..d1c255d --- /dev/null +++ b/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh @@ -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 [--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 close --job --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 diff --git a/tests/test_issue21_phase2_loop_a.py b/tests/test_issue21_phase2_loop_a.py index 144ee7e..9dbce76 100644 --- a/tests/test_issue21_phase2_loop_a.py +++ b/tests/test_issue21_phase2_loop_a.py @@ -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