#!/usr/bin/env bash # ff-run.sh - whole-run replay/status for a fleetflow run. # # resume: replay every packet in /manifest.json through ff-spawn, IN # MANIFEST ORDER, sequential. Unchanged packets cache-hit (ff-spawn exit 3) # and are reported "cached"; changed/new packets run live. A per-lane summary # goes to stderr; a JSON result array goes to stdout. Exit 0 if every lane is # ok or cached, 10 if any lane failed. # status: convenience alias for ff-status (its JSON on stdout, identical exit). # stdout: the JSON result list (resume) / ff-status JSON (status). stderr: chatter. # # Exit codes: 0 all ok/cached | 2 usage | 10 a lane failed set -u . "$(dirname "${BASH_SOURCE[0]}")/_env.sh" FF_VERSION="1.1.0" usage() { cat <<'EOF' Usage: ff-run.sh resume --run NAME [--repo PATH] ff-run.sh status --run NAME [--repo PATH] resume --run NAME replay every packet in /manifest.json through ff-spawn, in order. Unchanged packets cache-hit ("cached"), changed/new ones run live. JSON result list on stdout. status --run NAME alias for ff-status (run status JSON on stdout). --repo PATH repo root (default: git toplevel of cwd) EXAMPLES ff-run.sh resume --run currency ff-run.sh resume --run currency --repo /path/to/repo | jq '.[] | select(.status!="cached")' ff-run.sh status --run currency | jq '.lanes | length' EOF } err() { echo "ff-run: $*" >&2; } HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SPAWN="$HERE/ff-spawn.sh" MODE="" RUN="" REPO="" [ $# -gt 0 ] || { err "a subcommand is required (resume|status)"; usage >&2; exit 2; } case "$1" in resume) MODE="resume"; shift ;; status) MODE="status"; shift ;; -h|--help) usage; exit 0 ;; *) err "unknown subcommand: $1"; usage >&2; exit 2 ;; esac while [ $# -gt 0 ]; do case "$1" in --run) RUN="${2:-}"; shift 2 ;; --repo) REPO="${2:-}"; shift 2 ;; -h|--help) usage; exit 0 ;; *) err "unknown argument: $1"; usage >&2; exit 2 ;; esac done command -v jq >/dev/null || { err "jq required"; exit 2; } command -v git >/dev/null || { err "git required"; exit 2; } [ -n "$RUN" ] || { err "--run required"; usage >&2; exit 2; } [ -n "$REPO" ] || REPO="$(git rev-parse --show-toplevel 2>/dev/null)" || true [ -n "$REPO" ] && [ -d "$REPO" ] || { err "not in a git repo (or --repo invalid)"; exit 2; } # status = alias for ff-status (hand off and let it own exit codes). # exec THROUGH bash: a direct exec needs the +x bit, which zip installs and # mode-dropping checkouts lose — Git Bash fakes modes, so only Linux notices. if [ "$MODE" = "status" ]; then if [ -n "$REPO" ]; then exec bash "$HERE/ff-status.sh" --run "$RUN" --repo "$REPO"; fi exec bash "$HERE/ff-status.sh" --run "$RUN" fi RUNDIR="$REPO/.fleetflow/$RUN" MANIFEST="$RUNDIR/manifest.json" [ -f "$MANIFEST" ] || { err "no manifest at $MANIFEST (run ff-spawn first)"; exit 2; } # Snapshot the packets ONCE, before replay. ff-spawn upserts each packet on the # way in (remove-then-append), which REORDERS the live manifest - so re-reading # .packets[$i] mid-loop would drift and revisit the same packet. The snapshot is # the replay contract: spawn order = the order captured here, frozen. PACKETS="$(jq -c '.packets' "$MANIFEST" 2>/dev/null)" N="$(printf '%s' "$PACKETS" | jq -r 'length' 2>/dev/null)" [ "${N:-0}" -gt 0 ] 2>/dev/null || { err "manifest has no packets to replay"; exit 2; } err "resume: replaying $N packet(s) from $MANIFEST (sequential)" err " # id brain status" err " -- ----------------------- -------- --------" RESULTS="[]" ANY_FAIL=0 i=0 while [ "$i" -lt "$N" ]; do pid="$(printf '%s' "$PACKETS" | jq -r ".[$i].id")" pbrain="$(printf '%s' "$PACKETS" | jq -r ".[$i].brain")" # imported native packets are terminal facts, not replayable ("native" is not a # spawnable brain - ff-spawn rejects it). ff-run resume SKIPS them; to continue # from an imported result, spawn a fresh lane with a real brain. See ff-import. if [ "$pbrain" = "native" ]; then err " $((i+1)) $(printf '%-23s' "$pid") native imported (skipped)" RESULTS="$(jq -nc --argjson R "$RESULTS" --arg id "$pid" --arg s "imported" --argjson rc 0 \ '$R + [{id:$id,status:$s,rc:$rc}]')" i=$((i+1)); continue fi pphase="$(printf '%s' "$PACKETS" | jq -r ".[$i].phase // \"build\"")" ppf="$(printf '%s' "$PACKETS" | jq -r ".[$i].prompt_file")" pwt="$(printf '%s' "$PACKETS" | jq -r ".[$i].worktree // false")" pmt="$(printf '%s' "$PACKETS" | jq -r ".[$i].max_turns // 100")" peff="$(printf '%s' "$PACKETS" | jq -r ".[$i].effort // \"\"")" psch="$(printf '%s' "$PACKETS" | jq -r ".[$i].schema // \"\"")" # worktree is a boolean string ("true"/"false"); both are non-empty, so gate on # the literal value rather than ${pwt:+...} (which would always fire). WT_FLAG=""; [ "$pwt" = "true" ] && WT_FLAG="1" bash "$SPAWN" --run "$RUN" --id "$pid" --brain "$pbrain" --phase "$pphase" \ --prompt-file "$ppf" --max-turns "$pmt" --repo "$REPO" \ ${WT_FLAG:+--worktree} ${peff:+--effort "$peff"} ${psch:+--schema "$psch"} \ >/dev/null 2>>"$RUNDIR/$pid.resume.err" rc=$? case "$rc" in 0) status="ran" ;; 3) status="cached" ;; *) status="failed"; ANY_FAIL=1 ;; esac err " $((i+1)) $(printf '%-23s' "$pid") $(printf '%-8s' "$pbrain") $status${rc:+ (rc=$rc)}" RESULTS="$(jq -nc --argjson R "$RESULTS" --arg id "$pid" --arg s "$status" --argjson rc "$rc" \ '$R + [{id:$id,status:$s,rc:$rc}]')" i=$((i+1)) done RAN="$(printf '%s' "$RESULTS" | jq -r '[.[]|select(.status=="ran")]|length')" CACHED="$(printf '%s' "$RESULTS" | jq -r '[.[]|select(.status=="cached")]|length')" FAILED="$(printf '%s' "$RESULTS" | jq -r '[.[]|select(.status=="failed")]|length')" err " --" err " summary: $RAN ran, $CACHED cached, $FAILED failed" printf '%s\n' "$RESULTS" [ "$ANY_FAIL" = 1 ] && exit 10 exit 0