From f83aece049d47ae564d312119cb43b0cd59b9052 Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:53:42 +0200 Subject: [PATCH 1/2] wip(issue-96): preserve half-done ci-fix work from killed driver Driver exited ~08:56Z 2026-07-16 without committing. Snapshot taken during manual debris triage so the work can't be lost; needs review, tests, and completion before any PR. Co-Authored-By: Claude Fable 5 --- .claude/commands/pr-loop.md | 9 ++- .claude/scripts/loop-census.sh | 9 ++- .claude/scripts/loop-daemon.sh | 46 ++++++++--- .claude/scripts/loop-event.sh | 23 ++++-- .claude/scripts/loop-tick.sh | 103 +++++++++++++++++------- .claude/scripts/loop-tick.test.sh | 29 ++++--- .claude/scripts/pr-ci-fix.sh | 126 ++++++++++++++++++++++++++++++ .claude/scripts/pr-ci-fix.test.sh | 111 ++++++++++++++++++++++++++ .claude/self/pr-loop-self.md | 9 ++- 9 files changed, 397 insertions(+), 68 deletions(-) create mode 100755 .claude/scripts/pr-ci-fix.sh create mode 100755 .claude/scripts/pr-ci-fix.test.sh diff --git a/.claude/commands/pr-loop.md b/.claude/commands/pr-loop.md index 2e7e2a5..b1611fb 100644 --- a/.claude/commands/pr-loop.md +++ b/.claude/commands/pr-loop.md @@ -23,16 +23,17 @@ Prompt to use (the tick logic, with adaptive STEP 0): > Run one tick of the autonomous PR loop. ALL `gh` interaction (yours and every agent's) MUST run as the bot via `.claude/scripts/bot-gh.sh` — never bare `gh`; only `git` commits/pushes stay as the owner. > -> STEP 0 — run the tick. Invoke, as a REAL bash tool call, exactly: `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/loop-tick.sh`. This one script runs census, then notify-poll.sh, merge-ready.sh, and pr-feedback.sh, IN ORDER, with their full output, and ends with exactly one machine-readable verdict line as the LAST line of output: `action=none`, `action=advance issue=N`, or `action=feedback pr=N`. That verdict line is the SOLE source of truth for what to do next: never hand-count open PRs, planned issues, or feedback PRs yourself, and never skip this invocation because the tick "looks quiet" — it must run, and its output must be read, on every single tick with no exceptions. +> STEP 0 — run the tick. Invoke, as a REAL bash tool call, exactly: `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/loop-tick.sh`. This one script runs census, then notify-poll.sh, merge-ready.sh, pr-feedback.sh, and pr-ci-fix.sh, IN ORDER, with their full output, and ends with exactly one machine-readable verdict line as the LAST line of output: `action=none`, `action=advance issue=N`, `action=feedback pr=N`, or `action=ci-fix pr=N`. That verdict line is the SOLE source of truth for what to do next: never hand-count open PRs, planned issues, feedback PRs, or CI-red PRs yourself, and never skip this invocation because the tick "looks quiet" — it must run, and its output must be read, on every single tick with no exceptions. > -> CADENCE: the script's `=== 1/4 loop-census.sh ===` section includes a line `cadence=FAST|WATCH|IDLE cron=` — this is the desired cadence; consume it as-is, do NOT re-derive it from counts. If this cron job's current schedule differs from that `cron=`, CronDelete this job and CronCreate a durable replacement with this SAME prompt at the desired schedule. +> CADENCE: the script's `=== 1/5 loop-census.sh ===` section includes a line `cadence=FAST|WATCH|IDLE cron=` — this is the desired cadence; consume it as-is, do NOT re-derive it from counts. If this cron job's current schedule differs from that `cron=`, CronDelete this job and CronCreate a durable replacement with this SAME prompt at the desired schedule. > -> Then obey the verdict line (the tick already ran poll/merge/feedback-detection above — do not re-run those scripts): +> Then obey the verdict line (the tick already ran poll/merge/feedback-detection/ci-fix-detection above — do not re-run those scripts). Precedence when more than one is ready: `feedback` > `ci-fix` > `advance` (a PR with BOTH unaddressed feedback and failing CI is handled as feedback, never ci-fix): > - `action=feedback pr=N` → address PR N's feedback: run orchestrator → worktree implementer → reviewer lenses (per .claude/gates.json) on the SAME branch, push to update the PR in place, and post the `` marker comment via bot-gh.sh. Do NOT merge. +> - `action=ci-fix pr=N` → fix PR N's failing CI: label the PR `claude-ci-fixing` via bot-gh.sh first (in-flight guard), then run orchestrator → worktree implementer on the SAME branch (checkout the PR's existing branch, do NOT create a new one) → reviewer lenses (per .claude/gates.json) to fix the failure, push to update the PR in place. After pushing, query the PR's current head SHA (`bot-gh.sh pr view N --json headRefOid`) and post a bot comment containing exactly `` (the real SHA substituted in) so pr-ci-fix.sh's cursor recognizes this head as already addressed. Do NOT merge, and do NOT force-push. > - `action=advance issue=N` → advance issue N through the orchestrator (scope → worktree implementer → gate.sh gates → reviewer lenses → bot PR). One issue in flight at a time. `backlog` issues are owner-unapproved: never pick them, and if you file an issue yourself, label it `backlog` — NEVER `planned` (that label is the owner's formal approval and is assigned by the owner alone; see docs/USAGE.md → "Autonomous loop & the issue queue"). > - `action=none` → reply exactly one line: "No actionable activity." This is the ONLY path to that phrase — never reply it without loop-tick.sh having actually been invoked (and its output read) earlier in this same turn. > -> Token discipline: only read docs/USAGE.md and .claude/agents/* when the verdict actually requires orchestrating agents (advance/feedback); an `action=none` tick needs only the script's own output. Keep the tick report to a few lines — it is telemetry, not documentation. +> Token discipline: only read docs/USAGE.md and .claude/agents/* when the verdict actually requires orchestrating agents (advance/feedback/ci-fix); an `action=none` tick needs only the script's own output. Keep the tick report to a few lines — it is telemetry, not documentation. ## 2. Run one tick now Execute the tick logic above immediately so the loop doesn't wait for the next cron fire. Report what happened (polled items, merges, feedback addressed, issue advanced — or "No actionable activity"). diff --git a/.claude/scripts/loop-census.sh b/.claude/scripts/loop-census.sh index 7e769e6..4132b0a 100644 --- a/.claude/scripts/loop-census.sh +++ b/.claude/scripts/loop-census.sh @@ -6,6 +6,10 @@ # # open_prs=N open PRs against the adapter's base branch # feedback_prs=N bot PRs with unaddressed CHANGES_REQUESTED (pr-feedback.sh) +# ci_fix_prs=N bot PRs with a failing CI check on the current +# head, not already a feedback candidate, not +# already addressed for that head (pr-ci-fix.sh, +# issue #96) # planned_issues=N open issues labelled `planned` AND one of the # adapter's module:* labels, one detail line each: # issue= branch= title= @@ -238,6 +242,9 @@ open_issue_set=$(gh issue list -R "$repo" --state open --json number --jq '.[].n feedback_prs=$(PR_FEEDBACK_COUNT_ONLY=1 bash "$script_dir/pr-feedback.sh" "$repo" | grep -c . || true) echo "feedback_prs=$feedback_prs" +ci_fix_prs=$(bash "$script_dir/pr-ci-fix.sh" "$repo" | grep -c . || true) +echo "ci_fix_prs=$ci_fix_prs" + # Open `planned` issues carrying any of the adapter's module labels, ascending. planned=$(gh issue list -R "$repo" --state open --label planned --json number,title,labels \ --jq '.[] | [.number, ([.labels[].name]|join(",")), .title] | @tsv' | sort -n) @@ -389,7 +396,7 @@ fi echo "advance_ready=$advance_ready" # Desired cadence per the loop policy: FAST only when the loop can ACT now. -if [ "$feedback_prs" -ge 1 ] || { [ "$open_prs" -eq 0 ] && [ "$planned_count" -ge 1 ]; }; then +if [ "$feedback_prs" -ge 1 ] || [ "$ci_fix_prs" -ge 1 ] || { [ "$open_prs" -eq 0 ] && [ "$planned_count" -ge 1 ]; }; then echo 'cadence=FAST cron=* * * * *' elif [ "$open_prs" -ge 1 ]; then echo 'cadence=WATCH cron=*/5 * * * *' diff --git a/.claude/scripts/loop-daemon.sh b/.claude/scripts/loop-daemon.sh index 7022f39..31ab151 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=<pgid> session=<session_id> verdict=<advance issue=N|feedback pr=N> ts=<ISO8601> [result=exit|timeout|phantom rc=N] [pr=N] [debris=empty|publishable|half-done [action=deleted|resumable]] [verify=skipped] +# pid=<pgid> session=<session_id> verdict=<advance issue=N|feedback pr=N|ci-fix pr=N> ts=<ISO8601> [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 <session_id> --fork-session` (safe while it's still @@ -54,7 +54,8 @@ # reboot/sleep, or `wsl --shutdown`. Evidence 2026-07-14/15: five of six # 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<N>`/`pr-loop-driver-pr<N>`, derived from the verdict) +# (`pr-loop-driver-issue<N>`/`pr-loop-driver-pr<N>`/`pr-loop-driver-cifix-pr<N>`, +# derived from the verdict) # via `systemd-run --user --wait --collect --unit=... -p RuntimeMaxSec=<LOOP_DRIVER_TIMEOUT>`. # 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 @@ -279,7 +280,12 @@ classify_debris() { # # 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 +# driver has no work product to verify yet). `feedback pr=N` and `ci-fix +# pr=N` verdicts are DELIBERATELY excluded too (fall straight into the `*` +# pass-through arm below) — both push commits onto an EXISTING PR branch +# rather than creating a new local `feat/issue-N-*` one, so there is no +# freshly-created branch/worktree for this debris check to classify; the PR +# itself already existed before the driver ran. 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 @@ -367,15 +373,25 @@ verify_and_classify_post_exit() { printf '%s' "$out" } -# --- transient systemd unit naming (issue #119 pt 1) ------------------------- -# $1=verdict -> "pr-loop-driver-issue<N>" for "advance issue=N", or -# "pr-loop-driver-pr<N>" for "feedback pr=N". Used both to SPAWN the unit +# --- transient systemd unit naming (issue #119 pt 1; issue #96 pt ci-fix) --- +# $1=verdict -> "pr-loop-driver-issue<N>" for "advance issue=N", +# "pr-loop-driver-pr<N>" for "feedback pr=N", or +# "pr-loop-driver-cifix-pr<N>" for "ci-fix 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 gets its OWN distinct unit name (not "pr-loop-driver-pr<N>") so a +# feedback driver and a ci-fix driver on the SAME PR can never collide in +# naming or reattach — the verdict precedence (feedback > ci-fix) makes both +# firing for the same PR in the same tick impossible, but a feedback driver +# from a PRIOR tick could still be finishing up while a later tick, after that +# feedback was addressed, dispatches a ci-fix driver for the identical PR +# number; distinct unit names keep those two spawns/reattaches from ever being +# confused with each other. driver_unit_name() { case "$1" in "advance issue="*) printf 'pr-loop-driver-issue%s' "${1#advance issue=}" ;; + "ci-fix pr="*) printf 'pr-loop-driver-cifix-pr%s' "${1#ci-fix pr=}" ;; "feedback pr="*) printf 'pr-loop-driver-pr%s' "${1#feedback pr=}" ;; *) printf 'pr-loop-driver-unknown' ;; esac @@ -384,13 +400,19 @@ driver_unit_name() { # --- reverse of driver_unit_name: unit name -> verdict (issue #119 pt 3) ---- # Prints nothing (not an error) for a unit name that doesn't match the # expected naming convention — reattach_orphaned_drivers skips those rather -# than guessing. +# than guessing. The `pr-loop-driver-cifix-pr*` arm MUST be checked before +# `pr-loop-driver-pr*` would even matter for disambiguation (it doesn't here — +# "cifix-pr..." never matches the "pr..." prefix pattern either way — but the +# ordering keeps the two ci-fix/feedback arms visually adjacent to their +# distinct name shapes in driver_unit_name above, so the pairing stays obvious +# on read). 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-pr*) printf 'feedback pr=%s' "${unit#pr-loop-driver-pr}" ;; - *) : ;; + pr-loop-driver-issue*) printf 'advance issue=%s' "${unit#pr-loop-driver-issue}" ;; + pr-loop-driver-cifix-pr*) printf 'ci-fix pr=%s' "${unit#pr-loop-driver-cifix-pr}" ;; + pr-loop-driver-pr*) printf 'feedback pr=%s' "${unit#pr-loop-driver-pr}" ;; + *) : ;; esac } @@ -448,7 +470,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-<issueN|prN> naming — leaving it to systemd, not re-attaching" + log "startup re-attach: active unit '$unit' doesn't match the pr-loop-driver-<issueN|prN|cifix-prN> 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" @@ -657,7 +679,7 @@ run_once() { none|"") : # nothing actionable — no driver spawned ;; - "advance issue="*|"feedback pr="*) + "advance issue="*|"feedback pr="*|"ci-fix pr="*) local model prompt_file model="$(printf '%s\n' "$out" | sed -n 's/^loop-event: model=//p' | tail -1)" model="${model:-${LOOP_MODEL:-sonnet}}" diff --git a/.claude/scripts/loop-event.sh b/.claude/scripts/loop-event.sh index 0a3c7ce..0426c79 100644 --- a/.claude/scripts/loop-event.sh +++ b/.claude/scripts/loop-event.sh @@ -9,7 +9,7 @@ # setsid/timeout/ledger wrapping around the actual `claude -p` spawn, so a # broken/garbage verdict here can NEVER result in a driver being spawned — the # spawn is a whole separate step the caller only reaches by parsing the -# `loop-event: action=advance|feedback ...` line below. +# `loop-event: action=advance|feedback|ci-fix ...` line below. # # Never re-derives the verdict — issue #81 contract: it is computed ONCE, by # loop-tick.sh's shell logic, and passed through byte-identical. @@ -23,6 +23,7 @@ # -> nothing else is printed. NO model/driver process must be spawned. # loop-event: action=advance issue=N # loop-event: action=feedback pr=N +# loop-event: action=ci-fix pr=N # loop-event: model=<model> # loop-event: prompt-file=<absolute path to a plain-text file holding the # verdict-obeying prompt for the driver session> @@ -32,12 +33,12 @@ # in setsid + timeout + a run-ledger append) — this script never execs # claude, setsid, or timeout. # -# Exit code: 0 on `action=none` OR a successfully emitted advance/feedback -# verdict (in which case a prompt-file was written). Non-zero if loop-tick.sh -# itself failed, or its verdict line failed to parse — in EITHER case a -# `loop-event: action=none` line is STILL printed last (so a caller doing a -# blind `sed -n 's/^loop-event: action=//p' | tail -1` never sees a stale or -# missing action), and no prompt-file is written. +# Exit code: 0 on `action=none` OR a successfully emitted advance/feedback/ +# ci-fix verdict (in which case a prompt-file was written). Non-zero if +# loop-tick.sh itself failed, or its verdict line failed to parse — in EITHER +# case a `loop-event: action=none` line is STILL printed last (so a caller +# doing a blind `sed -n 's/^loop-event: action=//p' | tail -1` never sees a +# stale or missing action), and no prompt-file is written. # # Honors $GATES_FILE: not read directly here beyond quoting it into the # self-hosting adapter clause baked into the prompt below (loop-tick.sh and @@ -84,6 +85,7 @@ case "$verdict" in ;; "action=advance issue="*) n="${verdict#action=advance issue=}" ;; "action=feedback pr="*) n="${verdict#action=feedback pr=}" ;; + "action=ci-fix pr="*) n="${verdict#action=ci-fix pr=}" ;; *) echo "loop-event: unexpected verdict line: $verdict" >&2 echo "loop-event: action=none" @@ -113,7 +115,7 @@ adapter="" if [ -n "${GATES_FILE:-}" ]; then adapter="Export GATES_FILE=$GATES_FILE for every gate/orchestration step, and instruct every spawned agent (orchestrator, implementers, reviewers) to read $GATES_FILE — NOT the placeholder root .claude/gates.json — as its adapter (module map, gates, review lenses). Every gate.sh invocation MUST run as: GATES_FILE=$GATES_FILE bash $script_dir/gate.sh <name>. " fi -common="The tick (loop-tick.sh) already ran census/poll/merge/feedback-detection this firing and emitted this verdict — do NOT re-run those scripts and do NOT re-derive the verdict. ${adapter}ALL gh interaction (yours and every agent's) MUST run as the bot via bash $script_dir/bot-gh.sh — never bare gh; only git commits/pushes stay as the owner. Follow docs/USAGE.md and .claude/agents/*; reviewer lenses + consensus per the adapter. YOU ARE A HEADLESS ONE-SHOT SESSION: the moment you end your turn, this session and every background process/agent it spawned are terminated (a background orchestrator gets at most a short grace ceiling, then is killed mid-work — observed 2026-07-10: two drivers exited 'cleanly' leaving half-born local branches that wedged their issues as in_flight). Therefore run the ENTIRE orchestration SYNCHRONOUSLY: spawn the orchestrator and every agent in the FOREGROUND (run_in_background: false), wait for each to finish, and do NOT end your turn until the work product exists on GitHub (the bot PR is open, or the feedback push + marker comment landed) or you are reporting a definite failure — never a 'running in background, will report later' message, which is a self-deception in this mode. If orchestration fails partway, CLEAN UP before exiting: delete any local feat/issue-N-* branch and worktree you created that has no open PR, so census never mistakes your debris for in-flight work. Keep the final report to a few lines — it is telemetry, not documentation." +common="The tick (loop-tick.sh) already ran census/poll/merge/feedback-detection/ci-fix-detection this firing and emitted this verdict — do NOT re-run those scripts and do NOT re-derive the verdict. ${adapter}ALL gh interaction (yours and every agent's) MUST run as the bot via bash $script_dir/bot-gh.sh — never bare gh; only git commits/pushes stay as the owner. Follow docs/USAGE.md and .claude/agents/*; reviewer lenses + consensus per the adapter. YOU ARE A HEADLESS ONE-SHOT SESSION: the moment you end your turn, this session and every background process/agent it spawned are terminated (a background orchestrator gets at most a short grace ceiling, then is killed mid-work — observed 2026-07-10: two drivers exited 'cleanly' leaving half-born local branches that wedged their issues as in_flight). Therefore run the ENTIRE orchestration SYNCHRONOUSLY: spawn the orchestrator and every agent in the FOREGROUND (run_in_background: false), wait for each to finish, and do NOT end your turn until the work product exists on GitHub (the bot PR is open, or the feedback/ci-fix push + marker comment landed) or you are reporting a definite failure — never a 'running in background, will report later' message, which is a self-deception in this mode. If orchestration fails partway, CLEAN UP before exiting: delete any local feat/issue-N-* branch and worktree you created that has no open PR, so census never mistakes your debris for in-flight work. Keep the final report to a few lines — it is telemetry, not documentation." case "$verdict" in action=advance*) @@ -149,6 +151,11 @@ Drive issue #$n through the orchestrator: scope → worktree implementer → gat ;; esac ;; + action=ci-fix*) + prompt="Run the CI-FIX step of the autonomous PR loop for PR #$n. $common +PR #$n has a FAILING CI check on its current head (see the \`5/5 pr-ci-fix.sh\` section above for which check(s) and its head SHA). Before starting, label the PR \`claude-ci-fixing\` via bot-gh.sh (create the label with --force if it doesn't exist yet) as an in-flight guard against a second tick double-dispatching this same PR. Address the failure: orchestrator → worktree implementer on the SAME branch (checkout the PR's existing branch, do NOT create a new one) → reviewer lenses per the adapter, push the fix to update the PR in place. After pushing, query the PR's CURRENT head commit SHA (bot-gh.sh pr view $n --json headRefOid) and post a bot comment containing exactly \`<!-- claude-ci-addressed:<that-head-sha> -->\` (substituting the real SHA) — this is the cursor pr-ci-fix.sh checks so an unresolved-but-still-rerunning check isn't re-dispatched every tick, while a genuinely NEW failure on a NEW commit still re-triggers. Do NOT merge, and do NOT force-push." + action_line="action=ci-fix pr=$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 \`<!-- claude-addressed -->\` marker comment via bot-gh.sh. Do NOT merge." diff --git a/.claude/scripts/loop-tick.sh b/.claude/scripts/loop-tick.sh index 7d257fe..e7780f1 100644 --- a/.claude/scripts/loop-tick.sh +++ b/.claude/scripts/loop-tick.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash # loop-tick.sh — one-shot orchestration tick for the autonomous PR loop. # -# Runs the loop's four step scripts, IN ORDER, with their FULL output +# Runs the loop's five step scripts, IN ORDER, with their FULL output # preserved (never swallowed or `tail -1`'d), then emits exactly one # machine-readable verdict line as the LAST line of output: # action=none # action=advance issue=N # action=feedback pr=N +# action=ci-fix pr=N # action=resume issue=N branch=<name> (issue #98 -- see STEP 0.5 below) # # WHY THIS EXISTS (issue #81): the tick used to be a multi-step PROMPT @@ -15,25 +16,35 @@ # Haiku-driven tick has been observed to stop invoking the step scripts and # fabricate their output, and to double-spawn an orchestrator for the same # issue because it misread an in-flight worktree as hung. Collapsing the -# whole tick to ONE script plus one conditional spawn (of the ADVANCE/FEEDBACK -# work itself) makes the protocol immune to that drift: the verdict line is -# computed by shell/node logic, not recalled by the model from a prompt. +# whole tick to ONE script plus one conditional spawn (of the ADVANCE/FEEDBACK/ +# CI-FIX work itself) makes the protocol immune to that drift: the verdict +# line is computed by shell/node logic, not recalled by the model from a +# prompt. # -# This script does NOT reimplement census, polling, merge, or feedback-detection -# logic — it calls the existing sibling scripts and only adds the verdict -# arithmetic + the spawn lock (see .claude/state/loop-advance.lock below). +# This script does NOT reimplement census, polling, merge, feedback-detection, +# or CI-fix-detection logic — it calls the existing sibling scripts and only +# adds the verdict arithmetic + the spawn lock (see +# .claude/state/loop-advance.lock below). # -# Precedence: unaddressed CHANGES_REQUESTED feedback (pr-feedback.sh) always -# wins over ADVANCE — a human is waiting on a reply. When multiple PRs need -# feedback addressed, the lowest-numbered PR is picked. ADVANCE additionally -# requires: census says advance_ready=N (already means zero open PRs + a -# planned+module issue + no existing branch), N is not census's in_flight=N -# (a feat/issue-N-* branch with no open PR — someone/something is already -# mid-flight on it), and the spawn lock (below) is not already held for N. -# RESUME (issue #98, see STEP 0.5 below) is lowest precedence: it only fires -# when neither FEEDBACK nor a fresh ADVANCE claimed the tick (advance_ready= -# none), and picks the lowest-numbered in_flight issue that census's stall -# clock or debris classifier flags as stuck. +# Precedence (issue #96): feedback > ci-fix > advance > resume. +# - unaddressed CHANGES_REQUESTED feedback (pr-feedback.sh) always wins over +# everything else — a human is waiting on a reply. When multiple PRs need +# feedback addressed, the lowest-numbered PR is picked. +# - CI-FIX (pr-ci-fix.sh) wins over ADVANCE, but never over feedback: a PR +# that is BOTH a feedback candidate AND has failing CI is handled as +# feedback, never ci-fix (pr-ci-fix.sh itself already excludes feedback +# candidates from its own output, so this precedence is enforced twice — +# belt and suspenders). When multiple PRs need a CI fix, the +# lowest-numbered PR is picked, same tie-break as feedback. +# - ADVANCE additionally requires: census says advance_ready=N (already +# means zero open PRs + a planned+module issue + no existing branch), N is +# not census's in_flight=N (a feat/issue-N-* branch with no open PR — +# someone/something is already mid-flight on it), and the spawn lock +# (below) is not already held for N. +# - RESUME (issue #98, see STEP 0.5 below) is lowest precedence: it only +# fires when neither FEEDBACK, CI-FIX, nor a fresh ADVANCE claimed the +# tick (advance_ready=none), and picks the lowest-numbered in_flight issue +# that census's stall clock or debris classifier flags as stuck. # # Spawn lock: .claude/state/loop-advance.lock (root-relative; .claude/state/ # is already gitignored). Written the moment this script emits @@ -127,6 +138,7 @@ write_tick_record() { case "$verdict" in "action=advance issue="*) action="advance"; issue="${verdict#action=advance issue=}" ;; "action=feedback pr="*) action="feedback"; pr="${verdict#action=feedback pr=}" ;; + "action=ci-fix pr="*) action="ci-fix"; pr="${verdict#action=ci-fix pr=}" ;; "action=resume issue="*) action="resume" issue="${verdict#action=resume issue=}" @@ -191,20 +203,24 @@ write_tick_record() { return 0 } -echo "=== 1/4 loop-census.sh ===" +echo "=== 1/5 loop-census.sh ===" census_out="$(bash "$script_dir/loop-census.sh" "$repo")" printf '%s\n' "$census_out" -echo "=== 2/4 notify-poll.sh ===" +echo "=== 2/5 notify-poll.sh ===" bash "$script_dir/notify-poll.sh" "$repo" -echo "=== 3/4 merge-ready.sh ===" +echo "=== 3/5 merge-ready.sh ===" bash "$script_dir/merge-ready.sh" "$repo" -echo "=== 4/4 pr-feedback.sh ===" +echo "=== 4/5 pr-feedback.sh ===" feedback_out="$(bash "$script_dir/pr-feedback.sh" "$repo")" printf '%s\n' "$feedback_out" +echo "=== 5/5 pr-ci-fix.sh ===" +cifix_out="$(bash "$script_dir/pr-ci-fix.sh" "$repo")" +printf '%s\n' "$cifix_out" + echo "=== verdict ===" # --- Parse census telemetry needed for the verdict ------------------------- @@ -233,6 +249,19 @@ feedback_branch="$(printf '%s\n' "$feedback_line" | awk -F'\t' '{print $2}')" # number itself when the branch doesn't follow that convention. feedback_issue="$(printf '%s\n' "$feedback_branch" | sed -n 's#.*feat/issue-\([0-9][0-9]*\)-.*#\1#p')" +# --- Parse pr-ci-fix.sh's TSV (num, branch, failing_checks_csv, head_sha) --- +# Lowest-numbered PR wins when several need a CI fix, same tie-break as +# feedback above. pr-ci-fix.sh already excludes feedback candidates from its +# own output (precedence, issue #96), so no additional filtering is needed +# here beyond the verdict decision below. +cifix_line="$(printf '%s\n' "$cifix_out" | awk -F'\t' 'NF>=2 && $1 ~ /^[0-9]+$/ {print $1"\t"$2}' | sort -t $'\t' -k1,1n | head -1)" +cifix_pr="$(printf '%s\n' "$cifix_line" | awk -F'\t' '{print $1}')" +cifix_branch="$(printf '%s\n' "$cifix_line" | awk -F'\t' '{print $2}')" +# Same #95 per-issue attempt budget key derivation as feedback_issue above — +# ci-fix dispatches for issue N share the SAME counter as advance/feedback +# dispatches for issue N (do NOT invent a new counter file). +cifix_issue="$(printf '%s\n' "$cifix_branch" | sed -n 's#.*feat/issue-\([0-9][0-9]*\)-.*#\1#p')" + # --- Spawn lock: read + self-heal against the FRESH census above ----------- # TTL rationale: this lock is written the instant a tick emits # `action=advance issue=N`, before the orchestrator that will push @@ -489,19 +518,25 @@ if [ -z "$ceiling_block" ] && [ "$daily_count" -ge "$daily_action_ceiling" ]; th fi fi -# --- 3) per-issue advance/feedback attempt budget ---------------------------- +# --- 3) per-issue advance/feedback/ci-fix attempt budget --------------------- # .claude/state/loop-issue-attempts.json: { "<issue>": {attempts,escalated} }. -# Keyed by the ORIGINATING issue number (advance_ready directly; feedback via -# feedback_issue, parsed from the PR's feat/issue-N-* branch) so advance-phase -# and feedback-phase dispatches for the same issue share one counter -- the -# candidate mirrors the SAME preconditions the verdict decision below applies -# (feedback beats advance; in_flight/lock-held candidates are never charged). +# Keyed by the ORIGINATING issue number (advance_ready directly; feedback/ +# ci-fix via feedback_issue/cifix_issue, parsed from the PR's feat/issue-N-* +# branch) so advance-phase, feedback-phase, and ci-fix-phase dispatches for the +# same issue share ONE counter (issue #96 reuses the SAME #95 counter, no new +# state file) -- the candidate mirrors the SAME precedence the verdict +# decision below applies (feedback beats ci-fix beats advance; in_flight/ +# lock-held advance candidates are never charged). attempts_file="$state_dir/loop-issue-attempts.json" attempt_issue="" attempt_escalate_kind="" attempt_escalate_num="" if [ -n "$feedback_pr" ]; then attempt_issue="${feedback_issue:-$feedback_pr}" attempt_escalate_kind="pr" attempt_escalate_num="$feedback_pr" +elif [ -n "$cifix_pr" ]; then + attempt_issue="${cifix_issue:-$cifix_pr}" + attempt_escalate_kind="pr" + attempt_escalate_num="$cifix_pr" elif [ "$advance_ready" != "none" ] && [ -n "$advance_ready" ] \ && ! printf '%s\n' "$in_flight_issues" | grep -qx "$advance_ready" \ && [ "$lock_issue" != "$advance_ready" ]; then @@ -704,6 +739,8 @@ if [ -n "$ceiling_block" ]; then verdict="action=none" elif [ -n "$feedback_pr" ]; then verdict="action=feedback pr=$feedback_pr" +elif [ -n "$cifix_pr" ]; then + verdict="action=ci-fix pr=$cifix_pr" elif [ "$advance_ready" != "none" ] && [ -n "$advance_ready" ]; then if printf '%s\n' "$in_flight_issues" | grep -qx "$advance_ready"; then # Defensive only: real census can never report the SAME issue as both @@ -817,7 +854,7 @@ else fi # --- Spend-ceiling bookkeeping: increment counts on an ACTUAL dispatch ------ -# Only runs when the verdict just decided is a genuine advance/feedback +# Only runs when the verdict just decided is a genuine advance/feedback/ci-fix # dispatch (never on action=none, ceiling-blocked or not) -- so a blocked # tick never itself grows the very counters that blocked it. action=resume is # DELIBERATELY excluded here: resume attempts are tracked in the SIBLING @@ -836,6 +873,14 @@ case "$verdict" in dispatch_issue="$dispatch_pr" fi ;; + "action=ci-fix pr="*) + dispatch_pr="${verdict#action=ci-fix pr=}" + if [ "$dispatch_pr" = "$cifix_pr" ] && [ -n "${cifix_issue:-}" ]; then + dispatch_issue="$cifix_issue" + else + dispatch_issue="$dispatch_pr" + fi + ;; esac if [ -n "$dispatch_issue" ]; then diff --git a/.claude/scripts/loop-tick.test.sh b/.claude/scripts/loop-tick.test.sh index 62df439..a91a2db 100644 --- a/.claude/scripts/loop-tick.test.sh +++ b/.claude/scripts/loop-tick.test.sh @@ -34,11 +34,13 @@ check() { } # Build one fresh fake "consumer project" per scenario: <fixture>/.claude/scripts/. -# fake_census / fake_feedback are the exact stdout the corresponding real -# script would print; notify-poll.sh and merge-ready.sh are stubbed to just -# print a marker line (their output is passed through, never parsed). +# fake_census / fake_feedback / fake_cifix are the exact stdout the +# corresponding real script would print; notify-poll.sh and merge-ready.sh are +# stubbed to just print a marker line (their output is passed through, never +# parsed). fake_cifix defaults to empty (no ci-fix candidates) so every +# existing 3-arg call site keeps working unchanged. new_fixture() { - local name="$1" fake_census="$2" fake_feedback="$3" + local name="$1" fake_census="$2" fake_feedback="$3" fake_cifix="${4:-}" local dir="$work/$name/.claude/scripts" mkdir -p "$dir" "$work/$name/.claude/state" 2>/dev/null rm -rf "$work/$name/.claude/state" # loop-tick.sh must mkdir -p it itself @@ -71,6 +73,12 @@ EOF cat <<'FEEDBACK' $fake_feedback FEEDBACK +EOF + cat > "$dir/pr-ci-fix.sh" <<EOF +#!/usr/bin/env bash +cat <<'CIFIX' +$fake_cifix +CIFIX EOF chmod +x "$dir"/*.sh printf '%s\n' "$dir" @@ -174,13 +182,14 @@ check "scenario 6: self-heal diagnostic mentions the cleared stale issue=3" bash check "scenario 6: lock file now records the NEW issue=9, not the stale 3" grep -q '^issue=9 ts=' "$lock6" # --------------------------------------------------------------------------- -# 7. All four step scripts' full output is preserved (never swallowed). +# 7. All five step scripts' full output is preserved (never swallowed). # --------------------------------------------------------------------------- -check "all four labeled step headers appear in the tick's output" bash -c ' - printf "%s\n" "$1" | grep -q "1/4 loop-census.sh" && - printf "%s\n" "$1" | grep -q "2/4 notify-poll.sh" && - printf "%s\n" "$1" | grep -q "3/4 merge-ready.sh" && - printf "%s\n" "$1" | grep -q "4/4 pr-feedback.sh" +check "all five labeled step headers appear in the tick's output" bash -c ' + printf "%s\n" "$1" | grep -q "1/5 loop-census.sh" && + printf "%s\n" "$1" | grep -q "2/5 notify-poll.sh" && + printf "%s\n" "$1" | grep -q "3/5 merge-ready.sh" && + printf "%s\n" "$1" | grep -q "4/5 pr-feedback.sh" && + printf "%s\n" "$1" | grep -q "5/5 pr-ci-fix.sh" ' _ "$out1" check "notify-poll.sh full output line passed through, not swallowed" bash -c 'printf "%s\n" "$1" | grep -qF "fake notify-poll output"' _ "$out1" check "merge-ready.sh full output line passed through, not swallowed" bash -c 'printf "%s\n" "$1" | grep -qF "merge-ready: merged=0 skipped=0"' _ "$out1" diff --git a/.claude/scripts/pr-ci-fix.sh b/.claude/scripts/pr-ci-fix.sh new file mode 100755 index 0000000..ff805b7 --- /dev/null +++ b/.claude/scripts/pr-ci-fix.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# pr-ci-fix.sh — print open, bot-authored PRs whose CURRENT head commit has a +# FAILING CI check that hasn't already been addressed, so the loop can +# dispatch an implementer to push a fix onto the SAME branch. This is +# capability #1 of issue #96 (remote-CI-failure fixing); capabilities #2 +# (review-comment convergence) and #3 (rebase-after-sibling-merge) are +# deferred to a follow-up. Prints one TSV line per PR needing a CI fix: +# <number>\t<branch>\t<failing_check_names_csv>\t<head_sha> +# +# A PR is listed when ALL hold: +# - authored by the bot ($BOT_LOGIN, default robercano-ghbot), open, base is +# the adapter's merge.baseBranch ($GATES_FILE, default .claude/gates.json — +# same node-read as loop-census.sh/merge-ready.sh); +# - at least one CI check on the CURRENT head is FAILING: CheckRun +# conclusion in FAILURE/CANCELLED/TIMED_OUT/ACTION_REQUIRED/ +# STARTUP_FAILURE/STALE, or legacy StatusContext state FAILURE/ERROR — the +# EXACT parse merge-ready.sh's `decide()` uses for the same rollup shape. +# PENDING/in-progress checks do NOT qualify (a re-run in flight means +# "wait", not "fix"); +# - it is NOT ALSO a pr-feedback.sh candidate. VERDICT PRECEDENCE (issue +# #96): feedback > ci-fix > advance — owner CHANGES_REQUESTED always +# outranks a CI fix, so a PR that is both red-CI and awaiting an +# unaddressed review is left entirely to pr-feedback.sh here (excluded, +# not merely deprioritized) and the caller (loop-tick.sh) additionally +# enforces the same precedence at the verdict level; +# - not labeled `needs-human` (the loop's #95 attempt-budget escalation +# already gave up on this PR/issue — see loop-tick.sh's per-issue attempt +# budget) and not labeled `claude-ci-fixing` (an in-flight guard, mirroring +# pr-feedback.sh's `claude-addressing`, so overlapping ticks don't +# double-dispatch the SAME PR while a fix is already being worked); +# - the failure has NOT already been addressed for the CURRENT head. This +# mirrors pr-feedback.sh's `<!-- claude-addressed -->` + `claude-addressing` +# cursor discipline, but keyed to the head SHA rather than a timestamp: the +# implementer that fixes CI posts `<!-- claude-ci-addressed:<head_sha> -->` +# as a bot comment after pushing. If that EXACT marker (for the CURRENT +# head_sha) is already present, this PR is skipped this tick — either CI +# hasn't finished re-running the just-pushed fix yet, or it landed and a +# stale rollup just hasn't caught up. A NEW commit changes head_sha, so a +# marker tied to the OLD sha no longer matches and a genuinely NEW failure +# on the NEW commit re-triggers detection. (A timestamp cursor, like +# pr-feedback.sh uses against `submitted_at` on a review, doesn't work +# here: a CI conclusion carries no stable "when this failure state began" +# timestamp to compare against a marker's post time.) +# +# Repo derived from the git remote; override with $1. Bot login via +# $BOT_LOGIN. Adapter (baseBranch) via $GATES_FILE, default .claude/gates.json. +# Invoke as `bash .claude/scripts/pr-ci-fix.sh` (pre-approve that exact +# command). Read-only: detects, never mutates — the `claude-ci-fixing` label +# and the `<!-- claude-ci-addressed:... -->` marker comment are written by the +# driver this script's output goes on to dispatch, never by this script. +set -euo pipefail + +# Two-root derivation (issue #63): script_dir = sibling scripts, root = consumer project. +# shellcheck source=resolve-roots.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/resolve-roots.sh" +# Route EVERY gh call through the bot identity (see bot-gh.sh). +gh() { bash "$script_dir/bot-gh.sh" "$@"; } +repo="${1:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}" +bot="${BOT_LOGIN:-robercano-ghbot}" + +gates_rel="${GATES_FILE:-.claude/gates.json}" +case "$gates_rel" in /*) gates="$gates_rel" ;; *) gates="$root/$gates_rel" ;; esac +base="$(node -e 'const g=require(process.argv[1]); console.log((g.merge&&g.merge.baseBranch)||"main")' "$gates" 2>/dev/null || echo main)" + +# Feedback candidates OUTRANK ci-fix (precedence) — exclude their PR numbers +# up front so a PR that qualifies for both never shows up here at all. +feedback_prs="$(bash "$script_dir/pr-feedback.sh" "$repo" 2>/dev/null \ + | awk -F'\t' 'NF>=2 && $1 ~ /^[0-9]+$/ {print $1}' || true)" +is_feedback_candidate() { + local n="$1" + case $'\n'"$feedback_prs"$'\n' in + *$'\n'"$n"$'\n'*) return 0 ;; + *) return 1 ;; + esac +} + +gh pr list -R "$repo" --state open --base "$base" \ + --json number,headRefName,author,labels,statusCheckRollup,headRefOid \ + --jq '.[] | select(.author.login=="'"$bot"'")' \ +| while IFS= read -r pr_json; do + [ -z "$pr_json" ] && continue + + # One node call per PR: parse number/branch/labels/head_sha and compute + # the failing-check CSV + the guard-label skip decision together, so the + # rest of this loop body only branches on plain shell values. Extracted + # via `cut -f`, NOT `IFS=$'\t' read` — bash's `read` classifies tab as + # "IFS whitespace" REGARDLESS of what IFS is set to, so it silently + # collapses consecutive tabs (e.g. an empty $failing field on a PR with no + # failing check would swallow the NEXT field too); `cut` never does that. + parsed="$(printf '%s' "$pr_json" | node -e ' + const p = JSON.parse(require("fs").readFileSync(0, "utf8")); + const bad = ["FAILURE","CANCELLED","TIMED_OUT","ACTION_REQUIRED","STARTUP_FAILURE","STALE"]; + const names = []; + for (const c of (p.statusCheckRollup || [])) { + if (c.conclusion !== undefined && c.conclusion !== null && c.conclusion !== "") { + if (bad.includes(c.conclusion)) names.push(c.name || ""); + } else if (c.state) { + if (["FAILURE","ERROR"].includes(c.state)) names.push(c.context || ""); + } + } + const labels = (p.labels || []).map((l) => l.name); + const guardSkip = labels.includes("needs-human") || labels.includes("claude-ci-fixing"); + console.log([p.number, p.headRefName || "", names.join(","), p.headRefOid || "", guardSkip ? 1 : 0].join("\t")); + ')" + num="$(printf '%s' "$parsed" | cut -f1)" + branch="$(printf '%s' "$parsed" | cut -f2)" + failing="$(printf '%s' "$parsed" | cut -f3)" + head_sha="$(printf '%s' "$parsed" | cut -f4)" + guard_skip="$(printf '%s' "$parsed" | cut -f5)" + + [ -z "${num:-}" ] && continue + [ "$guard_skip" = "1" ] && continue + [ -z "$failing" ] && continue # no failing check on the current head -> nothing to fix + is_feedback_candidate "$num" && continue + + # Already-addressed cursor: skip if a bot comment carries the marker tied + # to THIS EXACT head_sha (see header doc above). + marker="<!-- claude-ci-addressed:${head_sha} -->" + already="$(gh api "repos/$repo/issues/$num/comments" \ + --jq '[.[]|select(.user.login=="'"$bot"'" and (.body|contains("'"$marker"'")))]|length' \ + 2>/dev/null || echo 0)" + case "$already" in ''|*[!0-9]*) already=0 ;; esac + [ "$already" -gt 0 ] && continue + + printf '%s\t%s\t%s\t%s\n' "$num" "$branch" "$failing" "$head_sha" + done diff --git a/.claude/scripts/pr-ci-fix.test.sh b/.claude/scripts/pr-ci-fix.test.sh new file mode 100755 index 0000000..54a7c3b --- /dev/null +++ b/.claude/scripts/pr-ci-fix.test.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# pr-ci-fix.test.sh — offline smoke test for pr-ci-fix.sh (issue #96 capability +# #1: CI-failure auto-fix detection). +# +# Builds a throwaway .claude/scripts/ fixture containing the REAL pr-ci-fix.sh +# + resolve-roots.sh next to a FAKE bot-gh.sh (which stands in for `gh` +# entirely — it prints the ALREADY-jq-filtered output the real `gh ... --jq` +# calls would have produced, mirroring loop-census.test.sh's own fake-gh +# convention) and a FAKE pr-feedback.sh (so the precedence exclusion can be +# exercised without any real feedback-detection logic). No network, no real +# `gh` CLI required. +# +# Exit 0 on success, non-zero if any assertion fails. Runnable bare: +# bash .claude/scripts/pr-ci-fix.test.sh +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cifix_src="$script_dir/pr-ci-fix.sh" +resolve_roots_src="$script_dir/resolve-roots.sh" + +work="$(mktemp -d "${TMPDIR:-/tmp}/pr-ci-fix-test.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +fail=0 +ok=0 +check() { + local desc="$1"; shift + if "$@"; then + ok=$((ok + 1)) + echo "ok - $desc" + else + fail=1 + echo "FAIL - $desc" + fi +} + +fixture="$work/fixture1" +scripts_dir="$fixture/.claude/scripts" +mkdir -p "$scripts_dir" +cp "$cifix_src" "$scripts_dir/pr-ci-fix.sh" +cp "$resolve_roots_src" "$scripts_dir/resolve-roots.sh" + +# --- fixture PR set ---------------------------------------------------------- +# 10: failing CI (CheckRun conclusion=FAILURE), no labels -> EMITTED +# 11: pending CI (conclusion=null, still running) -> NOT emitted (wait, don't fix) +# 12: failing CI, labeled needs-human -> NOT emitted (guard) +# 13: failing CI, labeled claude-ci-fixing (in-flight guard) -> NOT emitted (guard) +# 14: failing CI, ALSO a pr-feedback.sh candidate -> NOT emitted (precedence) +# 15: failing CI, but already has the <!-- claude-ci-addressed:sha15 --> marker +# -> NOT emitted (already-addressed cursor) +# 16: failing CI via the LEGACY StatusContext shape (state=ERROR, no +# "conclusion" field at all) -> EMITTED (dual-shape parse, same as merge-ready.sh) +cat > "$scripts_dir/bot-gh.sh" <<'EOF' +#!/usr/bin/env bash +case "$1" in + pr) + if printf '%s\n' "$*" | grep -q 'headRefOid'; then + cat <<'JSON' +{"number":10,"headRefName":"feat/issue-10-a","author":{"login":"testbot"},"labels":[],"statusCheckRollup":[{"name":"build","conclusion":"FAILURE"}],"headRefOid":"sha10"} +{"number":11,"headRefName":"feat/issue-11-a","author":{"login":"testbot"},"labels":[],"statusCheckRollup":[{"name":"build","conclusion":null,"status":"IN_PROGRESS"}],"headRefOid":"sha11"} +{"number":12,"headRefName":"feat/issue-12-a","author":{"login":"testbot"},"labels":[{"name":"needs-human"}],"statusCheckRollup":[{"name":"build","conclusion":"FAILURE"}],"headRefOid":"sha12"} +{"number":13,"headRefName":"feat/issue-13-a","author":{"login":"testbot"},"labels":[{"name":"claude-ci-fixing"}],"statusCheckRollup":[{"name":"build","conclusion":"FAILURE"}],"headRefOid":"sha13"} +{"number":14,"headRefName":"feat/issue-14-a","author":{"login":"testbot"},"labels":[],"statusCheckRollup":[{"name":"build","conclusion":"FAILURE"}],"headRefOid":"sha14"} +{"number":15,"headRefName":"feat/issue-15-a","author":{"login":"testbot"},"labels":[],"statusCheckRollup":[{"name":"build","conclusion":"FAILURE"}],"headRefOid":"sha15"} +{"number":16,"headRefName":"feat/issue-16-a","author":{"login":"testbot"},"labels":[],"statusCheckRollup":[{"context":"legacy-ci","state":"ERROR"}],"headRefOid":"sha16"} +JSON + else + echo "fake-bot-gh.sh: unexpected pr subcommand: $*" >&2 + exit 1 + fi + ;; + api) + case "$*" in + *"issues/15/comments"*) echo 1 ;; # already carries the claude-ci-addressed:sha15 marker + *"issues/"*"/comments"*) echo 0 ;; # no marker for any other PR + *) echo "fake-bot-gh.sh: unhandled api call: $*" >&2; exit 1 ;; + esac + ;; + *) echo "fake-bot-gh.sh: unhandled args: $*" >&2; exit 1 ;; +esac +EOF + +# 14 is a feedback candidate (precedence: feedback outranks ci-fix). +cat > "$scripts_dir/pr-feedback.sh" <<'EOF' +#!/usr/bin/env bash +printf '14\tfeat/issue-14-a\towner\t2026-01-01T00:00:00Z\n' +EOF + +chmod +x "$scripts_dir"/*.sh + +out="$(BOT_LOGIN=testbot bash "$scripts_dir/pr-ci-fix.sh" "acme/repo")" + +check "PR 10 (failing CI, not addressed): emitted with branch/check/sha" bash -c ' + printf "%s\n" "$1" | grep -qF "$(printf "10\tfeat/issue-10-a\tbuild\tsha10")"' _ "$out" +check "PR 11 (pending CI, no failing check yet): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^11\b"' _ "$out" +check "PR 12 (needs-human guard): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^12\b"' _ "$out" +check "PR 13 (claude-ci-fixing in-flight guard): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^13\b"' _ "$out" +check "PR 14 (also a feedback candidate — precedence): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^14\b"' _ "$out" +check "PR 15 (already-addressed marker for the current head): NOT emitted" bash -c '! printf "%s\n" "$1" | grep -qE "^15\b"' _ "$out" +check "PR 16 (failing CI via legacy StatusContext shape): emitted" bash -c ' + printf "%s\n" "$1" | grep -qF "$(printf "16\tfeat/issue-16-a\tlegacy-ci\tsha16")"' _ "$out" +check "exactly 2 PRs emitted total (10 and 16 only)" bash -c '[ "$(printf "%s\n" "$1" | grep -c .)" -eq 2 ]' _ "$out" + +echo "" +if [ "$fail" -eq 0 ]; then + echo "pr-ci-fix.test.sh: PASS ($ok checks)" + exit 0 +else + echo "pr-ci-fix.test.sh: FAIL (see FAIL lines above)" + exit 1 +fi diff --git a/.claude/self/pr-loop-self.md b/.claude/self/pr-loop-self.md index 885e042..15e974b 100644 --- a/.claude/self/pr-loop-self.md +++ b/.claude/self/pr-loop-self.md @@ -25,16 +25,17 @@ Prompt to use (the tick logic, with adaptive STEP 0): > Run one tick of the self-hosted PR loop. Export `GATES_FILE=.claude/self/gates.json` for every gate/orchestration step, and instruct every spawned agent (orchestrator, implementers, reviewers) to read `.claude/self/gates.json` — NOT the placeholder root `.claude/gates.json` — as its adapter (module map, gates, review lenses). Reviewer lenses + consensus per `.claude/self/gates.json` (`correctness`, `tests`; consensus `all`). Every `gate.sh` invocation MUST be run as `GATES_FILE=.claude/self/gates.json bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/gate.sh <name>`. ALL `gh` interaction (yours and every agent's) MUST run as the bot via `.claude/scripts/bot-gh.sh` — never bare `gh`; only `git` commits/pushes stay as the owner. > -> STEP 0 — run the tick. Invoke, as a REAL bash tool call, exactly: `GATES_FILE=.claude/self/gates.json bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/loop-tick.sh`. This one script runs census, then notify-poll.sh, merge-ready.sh, and pr-feedback.sh, IN ORDER, with their full output, and ends with exactly one machine-readable verdict line as the LAST line of output: `action=none`, `action=advance issue=N`, or `action=feedback pr=N`. That verdict line is the SOLE source of truth for what to do next: never hand-count open PRs, planned issues, or feedback PRs yourself, and never skip this invocation because the tick "looks quiet" — it must run, and its output must be read, on every single tick with no exceptions. (loop-census.sh reads `$GATES_FILE` directly, so it derives module labels from the self set — `module:docs`, `module:harness`, `module:examples`, `module:ci` — not the placeholder root adapter.) +> STEP 0 — run the tick. Invoke, as a REAL bash tool call, exactly: `GATES_FILE=.claude/self/gates.json bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/loop-tick.sh`. This one script runs census, then notify-poll.sh, merge-ready.sh, pr-feedback.sh, and pr-ci-fix.sh, IN ORDER, with their full output, and ends with exactly one machine-readable verdict line as the LAST line of output: `action=none`, `action=advance issue=N`, `action=feedback pr=N`, or `action=ci-fix pr=N`. That verdict line is the SOLE source of truth for what to do next: never hand-count open PRs, planned issues, feedback PRs, or CI-red PRs yourself, and never skip this invocation because the tick "looks quiet" — it must run, and its output must be read, on every single tick with no exceptions. (loop-census.sh reads `$GATES_FILE` directly, so it derives module labels from the self set — `module:docs`, `module:harness`, `module:examples`, `module:ci` — not the placeholder root adapter.) > -> CADENCE: the script's `=== 1/4 loop-census.sh ===` section includes a line `cadence=FAST|WATCH|IDLE cron=<expr>` — this is the desired cadence; consume it as-is, do NOT re-derive it from counts. If this cron job's current schedule differs from that `cron=<expr>`, CronDelete this job and CronCreate a durable replacement with this SAME prompt at the desired schedule. +> CADENCE: the script's `=== 1/5 loop-census.sh ===` section includes a line `cadence=FAST|WATCH|IDLE cron=<expr>` — this is the desired cadence; consume it as-is, do NOT re-derive it from counts. If this cron job's current schedule differs from that `cron=<expr>`, CronDelete this job and CronCreate a durable replacement with this SAME prompt at the desired schedule. > -> Then obey the verdict line (the tick already ran poll/merge/feedback-detection above — do not re-run those scripts): +> Then obey the verdict line (the tick already ran poll/merge/feedback-detection/ci-fix-detection above — do not re-run those scripts). Precedence when more than one is ready: `feedback` > `ci-fix` > `advance` (a PR with BOTH unaddressed feedback and failing CI is handled as feedback, never ci-fix): > - `action=feedback pr=N` → address PR N's feedback: run orchestrator → worktree implementer → reviewer lenses (self adapter: `GATES_FILE=.claude/self/gates.json`, lenses `correctness`/`tests`, consensus `all`) on the SAME branch, push to update the PR in place, and post the `<!-- claude-addressed -->` marker comment via bot-gh.sh. Do NOT merge. +> - `action=ci-fix pr=N` → fix PR N's failing CI: label the PR `claude-ci-fixing` via bot-gh.sh first (in-flight guard), then run orchestrator → worktree implementer on the SAME branch (checkout the PR's existing branch, do NOT create a new one) → reviewer lenses (self adapter, same lenses/consensus as feedback) to fix the failure, push to update the PR in place. After pushing, query the PR's current head SHA (`bot-gh.sh pr view N --json headRefOid`) and post a bot comment containing exactly `<!-- claude-ci-addressed:<head-sha> -->` (the real SHA substituted in) so pr-ci-fix.sh's cursor recognizes this head as already addressed. Do NOT merge, and do NOT force-push. > - `action=advance issue=N` → advance issue N through the orchestrator using `.claude/self/gates.json` as the adapter (scope → worktree implementer → `GATES_FILE=.claude/self/gates.json gate.sh` gates → reviewer lenses `correctness`/`tests` consensus `all` → bot PR). One issue in flight at a time. `backlog` issues are owner-unapproved: never pick them, and if you file an issue yourself, label it `backlog` — NEVER `planned` (that label is the owner's formal approval and is assigned by the owner alone; see docs/USAGE.md → "Autonomous loop & the issue queue"). > - `action=none` → reply exactly one line: "No actionable activity." This is the ONLY path to that phrase — never reply it without loop-tick.sh having actually been invoked (and its output read) earlier in this same turn. > -> Token discipline: only read docs/USAGE.md and .claude/agents/* when the verdict actually requires orchestrating agents (advance/feedback); an `action=none` tick needs only the script's own output. Keep the tick report to a few lines — it is telemetry, not documentation. +> Token discipline: only read docs/USAGE.md and .claude/agents/* when the verdict actually requires orchestrating agents (advance/feedback/ci-fix); an `action=none` tick needs only the script's own output. Keep the tick report to a few lines — it is telemetry, not documentation. ## 2. Run one tick now Execute the tick logic above immediately so the loop doesn't wait for the next cron fire. Report what happened (polled items, merges, feedback addressed, issue advanced — or "No actionable activity"). From f9390842b7b8161ffbb8802d994c8f98972fa9ed Mon Sep 17 00:00:00 2001 From: Roberto Cano <3525807+robercano@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:35:31 +0200 Subject: [PATCH 2/2] test(loop): cover ci-fix verdict wiring across the tick/census/daemon suite (issue #96) Completes the WIP snapshot from a killed driver: pr-ci-fix.sh and its wiring into loop-census.sh/loop-tick.sh/loop-daemon.sh/loop-event.sh (verdict precedence feedback > ci-fix > advance, shared #95 per-issue attempt budget, distinct pr-loop-driver-cifix-pr<N> transient unit) were already complete after rebasing onto main (#146-#151); this adds the test coverage that was still missing: - loop-census.test.sh: ci_fix_prs=N passthrough from pr-ci-fix.sh. - loop-tick.test.sh: ci-fix verdict selection, lowest-PR tie-break, ci-fix-beats-advance precedence, and its tick-record fields. - loop-ceilings.test.sh: ci-fix shares the per-issue attempt budget with advance/feedback, and the full three-way precedence (feedback > ci-fix > advance) end to end through loop-tick.sh. - loop-daemon.test.sh: driver_unit_name/verdict_from_unit_name for the ci-fix shape, verify_and_classify_post_exit pass-through, and a full driver-spawn integration scenario. - loop-event.test.sh: the CI-FIX prompt variant (in-flight guard label, claude-ci-addressed marker instructions, one-shot contract). - plan-gate.test.sh: fixtures updated for pr-ci-fix.sh's now-mandatory sibling-script presence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .claude/scripts/loop-ceilings.test.sh | 50 +++++++++++++++++++- .claude/scripts/loop-census.test.sh | 34 +++++++++++++- .claude/scripts/loop-daemon.test.sh | 54 +++++++++++++++++++++ .claude/scripts/loop-event.test.sh | 24 ++++++++++ .claude/scripts/loop-tick.test.sh | 68 ++++++++++++++++++++++++--- .claude/scripts/plan-gate.test.sh | 5 ++ 6 files changed, 225 insertions(+), 10 deletions(-) diff --git a/.claude/scripts/loop-ceilings.test.sh b/.claude/scripts/loop-ceilings.test.sh index 7fca7e2..d337329 100644 --- a/.claude/scripts/loop-ceilings.test.sh +++ b/.claude/scripts/loop-ceilings.test.sh @@ -39,9 +39,11 @@ check() { # $1=name $2=fake_census $3=fake_feedback (TSV body, may be empty) # $4=1 to also install a call-logging fake bot-gh.sh (default: no bot-gh.sh at # all, matching loop-tick.test.sh's own "gh must never be invoked" contract -# for scenarios that expect zero gh side effects). +# for scenarios that expect zero gh side effects). $5=fake_cifix (TSV body, +# issue #96 -- defaults to empty, i.e. no ci-fix candidates, so every existing +# 3/4-arg call site keeps working unchanged). new_fixture() { - local name="$1" fake_census="$2" fake_feedback="$3" with_gh="${4:-0}" + local name="$1" fake_census="$2" fake_feedback="$3" with_gh="${4:-0}" fake_cifix="${5:-}" local dir="$work/$name/.claude/scripts" mkdir -p "$dir" "$work/$name/.claude/state" 2>/dev/null rm -rf "$work/$name/.claude/state" # loop-tick.sh must mkdir -p it itself @@ -73,6 +75,12 @@ EOF cat <<'FEEDBACK' $fake_feedback FEEDBACK +EOF + cat > "$dir/pr-ci-fix.sh" <<EOF +#!/usr/bin/env bash +cat <<'CIFIX' +$fake_cifix +CIFIX EOF chmod +x "$dir"/*.sh @@ -394,6 +402,44 @@ check "scenario 10c: a SECOND 'issue create' fires (closed tracked issue is not [ "$(gh_calls "$1" | grep -c "^issue comment")" -eq 1 ] ' _ "$dir10" +# --------------------------------------------------------------------------- +# 11. Per-issue attempt budget applies across advance, feedback, AND ci-fix +# phases of the SAME issue (issue #96): a PR (23) cut from +# feat/issue-42-x, needing a CI fix rather than feedback, ALSO inherits +# issue 42's existing (exhausted) attempt count and is refused/escalated +# as PR 23 (not issue 42) -- mirrors scenario 6 above, but through the +# cifix_pr/cifix_issue path instead of feedback_pr/feedback_issue. +# --------------------------------------------------------------------------- +CIFIX_PR_23='23 feat/issue-42-x build deadbeef' +dir11="$(new_fixture scenario11 "$CENSUS_READY_42" "" 1 "$CIFIX_PR_23")" +node -e ' + const fs = require("fs"); + const dir = process.argv[1] + "/../state"; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(dir + "/loop-issue-attempts.json", JSON.stringify({ "42": { attempts: 5, escalated: false } })); +' "$dir11" +out11="$(run_tick "$dir11")" +check "scenario 11 (ci-fix for issue 42's PR, budget already exhausted): verdict is action=none" bash -c '[ "$(verdict_of "$1")" = "action=none" ]' _ "$out11" +check "scenario 11: diagnostic cites the attempt budget for issue=42, not PR 23" bash -c 'printf "%s\n" "$1" | grep -q "attempt budget exceeded for issue=42"' _ "$out11" +check "scenario 11: the PR (23), not the issue, was labeled/commented needs-human" bash -c 'gh_calls "$1" | grep -q "^pr edit 23 --add-label needs-human" && gh_calls "$1" | grep -q "^pr comment 23"' _ "$dir11" + +# --------------------------------------------------------------------------- +# 12. Precedence (issue #96): a ci-fix candidate wins over an ALSO-ready +# advance, but a feedback candidate still wins over ci-fix -- exercised +# end-to-end through loop-tick.sh's own verdict decision (not just +# pr-ci-fix.sh's own exclusion logic, covered separately in +# pr-ci-fix.test.sh). +# --------------------------------------------------------------------------- +CIFIX_PR_9='9 feat/issue-7-x build cafef00d' +dir12="$(new_fixture scenario12 "$CENSUS_READY_42" "" 0 "$CIFIX_PR_9")" +out12="$(run_tick "$dir12")" +check "scenario 12 (ci-fix beats an also-ready advance): verdict is action=ci-fix pr=9" bash -c '[ "$(verdict_of "$1")" = "action=ci-fix pr=9" ]' _ "$out12" +check "scenario 12: no spawn lock written (advance never attempted)" [ ! -e "$dir12/../state/loop-advance.lock" ] + +dir13="$(new_fixture scenario13 "$CENSUS_READY_42" "$FEEDBACK_PR_17" 0 "$CIFIX_PR_9")" +out13="$(run_tick "$dir13")" +check "scenario 13 (feedback beats an also-ready ci-fix): verdict is action=feedback pr=17, not ci-fix" bash -c '[ "$(verdict_of "$1")" = "action=feedback pr=17" ]' _ "$out13" + echo "" if [ "$fail" -eq 0 ]; then echo "loop-ceilings.test.sh: PASS ($ok checks)" diff --git a/.claude/scripts/loop-census.test.sh b/.claude/scripts/loop-census.test.sh index dc7f9ed..4957c19 100644 --- a/.claude/scripts/loop-census.test.sh +++ b/.claude/scripts/loop-census.test.sh @@ -107,6 +107,10 @@ cat > "$scripts_dir/pr-feedback.sh" <<'EOF' #!/usr/bin/env bash exit 0 EOF +cat > "$scripts_dir/pr-ci-fix.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF # Fake bot-gh.sh: no network, no real `gh` — dispatches on the subcommand and # a `--json` marker to canned, fixture-appropriate output. @@ -179,6 +183,22 @@ check "issue 100 (local branch, already has an open PR, exact-match control) is 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 "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" + +# --------------------------------------------------------------------------- +# ci_fix_prs (issue #96): loop-census.sh must surface pr-ci-fix.sh's own +# candidate count verbatim as `ci_fix_prs=N`, exactly mirroring how +# feedback_prs already wraps pr-feedback.sh (`grep -c .` over its TSV output) +# -- reusing fixture1's real git repo/adapter, just swapping in a pr-ci-fix.sh +# stub that prints two candidate lines instead of the no-op above. +# --------------------------------------------------------------------------- +cat > "$scripts_dir/pr-ci-fix.sh" <<'EOF' +#!/usr/bin/env bash +printf '10\tfeat/issue-10-a\tbuild\tsha10\n' +printf '11\tfeat/issue-11-a\tbuild\tsha11\n' +EOF +outCiFix="$(env -u GATES_FILE bash "$scripts_dir/loop-census.sh" "acme/repo")" +check "ci_fix_prs=2 counted when pr-ci-fix.sh reports two candidates" bash -c 'printf "%s\n" "$1" | grep -qx "ci_fix_prs=2"' _ "$outCiFix" # --------------------------------------------------------------------------- # driver_unit_active guard (issue #119 post-review finding #5): loop-census.sh @@ -210,6 +230,10 @@ EOF cat > "$scripts/pr-feedback.sh" <<'EOF' #!/usr/bin/env bash exit 0 +EOF + cat > "$scripts/pr-ci-fix.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 EOF cat > "$scripts/bot-gh.sh" <<'EOF' #!/usr/bin/env bash @@ -297,7 +321,11 @@ EOF #!/usr/bin/env bash exit 0 EOF - chmod +x "$scripts/pr-feedback.sh" "$scripts/cockpit.sh" "$scripts/loop-census.sh" + cat > "$scripts/pr-ci-fix.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF + chmod +x "$scripts/pr-feedback.sh" "$scripts/pr-ci-fix.sh" "$scripts/cockpit.sh" "$scripts/loop-census.sh" git -C "$dir" init -q -b main git -C "$dir" -c user.email=t@e.st -c user.name=t commit -q --allow-empty -m init } @@ -537,6 +565,10 @@ cat > "$scriptsStall/pr-feedback.sh" <<'EOF' #!/usr/bin/env bash exit 0 EOF +cat > "$scriptsStall/pr-ci-fix.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF cat > "$scriptsStall/bot-gh.sh" <<'EOF' #!/usr/bin/env bash case "$1" in diff --git a/.claude/scripts/loop-daemon.test.sh b/.claude/scripts/loop-daemon.test.sh index 7f0e0e4..61f81cb 100644 --- a/.claude/scripts/loop-daemon.test.sh +++ b/.claude/scripts/loop-daemon.test.sh @@ -154,6 +154,29 @@ check "verify_and_classify_post_exit: timeout rc=124 passes extra through unchan 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" ] +vp_cifix="$(verify_and_classify_post_exit 'ci-fix pr=23' 0 'result=exit rc=0')" +check "verify_and_classify_post_exit: ci-fix verdict passes extra through unchanged (issue #96 -- no fresh branch/worktree to classify)" [ "$vp_cifix" = "result=exit rc=0" ] + +# --- driver_unit_name / verdict_from_unit_name (issue #119 pt 1; issue #96) -- +# ci-fix gets its OWN distinct unit name (pr-loop-driver-cifix-pr<N>, not the +# feedback shape pr-loop-driver-pr<N>) so a feedback driver and a ci-fix driver +# on the same PR number can never collide in naming or reattach -- and the +# reverse mapping must be an exact inverse. +un_advance="$(driver_unit_name 'advance issue=42')" +check "driver_unit_name: advance issue=42 -> pr-loop-driver-issue42" [ "$un_advance" = "pr-loop-driver-issue42" ] +un_feedback="$(driver_unit_name 'feedback pr=9')" +check "driver_unit_name: feedback pr=9 -> pr-loop-driver-pr9" [ "$un_feedback" = "pr-loop-driver-pr9" ] +un_cifix="$(driver_unit_name 'ci-fix pr=9')" +check "driver_unit_name: ci-fix pr=9 -> pr-loop-driver-cifix-pr9 (distinct from feedback's same PR number)" [ "$un_cifix" = "pr-loop-driver-cifix-pr9" ] +check "driver_unit_name: ci-fix and feedback unit names for the SAME PR number never collide" bash -c '[ "$1" != "$2" ]' _ "$un_cifix" "$un_feedback" + +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" ] +vun_feedback="$(verdict_from_unit_name 'pr-loop-driver-pr9.service')" +check "verdict_from_unit_name: pr-loop-driver-pr9.service -> feedback pr=9 (exact inverse)" [ "$vun_feedback" = "feedback pr=9" ] +vun_cifix="$(verdict_from_unit_name 'pr-loop-driver-cifix-pr9.service')" +check "verdict_from_unit_name: pr-loop-driver-cifix-pr9.service -> ci-fix pr=9 (exact inverse)" [ "$vun_cifix" = "ci-fix pr=9" ] + # ============================================================================= # (B) Integration checks: real subprocess, fake loop-event.sh + fake claude. # ============================================================================= @@ -327,6 +350,37 @@ check "scenario 4 (feedback, no parseable session_id): ledger records session=un grep -Eq "^pid=[0-9]+ session=unknown verdict=feedback pr=9 ts=[0-9T:Z-]+ result=exit rc=0$" "$1" ' _ "$ledger4" +# --------------------------------------------------------------------------- +# 4b. action=ci-fix pr=N (issue #96): same driver path as feedback, a distinct +# verdict text, and its own transient unit name +# (pr-loop-driver-cifix-pr<N>, not pr-loop-driver-pr<N>) so it can never +# collide with a feedback driver on the same PR number. +# --------------------------------------------------------------------------- +prompt4b_dir="$work/scenario4b-support" +mkdir -p "$prompt4b_dir" +printf 'Run the CI-FIX step for PR #23. +' > "$prompt4b_dir/prompt.txt" +dir4b="$(new_fixture scenario4b "#!/usr/bin/env bash +echo 'cadence=FAST cron=* * * * *' +echo 'loop-event: action=ci-fix pr=23' +echo 'loop-event: model=sonnet' +echo 'loop-event: prompt-file=$prompt4b_dir/prompt.txt' +exit 0")" +fake_bin "$dir4b" setsid '#!/usr/bin/env bash +exec "$@"' +fake_bin "$dir4b" timeout '#!/usr/bin/env bash +shift; shift +exec "$@"' +fake_bin "$dir4b" claude '#!/usr/bin/env bash +echo "{\"session_id\":\"sess-cifix-23\"}" +exit 0' +run_daemon_once "$dir4b" >/dev/null 2>&1 +ledger4b="$dir4b/.claude/state/loop-runs.log" +check "scenario 4b (ci-fix): ledger records verdict=ci-fix pr=23" bash -c ' + grep -Eq "^pid=[0-9]+ session=sess-cifix-23 verdict=ci-fix pr=23 ts=[0-9T:Z-]+ result=exit rc=0$" "$1" +' _ "$ledger4b" +check "scenario 4b: prompt file was cleaned up after the driver ran" [ ! -f "$prompt4b_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 diff --git a/.claude/scripts/loop-event.test.sh b/.claude/scripts/loop-event.test.sh index 8e3e18c..745344f 100644 --- a/.claude/scripts/loop-event.test.sh +++ b/.claude/scripts/loop-event.test.sh @@ -120,6 +120,30 @@ check "scenario 3: prompt file mandates FOREGROUND-only spawn (run_in_background 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" +# --------------------------------------------------------------------------- +# 3b. action=ci-fix pr=N (issue #96) -> same one-shot contract, CI-FIX +# wording, in-flight-guard/marker-comment instructions, LOOP_MODEL honored. +# --------------------------------------------------------------------------- +dir3b="$(new_fixture scenario3b 'cadence=FAST cron=* * * * * +action=ci-fix pr=11')" +out3b="$(cd "$dir3b" && PATH="/usr/bin:/bin" LOOP_MODEL=opus bash .claude/scripts/loop-event.sh)"; rc3b=$? +check "scenario 3b (ci-fix): exits 0" [ "$rc3b" -eq 0 ] +check "scenario 3b: emits loop-event: action=ci-fix pr=11" bash -c 'printf "%s +" "$1" | grep -qxF "loop-event: action=ci-fix pr=11"' _ "$out3b" +check "scenario 3b: LOOP_MODEL is honored (model=opus)" bash -c 'printf "%s +" "$1" | grep -qxF "loop-event: model=opus"' _ "$out3b" +pf3b="$(printf '%s +' "$out3b" | sed -n 's/^loop-event: prompt-file=//p')" +check "scenario 3b: prompt file mentions PR #11" bash -c 'grep -q "PR #11" "$1"' _ "$pf3b" +check "scenario 3b: prompt file says CI-FIX step, and Do NOT merge" bash -c 'grep -q "CI-FIX step" "$1" && grep -q "Do NOT merge" "$1"' _ "$pf3b" +check "scenario 3b: prompt file mandates the claude-ci-fixing in-flight guard label" bash -c 'grep -q "claude-ci-fixing" "$1"' _ "$pf3b" +check "scenario 3b: prompt file mandates the claude-ci-addressed marker comment with the head SHA" bash -c 'grep -q "claude-ci-addressed" "$1"' _ "$pf3b" + +# Same one-shot contract clauses, on the CI-FIX prompt this time. +check "scenario 3b: prompt file mandates FOREGROUND-only spawn (run_in_background: false)" bash -c 'grep -qi "FOREGROUND" "$1" && grep -q "run_in_background: false" "$1"' _ "$pf3b" +check "scenario 3b: 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"' _ "$pf3b" +check "scenario 3b: prompt file mandates deleting debris on failure" bash -c 'grep -q "delete any local feat/issue-N-\* branch and worktree" "$1"' _ "$pf3b" + # --------------------------------------------------------------------------- # 4. Garbage verdict line -> non-zero exit, action=none fallback line, no # prompt-file (never spawns on a verdict it can't parse). diff --git a/.claude/scripts/loop-tick.test.sh b/.claude/scripts/loop-tick.test.sh index a91a2db..6eb9271 100644 --- a/.claude/scripts/loop-tick.test.sh +++ b/.claude/scripts/loop-tick.test.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash # loop-tick.test.sh — offline smoke test for loop-tick.sh (issue #81). # -# loop-tick.sh's own logic is just: run its four sibling step scripts, parse -# census/pr-feedback output, and emit one verdict line (plus the spawn lock). -# So this test doesn't touch real gh/network — it builds a throwaway -# .claude/scripts/ directory containing the REAL loop-tick.sh + resolve-roots.sh -# next to FAKE loop-census.sh / notify-poll.sh / merge-ready.sh / pr-feedback.sh -# that print canned, scripted output, then asserts the final verdict line and -# the spawn-lock file behavior for each scenario. +# loop-tick.sh's own logic is just: run its five sibling step scripts, parse +# census/pr-feedback/pr-ci-fix output, and emit one verdict line (plus the +# spawn lock). So this test doesn't touch real gh/network — it builds a +# throwaway .claude/scripts/ directory containing the REAL loop-tick.sh + +# resolve-roots.sh next to FAKE loop-census.sh / notify-poll.sh / +# merge-ready.sh / pr-feedback.sh / pr-ci-fix.sh (issue #96) that print +# canned, scripted output, then asserts the final verdict line and the +# spawn-lock file behavior for each scenario. # # Exit 0 on success, non-zero if any assertion fails. Runnable bare: # bash .claude/scripts/loop-tick.test.sh @@ -314,6 +315,55 @@ check "scenario 12: action=feedback tick record captures pr number and cadence" if (obj.cadence !== "WATCH") throw new Error("cadence mismatch: " + JSON.stringify(obj)); ' "$ticks12" +# --------------------------------------------------------------------------- +# 12b. CI-fix candidate present, no feedback, nothing advance_ready -> picked +# as action=ci-fix pr=N, and (mirroring scenario 4's "no spawn lock" for +# feedback) never writes the advance spawn lock (issue #96). +# --------------------------------------------------------------------------- +dir12b="$(new_fixture scenario12b 'open_prs=1 +feedback_prs=0 +planned_issues=0 +advance_ready=none +cadence=WATCH cron=*/5 * * * *' '' "$(printf '9 feat/issue-9-x build sha9 +4 feat/issue-4-y build sha4')")" +out12b="$(run_tick "$dir12b")" +check "scenario 12b (ci-fix, lowest-numbered PR wins): verdict is action=ci-fix pr=4" bash -c '[ "$(printf "%s +" "$1" | tail -1)" = "action=ci-fix pr=4" ]' _ "$out12b" +check "scenario 12b: no spawn lock written (advance never attempted)" [ ! -e "$dir12b/../state/loop-advance.lock" ] + +# --------------------------------------------------------------------------- +# 12c. CI-fix wins over an ALSO-ready advance (issue #96 precedence: ci-fix > +# advance), same shape as scenario 4's feedback-beats-advance check. +# --------------------------------------------------------------------------- +dir12c="$(new_fixture scenario12c 'open_prs=0 +feedback_prs=0 +planned_issues=1 +issue=7 branch=none title=Some issue +advance_ready=7 +cadence=FAST cron=* * * * *' '' "$(printf '11 feat/issue-11-x build sha11')")" +out12c="$(run_tick "$dir12c")" +check "scenario 12c (ci-fix beats an also-ready advance): verdict is action=ci-fix pr=11" bash -c '[ "$(printf "%s +" "$1" | tail -1)" = "action=ci-fix pr=11" ]' _ "$out12c" +check "scenario 12c: no spawn lock written (advance never attempted)" [ ! -e "$dir12c/../state/loop-advance.lock" ] + +# action=ci-fix tick record: pr number captured, cadence round-trips. +dir12d="$(new_fixture scenario12d 'open_prs=1 +feedback_prs=0 +planned_issues=0 +advance_ready=none +cadence=WATCH cron=*/5 * * * *' '' "$(printf '5 feat/issue-5-x build sha5')")" +ticks12d="$work/scenario12d-ticks.jsonl" +out12d="$(CLAUDE_TICKS_FILE="$ticks12d" run_tick "$dir12d")" +check "scenario 12d: verdict is still the LAST stdout line for action=ci-fix" bash -c '[ "$(printf "%s +" "$1" | tail -1)" = "action=ci-fix pr=5" ]' _ "$out12d" +check "scenario 12d: action=ci-fix tick record captures pr number and cadence" node -e ' + const fs = require("fs"); + const obj = JSON.parse(fs.readFileSync(process.argv[1], "utf8").trim()); + if (obj.action !== "ci-fix") throw new Error("action mismatch: " + JSON.stringify(obj)); + if (obj.pr !== "5") throw new Error("pr mismatch: " + JSON.stringify(obj)); + if (obj.cadence !== "WATCH") throw new Error("cadence mismatch: " + JSON.stringify(obj)); +' "$ticks12d" + # --------------------------------------------------------------------------- # 11. Rotation: LOOP_TICKS_MAX_LINES caps the tick log to the last N lines # across repeated ticks (mirrors log-event.sh's rotation, log-event.test.sh @@ -695,6 +745,10 @@ EOF cat > "$scripts/pr-feedback.sh" <<'EOF' #!/usr/bin/env bash exit 0 +EOF + cat > "$scripts/pr-ci-fix.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 EOF cat > "$scripts/notify-poll.sh" <<'EOF' #!/usr/bin/env bash diff --git a/.claude/scripts/plan-gate.test.sh b/.claude/scripts/plan-gate.test.sh index 2e3dd03..77b3891 100755 --- a/.claude/scripts/plan-gate.test.sh +++ b/.claude/scripts/plan-gate.test.sh @@ -68,6 +68,10 @@ EOF cat > "$scripts/pr-feedback.sh" <<'EOF' #!/usr/bin/env bash exit 0 +EOF + cat > "$scripts/pr-ci-fix.sh" <<'EOF' +#!/usr/bin/env bash +exit 0 EOF cat > "$scripts/bot-gh.sh" <<EOF #!/usr/bin/env bash @@ -135,6 +139,7 @@ cat > "$dirNoPlanKey/.claude/gates.json" <<'EOF' } EOF cp "$dirOff/.claude/scripts/pr-feedback.sh" "$scriptsNoPlanKey/pr-feedback.sh" +cp "$dirOff/.claude/scripts/pr-ci-fix.sh" "$scriptsNoPlanKey/pr-ci-fix.sh" cp "$dirOff/.claude/scripts/bot-gh.sh" "$scriptsNoPlanKey/bot-gh.sh" chmod +x "$scriptsNoPlanKey"/*.sh git -C "$dirNoPlanKey" init -q -b main