From 8b523cb08a2f73ed1577e9ab39f6a6646663b150 Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Tue, 10 Nov 2026 18:07:59 +0100 Subject: [PATCH 1/2] fix(cb-15cw): prune-worktrees.sh keeps a tree whose bead a live session holds A producer still planning has a clean, landed, quiet tree, and the pruner removed it from under the session. The holder is read from the state files and believed only when agent-alive says the session is running, so a crashed session's tree still goes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCDKf2aRvKRWd7G9dvQEJE --- scripts/prune-worktrees.sh | 49 +++++++++++++++++++++++++++++++++++++- tests/prune-worktrees.sh | 48 +++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/scripts/prune-worktrees.sh b/scripts/prune-worktrees.sh index b60e17675..3cfb2db75 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. # @@ -177,6 +185,40 @@ 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="$(jq -r '.bead // empty' "$file" 2>/dev/null || true)" + if [ -z "$bead" ] && [ -f "$state_dir/$name.handover" ]; then + IFS= read -r bead < "$state_dir/$name.handover" || true + fi + [ -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 +417,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 +428,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/tests/prune-worktrees.sh b/tests/prune-worktrees.sh index f3c7699d2..e6400d260 100755 --- a/tests/prune-worktrees.sh +++ b/tests/prune-worktrees.sh @@ -374,4 +374,52 @@ 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" +pass "a dead session's tree is removed as before" + suite_passed From 44ad073385232f7813dd41141a6aa890d62a01e4 Mon Sep 17 00:00:00 2001 From: Henrik Kurelid Date: Tue, 10 Nov 2026 18:15:16 +0100 Subject: [PATCH 2/2] fix(cb-15cw): one reader of a session's bead, shared by release-bead and the pruner Answers the review: the handover fallback gets its own case, case 17 asserts the branch goes too, and the state-or-handover read moves to scripts/session-bead.sh so the two callers cannot drift. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WCDKf2aRvKRWd7G9dvQEJE --- scripts/prune-worktrees.sh | 6 ++---- scripts/release-bead | 21 +++++++-------------- scripts/session-bead.sh | 26 ++++++++++++++++++++++++++ tests/prune-worktrees.sh | 18 +++++++++++++++++- 4 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 scripts/session-bead.sh diff --git a/scripts/prune-worktrees.sh b/scripts/prune-worktrees.sh index 3cfb2db75..cdeaed2a8 100755 --- a/scripts/prune-worktrees.sh +++ b/scripts/prune-worktrees.sh @@ -139,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 @@ -194,10 +195,7 @@ read_live_holders() { for file in "$state_dir"/*.state.json; do [ -f "$file" ] || continue name="$(basename "$file" .state.json)" - bead="$(jq -r '.bead // empty' "$file" 2>/dev/null || true)" - if [ -z "$bead" ] && [ -f "$state_dir/$name.handover" ]; then - IFS= read -r bead < "$state_dir/$name.handover" || true - fi + 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' diff --git a/scripts/release-bead b/scripts/release-bead index 6728d0ada..0514851b8 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 000000000..5c3ec1a98 --- /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 e6400d260..297e72f1e 100755 --- a/tests/prune-worktrees.sh +++ b/tests/prune-worktrees.sh @@ -420,6 +420,22 @@ pass "a live session's clean, landed, quiet tree is kept, and a tree nobody hold 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" -pass "a dead session's tree is removed as before" +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