← back to AbramsEgo
scripts/build-loop.sh
96 lines
#!/usr/bin/env bash
# AbramsEgo build loop — isolated, restart-forever autonomous builder.
#
# Mirrors the proven ~/.claude/yolo-queue/loop.sh contract but on a DEDICATED
# queue (this repo's build-queue/) so it never fights the DW catalog loop:
# - picks the lowest-numbered .md task from build-queue/tasks/
# - runs it through `claude --print --permission-mode acceptEdits` (Opus)
# - archives to build-queue/done/ on success, build-queue/failed/ on error
# - exits 75 (non-zero) when MORE tasks remain -> launchd KeepAlive respawns
# immediately ("if it stops, always restart right away")
# - exits 0 (clean) when the queue is EMPTY or STOP is set -> launchd rests
# - refuses to start if DISABLED marker present (durable off switch)
#
# Halt: touch build-queue/STOP (graceful, one-shot)
# Off: touch build-queue/DISABLED (durable; rm to re-enable)
set -u
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
Q="$ROOT/build-queue"
CLAUDE_BIN="${CLAUDE_BIN:-$HOME/.local/bin/claude}"
MODEL="${ABRAMSEGO_MODEL:-claude-fable-5}"
MAX_TURNS="${MAX_TURNS:-40}"
PER_TASK_TIMEOUT="${PER_TASK_TIMEOUT:-1200}" # 20 min hard budget per task (900 killed 4 tasks mid-verify on 2026-07-01)
COOLDOWN="${COOLDOWN:-20}"
# portable hard-timeout: GNU timeout / gtimeout / perl-alarm fallback (macOS has none of the first two)
if command -v timeout >/dev/null 2>&1; then RUN(){ timeout "$PER_TASK_TIMEOUT" "$@"; }
elif command -v gtimeout >/dev/null 2>&1; then RUN(){ gtimeout "$PER_TASK_TIMEOUT" "$@"; }
else RUN(){ perl -e 'alarm shift @ARGV; exec @ARGV' "$PER_TASK_TIMEOUT" "$@"; }
fi
mkdir -p "$Q"/{tasks,done,failed,logs}
LOG="$Q/loop.log"
ts(){ date '+%F %T'; }
[ -f "$Q/DISABLED" ] && { echo "[$(ts)] DISABLED — refusing to start. rm $Q/DISABLED to re-enable." | tee -a "$LOG"; exit 0; }
[ -f "$Q/STOP" ] && { echo "[$(ts)] STOP present — graceful halt." | tee -a "$LOG"; exit 0; }
# singleton (mkdir lock)
LOCK="$Q/.loop.lock"
if ! mkdir "$LOCK" 2>/dev/null; then
OLD=$(cat "$LOCK/pid" 2>/dev/null)
if [ -n "$OLD" ] && kill -0 "$OLD" 2>/dev/null; then echo "[$(ts)] another loop live ($OLD) — exit." | tee -a "$LOG"; exit 0; fi
rm -rf "$LOCK"; mkdir "$LOCK" 2>/dev/null || exit 0
fi
echo $$ > "$LOCK/pid"; trap 'rm -rf "$LOCK"' EXIT INT TERM
TASK=$(ls "$Q"/tasks/*.md 2>/dev/null | sort | head -1)
if [ -z "${TASK:-}" ]; then echo "[$(ts)] queue empty — resting (exit 0)." | tee -a "$LOG"; exit 0; fi
name=$(basename "$TASK")
echo "[$(ts)] START $name" | tee -a "$LOG"
PREFIX="You are the AbramsEgo autonomous builder working in $ROOT. Use /dtd to DECIDE any judgment call. Do only SAFE, REVERSIBLE work: edit code, add features/tests, commit with git (author steve@designerwallcoverings.com). The server ALREADY RUNS under pm2 as process 'abramsego' on port 9773 — to test changes run 'pm2 restart abramsego' then curl the running instance; NEVER start a second node on 9773. NEVER run a GATED action (deploy to Kamatera, DNS, domain registration, Stripe/pricing/publish, send-to-list, spend, remote push) — instead draft it to ~/.claude/yolo-queue/pending-approval/ with an officer APPROVE/REVISE/BLOCK block. Commit after each success. TASK FOLLOWS:\n\n"
LOGF="$Q/logs/${name%.md}.$(date +%s).log"
# Force Max-plan OAuth: strip any metered API key from the loop's env before
# invoking the claude BINARY directly. The .zshrc `claude` function that does this
# only applies to interactive shells; a launchd/bare env bypasses it. A metered
# ANTHROPIC_API_KEY overrides Max and can hit the monthly-spend hard stop — which
# is exactly what parked this loop on 2026-07-01.
cat <(printf '%b' "$PREFIX") "$TASK" | RUN env -u ANTHROPIC_API_KEY -u ANTHROPIC_AUTH_TOKEN "$CLAUDE_BIN" --print --permission-mode acceptEdits --max-turns "$MAX_TURNS" --model "$MODEL" >"$LOGF" 2>&1
rc=${PIPESTATUS[1]}
if [ "$rc" -eq 0 ]; then
# LOOP-ENGINEERING VERIFICATION GATE (Ng "close the loop"): don't trust the CLI's
# exit code that a change actually works — smoke-test the live instance before
# archiving. Pass → done/. Fail → needs-fix/ + re-queue a fix task, so the loop
# iterates until it meets spec instead of marking unverified work DONE.
if [ -x "$ROOT/scripts/smoke.sh" ] && ! "$ROOT/scripts/smoke.sh" >>"$LOGF" 2>&1; then
mkdir -p "$Q/needs-fix"; cp "$TASK" "$Q/needs-fix/$name"
fix="$Q/tasks/${name%.md}-verify-fix.md"
{ echo "# VERIFY-FIX for $name (smoke gate failed after it reported DONE)";
echo "The prior task claimed success but scripts/smoke.sh FAILED against the running";
echo ":9773 instance. Read build-queue/logs for the smoke output, find what broke,";
echo "fix it surgically, \`pm2 restart abramsego\`, and re-run scripts/smoke.sh until it";
echo "passes. Original task is in build-queue/needs-fix/$name. Commit the fix."; } > "$fix"
mv "$TASK" "$Q/failed/$name"
echo "[$(ts)] SMOKE-FAIL $name -> re-queued $fix" | tee -a "$LOG"
else
mv "$TASK" "$Q/done/$name"; echo "[$(ts)] DONE $name (smoke ✓) -> $LOGF" | tee -a "$LOG"
fi
elif grep -qiE "hit your (usage|session|monthly spend) limit|session limit|usage limit|spend limit|rate.?limit|resets [0-9]|overloaded|temporarily unavailable|not logged in" "$LOGF"; then
# TRANSIENT rate-limit / model-unavailable — DO NOT burn the task. Leave it queued
# and rest cleanly (exit 0). launchd StartInterval re-kicks us; once the Max session
# limit resets the very same task drains untouched. Smart "always restart": never
# destroy work on a transient cap. (Root cause of the 2026-07-01 10:20 mass-fail.)
echo "[$(ts)] RATELIMIT $name — left in queue, clean rest; launchd heartbeat will retry -> $LOGF" | tee -a "$LOG"
exit 0
else
mv "$TASK" "$Q/failed/$name"; echo "[$(ts)] FAIL $name (rc=$rc) -> $LOGF" | tee -a "$LOG"
fi
sleep "$COOLDOWN"
# more tasks? exit non-zero so launchd respawns us immediately; else clean rest.
if ls "$Q"/tasks/*.md >/dev/null 2>&1 && [ ! -f "$Q/STOP" ]; then exit 75; fi
exit 0