Files
Emberfall/scripts/run-agent-loop.sh
T
2026-10-20 20:33:35 +11:00

142 lines
6.9 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# run-agent-loop.sh — drives Qwen Code one STATUS.md step at a time.
# The script (not the model) decides: what's next, whether tests pass, commit vs rollback, when to stop.
#
# Usage: ./scripts/run-agent-loop.sh
# Options (env vars, all optional):
# AGENT_CMD agent command; prompt is appended as last arg (default: "qwen --yolo -p")
# MAX_STEPS max agent runs in total (default: 60)
# MAX_RETRIES attempts per step before giving up (default: 3)
# STEP_TIMEOUT per-run time limit, e.g. 30m, 1h (default: 45m)
# PAUSE_AT_PHASE 1 = wait for Enter between phases, 0 = don't (default: 1)
# STOP_AFTER_PHASE stop once this phase number is finished (default: run to the end)
# PROMPT_FILE prompt template (default: PROMPT_ONE_STEP.md)
#
# WARNING: --yolo lets the agent run ANY shell command without asking.
# Run inside this project folder only, ideally in a VM/container, with git initialised.
set -u
cd "$(dirname "$0")/.."
AGENT_CMD="${AGENT_CMD:-qwen --yolo -p}"
MAX_STEPS="${MAX_STEPS:-60}"
MAX_RETRIES="${MAX_RETRIES:-3}"
STEP_TIMEOUT="${STEP_TIMEOUT:-45m}"
PAUSE_AT_PHASE="${PAUSE_AT_PHASE:-1}"
STOP_AFTER_PHASE="${STOP_AFTER_PHASE:-}"
PROMPT_FILE="${PROMPT_FILE:-PROMPT_ONE_STEP.md}"
LOOP_DIR=".loop"; LOG_DIR="$LOOP_DIR/logs"
FEEDBACK="$LOOP_DIR/feedback.md"; PASS_FILE="$LOOP_DIR/pass_count"
mkdir -p "$LOG_DIR"
read -ra AGENT <<< "$AGENT_CMD"
# --- helpers ---------------------------------------------------------------
if command -v timeout >/dev/null 2>&1; then TO=timeout
elif command -v gtimeout >/dev/null 2>&1; then TO=gtimeout
else TO=""; echo "note: no 'timeout' command found; runs will not be time-limited"; fi
with_timeout() { local t="$1"; shift; if [ -n "$TO" ]; then "$TO" "$t" "$@"; else "$@"; fi; }
# First unchecked numbered step (e.g. 1.5). Stretch goals (S1...) are ignored on purpose.
next_step() { sed -n 's/^- \[ \] \*\*\([0-9][0-9]*\.[0-9][0-9]*\)\*\*.*/\1/p' STATUS.md | head -1; }
total_steps() { grep -cE '^- \[[ xX]\] \*\*[0-9]+\.[0-9]+\*\*' STATUS.md; }
TEST_OUT=""; TEST_PASS=""
run_tests() { # returns 0 = ok/skipped, 1 = failed
TEST_OUT=""; TEST_PASS=""
if [ -f package.json ] && grep -q '"test"' package.json \
&& find tests -name '*.js' 2>/dev/null | grep -q .; then
TEST_OUT="$(with_timeout 5m npm test 2>&1)" || { return 1; }
TEST_PASS="$(printf '%s\n' "$TEST_OUT" | grep -E '^(ℹ|#) pass ' | tail -1 | awk '{print $3}')"
# guard: the number of passing tests must never drop (stops "fix" by deleting tests)
if [ -n "$TEST_PASS" ] && [ -f "$PASS_FILE" ]; then
local prev; prev="$(cat "$PASS_FILE")"
if [ "$TEST_PASS" -lt "$prev" ] 2>/dev/null; then
TEST_OUT="Passing test count dropped from $prev to $TEST_PASS. Do not delete or weaken tests. Restore them.
$TEST_OUT"
return 1
fi
fi
fi
if [ -x scripts/smoke.sh ]; then # optional extra check you can add later
TEST_OUT="$TEST_OUT
$(with_timeout 2m ./scripts/smoke.sh 2>&1)" || return 1
fi
return 0
}
rollback() { git reset -q --hard HEAD && git clean -qfd; }
commit() { git add -A && git commit -qm "$1" 2>/dev/null || true; }
# --- preflight -------------------------------------------------------------
[ -f STATUS.md ] && [ -f "$PROMPT_FILE" ] || { echo "Run from the project; STATUS.md and $PROMPT_FILE needed."; exit 2; }
command -v "${AGENT[0]}" >/dev/null 2>&1 || { echo "Agent command '${AGENT[0]}' not found."; exit 2; }
[ "$(total_steps)" -ge 1 ] || { echo "No numbered steps found in STATUS.md (format: '- [ ] **1.1** ...')."; exit 2; }
git rev-parse --git-dir >/dev/null 2>&1 || git init -q
# make sure commits can't silently fail (no identity configured is a common cause)
git config user.email >/dev/null 2>&1 || git config user.email "loop@localhost"
git config user.name >/dev/null 2>&1 || git config user.name "agent-loop"
git add -A && git commit -qm "loop: baseline" 2>/dev/null
git rev-parse --verify HEAD >/dev/null 2>&1 || { echo "Could not create a git baseline commit; aborting (rollback needs one)."; exit 2; }
trap 'echo; echo "Interrupted. Working tree left as-is (uncommitted changes may exist)."; exit 130' INT
# --- main loop -------------------------------------------------------------
last_step=""; attempt=0; prev_phase=""
echo "Loop starting. Logs in $LOG_DIR/. Ctrl+C to stop."
for ((n = 1; n <= MAX_STEPS; n++)); do
step="$(next_step)"
if [ -z "$step" ]; then echo "✅ All numbered steps are ticked. Done."; exit 0; fi
phase="${step%%.*}"
if [ -n "$STOP_AFTER_PHASE" ] && [ "$phase" -gt "$STOP_AFTER_PHASE" ]; then
echo "✅ Reached STOP_AFTER_PHASE=$STOP_AFTER_PHASE. Next up would be step $step."; exit 0
fi
if [ -n "$prev_phase" ] && [ "$phase" != "$prev_phase" ] && [ "$PAUSE_AT_PHASE" = "1" ]; then
echo; echo "── Phase $prev_phase complete (HEAD: $(git log -1 --format=%s)). Playtest / review now."
read -r -p "Press Enter to start phase $phase (Ctrl+C to stop)… " _ || true
fi
prev_phase="$phase"
if [ "$step" = "$last_step" ]; then attempt=$((attempt + 1)); else attempt=1; fi
last_step="$step"
if [ "$attempt" -gt "$MAX_RETRIES" ]; then
echo "🛑 Stuck on step $step after $MAX_RETRIES attempts. See $LOG_DIR/step-$step-try-*.log and $FEEDBACK"
exit 1
fi
prompt="$(cat "$PROMPT_FILE")
## This run
Target step: **$step** (attempt $attempt of $MAX_RETRIES)"
[ -s "$FEEDBACK" ] && prompt="$prompt
## Feedback from the previous attempt (fix this first)
$(cat "$FEEDBACK")"
log="$LOG_DIR/step-$step-try-$attempt.log"
echo; echo "▶ [$n/$MAX_STEPS] step $step, attempt $attempt → $log"
with_timeout "$STEP_TIMEOUT" "${AGENT[@]}" "$prompt" < /dev/null 2>&1 | tee "$log"
rc=${PIPESTATUS[0]}
note=""; [ "$rc" = "124" ] && note="The previous attempt TIMED OUT after $STEP_TIMEOUT. Do less per run; avoid commands that never exit (e.g. 'npm start' in the foreground)."
after="$(next_step)"
if run_tests; then
[ -n "$TEST_PASS" ] && echo "$TEST_PASS" > "$PASS_FILE"
if [ "$after" != "$step" ]; then
commit "step $step: done"; : > "$FEEDBACK"
echo "✔ step $step verified and committed (tests: ${TEST_PASS:-n/a})"
else
commit "step $step: wip (not ticked)"
printf '%s\nCode exists and tests pass, but step %s is NOT ticked in STATUS.md. Check the Acceptance line; if truly met, tick it (and update Session Log + HANDOFF.md). If not, finish the missing work.\n' "$note" "$step" > "$FEEDBACK"
echo "… step $step not ticked; keeping work, retrying"
fi
else
rollback
{ [ -n "$note" ] && echo "$note"; echo "Your last attempt was rolled back because verification FAILED. Output (tail):"; echo '```'; printf '%s\n' "$TEST_OUT" | tail -40; echo '```'; } > "$FEEDBACK"
echo "✘ step $step failed verification; rolled back"
fi
done
echo "Hit MAX_STEPS=$MAX_STEPS. Re-run to continue."; exit 0