Files
bookshelf/tasks/wave-chain.sh
T

163 lines
7.1 KiB
Bash
Executable File

#!/usr/bin/env bash
# wave-chain.sh <wave>:<task> [<wave>:<task> ...]
# e.g. wave-chain.sh 9:K-manual 10:L-search
#
# Runs waves IN SEQUENCE with no orchestrator attached (2026-09-13: the user asked for
# wave 10 to start straight after wave 9 without waiting on them). Between waves it
# runs a mechanical acceptance gate, because starting a worker on a red tree wastes a
# whole session. This gate is NOT the orchestrator's review — it is the floor below it.
#
# Per wave:
# 1. run-task.sh <task> in the FOREGROUND (quota-aware, resumable)
# 2. gate: assembleDebug, testDebugUnitTest, verifyPaparazziDebug, test count UP from
# the XML, 0 failures, no build files touched, no "always 'false'" warning
# 3. gate red -> ONE fresh fix worker (<task>-fix) pointed at the gate log, re-gate
# 4. green -> commit + push; red -> write sentinel as FAILED and STOP the chain
#
# Holds the sprite lease itself (HAZARD #5). wave-guard.sh cannot be used: its
# workers_running() sees nothing during the 5-10 min gate between workers and would
# release the lease mid-chain. Liveness here is `kill -0` on this script's own PID,
# never pgrep -f (HAZARD #6/#10).
set -u
cd "$HOME/bookshelf" || exit 1
L="$HOME/bookshelf/logs"; mkdir -p "$L"
CLOG="$L/wave-chain.log"; LEASE="bookshelf-wave"
RESULTS="app/app/build/test-results/testDebugUnitTest"
log() { echo "[$(date -Is)] $*" >> "$CLOG"; }
lease_present() { sprite-env curl /v1/tasks 2>/dev/null | grep -q "\"$LEASE\""; }
lease_acquire() {
local a
for a in 1 2 3; do
sprite-env curl -X DELETE "/v1/tasks/$LEASE" >/dev/null 2>&1
sprite-env curl -X POST /v1/tasks -H 'Content-Type: application/json' \
-d "{\"name\":\"$LEASE\",\"expire\":\"3600s\"}" >/dev/null 2>&1
lease_present && return 0
sleep 5
done
return 1
}
lease_release() { sprite-env curl -X DELETE "/v1/tasks/$LEASE" >/dev/null 2>&1; }
CHAIN_PID=$$
(
last=$(date +%s)
lease_acquire && log "lease acquired and VERIFIED" || log "FATAL: lease acquire failed"
while kill -0 "$CHAIN_PID" 2>/dev/null; do
sleep 30
now=$(date +%s)
if [ $((now - last)) -ge 900 ] || ! lease_present; then
lease_acquire && log "lease renewed" || log "ERROR: lease renewal FAILED"
last=$now
fi
done
) &
RENEWER=$!
trap 'kill $RENEWER 2>/dev/null; lease_release; log "chain exiting, lease released"' EXIT
count_tests() {
python3 - "$RESULTS" <<'PY'
import glob, sys, xml.etree.ElementTree as ET
t = s = f = 0
for p in glob.glob(sys.argv[1] + "/TEST-*.xml"):
r = ET.parse(p).getroot()
t += int(r.get("tests")); s += int(r.get("skipped")); f += int(r.get("failures")) + int(r.get("errors"))
print(t, s, f)
PY
}
# gate <label> <baseline-test-count> -> 0 green, 1 red. Output in logs/gate-<label>.log
gate() {
local label="$1" base="$2" g="$L/gate-$1.log" ok=0
: > "$g"
echo "=== gate $label $(date -Is) baseline=$base ===" >> "$g"
for t in assembleDebug testDebugUnitTest verifyPaparazziDebug; do
echo "--- ./tasks/gw $t ---" >> "$g"
./tasks/gw "$t" >> "$g" 2>&1; rc=$?
echo "--- $t exit $rc ---" >> "$g"
[ $rc -ne 0 ] && ok=1
done
read -r n s f <<<"$(count_tests)"
echo "tests=$n skipped=$s failures+errors=$f (baseline $base)" >> "$g"
{ [ "$f" -ne 0 ] || [ "$n" -le "$base" ]; } && { echo "GATE: test count did not go up or failures present" >> "$g"; ok=1; }
if grep -n "always 'false'" "$g" >> "$g.warn"; then
echo "GATE: \"always 'false'\" warning present:" >> "$g"; cat "$g.warn" >> "$g"; ok=1
fi
rm -f "$g.warn"
echo "--- git status --porcelain ---" >> "$g"; git status --porcelain >> "$g"
if git status --porcelain | grep -qE '\.gradle\.kts$|libs\.versions\.toml$|gradle\.properties$|local\.properties$'; then
echo "GATE: a build file was touched (forbidden)" >> "$g"; ok=1
fi
echo "=== gate $label result: $([ $ok -eq 0 ] && echo GREEN || echo RED) ===" >> "$g"
return $ok
}
log "chain start: $*"
read -r BASE _ _ <<<"$(count_tests)"
log "baseline tests from XML: $BASE"
for spec in "$@"; do
W="${spec%%:*}"; T="${spec#*:}"; SENT="$L/WAVE$W-DONE"
log "wave $W ($T): launching worker"
./tasks/run-task.sh "$T" "./tasks/$T.txt"
log "wave $W ($T): worker exited: $(tail -1 "$L/$T.state")"
if gate "$T" "$BASE"; then
GREEN=1
else
log "wave $W ($T): gate RED; launching one fix worker"
cat > "tasks/$T-fix.txt" <<EOF
A previous worker implemented the task in ~/bookshelf/tasks/$T.txt and left the
working tree FAILING its acceptance gate. Read, in order: ~/bookshelf/docs/SPEC.md,
~/bookshelf/tasks/$T.txt (the original instructions — every constraint in it binds
you too), ~/bookshelf/logs/$T.summary (that worker's report), then the gate output at
~/bookshelf/logs/gate-$T.log. Lines starting "GATE:" and non-zero exits are the failures.
Fix ONLY what makes the gate red. Do not redesign or re-implement the feature. The
test count must end ABOVE $BASE with zero failures; do not delete or @Ignore tests to
get there. If a Paparazzi verify failed because snapshots were never recorded, record
them and LOOK at the PNGs before accepting them.
Run in the FOREGROUND (never background a build): ./tasks/gw assembleDebug,
./tasks/gw testDebugUnitTest, ./tasks/gw verifyPaparazziDebug. Do not git commit/add/push.
Report what was wrong, what you changed, and the verbatim output tails.
EOF
./tasks/run-task.sh "$T-fix" "./tasks/$T-fix.txt"
log "wave $W ($T-fix): worker exited: $(tail -1 "$L/$T-fix.state")"
if gate "$T-refix" "$BASE"; then GREEN=1; else GREEN=0; fi
fi
if [ "$GREEN" -ne 1 ]; then
log "wave $W ($T): gate still RED after fix worker. STOPPING CHAIN."
{ echo "=== WAVE $W ($T) FAILED GATE $(date -Is) — chain stopped, later waves NOT run ==="
echo "Work left UNCOMMITTED in the tree. See logs/gate-$T*.log, logs/$T*.summary."; } > "$SENT"
exit 1
fi
read -r BASE _ _ <<<"$(count_tests)"
COST="$(jq -r '"$" + ((.total_cost_usd//0)|tostring) + ", " + ((.num_turns//0)|tostring) + " turns"' "$L/$T.json" 2>/dev/null)"
git add -A
git commit -q -F - <<EOF
Wave $W ($T): $( [ "$T" = K-manual ] && echo "add a book by hand, no ISBN required" || echo "search own library + Open Library + Google Books, deduplicated" )
Committed by tasks/wave-chain.sh after its mechanical gate passed
(assembleDebug, testDebugUnitTest = $BASE tests, verifyPaparazziDebug, no build
files touched, no "always 'false'"). ORCHESTRATOR REVIEW STILL PENDING.
Prompt: tasks/$T.txt. Worker: $COST.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RWoivrRUEmJLFFwbsrGkqQ
EOF
SHA="$(git rev-parse --short HEAD)"
if timeout 120 git push -q origin HEAD 2>>"$CLOG"; then PUSHED="pushed"; else PUSHED="PUSH FAILED (see wave-chain.log)"; fi
log "wave $W ($T): GREEN, committed $SHA, $PUSHED, tests now $BASE"
{ echo "=== WAVE $W ($T) passed the chain gate $(date -Is) ==="
echo "commit $SHA ($PUSHED); tests $BASE; worker $COST"
echo "Mechanical gate only. Orchestrator must still review: read logs/$T.summary,"
echo "eyeball new Paparazzi PNGs, and read the diff (git show --stat $SHA)."; } > "$SENT"
done
log "chain complete"
echo "chain complete $(date -Is)" > "$L/CHAIN-DONE"