-
Notifications
You must be signed in to change notification settings - Fork 0
docs(agents): reap spent branches every tick and return to the default branch #2200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
8adfd16
docs(agents): reap spent branches every tick and return to the defaulβ¦
devantler 6bae508
fix(scripts): make branch-cleanup fail-closed on every surface codex β¦
devantler e1c85c6
fix(scripts): close codex round-2 P1s β fork-PR evidence, unpushed-coβ¦
devantler 5cf5e9e
fix(scripts): branch-cleanup round 3 β local CAS, merged-evidence locβ¦
devantler 436f673
fix(scripts): preserve detached submodule pins; recheck worktrees befβ¦
devantler 4b5a728
fix(branch-cleanup): fail closed on fetch and worktree-enum errors
devantler 7fd4d76
fix(branch-cleanup): guard keep-set enum, skip interactive branches, β¦
devantler File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| #!/usr/bin/env bash | ||
| # Per-tick branch hygiene: delete spent claude/* branches locally and on the remote. | ||
| # | ||
| # SAFETY CONTRACT (fail-closed β every ambiguity resolves to KEEP, every | ||
| # infrastructure failure ABORTS the phase that depends on it): | ||
| # KEEP - any branch that is the head of an OPEN PR (deleting it would CLOSE the PR) | ||
| # KEEP - any branch checked out by a worktree (git refuses anyway; we skip explicitly) | ||
| # KEEP - the default branch, and anything not claude/* (never touch codex/* = the sibling's lane) | ||
| # LOCAL - delete when not in KEEP (squash-merge means `-d` can't see merges, so `-D` + manifest) | ||
| # REMOTE- delete ONLY with positive evidence: an associated MERGED/CLOSED PR whose recorded head | ||
| # SHA equals the branch's CURRENT SHA (same incarnation β a re-pushed branch invalidates | ||
| # old PR evidence). Commit age is NOT evidence (commit time != push time), so no-PR | ||
| # branches are REPORTED as candidates, never deleted. | ||
| # CAS - every remote delete uses --force-with-lease pinned to the evidence SHA, so a branch a | ||
| # concurrent session moves between evidence-gathering and deletion is rejected, and the | ||
| # open-PR keep-set is re-fetched immediately before the delete loop. | ||
| # | ||
| # Every deletion is recorded (branch -> sha) to the manifest BEFORE the delete, and the write is | ||
| # verified β no restore record, no deletion. | ||
| set -uo pipefail | ||
|
|
||
| REPO_PATH="$1"; SLUG="$2"; MANIFEST="$3"; MODE="${4:-apply}" | ||
| errors=0 | ||
|
|
||
| cd "$REPO_PATH" || exit 1 | ||
|
|
||
| # DEFAULT reads the LOCAL origin/HEAD (set at clone) and needs no fresh fetch, | ||
| # so the checkout-restoration path can be armed BEFORE fetching. | ||
| DEFAULT=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##') | ||
| DEFAULT="${DEFAULT:-main}" | ||
|
|
||
| # Return the checkout to where it belongs, FIRST (and again on any abort, via | ||
| # trap): a repo left on its tick's claude/* branch would otherwise keep that | ||
| # branch in the worktree keep-set forever, and an abort must not strand the | ||
| # checkout. A DETACHED start is preserved as-is β an initialized submodule sits | ||
| # detached at its superproject pin, and checking out the default branch there | ||
| # would silently move it off the pin (superproject drift). | ||
| START_BRANCH=$(git branch --show-current 2>/dev/null) | ||
| return_to_default() { | ||
| local cur | ||
| cur=$(git branch --show-current 2>/dev/null) | ||
| if [ -z "$START_BRANCH" ]; then | ||
| sw="detached start (submodule pin) β left as-is" | ||
| return | ||
| fi | ||
| if [ "$cur" != "$DEFAULT" ]; then | ||
| git checkout "$DEFAULT" -q 2>/dev/null | ||
| local now | ||
| now=$(git branch --show-current 2>/dev/null) | ||
| if [ "$now" = "$DEFAULT" ]; then sw="-> $DEFAULT"; | ||
| else sw="FAILED to reach $DEFAULT (on '${now:-detached}')"; errors=$((errors+1)); fi | ||
| else sw="already on $DEFAULT"; fi | ||
| } | ||
| sw="" | ||
| # Arm restoration BEFORE the fetch (superseded by the tmpfile trap below once it | ||
| # is installed): an attached claude/* checkout whose fetch then fails must still | ||
| # be returned to the default branch, or it stays worktree-protected on the next | ||
| # sweep and defeats the end-of-tick return-and-reap guarantee. | ||
| trap 'return_to_default' EXIT | ||
| return_to_default | ||
|
|
||
| # Stale refs make every later judgement wrong β abort the whole run on fetch | ||
| # failure (the EXIT trap above still returns the checkout to the default first). | ||
| if ! git fetch origin --prune -q 2>/dev/null; then | ||
| echo "$SLUG: ABORT β git fetch failed; refusing to act on stale refs" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| # Verified manifest write: no restore record, no deletion. | ||
| manifest_write() { | ||
| if ! printf '%s\n' "$1" >>"$MANIFEST" 2>/dev/null; then | ||
| echo "$SLUG: ABORT β manifest write to '$MANIFEST' failed; refusing to delete without a restore record" >&2 | ||
| exit 1 | ||
| fi | ||
| } | ||
|
|
||
| # Fetch the open-PR head list; ABORT on failure (an empty keep-set on a failed | ||
| # query is the catastrophic fail-open: it would delete every open PR's branch). | ||
| fetch_open_heads() { | ||
| local out | ||
| if ! out=$(gh pr list --repo "devantler-tech/$SLUG" --state open --limit 300 \ | ||
| --json headRefName --jq '.[].headRefName' 2>/dev/null); then | ||
| echo "$SLUG: ABORT β open-PR query failed; keep-set cannot be trusted" >&2 | ||
| exit 1 | ||
| fi | ||
| printf '%s\n' "$out" | ||
| } | ||
|
|
||
| # --- KEEP set ------------------------------------------------------------- | ||
| keep=$(mktemp); prs=$(mktemp); keep2=$(mktemp) | ||
| trap 'rm -f "$keep" "$prs" "$keep2"; return_to_default' EXIT | ||
| # Capture the worktree enumeration SEPARATELY and ABORT on failure: the REMOTE | ||
| # delete loop trusts this one snapshot for its worktree KEEP rule (the local loop | ||
| # re-checks per branch, the remote loop does not), so a silently-empty list here | ||
| # could let a checked-out claude/* branch with merged/closed PR evidence have its | ||
| # upstream deleted β violating the documented worktree KEEP guarantee. A failed | ||
| # enumeration means the keep-set cannot be trusted. | ||
| if ! wt_branches=$(git worktree list --porcelain 2>/dev/null); then | ||
| echo "$SLUG: ABORT β worktree enumeration failed; keep-set cannot be trusted" >&2 | ||
| exit 1 | ||
| fi | ||
| { | ||
| fetch_open_heads | ||
| printf '%s\n' "$wt_branches" | awk '/^branch /{sub("refs/heads/","",$2); print $2}' | ||
| printf '%s\n' "$DEFAULT" | ||
|
devantler marked this conversation as resolved.
|
||
| } >>"$keep" | ||
|
|
||
| # --- PR evidence map for remote decisions ---------------------------------- | ||
| # newest-first per branch: state + the head SHA the PR evidence belongs to. | ||
| # Bounded evidence is FAIL-CLOSED: a merged PR older than the newest 1000 | ||
| # results yields "no evidence" => KEEP/candidate, never a deletion. | ||
| if ! gh pr list --repo "devantler-tech/$SLUG" --state all --limit 1000 \ | ||
| --json headRefName,state,headRefOid,headRepositoryOwner \ | ||
| --jq '.[]|select(.headRepositoryOwner.login=="devantler-tech")|"\(.headRefName)\t\(.state)\t\(.headRefOid)"' 2>/dev/null >"$prs"; then | ||
| echo "$SLUG: ABORT β PR-state query failed; remote evidence unavailable" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| is_kept() { grep -Fxq "$1" "$keep"; } | ||
|
|
||
| # HANDS-OFF the maintainer's INTERACTIVE Claude work. The maintainer also drives | ||
| # Claude Code interactively; those sessions use the harness's random-slug worktree | ||
| # branch `claude/<adjective>-<name>-<6hex>` (e.g. claude/unruffled-kepler-f3e922), | ||
| # marked HANDS-OFF in AGENTS.md. This routine's OWN branches are `claude/<area>-<desc>` | ||
| # and never carry a trailing 6-hex slug, so skip anything that does β fail-closed (an | ||
| # ambiguous match is KEPT, never reaped), so a merged/closed interactive PR's branch is | ||
| # never mistaken for one of this routine's spent per-run worktrees. | ||
| is_interactive_slug() { | ||
| local last="${1##*-}" | ||
| [[ "$last" =~ ^[0-9a-f]{6}$ ]] | ||
| } | ||
| pr_evidence() { awk -F'\t' -v b="$1" '$1==b{print $2 "\t" $3; exit}' "$prs"; } | ||
|
|
||
| l_del=0; r_del=0; l_keep=0; r_keep=0; candidates=0; r_rej=0 | ||
|
|
||
| # --- LOCAL ---------------------------------------------------------------- | ||
| while IFS= read -r b; do | ||
| [ -z "$b" ] && continue | ||
| if is_kept "$b"; then l_keep=$((l_keep+1)); continue; fi | ||
| if is_interactive_slug "$b"; then l_keep=$((l_keep+1)); continue; fi | ||
| sha=$(git rev-parse "$b" 2>/dev/null) || continue | ||
| # Never lose unpushed work: delete only when the tip is reachable from SOME | ||
| # remote ref, OR a MERGED/CLOSED PR accounts for this exact sha (a | ||
| # squash-merged branch whose remote ref was already pruned is reachable | ||
| # from nothing, yet its work is safely in the PR record). Otherwise keep as | ||
| # a candidate. | ||
| ev=$(pr_evidence "$b"); st="${ev%%$'\t'*}"; ev_sha="${ev#*$'\t'}" | ||
| if [ -z "$(git branch -r --contains "$sha" 2>/dev/null | head -1)" ] && | ||
| { [ "$st" != "MERGED" ] && [ "$st" != "CLOSED" ] || [ "$ev_sha" != "$sha" ]; }; then | ||
| candidates=$((candidates+1)); l_keep=$((l_keep+1)); continue | ||
| fi | ||
| manifest_write "$(printf '%s\tlocal\t%s\t%s' "$SLUG" "$b" "$sha")" | ||
| if [ "$MODE" = "apply" ]; then | ||
| # update-ref -d BYPASSES the checked-out-branch refusal `git branch -D` | ||
| # has, so re-check the live worktree list right before deleting β a | ||
| # concurrent session may have checked the branch out since the keep-set | ||
| # snapshot. Capture the enumeration SEPARATELY and fail CLOSED (keep) if it | ||
| # errors: piping the failing command straight into grep would let a | ||
| # transient worktree-metadata read error read as "checked out nowhere", | ||
| # and update-ref would then delete a branch live in another worktree, | ||
| # leaving that worktree on a dangling/unborn HEAD. | ||
| if ! wt_list=$(git worktree list --porcelain 2>/dev/null); then | ||
| echo "$SLUG: keep '$b' β worktree enumeration failed; refusing to delete (fail-closed)" >&2 | ||
| l_keep=$((l_keep+1)); errors=$((errors+1)); continue | ||
| fi | ||
| if printf '%s\n' "$wt_list" | grep -Fxq "branch refs/heads/$b"; then | ||
| echo "$SLUG: keep '$b' β checked out by a worktree since the snapshot" >&2 | ||
| l_keep=$((l_keep+1)); continue | ||
| fi | ||
| # CAS delete: update-ref -d with the expected old value refuses if a | ||
| # concurrent session re-pointed the ref after evidence-gathering. | ||
| if git update-ref -d "refs/heads/$b" "$sha" >/dev/null 2>&1; then l_del=$((l_del+1)); | ||
|
devantler marked this conversation as resolved.
|
||
| else echo "$SLUG: WARN β local delete of '$b' rejected (ref moved) or failed" >&2; errors=$((errors+1)); fi | ||
| else l_del=$((l_del+1)); fi | ||
| done < <(git branch --list 'claude/*' --format='%(refname:short)') | ||
|
devantler marked this conversation as resolved.
|
||
|
|
||
| # --- REMOTE --------------------------------------------------------------- | ||
| # Re-fetch the open-PR keep-set IMMEDIATELY before the delete loop: a PR opened | ||
| # while we were working must re-protect its branch (ABORTs on query failure). | ||
| fetch_open_heads >"$keep2" | ||
| re_kept() { grep -Fxq "$1" "$keep2" || grep -Fxq "$1" "$keep"; } | ||
|
|
||
| while IFS= read -r rb; do | ||
| b="${rb#origin/}" | ||
| [ -z "$b" ] && continue | ||
| if re_kept "$b"; then r_keep=$((r_keep+1)); continue; fi | ||
| if is_interactive_slug "$b"; then r_keep=$((r_keep+1)); continue; fi | ||
| sha=$(git rev-parse "$rb" 2>/dev/null) || { r_keep=$((r_keep+1)); continue; } | ||
| ev=$(pr_evidence "$b"); st="${ev%%$'\t'*}"; ev_sha="${ev#*$'\t'}" | ||
| if [ "$st" != "MERGED" ] && [ "$st" != "CLOSED" ]; then | ||
| # No PR evidence. Commit age is NOT push age β a recent push of old commits | ||
| # would look stale β so no-PR branches are candidates to REPORT, never delete. | ||
| candidates=$((candidates+1)); r_keep=$((r_keep+1)); continue | ||
| fi | ||
| if [ "$ev_sha" != "$sha" ]; then | ||
| # The PR evidence belongs to an older incarnation of this branch name; the | ||
| # current ref carries commits no PR accounts for. Keep it. | ||
| r_keep=$((r_keep+1)); continue | ||
| fi | ||
| manifest_write "$(printf '%s\tremote\t%s\t%s\t%s' "$SLUG" "$b" "$sha" "$st")" | ||
|
devantler marked this conversation as resolved.
|
||
| if [ "$MODE" = "apply" ]; then | ||
| # Final per-branch TOCTOU guard: a PR can open between the loop-level | ||
| # keep-set refresh and this very deletion. Fail closed on query failure. | ||
| # RESIDUAL RISK (accepted, no server-side conditional delete exists): a PR | ||
| # opened in the milliseconds between this query and the push below would | ||
| # be closed by the deletion; the manifest sha makes that restorable | ||
| # (push the sha back, reopen the PR). | ||
| open_now=$(gh pr list --repo "devantler-tech/$SLUG" --state open --head "$b" \ | ||
| --json number --jq 'length' 2>/dev/null) | ||
| if [ -z "$open_now" ] || [ "$open_now" != "0" ]; then | ||
| echo "$SLUG: keep '$b' β open-PR recheck non-empty or failed" >&2 | ||
| r_keep=$((r_keep+1)); continue | ||
| fi | ||
| # CAS delete: rejected if the remote ref moved off the evidence SHA. | ||
| if git push --force-with-lease="refs/heads/$b:$sha" origin ":refs/heads/$b" >/dev/null 2>&1; then | ||
|
devantler marked this conversation as resolved.
devantler marked this conversation as resolved.
|
||
| r_del=$((r_del+1)) | ||
| else | ||
| echo "$SLUG: WARN β remote delete of '$b' rejected (ref moved or push failed); kept" >&2 | ||
| r_rej=$((r_rej+1)); r_keep=$((r_keep+1)) | ||
| fi | ||
|
devantler marked this conversation as resolved.
|
||
| else r_del=$((r_del+1)); fi | ||
| done < <(git branch -r --list 'origin/claude/*' --format='%(refname:short)') | ||
|
|
||
| printf '%-24s local: -%-4s keep %-3s | remote: -%-3s keep %-3s rej %-2s cand %-3s | %s\n' \ | ||
| "$SLUG" "$l_del" "$l_keep" "$r_del" "$r_keep" "$r_rej" "$candidates" "$sw" | ||
|
|
||
| [ "$errors" -gt 0 ] && exit 3 | ||
| exit 0 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.