Files
bookshelf/tasks/wave-guard.sh
T
Spriteandclaude 007a1f426d wave-guard: match workers by argv shape; accept wave 8; record HAZARD #10
The guard's `pgrep -f 'run-task\.sh'` matched the orchestrator's own stale
launching shell, whose command line contains that text, so it kept the sprite
awake for 17 hours after J-crashsafe finished. workers_running() now requires
argv[1] to be the runner script and argv[2] to be one of this wave's tasks.

Also commits the wave-7 sid/sentinel that were never added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AQb3d68LqWD4EdND3C8jkD
2026-09-13 11:33:21 +00:00

116 lines
4.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# wave-guard.sh <sentinel-name> <task> [<task>...]
#
# Solves the real cause of the wave-3 loss: the sprite AUTO-SUSPENDS when idle
# ("When idle, sprites pause automatically" — /.sprite/llm.txt). Detached workers
# do NOT keep it awake; only services, live sessions, or a task lease do.
#
# Holds a sprite task lease (POST /v1/tasks, max expire 3600s) and renews it while
# workers run, so the box stays hot with no console attached. When the workers stop
# it writes the durable completion record AND releases the lease, so the sprite is
# free to suspend instead of burning money idling.
#
# WAVE 4 POST-MORTEM — why this file was rewritten:
# The previous lease_hold() did DELETE-then-POST with both results discarded, and
# logged "lease renewed" unconditionally. If that POST ever failed, the box was left
# with NO lease while the log claimed otherwise. That is exactly what wave 4 looks
# like: last "renewal" 11:39, workers dead by 11:46, box rebooted 12:55. So now:
# - every acquire is VERIFIED against GET /v1/tasks before it is believed
# - a failed acquire retries, and is logged as a failure, never as a success
# - the lease is re-checked every POLL (cheap), not only every RENEW, so a lease
# lost between renewals is noticed in seconds instead of never
set -u
SENT="$1"; shift
TASKS=("$@")
L="$HOME/bookshelf/logs"; OUT="$L/$SENT"; LEASE="bookshelf-wave"
RENEW="${RENEW:-900}" # proactively renew every 15 min against a 60 min lease
POLL="${POLL:-30}" # but CHECK completion + lease health every 30s
GUARD_LOG="$L/wave-guard.log"
log() { echo "[$(date -Is)] $*" >> "$GUARD_LOG"; }
lease_present() { sprite-env curl /v1/tasks 2>/dev/null | grep -q "\"$LEASE\""; }
# Acquire and VERIFY. Returns 0 only if the lease is actually live afterwards.
lease_acquire() {
local attempt
for attempt 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
if lease_present; then return 0; fi
log "WARNING: lease acquire attempt $attempt did not take; retrying"
sleep 5
done
return 1
}
lease_release() { sprite-env curl -X DELETE "/v1/tasks/$LEASE" >/dev/null 2>&1; }
trap 'lease_release; log "guard exiting, lease released"; exit 0' TERM INT
log "guard start: sentinel=$SENT tasks=${TASKS[*]} renew=${RENEW}s poll=${POLL}s"
if lease_acquire; then
log "lease '$LEASE' acquired and VERIFIED (3600s)"
else
log "FATAL: could not acquire lease '$LEASE' -- the sprite may suspend mid-wave"
fi
last_renew=$(date +%s)
# WAVE 8 POST-MORTEM — why this is not `pgrep -f 'run-task\.sh'`:
# pgrep -f matches ANY command line containing that text. The orchestrator's own
# launching `bash -c '... setsid nohup ./tasks/run-task.sh ...'` stayed alive after
# the worker finished, so the guard saw "workers still running" for 17 hours and
# held the sprite hot the whole time (HAZARD #6). Match on argv SHAPE instead: the
# interpreter is bash, argv[1] IS the runner script, and argv[2] is one of THIS
# wave's task names. A `bash -c` has argv[1] = "-c" and can never match.
workers_running() {
ps -eo args= | awk -v tasks=" ${TASKS[*]} " '
$1 ~ /(^|\/)bash$/ && $2 ~ /(^|\/)run-(task|resume)\.sh$/ && index(tasks, " " $3 " ") { found=1 }
END { exit !found }'
}
while workers_running; do
sleep "$POLL"
now=$(date +%s)
if [ $(( now - last_renew )) -ge "$RENEW" ]; then
if lease_acquire; then
log "lease renewed and VERIFIED; workers still running"
else
log "ERROR: scheduled renewal FAILED; lease is not held"
fi
last_renew=$now
elif ! lease_present; then
# Lost between renewals (expiry race, API blip, someone else's DELETE).
log "ERROR: lease disappeared between renewals -- re-acquiring now"
if lease_acquire; then
log "lease re-acquired and VERIFIED"
last_renew=$now
else
log "ERROR: emergency re-acquire FAILED; sprite is unprotected"
fi
fi
done
log "workers stopped; writing $SENT"
{
echo "=== $SENT written $(date -Is) ==="
echo "Workers finished. The orchestrator was NOT necessarily alive for this."
echo
for t in "${TASKS[@]}"; do
echo "--- $t ---"
grep -hE 'SUCCESS|GIVING UP|WALL CLOCK|QUOTA' "$L/$t.state" 2>/dev/null | tail -3
if [ -s "$L/$t.json" ]; then
jq -r '"cost=$" + ((.total_cost_usd//0)|tostring) + " turns=" + ((.num_turns//0)|tostring)' "$L/$t.json" 2>/dev/null
else
echo "!! $t.json is 0 bytes -> worker was KILLED, not finished (hazard #3/#5)"
fi
echo
done
echo "NEXT: orchestrator must independently verify before accepting:"
echo " cd ~/bookshelf && ./tasks/gw assembleDebug && ./tasks/gw testDebugUnitTest"
echo " git status --porcelain # boundary check: who touched what"
} > "$OUT"
lease_release; log "lease released; sprite may suspend"