diff --git a/.claude/scripts/loop-daemon.sh b/.claude/scripts/loop-daemon.sh index 07fa70b..15462d0 100644 --- a/.claude/scripts/loop-daemon.sh +++ b/.claude/scripts/loop-daemon.sh @@ -21,7 +21,7 @@ # 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|phantom rc=N] [pr=N] [debris=empty|publishable|half-done [action=deleted|resumable]] [verify=skipped] +# 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 @@ -55,7 +55,7 @@ # drivers died ledger-less this way. The fix: when `systemd-run` is on PATH, # `run_driver` spawns each driver as its OWN transient `--user` unit # (`pr-loop-driver-issue`/`pr-loop-driver-pr`/`pr-loop-driver-cifix-pr`/ -# `pr-loop-driver-rebase-pr`, derived from the verdict) +# `pr-loop-driver-rebase-pr`/`pr-loop-driver-resume-issue`, derived from the verdict) # via `systemd-run --user --wait --collect --unit=... -p RuntimeMaxSec=`. # The driver's real parent becomes the user manager — it lives in ITS OWN # scope, independent of the daemon's cgroup — so a daemon restart/crash kills @@ -291,6 +291,17 @@ classify_debris() { # — it NEVER falsely declares `result=phantom`, and NEVER deletes anything, on # a network hiccup. classify_debris (pure git) still runs regardless of # network reachability. +# +# `resume issue=N` (issue #98/#154) is ALSO deliberately excluded here (falls +# into the same `*` pass-through arm) — DO NOT "fix" this by adding it to the +# advance arm. A resume driver continues an EXISTING, possibly half-done local +# `feat/issue-N-*` branch left by a PRIOR stalled driver; running this debris +# check against it could misclassify genuine in-progress work as `empty` and +# delete it out from under the resume driver. Unlike feedback/ci-fix/rebase +# (which never even create a local branch — they push straight onto an +# existing PR's branch), resume's branch predates this run but is exactly the +# kind of half-finished work classify_debris's `half-done`/`publishable` +# outcomes are meant to protect, never destroy. verify_and_classify_post_exit() { local verdict="$1" rc="$2" extra="$3" @@ -375,31 +386,41 @@ verify_and_classify_post_exit() { } # --- transient systemd unit naming (issue #119 pt 1; issue #96 pt ci-fix; -# issue #96 part 2 pt comment-fix; issue #96 part 3 pt rebase) --------------- +# issue #96 part 2 pt comment-fix; issue #96 part 3 pt rebase; issue #98/#154 +# pt resume) ------------------------------------------------------------- # $1=verdict -> "pr-loop-driver-issue" for "advance issue=N", # "pr-loop-driver-pr" for "feedback pr=N", "pr-loop-driver-cifix-pr" # for "ci-fix pr=N", "pr-loop-driver-commentfix-pr" for -# "comment-fix pr=N", or "pr-loop-driver-rebase-pr" for "rebase pr=N". -# Used both to SPAWN the unit (run_driver) and, in reverse -# (verdict_from_unit_name below), to recover the verdict from a unit already -# running when this daemon process starts up (reattach_orphaned_drivers) — the -# two must stay exact inverses of each other. ci-fix, comment-fix, and rebase -# EACH get their OWN distinct unit name (none reuses "pr-loop-driver-pr") -# so a feedback driver, a comment-fix driver, a ci-fix driver, and a rebase -# driver on the SAME PR can never collide in naming or reattach — the verdict -# precedence (feedback > comment-fix > ci-fix > rebase) makes more than one of -# these firing for the same PR in the SAME tick impossible, but a driver from -# a PRIOR tick could still be finishing up while a LATER tick, after that -# reaction was addressed, dispatches a DIFFERENT kind of driver for the -# identical PR number; distinct unit names keep those spawns/reattaches from -# ever being confused with each other. "pr-loop-driver-rebase-pr" cannot -# collide with the plain "pr-loop-driver-pr" shape either: the literal -# string immediately following the common "pr-loop-driver-" prefix is -# "rebase-pr" vs bare "pr", so neither is ever a prefix of the other, and the -# same holds against the "cifix-pr"/"commentfix-pr" shapes. +# "comment-fix pr=N", "pr-loop-driver-rebase-pr" for "rebase pr=N", or +# "pr-loop-driver-resume-issue" for "resume issue=N". Used both to SPAWN +# the unit (run_driver) and, in reverse (verdict_from_unit_name below), to +# recover the verdict from a unit already running when this daemon process +# starts up (reattach_orphaned_drivers) — the two must stay exact inverses of +# each other. ci-fix, comment-fix, rebase, and resume EACH get their OWN +# distinct unit name (none reuses "pr-loop-driver-pr", and resume does NOT +# reuse the advance shape "pr-loop-driver-issue" either — a resume driver +# continues an EXISTING branch with partial work, not a fresh advance, so +# reusing that name would risk colliding with a genuine advance driver on +# reattach) so a feedback driver, a comment-fix driver, a ci-fix driver, a +# rebase driver, and a resume driver on the SAME PR/issue can never collide in +# naming or reattach — the verdict precedence (feedback > comment-fix > ci-fix +# > rebase) makes more than one of these firing for the same PR in the SAME +# tick impossible, but a driver from a PRIOR tick could still be finishing up +# while a LATER tick, after that reaction was addressed, dispatches a +# DIFFERENT kind of driver for the identical PR/issue number; distinct unit +# names keep those spawns/reattaches from ever being confused with each other. +# "pr-loop-driver-rebase-pr" cannot collide with the plain +# "pr-loop-driver-pr" shape either: the literal string immediately +# following the common "pr-loop-driver-" prefix is "rebase-pr" vs bare "pr", +# so neither is ever a prefix of the other, and the same holds against the +# "cifix-pr"/"commentfix-pr" shapes. "pr-loop-driver-resume-issue" cannot +# collide with "pr-loop-driver-issue" either: the literal string +# immediately following "pr-loop-driver-" is "resume-issue" vs bare "issue", +# so neither is ever a prefix of the other. driver_unit_name() { case "$1" in "advance issue="*) printf 'pr-loop-driver-issue%s' "${1#advance issue=}" ;; + "resume issue="*) printf 'pr-loop-driver-resume-issue%s' "${1#resume issue=}" ;; "comment-fix pr="*) printf 'pr-loop-driver-commentfix-pr%s' "${1#comment-fix pr=}" ;; "ci-fix pr="*) printf 'pr-loop-driver-cifix-pr%s' "${1#ci-fix pr=}" ;; "rebase pr="*) printf 'pr-loop-driver-rebase-pr%s' "${1#rebase pr=}" ;; @@ -417,11 +438,16 @@ driver_unit_name() { # "commentfix-pr..." ever matches the plain "pr..." prefix pattern either # way — but the ordering keeps all three non-advance arms visually adjacent to # their distinct name shapes in driver_unit_name above, so the pairing stays -# obvious on read). +# obvious on read). Same reasoning for `pr-loop-driver-resume-issue*`: it can +# never be shadowed by `pr-loop-driver-issue*` (the literal "resume-issue" vs +# bare "issue" following the common prefix means neither is ever a prefix of +# the other), but it's placed right after the advance arm anyway to keep it +# visually adjacent to its distinct name shape in driver_unit_name above. verdict_from_unit_name() { local unit="${1%.service}" case "$unit" in pr-loop-driver-issue*) printf 'advance issue=%s' "${unit#pr-loop-driver-issue}" ;; + pr-loop-driver-resume-issue*) printf 'resume issue=%s' "${unit#pr-loop-driver-resume-issue}" ;; pr-loop-driver-cifix-pr*) printf 'ci-fix pr=%s' "${unit#pr-loop-driver-cifix-pr}" ;; pr-loop-driver-commentfix-pr*) printf 'comment-fix pr=%s' "${unit#pr-loop-driver-commentfix-pr}" ;; pr-loop-driver-rebase-pr*) printf 'rebase pr=%s' "${unit#pr-loop-driver-rebase-pr}" ;; @@ -484,7 +510,7 @@ reattach_orphaned_drivers() { [ -n "$unit" ] || continue local verdict; verdict="$(verdict_from_unit_name "$unit")" if [ -z "$verdict" ]; then - log "startup re-attach: active unit '$unit' doesn't match the pr-loop-driver- naming — leaving it to systemd, not re-attaching" + log "startup re-attach: active unit '$unit' doesn't match the pr-loop-driver- naming — leaving it to systemd, not re-attaching" continue fi log "startup re-attach: found active driver unit '$unit' from a previous daemon ($verdict) — waiting instead of spawning a new one" @@ -494,6 +520,10 @@ reattach_orphaned_drivers() { case "$rc" in 124|137) extra="result=timeout rc=$rc" ;; esac + # "advance issue=N" only — a "resume issue=N" verdict is deliberately + # excluded here too (same reasoning as verify_and_classify_post_exit's own + # guard above): its branch predates this run and may hold real half-done + # work that must never be misclassified as debris. case "$verdict" in "advance issue="*) extra="$(verify_and_classify_post_exit "$verdict" "$rc" "$extra")" ;; esac @@ -693,7 +723,7 @@ run_once() { none|"") : # nothing actionable — no driver spawned ;; - "advance issue="*|"feedback pr="*|"comment-fix pr="*|"ci-fix pr="*|"rebase pr="*) + "advance issue="*|"feedback pr="*|"comment-fix pr="*|"ci-fix pr="*|"rebase pr="*|"resume issue="*) local model prompt_file model="$(printf '%s\n' "$out" | sed -n 's/^loop-event: model=//p' | tail -1)" model="${model:-${LOOP_MODEL:-sonnet}}" @@ -701,7 +731,16 @@ run_once() { if [ -z "$prompt_file" ]; then log "action=$action_line but no prompt-file was emitted — refusing to spawn" else - run_driver "$action_line" "$model" "$prompt_file" || true + # A "resume issue=N" action line may carry an OPTIONAL trailing + # " branch=" (issue #98/#154) -- that's context for the PROMPT + # only; strip it before this reaches run_driver/driver_unit_name/the + # ledger so all three stay the bare "resume issue=N" shape, exactly + # like every other verdict kind dispatched here. + local driver_verdict="$action_line" + case "$driver_verdict" in + "resume issue="*" branch="*) driver_verdict="${driver_verdict%% branch=*}" ;; + esac + run_driver "$driver_verdict" "$model" "$prompt_file" || true fi ;; *) diff --git a/.claude/scripts/loop-daemon.test.sh b/.claude/scripts/loop-daemon.test.sh index 3de7661..c496459 100644 --- a/.claude/scripts/loop-daemon.test.sh +++ b/.claude/scripts/loop-daemon.test.sh @@ -163,6 +163,9 @@ check "verify_and_classify_post_exit: comment-fix verdict passes extra through u vp_rebase="$(verify_and_classify_post_exit 'rebase pr=23' 0 'result=exit rc=0')" check "verify_and_classify_post_exit: rebase verdict passes extra through unchanged (issue #96 part 3 -- no fresh branch/worktree to classify)" [ "$vp_rebase" = "result=exit rc=0" ] +vp_resume="$(verify_and_classify_post_exit 'resume issue=42' 0 'result=exit rc=0')" +check "verify_and_classify_post_exit: resume verdict passes extra through unchanged (issue #98/#154 -- branch PREDATES this run and may hold real work, never classify it as debris)" [ "$vp_resume" = "result=exit rc=0" ] + # --- driver_unit_name / verdict_from_unit_name (issue #119 pt 1; issue #96; # issue #96 part 2; issue #96 part 3) ----------------------------------------- # ci-fix, comment-fix, and rebase EACH get their OWN distinct unit name @@ -186,6 +189,9 @@ check "driver_unit_name: rebase, comment-fix, ci-fix, and feedback unit names fo [ "$1" != "$2" ] && [ "$1" != "$3" ] && [ "$1" != "$4" ] && [ "$2" != "$3" ] && [ "$2" != "$4" ] && [ "$3" != "$4" ] ' _ "$un_rebase" "$un_commentfix" "$un_cifix" "$un_feedback" +un_resume="$(driver_unit_name 'resume issue=42')" +check "driver_unit_name: resume issue=42 -> pr-loop-driver-resume-issue42 (distinct from advance's SAME issue number, issue #98/#154)" [ "$un_resume" = "pr-loop-driver-resume-issue42" ] +check "driver_unit_name: resume and advance unit names for the SAME issue number never collide" bash -c '[ "$1" != "$2" ]' _ "$un_resume" "$un_advance" vun_advance="$(verdict_from_unit_name 'pr-loop-driver-issue42.service')" check "verdict_from_unit_name: pr-loop-driver-issue42.service -> advance issue=42 (exact inverse)" [ "$vun_advance" = "advance issue=42" ] @@ -197,6 +203,9 @@ vun_commentfix="$(verdict_from_unit_name 'pr-loop-driver-commentfix-pr9.service' check "verdict_from_unit_name: pr-loop-driver-commentfix-pr9.service -> comment-fix pr=9 (exact inverse)" [ "$vun_commentfix" = "comment-fix pr=9" ] vun_rebase="$(verdict_from_unit_name 'pr-loop-driver-rebase-pr9.service')" check "verdict_from_unit_name: pr-loop-driver-rebase-pr9.service -> rebase pr=9 (exact inverse)" [ "$vun_rebase" = "rebase pr=9" ] +vun_resume="$(verdict_from_unit_name 'pr-loop-driver-resume-issue42.service')" +check "verdict_from_unit_name: pr-loop-driver-resume-issue42.service -> resume issue=42 (exact inverse, issue #98/#154)" [ "$vun_resume" = "resume issue=42" ] +check "verdict_from_unit_name: pr-loop-driver-resume-issue42.service is NEVER mistaken for an advance verdict" bash -c '[ "$1" != "$2" ]' _ "$vun_resume" "$vun_advance" # ============================================================================= # (B) Integration checks: real subprocess, fake loop-event.sh + fake claude. @@ -466,6 +475,72 @@ check "scenario 4d (rebase): ledger records verdict=rebase pr=21" bash -c ' ' _ "$ledger4d" check "scenario 4d: prompt file was cleaned up after the driver ran" [ ! -f "$prompt4d_dir/prompt.txt" ] +# --------------------------------------------------------------------------- +# 4e. action=resume issue=N (issue #98/#154 -- stall recovery): a resume +# verdict must actually spawn a driver (NOT fall through to the +# unrecognized-action branch, which was #154's bug), with its own +# distinct verdict text (never confused with an advance issue=N verdict +# for the same issue number). +# --------------------------------------------------------------------------- +prompt4e_dir="$work/scenario4e-support" +mkdir -p "$prompt4e_dir" +printf 'Run the RESUME step for issue #55. +' > "$prompt4e_dir/prompt.txt" +dir4e="$(new_fixture scenario4e "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=resume issue=55' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt4e_dir/prompt.txt' +exit 0")" +fake_bin "$dir4e" setsid '#!/usr/bin/env bash +exec "$@"' +fake_bin "$dir4e" timeout '#!/usr/bin/env bash +shift; shift +exec "$@"' +fake_bin "$dir4e" claude '#!/usr/bin/env bash +echo "claude-ran" >> "'"$dir4e"'/claude.marker" +echo "{\"session_id\":\"sess-resume-55\"}" +exit 0' +run_daemon_once "$dir4e" >/dev/null 2>&1 +check "scenario 4e (resume): driver actually spawned (claude ran, NOT the unrecognized-action fallthrough -- issue #154's bug)" [ -f "$dir4e/claude.marker" ] +ledger4e="$dir4e/.claude/state/loop-runs.log" +check "scenario 4e: ledger records verdict=resume issue=55" bash -c ' + grep -Eq "^pid=[0-9]+ session=sess-resume-55 verdict=resume issue=55 ts=[0-9T:Z-]+ result=exit rc=0$" "$1" +' _ "$ledger4e" +check "scenario 4e: prompt file was cleaned up after the driver ran" [ ! -f "$prompt4e_dir/prompt.txt" ] + +# --------------------------------------------------------------------------- +# 4f. action=resume issue=N branch= (issue #98/#154): loop-tick.sh's +# resume verdict may carry an OPTIONAL trailing " branch=" -- even +# if it reaches this daemon unstripped, driver_unit_name/the ledger must +# still see the BARE "resume issue=N" shape (the branch is prompt-only +# context, never part of the verdict/unit-name identity). +# --------------------------------------------------------------------------- +prompt4f_dir="$work/scenario4f-support" +mkdir -p "$prompt4f_dir" +printf 'Run the RESUME step for issue #56. +' > "$prompt4f_dir/prompt.txt" +dir4f="$(new_fixture scenario4f "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=resume issue=56 branch=feat/issue-56-wip' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt4f_dir/prompt.txt' +exit 0")" +fake_bin "$dir4f" setsid '#!/usr/bin/env bash +exec "$@"' +fake_bin "$dir4f" timeout '#!/usr/bin/env bash +shift; shift +exec "$@"' +fake_bin "$dir4f" claude '#!/usr/bin/env bash +echo "{\"session_id\":\"sess-resume-56\"}" +exit 0' +run_daemon_once "$dir4f" >/dev/null 2>&1 +ledger4f="$dir4f/.claude/state/loop-runs.log" +check "scenario 4f (resume with branch suffix): ledger records the BARE verdict=resume issue=56 (branch suffix stripped before driver_unit_name/the ledger)" bash -c ' + grep -Eq "^pid=[0-9]+ session=sess-resume-56 verdict=resume issue=56 ts=[0-9T:Z-]+ result=exit rc=0$" "$1" +' _ "$ledger4f" +check "scenario 4f: prompt file was cleaned up after the driver ran" [ ! -f "$prompt4f_dir/prompt.txt" ] + # --------------------------------------------------------------------------- # 5. Driver timeout: fake timeout stub exits 124 (as GNU timeout does on a # real kill) without ever invoking claude; ledger must record @@ -705,6 +780,50 @@ check "scenario 10: nothing was deleted on the offline path — branch still exi 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" ] +# --------------------------------------------------------------------------- +# 10b. resume verdicts NEVER reach the debris classifier (issue #98/#154): +# same "empty branch, no open PR" fixture as scenario 8's Case A, but +# with a resume issue=N verdict instead of advance issue=N, and +# deliberately NO bot-gh.sh stub installed. Scenario 8 proves an ADVANCE +# driver's provably-empty branch IS auto-deleted; this proves a RESUME +# driver's branch is NEVER touched even when it looks identically +# "empty" by classify_debris's own metric — verify_and_classify_post_exit +# excludes every non-advance verdict (resume included), so the whole +# debris path (and the bot-gh.sh PR query it would otherwise make) must +# never even run. If it did run here, the missing bot-gh.sh stub would +# make it fail loudly rather than silently. +# --------------------------------------------------------------------------- +prompt10b_dir="$work/scenario10b-support" +mkdir -p "$prompt10b_dir" +printf 'Run the RESUME step for issue #89.\n' > "$prompt10b_dir/prompt.txt" +dir10b="$(git_fixture scenario10b "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=resume issue=89' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt10b_dir/prompt.txt' +exit 0")" +wt10b="$work/scenario10b-wt" +git -C "$dir10b" branch feat/issue-89-empty main +git -C "$dir10b" worktree add -q "$wt10b" feat/issue-89-empty +fake_bin "$dir10b" setsid '#!/usr/bin/env bash +exec "$@"' +fake_bin "$dir10b" timeout '#!/usr/bin/env bash +shift; shift +exec "$@"' +fake_bin "$dir10b" claude '#!/usr/bin/env bash +echo "{\"session_id\":\"sess-89\",\"result\":\"ok\"}" +exit 0' +# Deliberately NO bot-gh.sh stub installed at all in this fixture -- proves +# verify_and_classify_post_exit's GitHub query never even runs for resume. +run_daemon_once "$dir10b" >/dev/null 2>&1 +ledger10b="$dir10b/.claude/state/loop-runs.log" +check "scenario 10b (resume debris-exclusion): ledger records the plain verdict, no debris=/pr=/verify= fields (post-exit verify never ran)" bash -c ' + grep -Eq "^pid=[0-9]+ session=sess-89 verdict=resume issue=89 ts=[0-9T:Z-]+ result=exit rc=0$" "$1"' _ "$ledger10b" +check "scenario 10b: ledger never records action=deleted for a resume verdict" bash -c '! grep -q "action=deleted" "$1"' _ "$ledger10b" +check "scenario 10b (debris-exclusion safety): the branch was NOT deleted" bash -c ' + git -C "$1" rev-parse --verify --quiet refs/heads/feat/issue-89-empty >/dev/null 2>&1' _ "$dir10b" +check "scenario 10b: the worktree was NOT removed" [ -d "$wt10b" ] + # --------------------------------------------------------------------------- # fake_systemd_run: a generic stub that records its full invocation (so a # scenario can assert on --unit=/RuntimeMaxSec=/--setenv= verbatim) and then @@ -793,6 +912,33 @@ ledger12="$dir12/.claude/state/loop-runs.log" check "scenario 12: ledger records the feedback verdict with rc=0 propagated" bash -c ' grep -Eq "verdict=feedback pr=201 ts=[0-9T:Z-]+ result=exit rc=0" "$1"' _ "$ledger12" +# --------------------------------------------------------------------------- +# 12b. Transient systemd unit path, resume verdict (issue #98/#154): unit +# naming derived as pr-loop-driver-resume-issue (NOT +# pr-loop-driver-issue -- that shape is reserved for a fresh advance +# driver on the SAME issue number), and exit-code propagation still +# lands in the ledger. +# --------------------------------------------------------------------------- +prompt12b_dir="$work/scenario12b-support" +mkdir -p "$prompt12b_dir" +printf 'Run the RESUME step for issue #202.\n' > "$prompt12b_dir/prompt.txt" +dir12b="$(new_fixture scenario12b "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=resume issue=202' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt12b_dir/prompt.txt' +exit 0")" +fake_systemd_run "$dir12b" +fake_bin "$dir12b" claude '#!/usr/bin/env bash +echo "{\"session_id\":\"sess-202\",\"result\":\"ok\"}" +exit 0' +run_daemon_once "$dir12b" >/dev/null 2>&1 +check "scenario 12b: unit name derived from a resume verdict (pr-loop-driver-resume-issue202, distinct from advance's pr-loop-driver-issue202)" bash -c ' + grep -qF -- "--unit=pr-loop-driver-resume-issue202" "$1"' _ "$dir12b/systemd-run.args" +ledger12b="$dir12b/.claude/state/loop-runs.log" +check "scenario 12b: ledger records the resume verdict with rc=0 propagated" bash -c ' + grep -Eq "verdict=resume issue=202 ts=[0-9T:Z-]+ result=exit rc=0" "$1"' _ "$ledger12b" + # --------------------------------------------------------------------------- # 13. Startup re-attach (issue #119 pt 3): a driver unit for issue #300 is # ALREADY active (left running by a previous, now-dead daemon) when this diff --git a/.claude/scripts/loop-event.sh b/.claude/scripts/loop-event.sh index 1642a0e..fef5b29 100644 --- a/.claude/scripts/loop-event.sh +++ b/.claude/scripts/loop-event.sh @@ -26,6 +26,7 @@ # loop-event: action=comment-fix pr=N (issue #96 part 2) # loop-event: action=ci-fix pr=N # loop-event: action=rebase pr=N (issue #96 part 3) +# loop-event: action=resume issue=N (issue #98/#154) # loop-event: model= # loop-event: prompt-file= @@ -79,6 +80,12 @@ verdict="$(printf '%s\n' "$tick_out" | tail -1)" # --- 2. Obey the verdict ----------------------------------------------------- n="" +# resume_branch (issue #98/#154): the OPTIONAL branch name loop-tick.sh's +# "action=resume issue=N [branch=]" verdict carries when it already +# knows the stalled issue's local feat/issue-N-* branch -- empty when it +# doesn't (loop-tick.sh still emits the bare form in that case). Only ever +# set by the resume arm below; every other verdict leaves it empty. +resume_branch="" case "$verdict" in action=none) echo "loop-event: no actionable activity — no driver to spawn" @@ -90,6 +97,16 @@ case "$verdict" in "action=comment-fix pr="*) n="${verdict#action=comment-fix pr=}" ;; "action=ci-fix pr="*) n="${verdict#action=ci-fix pr=}" ;; "action=rebase pr="*) n="${verdict#action=rebase pr=}" ;; + "action=resume issue="*) + # Strip the "action=resume issue=" prefix, then split the OPTIONAL + # " branch=" suffix off the remainder: n is always just the digits, + # resume_branch is whatever followed "branch=" when present. + rest="${verdict#action=resume issue=}" + n="${rest%% branch=*}" + case "$rest" in + *" branch="*) resume_branch="${rest#*branch=}" ;; + esac + ;; *) echo "loop-event: unexpected verdict line: $verdict" >&2 echo "loop-event: action=none" @@ -173,6 +190,23 @@ PR #$n has gone unmergeable (GitHub reports mergeable=CONFLICTING against base), NEVER force-merge, and NEVER merge, in either outcome." action_line="action=rebase pr=$n" ;; + action=resume*) + branch_clause="" + if [ -n "$resume_branch" ]; then + branch_clause="Its branch is \`$resume_branch\` — checkout/reattach THAT worktree/branch directly." + else + branch_clause="No branch name was given with this verdict — locate the existing local branch matching \`feat/issue-$n-*\` (\`git branch --list 'feat/issue-$n-*'\`) and its worktree yourself before doing anything else." + fi + prompt="Run the RESUME step of the autonomous PR loop for issue #$n (stall recovery, issue #98/#154). $common +Issue #$n was flagged STALLED by loop-census.sh (no recorded activity for the adapter's stall_minutes window) and a PRIOR driver already left partial work on a local \`feat/issue-$n-*\` branch. $branch_clause This is a CONTINUATION, NOT a fresh advance: do NOT create a new branch, do NOT re-scope the issue from scratch, and do NOT start a new worktree implementer with a clean slate — inspect what is ALREADY THERE (git log/diff on that branch) and pick up from it. +1. Locate and reattach to the EXISTING worktree for that branch (or re-create a worktree checked out on the SAME branch if the prior one was already cleaned up) — the commits/WIP on the branch are the authoritative starting point. +2. Assess what's actually done vs. still missing against the issue's acceptance criteria, then finish the implementation on that SAME branch. +3. Re-run the module's gates before publishing: \`GATES_FILE=.claude/self/gates.json bash $script_dir/gate.sh \` for build/lint/typecheck/test/coverage per the adapter (per the adapter clause above) — do not skip this just because some gates may have passed in a prior, now-stale attempt. +4. Route the finished diff through the reviewer lenses per the adapter (consensus as configured), addressing any reject before proceeding. +5. Publish: push the branch and open the bot PR if none exists yet (\`bash $script_dir/bot-gh.sh pr create ...\`), or push the update to refresh an already-open PR for this issue. The work product MUST land on GitHub before you end your turn — a resume that ends without a pushed branch/PR just re-stalls the issue for the next tick. +Do NOT merge." + action_line="action=resume issue=$n" + ;; *) prompt="Run the ADDRESS FEEDBACK step of the autonomous PR loop for PR #$n. $common Address the unaddressed CHANGES_REQUESTED feedback on PR #$n: orchestrator → worktree implementer → reviewer lenses on the SAME branch, push to update the PR in place, and post the \`\` marker comment via bot-gh.sh. Do NOT merge." diff --git a/.claude/scripts/loop-event.test.sh b/.claude/scripts/loop-event.test.sh index 15edf28..a912217 100644 --- a/.claude/scripts/loop-event.test.sh +++ b/.claude/scripts/loop-event.test.sh @@ -198,6 +198,58 @@ check "scenario 3d: prompt file mandates FOREGROUND-only spawn (run_in_backgroun check "scenario 3d: 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"' _ "$pf3d" check "scenario 3d: prompt file mandates deleting debris on failure" bash -c 'grep -q "delete any local feat/issue-N-\* branch and worktree" "$1"' _ "$pf3d" +# --------------------------------------------------------------------------- +# 3e. action=resume issue=N branch= (issue #98/#154 -- stall recovery, +# WITH a branch name): one-shot contract, RESUME wording, "continue the +# EXISTING branch, never recreate" instructions, the branch name threaded +# into the prompt, gate-rerun + publish instructions, LOOP_MODEL honored. +# The EMITTED action line itself must stay branch-less (bare "issue=N"), +# matching every other verdict kind's action_line shape -- the branch is +# prompt context only, never part of the dispatched verdict identity. +# --------------------------------------------------------------------------- +dir3e="$(new_fixture scenario3e 'cadence=FAST cron=* * * * * +action=resume issue=33 branch=feat/issue-33-something')" +out3e="$(cd "$dir3e" && PATH="/usr/bin:/bin" LOOP_MODEL=opus bash .claude/scripts/loop-event.sh)"; rc3e=$? +check "scenario 3e (resume, with branch): exits 0" [ "$rc3e" -eq 0 ] +check "scenario 3e: emits the BARE loop-event: action=resume issue=33 (no branch= in the action line itself)" bash -c 'printf "%s +" "$1" | grep -qxF "loop-event: action=resume issue=33"' _ "$out3e" +check "scenario 3e: LOOP_MODEL is honored (model=opus)" bash -c 'printf "%s +" "$1" | grep -qxF "loop-event: model=opus"' _ "$out3e" +pf3e="$(printf '%s +' "$out3e" | sed -n 's/^loop-event: prompt-file=//p')" +check "scenario 3e: prompt-file line points at a real file" bash -c '[ -n "$1" ] && [ -f "$1" ]' _ "$pf3e" +check "scenario 3e: prompt file mentions issue #33" bash -c 'grep -q "issue #33" "$1"' _ "$pf3e" +check "scenario 3e: prompt file says RESUME step" bash -c 'grep -q "RESUME step" "$1"' _ "$pf3e" +check "scenario 3e: prompt file references the given branch name" bash -c 'grep -qF "feat/issue-33-something" "$1"' _ "$pf3e" +check "scenario 3e: prompt file instructs CONTINUING the existing branch, never recreating it" bash -c 'grep -qi "do not create a new branch\|do NOT create a new branch\|CONTINUATION, NOT a fresh advance" "$1"' _ "$pf3e" +check "scenario 3e: prompt file mandates re-running the module gates via gate.sh" bash -c 'grep -q "gate.sh" "$1"' _ "$pf3e" +check "scenario 3e: prompt file mandates publishing (push + open/refresh the bot PR via bot-gh.sh)" bash -c 'grep -q "bot-gh.sh pr create" "$1" && grep -qi "push" "$1"' _ "$pf3e" +check "scenario 3e: prompt file says Do NOT merge" bash -c 'grep -q "Do NOT merge" "$1"' _ "$pf3e" + +# Same one-shot contract clauses, on the RESUME prompt this time. +check "scenario 3e: prompt file mandates FOREGROUND-only spawn (run_in_background: false)" bash -c 'grep -qi "FOREGROUND" "$1" && grep -q "run_in_background: false" "$1"' _ "$pf3e" +check "scenario 3e: 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"' _ "$pf3e" + +# --------------------------------------------------------------------------- +# 3f. action=resume issue=N (issue #98/#154, WITHOUT a branch=... suffix): +# loop-tick.sh doesn't always know the branch name -- proves parsing +# handles the branch-less form too, and the prompt falls back to +# instructing the driver to LOCATE the existing feat/issue-N-* branch +# itself rather than referencing a name that was never given. +# --------------------------------------------------------------------------- +dir3f="$(new_fixture scenario3f 'cadence=FAST cron=* * * * * +action=resume issue=34')" +out3f="$(run_event "$dir3f")"; rc3f=$? +check "scenario 3f (resume, no branch): exits 0" [ "$rc3f" -eq 0 ] +check "scenario 3f: emits loop-event: action=resume issue=34" bash -c 'printf "%s +" "$1" | grep -qxF "loop-event: action=resume issue=34"' _ "$out3f" +pf3f="$(printf '%s +' "$out3f" | sed -n 's/^loop-event: prompt-file=//p')" +check "scenario 3f: prompt-file line points at a real file" bash -c '[ -n "$1" ] && [ -f "$1" ]' _ "$pf3f" +check "scenario 3f: prompt file mentions issue #34" bash -c 'grep -q "issue #34" "$1"' _ "$pf3f" +check "scenario 3f: prompt file says RESUME step" bash -c 'grep -q "RESUME step" "$1"' _ "$pf3f" +check "scenario 3f: prompt file instructs LOCATING the existing feat/issue-34-* branch (no branch name was given)" bash -c 'grep -qF "feat/issue-34-" "$1"' _ "$pf3f" + # --------------------------------------------------------------------------- # 4. Garbage verdict line -> non-zero exit, action=none fallback line, no # prompt-file (never spawns on a verdict it can't parse).