diff --git a/.claude/scripts/loop-census.sh b/.claude/scripts/loop-census.sh index 7006039..8bdcc70 100644 --- a/.claude/scripts/loop-census.sh +++ b/.claude/scripts/loop-census.sh @@ -29,6 +29,12 @@ # hasn't reached PR stage. A tick uses this to # avoid double-spawning an orchestrator for an # issue that already has a worktree in progress. +# A REMOTE-ONLY branch match (no local branch of +# the same name) that turns out to be a stale +# ref for an issue whose PR already MERGED is +# ignored (branch=none) instead of counting as +# in_flight forever — see STALE-MERGED-REMOTE +# below (issue #158). # stalled= age_min= one line PER in_flight issue whose most recent # events.jsonl activity is older than the stall # threshold (issue #98) — see STALL DETECTION @@ -117,6 +123,26 @@ # one candidate was otherwise eligible; with zero eligible candidates, # advance_ready stays "none" exactly as before this feature. # +# --- STALE-MERGED-REMOTE (issue #158) --------------------------------------- +# `git branch -a --list "*feat/issue-N-*"` matches BOTH local branches and +# remote-tracking refs (`remotes/origin/...`). After a PR merges — even with +# `gh pr merge --delete-branch` — the MAIN checkout's local remote-tracking +# ref for that branch can survive until pruned (`git fetch --prune` / +# `git remote prune`). Without this guard, a still-open issue whose partial +# PR already merged would keep matching that stale ref FOREVER, so census +# would report it in_flight forever — wedging advance_ready and eventually +# tripping stall/escalation for an issue that in truth has no active branch. +# +# Fix: when the ONLY match for an issue is a remote-tracking ref (no local +# branch of the same name), look up whether a MERGED PR exists for that bare +# branch name. If one does, treat the issue as branch=none (ignore the stale +# ref) — it neither counts as in_flight nor blocks advance_ready. If no +# merged PR is found (the common case: work genuinely in progress, only +# pushed, local branch since deleted), remote-only in_flight is preserved +# exactly as before this fix. A genuine LOCAL branch match is unaffected — +# it always wins over a remote-only one, so this only changes behavior for +# the specific remote-only-and-already-merged case. +# # --- STALL DETECTION (issue #98) -------------------------------------------- # An `in_flight` issue (feat/issue-N-* branch exists, no open PR yet) can sit # forever if the driver that created it hung or died without ever reaching @@ -348,12 +374,51 @@ while IFS=$'\t' read -r num labels title; do done <<< "$module_labels" [ "$hit" -eq 1 ] || continue planned_count=$((planned_count + 1)) - # Existing feat/issue--* branch (local or remote) means it's already in flight. - # NOTE: `| head -1` can make `git` see SIGPIPE (exit 141) if head closes the - # pipe before git finishes writing; under `set -euo pipefail` that would abort - # this whole script. `|| true` on the assignment absorbs that non-fatal - # pipeline failure — the captured output (head's one line) is unaffected. - branch=$(git -C "$root" branch -a --list "*feat/issue-$num-*" | head -1 | sed 's/^[* ]*//;s|^remotes/||') || true + # Existing feat/issue--* branch: a LOCAL branch means genuinely in_flight + # (branch #158) regardless of GitHub state. A REMOTE-ONLY match + # (`remotes/origin/...`, no local branch of the same name) is only treated + # as "existing" if it is NOT a stale ref left over from an already-merged + # PR (issue #158): `git branch -a --list` matches BOTH local branches and + # remote-tracking refs, and after a PR merges (even with + # `gh pr merge --delete-branch`) the MAIN checkout's local remote-tracking + # ref for it can survive until pruned — so a still-open issue whose partial + # PR already merged would otherwise match that stale ref FOREVER and never + # advance (wedges advance_ready, triggers false stall/escalation). A local + # match, when present, always wins over a remote-only one. + local_branch="" + remote_branch="" + branch_lines=$(git -C "$root" branch -a --list "*feat/issue-$num-*" | sed 's/^[* ]*//') || true + if [ -n "$branch_lines" ]; then + while IFS= read -r bl; do + [ -z "$bl" ] && continue + case "$bl" in + remotes/*) [ -z "$remote_branch" ] && remote_branch="$bl" ;; + *) [ -z "$local_branch" ] && local_branch="$bl" ;; + esac + done <<< "$branch_lines" + fi + + if [ -n "$local_branch" ]; then + branch="$local_branch" + elif [ -n "$remote_branch" ]; then + # Bare branch name (strip the "remotes//" prefix, e.g. + # "remotes/origin/feat/issue-158-x" -> "feat/issue-158-x") to query gh + # for a merged PR under it. Only done on this remote-only path — never on + # the common local-branch path above — to avoid the extra gh call there. + # Guarded with `|| true` (transient gh failure degrades gracefully to "no + # merged PR found", i.e. the prior remote-only-stays-in_flight behavior; + # it must never abort the census under `set -euo pipefail`). + bare_branch="${remote_branch#remotes/*/}" + merged_count=$(gh pr list -R "$repo" --state merged --head "$bare_branch" --json number --jq 'length' 2>/dev/null) || true + case "$merged_count" in ''|*[!0-9]*) merged_count=0 ;; esac + if [ "$merged_count" -ge 1 ]; then + branch="none" # stale remote-tracking ref for an already-merged PR — ignore it (issue #158) + else + branch="${remote_branch#remotes/}" + fi + else + branch="none" + fi [ -n "$branch" ] || branch="none" detail+="issue=$num branch=$branch title=$title"$'\n' diff --git a/.claude/scripts/loop-census.test.sh b/.claude/scripts/loop-census.test.sh index c973900..67d32a2 100644 --- a/.claude/scripts/loop-census.test.sh +++ b/.claude/scripts/loop-census.test.sh @@ -85,6 +85,11 @@ check() { # - issue 100: a LOCAL branch feat/issue-100-w, which ALREADY has an open # PR under its bare (exact, no prefix) name -> must NOT be # in_flight (the plain exact-match control case). +# - issue 44: a REMOTE-tracking-only branch feat/issue-44-m (pushed, local +# copy deleted, same shape as issue 42), but a MERGED PR +# already exists for it -> stale-merged-remote (issue #158): +# must report branch=none and NOT be in_flight, even though +# the remote-tracking ref itself still exists. # --------------------------------------------------------------------------- fixture="$work/fixture1" scripts_dir="$fixture/.claude/scripts" @@ -122,18 +127,28 @@ EOF # Fake bot-gh.sh: no network, no real `gh` — dispatches on the subcommand and # a `--json` marker to canned, fixture-appropriate output. +# - `pr list --state merged --head ...`: merged-PR count for a +# bare branch name (issue #158) — 1 for feat/issue-44-m (the +# stale-merged-remote case), 0 for everything else (default). Checked +# BEFORE the headRefName/default branches below since it shares the `pr` +# subcommand but never carries the `headRefName` marker. # - `pr list ... --json headRefName ...`: bare branch names of open PRs — -# issues 43 and 100 already have one; issue 42 does not (issue 4 has no -# branch, so it can't have a PR either). +# issues 43 and 100 already have one; issue 42 and 44 do not (issue 4 has +# no branch, so it can't have a PR either). # - `pr list ... --json number ...`: open PR count (2, matching above). # - `issue list ...`: TSV `numlabelstitle` -# for the four planned+module:test issues. +# for the five planned+module:test issues. cat > "$scripts_dir/bot-gh.sh" <<'EOF' #!/usr/bin/env bash case "$1" in repo) echo "acme/repo" ;; pr) - if printf '%s\n' "$*" | grep -q 'headRefName'; then + if printf '%s\n' "$*" | grep -q -- '--state merged'; then + case "$*" in + *"--head feat/issue-44-m"*) echo 1 ;; + *) echo 0 ;; + esac + elif printf '%s\n' "$*" | grep -q 'headRefName'; then printf '%s\n' "feat/issue-43-z" printf '%s\n' "feat/issue-100-w" else @@ -144,6 +159,7 @@ case "$1" in printf '4\tplanned,module:test\tIssue four\n' printf '42\tplanned,module:test\tIssue forty two\n' printf '43\tplanned,module:test\tIssue forty three\n' + printf '44\tplanned,module:test\tIssue forty four\n' printf '100\tplanned,module:test\tIssue one hundred\n' ;; *) echo "fake-bot-gh.sh: unhandled args: $*" >&2; exit 1 ;; @@ -193,6 +209,14 @@ git -C "$fixture" branch feat/issue-43-z main >/dev/null git -C "$fixture" push -q origin feat/issue-43-z >/dev/null 2>&1 git -C "$fixture" branch -D feat/issue-43-z >/dev/null +# issue 44: same shape as 42/43 (remote-tracking-only ref, local copy +# deleted), but the fake bot-gh.sh reports a MERGED PR already exists for its +# bare branch name — the stale-merged-remote case (issue #158): this ref must +# be ignored (branch=none), not counted as in_flight forever. +git -C "$fixture" branch feat/issue-44-m main >/dev/null +git -C "$fixture" push -q origin feat/issue-44-m >/dev/null 2>&1 +git -C "$fixture" branch -D feat/issue-44-m >/dev/null + # issue 100: LOCAL-only branch (never pushed) — exact-match control. git -C "$fixture" branch feat/issue-100-w main >/dev/null @@ -208,8 +232,10 @@ check "issue 4 is NOT in_flight (no branch to be in flight with)" bash -c '! pri check "issue 42 (remote-only branch, no open PR) IS in_flight" bash -c 'printf "%s\n" "$1" | grep -qx "in_flight=42"' _ "$out" check "issue 43 (remote-only branch, already has an open PR via origin/ strip+suffix match) is NOT in_flight" bash -c '! printf "%s\n" "$1" | grep -qx "in_flight=43"' _ "$out" check "issue 100 (local branch, already has an open PR, exact-match control) is NOT in_flight" bash -c '! printf "%s\n" "$1" | grep -qx "in_flight=100"' _ "$out" +check "issue 44 (remote-only branch, MERGED PR already exists) reports branch=none (issue #158)" bash -c 'printf "%s\n" "$1" | grep -q "^issue=44 branch=none"' _ "$out" +check "issue 44 (stale merged remote-only ref) is NOT in_flight (issue #158)" bash -c '! printf "%s\n" "$1" | grep -qx "in_flight=44"' _ "$out" check "exactly one in_flight line total (only issue 42 qualifies)" bash -c '[ "$(printf "%s\n" "$1" | grep -c "^in_flight=")" -eq 1 ]' _ "$out" -check "planned_issues=4 counted" bash -c 'printf "%s\n" "$1" | grep -qx "planned_issues=4"' _ "$out" +check "planned_issues=5 counted" bash -c 'printf "%s\n" "$1" | grep -qx "planned_issues=5"' _ "$out" check "issue=42 branch line shows the origin-prefixed remote-tracking name" bash -c 'printf "%s\n" "$1" | grep -q "^issue=42 branch=origin/feat/issue-42-y"' _ "$out" check "ci_fix_prs=0 counted (no-op pr-ci-fix.sh stub, issue #96)" bash -c 'printf "%s\n" "$1" | grep -qx "ci_fix_prs=0"' _ "$out" check "comment_fix_prs=0 counted (no-op pr-comment-fix.sh stub, issue #96 part 2)" bash -c 'printf "%s\n" "$1" | grep -qx "comment_fix_prs=0"' _ "$out"