Просмотр исходного кода

feat(skills): fleetflow ff-run — whole-run resume + status (feature 2)

ff-run.sh resume replays every packet in <run>/manifest.json through
ff-spawn in manifest order; unchanged packets cache-hit ("cached"), changed/
new ones run live. Per-lane summary to stderr, JSON result list to stdout;
exit 0 if all ok/cached, 10 if any lane failed. `ff-run status` is an alias
for ff-status.

Snapshot the packet list once before the replay loop: ff-spawn upserts each
packet on the way in (remove-then-append), which reorders the live manifest,
so re-reading .packets[$i] mid-loop drifted and revisited the same id.

Tests: manifest create/append/idempotent + packet fields; ff-run usage,
resume all-cached + order preservation (the reorder-regression guard), and
status==ff-status on stable fields.

Co-Authored-By: Claude <noreply@anthropic.com>
0xDarkMatter 2 недель назад
Родитель
Сommit
c29533ac5b
2 измененных файлов с 160 добавлено и 1 удалено
  1. 124 0
      skills/fleetflow/scripts/ff-run.sh
  2. 36 1
      skills/fleetflow/tests/run.sh

+ 124 - 0
skills/fleetflow/scripts/ff-run.sh

@@ -0,0 +1,124 @@
+#!/usr/bin/env bash
+# ff-run.sh - whole-run replay/status for a fleetflow run.
+#
+# resume: replay every packet in <run>/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
+
+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 <run>/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)
+if [ "$MODE" = "status" ]; then
+  if [ -n "$REPO" ]; then exec "$HERE/ff-status.sh" --run "$RUN" --repo "$REPO"; fi
+  exec "$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")"
+  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

+ 36 - 1
skills/fleetflow/tests/run.sh

@@ -26,7 +26,7 @@ git -C "$REPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init
 PKT="$TMP/packet.txt"; echo "Do the thing. FINAL REPLY: one line." > "$PKT"
 
 # --- syntax + help ------------------------------------------------------------
-for s in ff-spawn.sh ff-collect.sh ff-doctor.sh ff-status.sh; do
+for s in ff-spawn.sh ff-collect.sh ff-doctor.sh ff-status.sh ff-run.sh; do
   bash -n "$S/$s" 2>/dev/null && ok "syntax $s" || bad "syntax $s"
   bash "$S/$s" --help 2>/dev/null | grep -q "EXAMPLES" && ok "$s --help has EXAMPLES" || bad "$s --help lacks EXAMPLES"
   check "$s --help exits 0" 0 bash "$S/$s" --help
@@ -107,6 +107,41 @@ bash "$S/ff-doctor.sh" --offline >/dev/null 2>&1; RC=$?
 [ "$RC" = 0 ] || [ "$RC" = 10 ] && ok "doctor --offline runs (rc=$RC)" || bad "doctor --offline rc=$RC"
 bash "$S/ff-doctor.sh" --offline 2>/dev/null | grep -qE "^bin-jq	ok" && ok "doctor TSV output" || bad "doctor TSV output missing"
 
+# --- manifest (feature 1): created on first spawn, append, idempotent -----------
+M="$REPO/.fleetflow/r1/manifest.json"
+[ -f "$M" ] && ok "manifest created on first spawn" || bad "manifest not created"
+jq -e '.run=="r1" and .base=="main" and (.created_by|startswith("ff-spawn/"))' "$M" >/dev/null \
+  && ok "manifest header fields" || bad "manifest header wrong"
+# lane 'a' was spawned several times (cache-hit, --force, changed) yet stays 1 entry
+NA="$(jq '[.packets[]|select(.id=="a")]|length' "$M")"
+[ "$NA" = "1" ] && ok "manifest packet idempotent (one entry per id)" || bad "manifest has $NA 'a' entries"
+# every packet carries the Wave-1 fields the brief requires
+jq -e '.packets[]|select(.id=="a")|has("effort") and has("key") and has("max_turns") and has("worktree")' "$M" >/dev/null \
+  && ok "manifest packet has effort+key+max_turns+worktree" || bad "manifest packet fields missing"
+# ff-status surfaces the manifest summary
+bash "$S/ff-status.sh" --run r1 --repo "$REPO" 2>/dev/null | jq -e '.manifest.packet_count >= 1' >/dev/null \
+  && ok "status: surfaces manifest.packet_count" || bad "status: manifest summary missing"
+
+# --- ff-run.sh (feature 2): whole-run resume + status alias ---------------------
+check "ff-run: no subcommand -> 2" 2 bash "$S/ff-run.sh"
+check "ff-run: bad subcommand -> 2" 2 bash "$S/ff-run.sh" frobnicate --run r1 --repo "$REPO"
+check "ff-run: resume missing run -> 2" 2 bash "$S/ff-run.sh" resume --run nope --repo "$REPO"
+check "ff-run: resume no manifest -> 2" 2 bash "$S/ff-run.sh" resume --run r1 --repo "$TMP"
+# fresh run, two DISTINCT packets so replay order is unambiguous
+PA="$TMP/r2-a.txt"; echo "do A. FINAL REPLY: a" > "$PA"
+PB="$TMP/r2-b.txt"; echo "do B. FINAL REPLY: b" > "$PB"
+bash "$S/ff-spawn.sh" --run r2 --id a --brain sonnet --prompt-file "$PA" --repo "$REPO" --dry-run >/dev/null 2>&1
+bash "$S/ff-spawn.sh" --run r2 --id b --brain sonnet --prompt-file "$PB" --repo "$REPO" --dry-run >/dev/null 2>&1
+# resume re-spawns both -> both cache-hit -> exit 0; ids return in manifest order
+RR="$(bash "$S/ff-run.sh" resume --run r2 --repo "$REPO" 2>/dev/null)"; RC=$?
+[ "$RC" = "0" ] && ok "ff-run: all-cached resume exits 0" || bad "ff-run: resume rc=$RC"
+printf '%s' "$RR" | jq -e 'length==2 and .[0].id=="a" and .[1].id=="b" and all(.[]; .status=="cached")' >/dev/null \
+  && ok "ff-run: resume preserves packet order (no reorder drift)" || bad "ff-run: resume order wrong: $RR"
+# status subcommand == ff-status (compare stable fields; generated_at differs by design)
+SA="$(bash "$S/ff-run.sh" status --run r2 --repo "$REPO" 2>/dev/null | jq -c '{run,lanes:[.lanes[].id]}')"
+SB="$(bash "$S/ff-status.sh" --run r2 --repo "$REPO" 2>/dev/null | jq -c '{run,lanes:[.lanes[].id]}')"
+[ "$SA" = "$SB" ] && ok "ff-run status aliases ff-status" || bad "ff-run status != ff-status"
+
 echo "=== $PASS passed, $FAILN failed ==="
 [ "$FAILN" = 0 ] || exit 1
 exit 0