diff --git a/.claude/scripts/loop-daemon.sh b/.claude/scripts/loop-daemon.sh index 8c79187..6320092 100644 --- a/.claude/scripts/loop-daemon.sh +++ b/.claude/scripts/loop-daemon.sh @@ -21,13 +21,30 @@ # so a driver's own bash children can never be orphaned by a bare SIGTERM. # # RUN LEDGER: one line per driver appended to .claude/state/loop-runs.log: -# pid= session= verdict= ts= [result=exit|timeout rc=N] +# pid= session= verdict= ts= [result=exit|timeout|phantom rc=N] [pr=N] [debris=empty|publishable|half-done [action=deleted|resumable]] [verify=skipped] # session_id is parsed out of the driver's own --output-format json stdout, # so a hung/dead driver can be inspected later with # `claude --resume --fork-session` (safe while it's still # running; transcripts are append-only JSONL). .claude/state/ is gitignored — # this ledger is never committed. # +# POST-EXIT VERIFICATION + DEBRIS CLASSIFIER (issue #111): two incident +# classes wedged the loop before this fix — (1) a driver spawned its +# orchestrator in the BACKGROUND and ended its own headless turn early, +# leaving a half-born local `feat/issue-N-*` branch with no commits/push/PR +# while the ledger recorded a phantom `result=exit rc=0` and census read the +# local branch as in_flight forever, silently starving that issue; (2) a +# driver killed mid-flight AFTER committing gates-green work but BEFORE +# pushing — a naive "delete any debris branch" fix would have DESTROYED that +# finished work (recovered manually as PR #117 for issue #107). The fix: +# after every `advance issue=N` driver exits (skipping timeouts/spawn-errors, +# which have no work product to check yet), verify_and_classify_post_exit +# queries GitHub for an open PR on that issue's branch, corrects +# `result=exit rc=0` to `result=phantom rc=0` when none exists, and calls +# classify_debris (pure git, no network) to tell an `empty` branch (safe to +# delete — case 1 above) apart from `publishable`/`half-done` (real work, +# NEVER deleted — case 2 above). Only the `empty`+no-PR case is destructive. +# # Env: # LOOP_MODEL model for the driver (default sonnet; read by loop-event.sh) # GATES_FILE adapter override, passed straight through the environment @@ -35,6 +52,9 @@ # LOOP_DRIVER_TIMEOUT wall-clock cap per driver (default 90m) # LOOP_DAEMON_SLEEP_FAST/WATCH/IDLE/FALLBACK override the adaptive-sleep seconds (test hook) # LOOP_DAEMON_MAX_ITERATIONS bound the forever loop; 0 = unbounded (test/debug hook) +# CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS forced to 0 for the driver spawn (issue #111 pt 4) +# unless the caller already set it — fail-fast on a +# backgrounded driver instead of a silent half-completion # # Sourcing this file (rather than executing it) has ZERO side effects — every # function below only runs when called, and `main` only runs when this file @@ -108,6 +128,182 @@ extract_session_id() { | sed -E 's/.*"session_id"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/' } +# --- map a local branch name -> its worktree's absolute path, if any -------- +# Same idea as worktree-cleanup.sh's own worktree_for_branch, but deliberately +# pure bash/git — NOT node — parsing `git worktree list --porcelain`'s +# blank-line-separated records. This mirrors extract_session_id's own +# grep/sed-not-node rule above: a daemon/service environment that needed +# ensure_claude_on_path's nvm fallback to find `claude` may still not have +# `node` resolvable, and this runs unconditionally on every advance driver +# exit (unlike worktree-cleanup.sh, which only runs after a successful gh +# merge where node was already required). Prints nothing (not an error) when +# the branch has no worktree. +worktree_for_branch() { + local want="$1" path="" branch="" line + while IFS= read -r line; do + case "$line" in + "worktree "*) path="${line#worktree }" ;; + "branch refs/heads/"*) branch="${line#branch refs/heads/}" ;; + "") + if [ -n "$path" ] && [ "$branch" = "$want" ]; then + printf '%s' "$path" + return 0 + fi + path="" branch="" + ;; + esac + done < <(git -C "$root" worktree list --porcelain 2>/dev/null; printf '\n') +} + +# --- debris classifier (issue #111 pt 2) ------------------------------------- +# $1=branch $2=worktree_dir (may be empty/nonexistent). PURE SHELL, NETWORK +# FREE — only `git` against the local repo. Echoes exactly one of: +# absent branch doesn't exist at all in the relevant repo (nothing to +# classify) +# empty no commits ahead of main AND worktree clean/absent — safe to +# delete (the #91/#92 half-born-branch case) +# publishable commits ahead of main AND worktree clean — real work, a PR +# should exist or be opened for it, NEVER delete +# half-done commits ahead but the worktree is dirty (uncommitted work), +# OR ahead=0 with a dirty worktree — resumable, NEVER delete +# +# Repo context: when $2 is a real directory it's used as the git context for +# BOTH the branch-existence/ahead-count check and the dirty check (a linked +# worktree shares refs/objects with its parent repo, so this works whether +# $2 is a genuine `git worktree add` checkout or a standalone repo — the +# latter is what loop-daemon.test.sh uses to unit-test this function in full +# isolation from the real project's own git state). Falls back to the +# daemon's own $root only when no worktree dir was given/found. +# +# NOTE (documented assumption): the issue asked for a per-branch events.jsonl +# gates/review signal to distinguish "publishable" more precisely. This repo's +# events.jsonl (log-event.sh) is a single GLOBAL, size-capped, rotating log — +# there is no per-branch event trail to inspect. So "publishable" here is +# "commits ahead of main + clean worktree", full stop; a finer-grained +# gates/review-verified signal is left for a follow-up if it's ever needed. +classify_debris() { + local branch="$1" wt="${2:-}" + local repo="$root" + [ -n "$wt" ] && [ -d "$wt" ] && repo="$wt" + + if ! git -C "$repo" rev-parse --verify --quiet "refs/heads/$branch" >/dev/null 2>&1; then + echo "absent" + return 0 + fi + + local ahead + ahead="$(git -C "$repo" rev-list --count "main..$branch" 2>/dev/null || echo 0)" + [ -n "$ahead" ] || ahead=0 + + local dirty=0 + if [ -n "$wt" ] && [ -d "$wt" ]; then + [ -n "$(git -C "$wt" status --porcelain 2>/dev/null)" ] && dirty=1 + fi + + if [ "$ahead" -eq 0 ] && [ "$dirty" -eq 0 ]; then + echo "empty" + elif [ "$ahead" -gt 0 ] && [ "$dirty" -eq 0 ]; then + echo "publishable" + else + echo "half-done" + fi +} + +# --- post-exit verification + ledger honesty (issue #111 pt 1) -------------- +# $1=verdict $2=rc $3=extra (the "result=... rc=N" string run_driver already +# built). Echoes the (possibly augmented/replaced) extra string the ONE +# ledger line should carry instead — never appends a second ledger line. +# +# Only applies to `advance issue=N` verdicts with a non-timeout/non-spawn-error +# rc (124/137/127 pass $3 straight through unchanged: a killed/never-spawned +# driver has no work product to verify yet). All GitHub access goes through +# bot-gh.sh; a failed/offline/empty query degrades to appending `verify=skipped` +# — it NEVER falsely declares `result=phantom`, and NEVER deletes anything, on +# a network hiccup. classify_debris (pure git) still runs regardless of +# network reachability. +verify_and_classify_post_exit() { + local verdict="$1" rc="$2" extra="$3" + + case "$verdict" in + "advance issue="*) : ;; + *) printf '%s' "$extra"; return 0 ;; + esac + case "$rc" in + 124|137|127) printf '%s' "$extra"; return 0 ;; + esac + local n="${verdict#advance issue=}" + case "$n" in + *[!0-9]*|'') printf '%s' "$extra"; return 0 ;; + esac + + # Local branch for this issue, if any — there may be none (e.g. the driver + # never even reached `git checkout -b`). `git branch --list` prefixes the + # CURRENTLY CHECKED OUT branch with "* " and any branch checked out in a + # DIFFERENT linked worktree with "+ " (this is exactly that case — the + # driver's own worktree has it checked out) — strip both markers, same as + # worktree-cleanup.sh's own `git branch --merged` parsing does. + local branch + branch="$(git -C "$root" branch --list "feat/issue-$n-*" 2>/dev/null | sed 's/^[*+ ]*//' | head -1)" + + # Query GitHub for an OPEN PR whose head branch matches feat/issue-N-* — + # this works whether or not a LOCAL branch still exists (the driver may + # have pushed + opened a PR from a worktree already cleaned up elsewhere). + local gh_out gh_rc pr_num="" + gh_out="$(bash "$script_dir/bot-gh.sh" pr list --state open --json number,headRefName \ + --jq ".[] | select(.headRefName | test(\"^feat/issue-$n-\")) | .number" 2>/dev/null)" + gh_rc=$? + + if [ "$gh_rc" -ne 0 ]; then + # offline/failure: degrade gracefully — never falsely declare phantom, + # never delete on a network hiccup. + printf '%s verify=skipped' "$extra" + return 0 + fi + pr_num="$(printf '%s\n' "$gh_out" | head -1)" + + local out="$extra" + if [ -n "$pr_num" ]; then + out="$out pr=$pr_num" + elif [ "$rc" -eq 0 ]; then + # rc=0 with NO open PR: the naive "result=exit rc=0" would be a phantom + # success (issue #111 pt 1) — the driver ended cleanly without its work + # product ever landing on GitHub. Correct the record, don't just append. + out="$(printf '%s' "$out" | sed -E 's/result=exit/result=phantom/')" + fi + + if [ -n "$branch" ]; then + local wt state + wt="$(worktree_for_branch "$branch")" + state="$(classify_debris "$branch" "$wt")" + out="$out debris=$state" + + case "$state" in + empty) + # The ONLY destructive path (case A) — provably no commits, no dirty + # worktree, no open PR. Case B/C (publishable/half-done) are NEVER + # touched here (deferred publish / resumable, respectively). + if [ -z "$pr_num" ]; then + if [ -n "$wt" ]; then + git -C "$root" worktree remove --force "$wt" 2>/dev/null || true + fi + if git -C "$root" branch -D "$branch" >/dev/null 2>&1; then + out="$out action=deleted" + log "post-exit debris cleanup: deleted empty local branch/worktree for issue #$n ($branch)" + fi + fi + ;; + half-done) + out="$out resumable" + ;; + *) + : # publishable/absent: recorded via debris=$state above, nothing else to do + ;; + esac + fi + + printf '%s' "$out" +} + # --- spawn ONE contained driver, block until it exits/times out, ledger it --- # $1=verdict (e.g. "advance issue=42"), $2=model, $3=prompt-file (plain text). # Returns the driver's exit code (124/137 on timeout). @@ -131,6 +327,14 @@ run_driver() { local out_file; out_file="$(mktemp "$state_dir/.loop-driver-out.XXXXXX.json")" log "spawning driver ($verdict, model=$model, timeout=$timeout_dur)" + # Fail-fast spawn (issue #111 pt 4): a driver session that backgrounds its + # own orchestrator/agents can otherwise end its headless turn "cleanly" + # while that background work is still mid-flight — the half-born-branch + # incident this whole file's post-exit verification exists to catch. + # Forcing this ceiling to 0 makes a backgrounded spawn die loudly (instead + # of half-completing) so the failure is immediate and visible, not a + # phantom success discovered later. Still overridable by the caller's env. + export CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS="${CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS:-0}" # setsid: own session, so the whole tree (claude + any bash children it # spawns) shares ONE fresh process group independent of this daemon's own — # timeout's --kill-after below then has a single group to aim at. Backstop @@ -153,6 +357,20 @@ run_driver() { *) extra="result=exit rc=$rc" ;; esac + # Post-exit verification + debris classification (issue #111 pts 1-2) — + # ONLY for advance verdicts with a non-timeout/non-spawn-error rc; may + # replace `result=exit` with `result=phantom` and/or append pr=/debris= + # fields onto the SAME extra string, so exactly one ledger line still + # covers this whole run. + case "$verdict" in + "advance issue="*) + case "$rc" in + 124|137|127) : ;; + *) extra="$(verify_and_classify_post_exit "$verdict" "$rc" "$extra")" ;; + esac + ;; + esac + append_ledger "$pgid" "$session_id" "$verdict" "$ts" "$extra" log "driver finished ($verdict): $extra session=${session_id:-unknown}" rm -f "$out_file" "$out_file.stderr" "$prompt_file" diff --git a/.claude/scripts/loop-daemon.test.sh b/.claude/scripts/loop-daemon.test.sh index 2f510ae..effd56b 100644 --- a/.claude/scripts/loop-daemon.test.sh +++ b/.claude/scripts/loop-daemon.test.sh @@ -75,6 +75,66 @@ check "ledger line: empty session_id prints 'unknown'" [ "$line2" = "pid=999 ses line3="$(ledger_line 111 sess-x 'advance issue=1' '2026-07-09T02:00:00Z' 'result=timeout rc=124')" check "ledger line: timeout result recorded verbatim" [ "$line3" = "pid=111 session=sess-x verdict=advance issue=1 ts=2026-07-09T02:00:00Z result=timeout rc=124" ] +# --- classify_debris (issue #111 pt 2): pure git, fully isolated fixture ---- +# classify_debris uses $2 (worktree_dir) AS the git repo context whenever it's +# a real directory, so these checks build their own throwaway repo + worktrees +# (under $work, cleaned up by the top-level trap) and never touch the REAL +# project's own git state (the sourced $root is the real repo root, but is +# never reached here because $2 is always given). +cd_repo="$work/classify-repo" +mkdir -p "$cd_repo" +git -C "$cd_repo" init -q -b main +git -C "$cd_repo" config user.email test@example.com +git -C "$cd_repo" config user.name test +git -C "$cd_repo" commit -q --allow-empty -m init + +# empty: branch off main, zero extra commits, worktree clean. +git -C "$cd_repo" branch feat/issue-1-a main +cd_wt_empty="$work/classify-wt-empty" +git -C "$cd_repo" worktree add -q "$cd_wt_empty" feat/issue-1-a +s_empty="$(classify_debris feat/issue-1-a "$cd_wt_empty")" +check "classify_debris: no commits ahead + clean worktree -> empty" [ "$s_empty" = "empty" ] + +# publishable: one commit ahead of main, worktree clean. +cd_wt_pub="$work/classify-wt-pub" +git -C "$cd_repo" worktree add -q -b feat/issue-2-a "$cd_wt_pub" main +( cd "$cd_wt_pub" && echo hi > f.txt && git add f.txt && git -c user.email=test@example.com -c user.name=test commit -q -m work ) +s_pub="$(classify_debris feat/issue-2-a "$cd_wt_pub")" +check "classify_debris: commits ahead + clean worktree -> publishable" [ "$s_pub" = "publishable" ] + +# half-done: one commit ahead of main, worktree DIRTY (uncommitted changes). +cd_wt_half="$work/classify-wt-half" +git -C "$cd_repo" worktree add -q -b feat/issue-3-a "$cd_wt_half" main +( cd "$cd_wt_half" && echo hi > f.txt && git add f.txt && git -c user.email=test@example.com -c user.name=test commit -q -m work && echo more >> f.txt ) +s_half="$(classify_debris feat/issue-3-a "$cd_wt_half")" +check "classify_debris: commits ahead + dirty worktree -> half-done" [ "$s_half" = "half-done" ] + +# half-done (variant): zero commits ahead but worktree DIRTY (uncommitted-only +# work — never even committed) still counts as resumable, not empty. +cd_wt_dirty0="$work/classify-wt-dirty0" +git -C "$cd_repo" branch feat/issue-4-a main +git -C "$cd_repo" worktree add -q "$cd_wt_dirty0" feat/issue-4-a +echo untracked > "$cd_wt_dirty0/untracked.txt" +s_dirty0="$(classify_debris feat/issue-4-a "$cd_wt_dirty0")" +check "classify_debris: no commits ahead but dirty worktree -> half-done (not empty)" [ "$s_dirty0" = "half-done" ] + +# absent: branch simply doesn't exist in that repo. +s_absent="$(classify_debris feat/issue-999-nope "$cd_wt_empty")" +check "classify_debris: nonexistent branch -> absent" [ "$s_absent" = "absent" ] + +# --- verify_and_classify_post_exit: pure pass-through cases (no bot-gh.sh call) --- +# These never reach the bot-gh.sh query at all (guarded before it), so they're +# safe pure-unit checks even though the sourced $script_dir/$root point at the +# REAL project — no network, no git mutation. +vp_feedback="$(verify_and_classify_post_exit 'feedback pr=9' 0 'result=exit rc=0')" +check "verify_and_classify_post_exit: non-advance verdict passes extra through unchanged" [ "$vp_feedback" = "result=exit rc=0" ] + +vp_timeout="$(verify_and_classify_post_exit 'advance issue=5' 124 'result=timeout rc=124')" +check "verify_and_classify_post_exit: timeout rc=124 passes extra through unchanged" [ "$vp_timeout" = "result=timeout rc=124" ] + +vp_spawnerr="$(verify_and_classify_post_exit 'advance issue=5' 127 'result=spawn-error rc=127')" +check "verify_and_classify_post_exit: spawn-error rc=127 passes extra through unchanged" [ "$vp_spawnerr" = "result=spawn-error rc=127" ] + # ============================================================================= # (B) Integration checks: real subprocess, fake loop-event.sh + fake claude. # ============================================================================= @@ -97,6 +157,16 @@ fake_bin() { chmod +x "$dir/bin/$name" } +fake_bot_gh() { + # $1=fixture root $2=script body -> installs a fake .claude/scripts/bot-gh.sh, + # since verify_and_classify_post_exit always calls "$script_dir/bot-gh.sh" as + # an explicit path (mirroring every OTHER script in this repo's bot-gh.sh + # policy), never a bare `bot-gh.sh` resolved off PATH like fake_bin's targets. + local dir="$1" body="$2" + printf '%s\n' "$body" > "$dir/.claude/scripts/bot-gh.sh" + chmod +x "$dir/.claude/scripts/bot-gh.sh" +} + run_daemon_once() { # $1=fixture root; runs loop-daemon.sh for exactly one iteration. NVM_DIR # points inside the fixture (nothing there) so ensure_claude_on_path's nvm @@ -174,17 +244,29 @@ shift # duration (e.g. 90m) exec "$@"' fake_bin "$dir3" claude '#!/usr/bin/env bash echo "claude-ran args=$*" >> "'"$dir3"'/claude.marker" +echo "$CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS" > "'"$dir3"'/claude.bg-ceiling-env" echo "{\"session_id\":\"sess-fixture-55\",\"result\":\"ok\"}" exit 0' +# Post-exit verification (issue #111) now queries bot-gh.sh for an open PR on +# every advance verdict that exits without timing out. Stub it as an already- +# open PR #77, so this scenario's ledger keeps recording a genuine success +# (result=exit, not phantom) — the phantom/offline/classifier paths get their +# own dedicated scenarios below. +fake_bot_gh "$dir3" '#!/usr/bin/env bash +echo "bot-gh-ran args=$*" >> "'"$dir3"'/bot-gh.marker" +echo "77" +exit 0' run_daemon_once "$dir3" >/dev/null 2>&1 check "scenario 3 (advance): setsid stub was invoked" [ -f "$dir3/setsid.marker" ] check "scenario 3: timeout stub was invoked" [ -f "$dir3/timeout.marker" ] check "scenario 3: claude stub was invoked" [ -f "$dir3/claude.marker" ] check "scenario 3: claude stub received the prompt text" bash -c 'grep -qF "issue #55" "$1"' _ "$dir3/claude.marker" +check "scenario 3: CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0 was exported to the driver (issue #111 pt 4 fail-fast spawn)" bash -c ' + [ "$(cat "$1" 2>/dev/null)" = "0" ]' _ "$dir3/claude.bg-ceiling-env" ledger3="$dir3/.claude/state/loop-runs.log" check "scenario 3: exactly one ledger line was appended" [ "$(wc -l < "$ledger3" 2>/dev/null || echo 0)" -eq 1 ] -check "scenario 3: ledger line has pid=/session=/verdict=/ts=/result= fields" bash -c ' - grep -Eq "^pid=[0-9]+ session=sess-fixture-55 verdict=advance issue=55 ts=[0-9T:Z-]+ result=exit rc=0$" "$1" +check "scenario 3: ledger line has pid=/session=/verdict=/ts=/result=/pr= fields (post-exit verify found PR #77)" bash -c ' + grep -Eq "^pid=[0-9]+ session=sess-fixture-55 verdict=advance issue=55 ts=[0-9T:Z-]+ result=exit rc=0 pr=77$" "$1" ' _ "$ledger3" check "scenario 3: prompt file was cleaned up after the driver ran" [ ! -f "$prompt3_dir/prompt.txt" ] @@ -281,6 +363,179 @@ run_daemon_once_stripped_path "$dir6" "$fake_nvm_dir" >/dev/null 2>&1 check "scenario 6 (startup PATH resolution): node+claude resolved in child before run_once" [ -f "$dir6/node-resolved.marker" ] check "scenario 6: no node-missing marker was left (node/claude never resolved)" [ ! -f "$dir6/node-missing.marker" ] +# --------------------------------------------------------------------------- +# git_fixture: like new_fixture, but ALSO git-inits the fixture root itself as +# a real repo with an initial commit on `main` — the repo context +# verify_and_classify_post_exit's `git -C "$root" ...` calls operate on for +# scenarios 7-10 below (issue #111 pts 1-2: post-exit verification + the +# debris classifier need a real branch/worktree to classify, not just a +# scripted loop-event.sh). +# --------------------------------------------------------------------------- +git_fixture() { + local name="$1" body="$2" + local dir; dir="$(new_fixture "$name" "$body")" + git -C "$dir" init -q -b main + git -C "$dir" config user.email test@example.com + git -C "$dir" config user.name test + git -C "$dir" commit -q --allow-empty -m init + printf '%s\n' "$dir" +} + +# --------------------------------------------------------------------------- +# 7. Phantom + debris (no deletion): advance issue=77, claude exits 0, fake +# bot-gh.sh reports NO open PR. issue #77's branch is in the `half-done` +# state (a commit ahead of main, worktree dirty) — real, unpushed work. +# Assert the ledger corrects result=exit -> result=phantom (issue #111 pt +# 1's ledger-honesty fix) AND records debris=half-done resumable, and +# (Case B/C safety) NEITHER the branch NOR its worktree get touched. +# --------------------------------------------------------------------------- +prompt7_dir="$work/scenario7-support" +mkdir -p "$prompt7_dir" +printf 'Run the ADVANCE step for issue #77.\n' > "$prompt7_dir/prompt.txt" +dir7="$(git_fixture scenario7 "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=advance issue=77' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt7_dir/prompt.txt' +exit 0")" +wt7="$work/scenario7-wt" +git -C "$dir7" worktree add -q -b feat/issue-77-broken "$wt7" main +( cd "$wt7" && echo hi > f.txt && git add f.txt && git -c user.email=test@example.com -c user.name=test commit -q -m work && echo dirty >> f.txt ) +fake_bin "$dir7" setsid '#!/usr/bin/env bash +exec "$@"' +fake_bin "$dir7" timeout '#!/usr/bin/env bash +shift; shift +exec "$@"' +fake_bin "$dir7" claude '#!/usr/bin/env bash +echo "{\"session_id\":\"sess-77\",\"result\":\"ok\"}" +exit 0' +fake_bot_gh "$dir7" '#!/usr/bin/env bash +# No open PR for this issue — empty stdout, rc=0 (a genuine "queried fine, found nothing"). +exit 0' +run_daemon_once "$dir7" >/dev/null 2>&1 +ledger7="$dir7/.claude/state/loop-runs.log" +check "scenario 7 (phantom+half-done): ledger corrects result=exit -> result=phantom rc=0" bash -c ' + grep -q "result=phantom rc=0" "$1"' _ "$ledger7" +check "scenario 7: ledger records debris=half-done resumable" bash -c ' + grep -q "debris=half-done resumable" "$1"' _ "$ledger7" +check "scenario 7: ledger never claims action=deleted for half-done debris" bash -c '! grep -q "action=deleted" "$1"' _ "$ledger7" +check "scenario 7 (Case B/C safety): the branch was NOT deleted" bash -c ' + git -C "$1" rev-parse --verify --quiet refs/heads/feat/issue-77-broken >/dev/null 2>&1' _ "$dir7" +check "scenario 7: the worktree was NOT removed" [ -d "$wt7" ] + +# --------------------------------------------------------------------------- +# 8. Case A (the ONLY destructive path): advance issue=88, claude exits 0, no +# open PR, and issue #88's branch is provably `empty` (no commits ahead of +# main, clean worktree) — the #91/#92 half-born-branch incident this whole +# feature exists to clean up safely. Assert the branch + worktree ARE +# deleted and the ledger records result=phantom ... debris=empty action=deleted. +# --------------------------------------------------------------------------- +prompt8_dir="$work/scenario8-support" +mkdir -p "$prompt8_dir" +printf 'Run the ADVANCE step for issue #88.\n' > "$prompt8_dir/prompt.txt" +dir8="$(git_fixture scenario8 "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=advance issue=88' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt8_dir/prompt.txt' +exit 0")" +wt8="$work/scenario8-wt" +git -C "$dir8" branch feat/issue-88-empty main +git -C "$dir8" worktree add -q "$wt8" feat/issue-88-empty +fake_bin "$dir8" setsid '#!/usr/bin/env bash +exec "$@"' +fake_bin "$dir8" timeout '#!/usr/bin/env bash +shift; shift +exec "$@"' +fake_bin "$dir8" claude '#!/usr/bin/env bash +echo "{\"session_id\":\"sess-88\",\"result\":\"ok\"}" +exit 0' +fake_bot_gh "$dir8" '#!/usr/bin/env bash +exit 0' +run_daemon_once "$dir8" >/dev/null 2>&1 +ledger8="$dir8/.claude/state/loop-runs.log" +check "scenario 8 (Case A): ledger records result=phantom rc=0 debris=empty action=deleted" bash -c ' + grep -q "result=phantom rc=0.*debris=empty.*action=deleted" "$1"' _ "$ledger8" +check "scenario 8: the empty local branch WAS deleted" bash -c ' + ! git -C "$1" rev-parse --verify --quiet refs/heads/feat/issue-88-empty >/dev/null 2>&1' _ "$dir8" +check "scenario 8: the worktree WAS removed" [ ! -d "$wt8" ] + +# --------------------------------------------------------------------------- +# 9. Case C safety, with an open PR (not phantom): advance issue=99, claude +# exits 0, bot-gh.sh reports an OPEN PR #42, but issue #99's branch is +# `half-done` (unpushed local changes on top of the pushed commit — e.g. a +# driver that opened the PR but was killed before pushing a final fixup). +# Assert NOTHING gets deleted, the PR is still recorded (not phantom), and +# the ledger records debris=half-done resumable. +# --------------------------------------------------------------------------- +prompt9_dir="$work/scenario9-support" +mkdir -p "$prompt9_dir" +printf 'Run the ADVANCE step for issue #99.\n' > "$prompt9_dir/prompt.txt" +dir9="$(git_fixture scenario9 "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=advance issue=99' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt9_dir/prompt.txt' +exit 0")" +wt9="$work/scenario9-wt" +git -C "$dir9" worktree add -q -b feat/issue-99-mid "$wt9" main +( cd "$wt9" && echo hi > f.txt && git add f.txt && git -c user.email=test@example.com -c user.name=test commit -q -m work && echo dirty >> f.txt ) +fake_bin "$dir9" setsid '#!/usr/bin/env bash +exec "$@"' +fake_bin "$dir9" timeout '#!/usr/bin/env bash +shift; shift +exec "$@"' +fake_bin "$dir9" claude '#!/usr/bin/env bash +echo "{\"session_id\":\"sess-99\",\"result\":\"ok\"}" +exit 0' +fake_bot_gh "$dir9" '#!/usr/bin/env bash +echo "42" +exit 0' +run_daemon_once "$dir9" >/dev/null 2>&1 +ledger9="$dir9/.claude/state/loop-runs.log" +check "scenario 9 (Case C safety, open PR): ledger records result=exit (NOT phantom), pr=42, debris=half-done resumable" bash -c ' + grep -Eq "result=exit rc=0 pr=42 debris=half-done resumable" "$1"' _ "$ledger9" +check "scenario 9: nothing was deleted — branch still exists" bash -c ' + git -C "$1" rev-parse --verify --quiet refs/heads/feat/issue-99-mid >/dev/null 2>&1' _ "$dir9" +check "scenario 9: nothing was deleted — worktree still exists" [ -d "$wt9" ] + +# --------------------------------------------------------------------------- +# 10. Graceful degrade: bot-gh.sh is offline/missing entirely (no stub +# installed) for an otherwise textbook-empty issue #100 branch. Assert +# verify_and_classify_post_exit NEVER falsely declares phantom and NEVER +# deletes anything on a network hiccup — it just records verify=skipped. +# --------------------------------------------------------------------------- +prompt10_dir="$work/scenario10-support" +mkdir -p "$prompt10_dir" +printf 'Run the ADVANCE step for issue #100.\n' > "$prompt10_dir/prompt.txt" +dir10="$(git_fixture scenario10 "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=advance issue=100' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt10_dir/prompt.txt' +exit 0")" +wt10="$work/scenario10-wt" +git -C "$dir10" branch feat/issue-100-empty main +git -C "$dir10" worktree add -q "$wt10" feat/issue-100-empty +fake_bin "$dir10" setsid '#!/usr/bin/env bash +exec "$@"' +fake_bin "$dir10" timeout '#!/usr/bin/env bash +shift; shift +exec "$@"' +fake_bin "$dir10" claude '#!/usr/bin/env bash +echo "{\"session_id\":\"sess-100\",\"result\":\"ok\"}" +exit 0' +# Deliberately NO bot-gh.sh stub installed at all in this fixture. +run_daemon_once "$dir10" >/dev/null 2>&1 +ledger10="$dir10/.claude/state/loop-runs.log" +check "scenario 10 (offline bot-gh.sh): ledger records verify=skipped, NOT phantom" bash -c ' + grep -q "verify=skipped" "$1" && ! grep -q "phantom" "$1"' _ "$ledger10" +check "scenario 10: no debris/action fields were recorded (verify never even ran)" bash -c ' + ! grep -Eq "debris=|action=deleted" "$1"' _ "$ledger10" +check "scenario 10: nothing was deleted on the offline path — branch still exists" bash -c ' + git -C "$1" rev-parse --verify --quiet refs/heads/feat/issue-100-empty >/dev/null 2>&1' _ "$dir10" +check "scenario 10: nothing was deleted on the offline path — worktree still exists" [ -d "$wt10" ] + echo "" if [ "$fail" -eq 0 ]; then echo "loop-daemon.test.sh: PASS ($ok checks)" diff --git a/.claude/scripts/loop-event.test.sh b/.claude/scripts/loop-event.test.sh index 4b102d0..8e3e18c 100644 --- a/.claude/scripts/loop-event.test.sh +++ b/.claude/scripts/loop-event.test.sh @@ -92,6 +92,15 @@ check "scenario 2: prompt-file line points at a real file" bash -c '[ -n "$1" ] check "scenario 2: prompt file mentions issue #42" bash -c 'grep -q "issue #42" "$1"' _ "$pf2" check "scenario 2: prompt file says ADVANCE, not feedback" bash -c 'grep -q "ADVANCE step" "$1"' _ "$pf2" +# --- one-shot contract clauses (hotfix d37e951, guarded against regression by +# issue #111): every driver prompt (advance AND feedback, both baked from the +# shared $common string) must carry all three clauses that stop a driver from +# quietly backgrounding its own orchestrator and ending its turn early, which +# is exactly the incident that wedged issues #91/#92. +check "scenario 2: prompt file mandates FOREGROUND-only spawn (run_in_background: false)" bash -c 'grep -qi "FOREGROUND" "$1" && grep -q "run_in_background: false" "$1"' _ "$pf2" +check "scenario 2: prompt file forbids ending the turn before the work product exists on GitHub" bash -c 'grep -q "do NOT end your turn until the work product exists on GitHub" "$1"' _ "$pf2" +check "scenario 2: prompt file mandates deleting debris on failure" bash -c 'grep -q "delete any local feat/issue-N-\* branch and worktree" "$1"' _ "$pf2" + # --------------------------------------------------------------------------- # 3. action=feedback pr=N -> same contract, feedback wording, LOOP_MODEL honored. # --------------------------------------------------------------------------- @@ -105,6 +114,12 @@ pf3="$(printf '%s\n' "$out3" | sed -n 's/^loop-event: prompt-file=//p')" check "scenario 3: prompt file mentions PR #7" bash -c 'grep -q "PR #7" "$1"' _ "$pf3" check "scenario 3: prompt file says ADDRESS FEEDBACK, and Do NOT merge" bash -c 'grep -q "ADDRESS FEEDBACK" "$1" && grep -q "Do NOT merge" "$1"' _ "$pf3" +# Same one-shot contract clauses, on the FEEDBACK prompt this time (also baked +# from the shared $common string — verdict wording differs, the contract does not). +check "scenario 3: prompt file mandates FOREGROUND-only spawn (run_in_background: false)" bash -c 'grep -qi "FOREGROUND" "$1" && grep -q "run_in_background: false" "$1"' _ "$pf3" +check "scenario 3: prompt file forbids ending the turn before the work product exists on GitHub" bash -c 'grep -q "do NOT end your turn until the work product exists on GitHub" "$1"' _ "$pf3" +check "scenario 3: prompt file mandates deleting debris on failure" bash -c 'grep -q "delete any local feat/issue-N-\* branch and worktree" "$1"' _ "$pf3" + # --------------------------------------------------------------------------- # 4. Garbage verdict line -> non-zero exit, action=none fallback line, no # prompt-file (never spawns on a verdict it can't parse).