Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion scripts/prune-worktrees.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
#
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -177,6 +186,37 @@ is_verifier_tree() {
[ "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" = "$verifier_lc" ]
}

# `<bead> <Name>` 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
Expand Down Expand Up @@ -375,6 +415,8 @@ sweep() {
fi
fi

read_live_holders

# Each line is `<owning repository>|<worktree>`. 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
Expand All @@ -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
Expand Down
21 changes: 7 additions & 14 deletions scripts/release-bead
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<status>US<assignee>US<labels, comma-joined>" for the bead, or fails. US (0x1f), not a
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions scripts/session-bead.sh
Original file line number Diff line number Diff line change
@@ -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 <state dir> <Name>
# prints the bead <Name>'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
}
64 changes: 64 additions & 0 deletions tests/prune-worktrees.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<STUB
#!/usr/bin/env bash
grep -qx "\$1" "$alive_names"
STUB
chmod +x "$live/.cerebro/cerebro/scripts/agent-alive"
mkdir -p "$live/.cerebro/state"
printf '{"state":"working","phase":"design","bead":"cb-live","pid":1}' > "$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
Loading