diff --git a/scripts/prune-worktrees.sh b/scripts/prune-worktrees.sh index b60e1767..cdeaed2a 100755 --- a/scripts/prune-worktrees.sh +++ b/scripts/prune-worktrees.sh @@ -53,6 +53,14 @@ # on it would protect exactly the trees most in need of removing. ah-6xq.8 was one: merged, closed by # nobody, claim still standing, worktree still on disk. # +# What IS part of it, ahead of the four checks: **whether the session holding the tree's bead is +# alive** (cb-15cw). A producer still planning has made no commit and written nothing, so its tree is +# clean, at main and quiet past STALE_MINUTES — "nothing can be lost" by git's measure, while the +# session's ignored files and its branch are still in use. The holder is read from the state files +# (`bead`, or the handover while the state names none) and believed only when `agent-alive` says +# the session is running, so a crashed agent's tree is still removed, exactly as the claim rule +# above intends. `release-bead --worktree` asks the same question for the fleet view's tidy. +# # `git worktree prune` runs first regardless. That only clears registrations whose directory is # already gone, which can never lose anything. # @@ -131,6 +139,7 @@ done # consumer-root's header for the two roots and why a sweep needs the shared one. script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$script_dir/worktree-safety.sh" +source "$script_dir/session-bead.sh" repo_root="$("$script_dir/consumer-root" --shared)" || exit 1 @@ -177,6 +186,37 @@ is_verifier_tree() { [ "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" = "$verifier_lc" ] } +# ` ` for every session that is alive and on a bead: its state file's `bead`, or, while +# that names none, the bead its handover names. Read once per sweep; see the header (cb-15cw). +live_holders="" +read_live_holders() { + local state_dir="$repo_root/.cerebro/state" file name bead + [ -d "$state_dir" ] || return 0 + for file in "$state_dir"/*.state.json; do + [ -f "$file" ] || continue + name="$(basename "$file" .state.json)" + bead="$(cerebro_session_bead "$state_dir" "$name")" + [ -n "$bead" ] || continue + if "$script_dir/agent-alive" "$name" >/dev/null 2>&1; then + live_holders+="$bead $name"$'\n' + fi + done +} + +# Prints the name of the live session holding one of the tree's beads, or nothing. +live_holder_of() { + local bead holder + while IFS= read -r bead; do + [ -n "$bead" ] || continue + holder="$(awk -v b="$bead" '$1 == b { print $2; exit }' <<<"$live_holders")" + if [ -n "$holder" ]; then + printf '%s\n' "$holder" + return 0 + fi + done < <(cerebro_worktree_bead "$1") + return 0 +} + # Whether the mount and the consumer are ONE GIT REPOSITORY, so that `git worktree list` asked of # each returns the same list. When they are, walking "both" walks one list twice — every tree # enumerated once per owner, the tallies inflated, and a tree already removed on the first pass @@ -375,6 +415,8 @@ sweep() { fi fi + read_live_holders + # Each line is `|`. Every other git call in this loop already # passes `$tree` and needs no owner; the four that address the repository do (ah-apw4). local owner @@ -384,12 +426,15 @@ sweep() { *) continue ;; esac - local name reason="" + local name reason="" holder name="$(basename "$tree")" + holder="$(live_holder_of "$tree")" if is_verifier_tree "$name"; then reason="it is $verifier_name's verification tree, reset to origin/$default_branch before every use (ah-p31)" reclaim_cold_target "$tree" "$name" + elif [ -n "$holder" ]; then + reason="$holder is alive on it" elif [ -n "$(git -C "$tree" status --porcelain 2>/dev/null)" ]; then reason="it has uncommitted or untracked changes" elif ! cerebro_worktree_landed "$tree" "$(owner_default_branch "$owner")" "$merged_check" "$COLD_TARGET_MINUTES"; then diff --git a/scripts/release-bead b/scripts/release-bead index 6728d0ad..0514851b 100755 --- a/scripts/release-bead +++ b/scripts/release-bead @@ -80,24 +80,16 @@ usage() { # NAME is alive and on bead ID: its state file names ID, or names nothing while its handover is # absent or names ID. Reads $name, $id and $state. +# +# The bead is read by `cerebro_session_bead` (scripts/session-bead.sh), shared with the pruner. A +# live session naming no bead at all is given the benefit of the doubt here. alive_on_bead() { - local current="" handed="" + local current if ! "$script_dir/agent-alive" "$name" >/dev/null 2>&1; then return 1 fi - if [[ -f "$state/$name.state.json" ]]; then - current="$(jq -r '.bead // empty' "$state/$name.state.json" 2>/dev/null || true)" - fi - if [[ -f "$state/$name.handover" ]]; then - IFS= read -r handed < "$state/$name.handover" || true - fi - if [[ "$current" == "$id" ]]; then - return 0 - fi - if [[ -z "$current" && ( -z "$handed" || "$handed" == "$id" ) ]]; then - return 0 - fi - return 1 + current="$(cerebro_session_bead "$state" "$name")" + [[ -z "$current" || "$current" == "$id" ]] } # Prints "USUS" for the bead, or fails. US (0x1f), not a @@ -135,6 +127,7 @@ clear_assignment() { } script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$script_dir/session-bead.sh" if [[ "${1:-}" == "--worktree" ]]; then [[ $# -eq 3 && -n "$2" && -n "$3" ]] || usage diff --git a/scripts/session-bead.sh b/scripts/session-bead.sh new file mode 100644 index 00000000..5c3ec1a9 --- /dev/null +++ b/scripts/session-bead.sh @@ -0,0 +1,26 @@ +# shellcheck shell=bash +# +# scripts/session-bead.sh - the one place bash reads which bead a session says it is on (cb-15cw). +# Sourced, never executed; it sets no shell options of its own. +# +# cerebro_session_bead +# prints the bead 's state file names, or, while it names none, the bead its handover +# names (`scripts/assign-bead` writes the handover before the session's first state write); +# prints nothing when neither names one. Always exit 0. +# +# Whether the session is ALIVE is not asked here: that is `scripts/agent-alive`, which each caller +# runs itself. The callers then ask different questions of the answer. `release-bead` asks whether +# NAME is on bead ID, and gives a live session that names nothing the benefit of the doubt; +# `prune-worktrees.sh` asks which tree each live session holds, and a session naming nothing +# holds none. +cerebro_session_bead() { + local state_dir="$1" name="$2" bead="" + if [ -f "$state_dir/$name.state.json" ]; then + bead="$(jq -r '.bead // empty' "$state_dir/$name.state.json" 2>/dev/null || true)" + fi + if [ -z "$bead" ] && [ -f "$state_dir/$name.handover" ]; then + IFS= read -r bead < "$state_dir/$name.handover" || true + fi + printf '%s' "$bead" + return 0 +} diff --git a/tests/prune-worktrees.sh b/tests/prune-worktrees.sh index f3c7699d..297e72f1 100755 --- a/tests/prune-worktrees.sh +++ b/tests/prune-worktrees.sh @@ -374,4 +374,68 @@ out="$(cd "$selfmount" && PATH="$sub_stub_dir:$PATH" bash "$selfmount/scripts/pr || fail "a self-mounted cerebro enumerated its worktrees twice: $out" pass "a self-mounted cerebro is walked once, not twice" +# ================================================================================================ +# A live session's tree is never pruned from under it (cb-15cw). +# +# A producer still planning has made no commit and written nothing: its tree is clean, at main and +# quiet past STALE_MINUTES, so it passed all four checks and was removed mid-pass. The bead's claim +# is deliberately not the guard (a crashed agent's claim stands for ever); whether the session that +# holds the bead is ALIVE is. `agent-alive` is stubbed: its own suite proves the marker rule. +# ================================================================================================ + +live="$(consumer_new live --origin --link consumer-root project-conf default-branch roster prune-worktrees.sh)" +alive_names="$work_dir/alive-names" +: > "$alive_names" +cat > "$live/.cerebro/cerebro/scripts/agent-alive" < "$live/.cerebro/state/Cyclops.state.json" + +live_tree() { + local tree="$live/.cerebro/worktrees/$1" + git_q -C "$live" worktree add -q "$tree" -b "$1-branch" + age "$tree" +} +live_tree cb-live +live_tree cb-other + +# --- 16. a live session's clean, landed, quiet tree is kept ------------------------------------- +echo Cyclops > "$alive_names" +out="$(cd "$live" && PATH="$stub_dir:$PATH" bash "$live/.cerebro/cerebro/scripts/prune-worktrees.sh" 2>&1)" +[ -d "$live/.cerebro/worktrees/cb-live" ] \ + || fail "a live session's worktree was removed from under it: $out" +grep -q . <<<"$(git -C "$live" branch --list cb-live-branch)" \ + || fail "a live session's branch was deleted from under it: $out" +grep -q "keeping cb-live — Cyclops is alive on it" <<<"$out" \ + || fail "the keep did not name the live session: $out" +[ ! -d "$live/.cerebro/worktrees/cb-other" ] \ + || fail "a tree no live session holds was kept as well: $out" +pass "a live session's clean, landed, quiet tree is kept, and a tree nobody holds still goes" + +# --- 17. once the session is gone, the same tree goes ------------------------------------------- +: > "$alive_names" +out="$(cd "$live" && PATH="$stub_dir:$PATH" bash "$live/.cerebro/cerebro/scripts/prune-worktrees.sh" 2>&1)" +[ ! -d "$live/.cerebro/worktrees/cb-live" ] \ + || fail "a dead session's tree was kept: $out" +grep -q . <<<"$(git -C "$live" branch --list cb-live-branch)" \ + && fail "a dead session's branch survived its tree: $out" +pass "a dead session's tree and branch are removed as before" + +# --- 18. a session whose state names no bead yet is held by its handover ------------------------ +# Between `assign-bead` and the session's first state write, the handover is the only record of +# which bead the session was given. +live_tree cb-handed +printf '{"state":"working","pid":1}' > "$live/.cerebro/state/Cyclops.state.json" +echo cb-handed > "$live/.cerebro/state/Cyclops.handover" +echo Cyclops > "$alive_names" +out="$(cd "$live" && PATH="$stub_dir:$PATH" bash "$live/.cerebro/cerebro/scripts/prune-worktrees.sh" 2>&1)" +[ -d "$live/.cerebro/worktrees/cb-handed" ] \ + || fail "a live session's handed tree was removed before its first state write: $out" +grep -q "keeping cb-handed — Cyclops is alive on it" <<<"$out" \ + || fail "the keep did not name the session its handover names: $out" +pass "a live session whose state names no bead yet keeps the tree its handover names" + suite_passed