run.sh 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. #!/usr/bin/env bash
  2. # Offline behavioural suite for the fleetflow skill scripts.
  3. # Self-contained: builds a throwaway git repo, exercises spawn/collect/doctor
  4. # via --dry-run (no network, no workers). Exits nonzero on any failure.
  5. set -u
  6. HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  7. S="$HERE/../scripts"
  8. # heal the runner's own PATH too, or the jq guard below self-skips on hosts
  9. # with a stale env snapshot (the exact failure _env.sh exists to fix)
  10. [ -f "$S/_env.sh" ] && . "$S/_env.sh"
  11. PASS=0; FAILN=0
  12. ok() { PASS=$((PASS+1)); echo " PASS $1"; }
  13. bad() { FAILN=$((FAILN+1)); echo " FAIL $1"; }
  14. check() { # desc, expected-rc, cmd...
  15. local desc="$1" want="$2"; shift 2
  16. "$@" >/dev/null 2>&1; local got=$?
  17. [ "$got" = "$want" ] && ok "$desc (exit $want)" || bad "$desc: wanted $want got $got"
  18. }
  19. command -v jq >/dev/null 2>&1 || { echo "SKIP: jq unavailable on this platform"; exit 0; }
  20. command -v git >/dev/null 2>&1 || { echo "SKIP: git unavailable"; exit 0; }
  21. TMP="$(mktemp -d)"
  22. trap 'rm -rf "$TMP"' EXIT
  23. REPO="$TMP/repo"
  24. mkdir -p "$REPO" && git -C "$REPO" init -q -b main
  25. git -C "$REPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init
  26. PKT="$TMP/packet.txt"; echo "Do the thing. FINAL REPLY: one line." > "$PKT"
  27. # --- syntax + help ------------------------------------------------------------
  28. for s in ff-spawn.sh ff-collect.sh ff-doctor.sh ff-status.sh ff-run.sh ff-clean.sh ff-import.sh; do
  29. bash -n "$S/$s" 2>/dev/null && ok "syntax $s" || bad "syntax $s"
  30. bash "$S/$s" --help 2>/dev/null | grep -q "EXAMPLES" && ok "$s --help has EXAMPLES" || bad "$s --help lacks EXAMPLES"
  31. check "$s --help exits 0" 0 bash "$S/$s" --help
  32. done
  33. # --- usage validation -----------------------------------------------------------
  34. check "spawn: no args" 2 bash "$S/ff-spawn.sh"
  35. check "spawn: bad brain" 2 bash "$S/ff-spawn.sh" --run r1 --id a --brain gpt9 --prompt-file "$PKT" --repo "$REPO"
  36. check "spawn: bad run name" 2 bash "$S/ff-spawn.sh" --run "R 1" --id a --brain glm --prompt-file "$PKT" --repo "$REPO"
  37. check "spawn: missing prompt file" 2 bash "$S/ff-spawn.sh" --run r1 --id a --brain glm --prompt-file "$TMP/nope" --repo "$REPO"
  38. check "collect: no args" 2 bash "$S/ff-collect.sh" --repo "$REPO"
  39. check "doctor: bad flag" 2 bash "$S/ff-doctor.sh" --frobnicate
  40. # --- dry-run lifecycle -----------------------------------------------------------
  41. check "spawn: dry-run ok" 0 bash "$S/ff-spawn.sh" --run r1 --id a --brain sonnet --prompt-file "$PKT" --repo "$REPO" --dry-run
  42. [ -f "$REPO/.fleetflow/r1/a.result.json" ] && ok "artifact written" || bad "artifact missing"
  43. [ -f "$REPO/.fleetflow/r1/journal.jsonl" ] && ok "journal exists" || bad "journal missing"
  44. N_STARTED="$(jq -r 'select(.type=="started")|.key' "$REPO/.fleetflow/r1/journal.jsonl" | wc -l)"
  45. N_RESULT="$(jq -r 'select(.type=="result")|.key' "$REPO/.fleetflow/r1/journal.jsonl" | wc -l)"
  46. [ "$N_STARTED" -ge 1 ] && [ "$N_RESULT" -ge 1 ] && ok "journal has started+result" || bad "journal records missing"
  47. grep -q '^v2:' <(jq -r '.key' "$REPO/.fleetflow/r1/journal.jsonl") && ok "keys carry v2: prefix" || bad "key prefix wrong"
  48. grep -qs '^\.fleetflow/$' "$REPO/.git/info/exclude" && ok ".fleetflow gitignored via info/exclude" || bad "info/exclude not updated"
  49. [ -f "$REPO/.fleetflow/r1/main-baseline.txt" ] && ok "escape baseline snapshotted" || bad "baseline missing"
  50. grep -q "relative to cwd" "$REPO/.fleetflow/r1/a.prompt.txt" && ok "guard preamble injected" || bad "guard preamble absent"
  51. # resume: identical packet -> cache hit (exit 3); --force -> re-run (exit 0)
  52. check "spawn: cache hit on identical packet" 3 bash "$S/ff-spawn.sh" --run r1 --id a --brain sonnet --prompt-file "$PKT" --repo "$REPO" --dry-run
  53. check "spawn: --force re-runs" 0 bash "$S/ff-spawn.sh" --run r1 --id a --brain sonnet --prompt-file "$PKT" --repo "$REPO" --dry-run --force
  54. echo "changed" >> "$PKT"
  55. check "spawn: changed packet re-runs" 0 bash "$S/ff-spawn.sh" --run r1 --id a --brain sonnet --prompt-file "$PKT" --repo "$REPO" --dry-run
  56. # worktree lane creation
  57. check "spawn: worktree lane" 0 bash "$S/ff-spawn.sh" --run r1 --id lane --brain sonnet --prompt-file "$PKT" --repo "$REPO" --dry-run --worktree
  58. git -C "$REPO" show-ref --verify --quiet refs/heads/fleetflow/r1/lane && ok "lane branch created" || bad "lane branch missing"
  59. [ -d "$REPO/.fleetflow/r1/wt-lane" ] && ok "lane worktree created" || bad "lane worktree missing"
  60. # --- collect gating ---------------------------------------------------------------
  61. check "collect: dry-run result passes" 0 bash "$S/ff-collect.sh" --run r1 --id a --repo "$REPO"
  62. OUT="$(bash "$S/ff-collect.sh" --run r1 --id a --repo "$REPO" 2>/dev/null)"
  63. [ "$OUT" = "DRYRUN" ] && ok "collect prints final text" || bad "collect text wrong: '$OUT'"
  64. jq -nc '{is_error:true,result:"boom"}' > "$REPO/.fleetflow/r1/bad.result.json"
  65. jq -nc '{type:"result",key:"v2:x",id:"bad",brain:"sonnet",rc:0,artifact:"x"}' >> "$REPO/.fleetflow/r1/journal.jsonl"
  66. check "collect: is_error=true fails gate" 10 bash "$S/ff-collect.sh" --run r1 --id bad --repo "$REPO"
  67. check "collect: missing artifact" 3 bash "$S/ff-collect.sh" --run r1 --id ghost --repo "$REPO"
  68. # codex-style artifact: last.txt path with schema validation
  69. printf '{"verdict":"ok"}' > "$REPO/.fleetflow/r1/cx.last.txt"
  70. check "collect: codex last-message passes" 0 bash "$S/ff-collect.sh" --run r1 --id cx --repo "$REPO"
  71. check "collect: codex schema-valid JSON" 0 bash "$S/ff-collect.sh" --run r1 --id cx --repo "$REPO" --schema
  72. printf 'not json' > "$REPO/.fleetflow/r1/cx.last.txt"
  73. check "collect: codex schema-invalid fails" 10 bash "$S/ff-collect.sh" --run r1 --id cx --repo "$REPO" --schema
  74. # --- grok brain (non-Anthropic worker: grok -p envelope, no is_error) ----------
  75. GKP="$TMP/grok-packet.txt"; echo "grok task. FINAL REPLY: one line." > "$GKP"
  76. check "spawn: grok brain accepted (dry-run)" 0 bash "$S/ff-spawn.sh" --run rg --id g --brain grok --prompt-file "$GKP" --repo "$REPO" --dry-run
  77. jq -e '.text=="DRYRUN" and .stopReason=="EndTurn" and (has("is_error")|not)' "$REPO/.fleetflow/rg/g.result.json" >/dev/null \
  78. && ok "spawn: grok dry-run stub is a grok envelope (text+stopReason, no is_error)" || bad "spawn: grok stub shape wrong"
  79. jq -e '.packets[]|select(.id=="g")|.brain=="grok"' "$REPO/.fleetflow/rg/manifest.json" >/dev/null \
  80. && ok "manifest records brain=grok" || bad "manifest grok brain wrong"
  81. check "collect: grok envelope passes gate" 0 bash "$S/ff-collect.sh" --run rg --id g --repo "$REPO"
  82. GOUT="$(bash "$S/ff-collect.sh" --run rg --id g --repo "$REPO" 2>/dev/null)"
  83. [ "$GOUT" = "DRYRUN" ] && ok "collect: grok prints .text" || bad "collect: grok text wrong: '$GOUT'"
  84. # grok --schema lane prefers the already-parsed .structuredOutput
  85. jq -nc '{text:"ignored prose",stopReason:"EndTurn",structuredOutput:{verdict:"ok"}}' > "$REPO/.fleetflow/rg/gs.result.json"
  86. jq -nc '{type:"result",key:"v2:gs",id:"gs",brain:"grok",rc:0,artifact:"x"}' >> "$REPO/.fleetflow/rg/journal.jsonl"
  87. GSOUT="$(bash "$S/ff-collect.sh" --run rg --id gs --repo "$REPO" --schema 2>/dev/null)"; GSRC=$?
  88. { [ "$GSRC" = "0" ] && printf '%s' "$GSOUT" | jq -e '.verdict=="ok"' >/dev/null; } \
  89. && ok "collect: grok --schema returns structuredOutput" || bad "collect: grok structuredOutput wrong (rc=$GSRC out=$GSOUT)"
  90. # grok schema fallback: no structuredOutput, .text carries fenced JSON
  91. jq -nc '{text:"```json\n{\"verdict\":\"fb\"}\n```",stopReason:"EndTurn"}' > "$REPO/.fleetflow/rg/gf.result.json"
  92. jq -nc '{type:"result",key:"v2:gf",id:"gf",brain:"grok",rc:0,artifact:"x"}' >> "$REPO/.fleetflow/rg/journal.jsonl"
  93. GFOUT="$(bash "$S/ff-collect.sh" --run rg --id gf --repo "$REPO" --schema 2>/dev/null)"; GFRC=$?
  94. { [ "$GFRC" = "0" ] && printf '%s' "$GFOUT" | jq -e '.verdict=="fb"' >/dev/null; } \
  95. && ok "collect: grok --schema falls back to fenced .text" || bad "collect: grok fallback wrong (rc=$GFRC)"
  96. # grok empty-text envelope fails the gate
  97. jq -nc '{text:"",stopReason:"EndTurn"}' > "$REPO/.fleetflow/rg/ge.result.json"
  98. jq -nc '{type:"result",key:"v2:ge",id:"ge",brain:"grok",rc:0,artifact:"x"}' >> "$REPO/.fleetflow/rg/journal.jsonl"
  99. check "collect: grok empty text fails gate" 10 bash "$S/ff-collect.sh" --run rg --id ge --repo "$REPO"
  100. # ff-doctor surfaces a bin-grok structural check
  101. bash "$S/ff-doctor.sh" --offline 2>/dev/null | grep -qE "^bin-grok (ok|advisory)" \
  102. && ok "doctor: reports bin-grok check" || bad "doctor: bin-grok check missing"
  103. # --- phases --------------------------------------------------------------------------
  104. check "spawn: --phase accepted" 0 bash "$S/ff-spawn.sh" --run r1 --id ver --brain opus --phase verify --prompt-file "$PKT" --repo "$REPO" --dry-run
  105. bash "$S/ff-status.sh" --run r1 --repo "$REPO" 2>/dev/null | jq -e '.lanes[] | select(.id=="ver") | .phase=="verify"' >/dev/null \
  106. && ok "status: phase propagates" || bad "status: phase missing"
  107. bash "$S/ff-status.sh" --run r1 --repo "$REPO" 2>/dev/null | jq -e '.lanes[] | select(.id=="a") | .phase=="build"' >/dev/null \
  108. && ok "status: default phase is build" || bad "status: default phase wrong"
  109. # --- status feed --------------------------------------------------------------------
  110. check "status: no args" 2 bash "$S/ff-status.sh"
  111. check "status: watch without out" 2 bash "$S/ff-status.sh" --run r1 --repo "$REPO" --watch 3
  112. bash "$S/ff-status.sh" --run r1 --repo "$REPO" 2>/dev/null | jq -e '.lanes | length >= 2' >/dev/null \
  113. && ok "status: emits lanes JSON" || bad "status: JSON invalid"
  114. bash "$S/ff-status.sh" --run r1 --repo "$REPO" 2>/dev/null | jq -e '.lanes[] | select(.id=="a") | .state=="done"' >/dev/null \
  115. && ok "status: dry-run lane state done" || bad "status: lane state wrong"
  116. [ -f "$HERE/../assets/ff-monitor.html" ] && grep -q "status.json" "$HERE/../assets/ff-monitor.html" \
  117. && ok "monitor asset present + polls status.json" || bad "monitor asset missing"
  118. # torn-write guard: an empty/missing-lanes payload is treated as a fetch miss
  119. grep -q "torn-write guard" "$HERE/../assets/ff-monitor.html" \
  120. && ok "monitor: empty-lanes torn-write guard present" || bad "monitor: torn-write guard missing"
  121. grep -q "d.lanes.length === 0" "$HERE/../assets/ff-monitor.html" \
  122. && ok "monitor: guards on empty lanes array" || bad "monitor: empty-lanes guard logic missing"
  123. # --- escape guard ------------------------------------------------------------------
  124. check "escape guard: clean main" 0 bash "$S/ff-collect.sh" --check-main-clean --run r1 --repo "$REPO"
  125. echo rogue > "$REPO/rogue.txt"
  126. check "escape guard: detects new file" 12 bash "$S/ff-collect.sh" --check-main-clean --run r1 --repo "$REPO"
  127. rm "$REPO/rogue.txt"
  128. check "escape guard: clean again" 0 bash "$S/ff-collect.sh" --check-main-clean --run r1 --repo "$REPO"
  129. # --- doctor (offline only; never hits network) ---------------------------------------
  130. bash "$S/ff-doctor.sh" --offline >/dev/null 2>&1; RC=$?
  131. [ "$RC" = 0 ] || [ "$RC" = 10 ] && ok "doctor --offline runs (rc=$RC)" || bad "doctor --offline rc=$RC"
  132. bash "$S/ff-doctor.sh" --offline 2>/dev/null | grep -qE "^bin-jq ok" && ok "doctor TSV output" || bad "doctor TSV output missing"
  133. # --- manifest (feature 1): created on first spawn, append, idempotent -----------
  134. M="$REPO/.fleetflow/r1/manifest.json"
  135. [ -f "$M" ] && ok "manifest created on first spawn" || bad "manifest not created"
  136. jq -e '.run=="r1" and .base=="main" and (.created_by|startswith("ff-spawn/"))' "$M" >/dev/null \
  137. && ok "manifest header fields" || bad "manifest header wrong"
  138. # lane 'a' was spawned several times (cache-hit, --force, changed) yet stays 1 entry
  139. NA="$(jq '[.packets[]|select(.id=="a")]|length' "$M")"
  140. [ "$NA" = "1" ] && ok "manifest packet idempotent (one entry per id)" || bad "manifest has $NA 'a' entries"
  141. # every packet carries the Wave-1 fields the brief requires
  142. jq -e '.packets[]|select(.id=="a")|has("effort") and has("key") and has("max_turns") and has("worktree")' "$M" >/dev/null \
  143. && ok "manifest packet has effort+key+max_turns+worktree" || bad "manifest packet fields missing"
  144. # ff-status surfaces the manifest summary
  145. bash "$S/ff-status.sh" --run r1 --repo "$REPO" 2>/dev/null | jq -e '.manifest.packet_count >= 1' >/dev/null \
  146. && ok "status: surfaces manifest.packet_count" || bad "status: manifest summary missing"
  147. # --- ff-run.sh (feature 2): whole-run resume + status alias ---------------------
  148. check "ff-run: no subcommand -> 2" 2 bash "$S/ff-run.sh"
  149. check "ff-run: bad subcommand -> 2" 2 bash "$S/ff-run.sh" frobnicate --run r1 --repo "$REPO"
  150. check "ff-run: resume missing run -> 2" 2 bash "$S/ff-run.sh" resume --run nope --repo "$REPO"
  151. check "ff-run: resume no manifest -> 2" 2 bash "$S/ff-run.sh" resume --run r1 --repo "$TMP"
  152. # fresh run, two DISTINCT packets so replay order is unambiguous
  153. PA="$TMP/r2-a.txt"; echo "do A. FINAL REPLY: a" > "$PA"
  154. PB="$TMP/r2-b.txt"; echo "do B. FINAL REPLY: b" > "$PB"
  155. bash "$S/ff-spawn.sh" --run r2 --id a --brain sonnet --prompt-file "$PA" --repo "$REPO" --dry-run >/dev/null 2>&1
  156. bash "$S/ff-spawn.sh" --run r2 --id b --brain sonnet --prompt-file "$PB" --repo "$REPO" --dry-run >/dev/null 2>&1
  157. # resume re-spawns both -> both cache-hit -> exit 0; ids return in manifest order
  158. RR="$(bash "$S/ff-run.sh" resume --run r2 --repo "$REPO" 2>/dev/null)"; RC=$?
  159. [ "$RC" = "0" ] && ok "ff-run: all-cached resume exits 0" || bad "ff-run: resume rc=$RC"
  160. printf '%s' "$RR" | jq -e 'length==2 and .[0].id=="a" and .[1].id=="b" and all(.[]; .status=="cached")' >/dev/null \
  161. && ok "ff-run: resume preserves packet order (no reorder drift)" || bad "ff-run: resume order wrong: $RR"
  162. # status subcommand == ff-status (compare stable fields; generated_at differs by design)
  163. SA="$(bash "$S/ff-run.sh" status --run r2 --repo "$REPO" 2>/dev/null | jq -c '{run,lanes:[.lanes[].id]}')"
  164. SB="$(bash "$S/ff-status.sh" --run r2 --repo "$REPO" 2>/dev/null | jq -c '{run,lanes:[.lanes[].id]}')"
  165. [ "$SA" = "$SB" ] && ok "ff-run status aliases ff-status" || bad "ff-run status != ff-status"
  166. # --- schema fence-strip (feature 3) --------------------------------------------
  167. # a result whose text is fenced JSON must still validate (--schema strips fences)
  168. jq -nc '{is_error:false,result:"```json\n{\"verdict\":\"ok\"}\n```"}' > "$REPO/.fleetflow/r1/fence.result.json"
  169. jq -nc '{type:"result",key:"v2:fence",id:"fence",brain:"sonnet",rc:0,artifact:"x"}' >> "$REPO/.fleetflow/r1/journal.jsonl"
  170. FOUT="$(bash "$S/ff-collect.sh" --run r1 --id fence --repo "$REPO" --schema 2>/dev/null)"; FRC=$?
  171. [ "$FRC" = "0" ] && ok "collect: fence-strip lets fenced JSON validate" || bad "collect: fence-strip failed rc=$FRC"
  172. printf '%s' "$FOUT" | jq -e '.verdict=="ok"' >/dev/null && ok "collect: fence-strip returns inner JSON" || bad "collect: fence-strip output wrong"
  173. # --- schema --repair seam (feature 3) ------------------------------------------
  174. # bad result + FLEETFLOW_REPAIR_DRYRUN: do_repair saves <id>.invalid.txt and
  175. # respawns a <id>-repair lane. The dry-run lane replies "DRYRUN" (not JSON), so
  176. # the repair gate fails -> exit 10; we assert the SEAM fired, not a happy path.
  177. jq -nc '{is_error:false,result:"this is not json"}' > "$REPO/.fleetflow/r1/rp.result.json"
  178. jq -nc '{type:"result",key:"v2:rp",id:"rp",brain:"sonnet",rc:0,artifact:"x"}' >> "$REPO/.fleetflow/r1/journal.jsonl"
  179. FLEETFLOW_REPAIR_DRYRUN=1 bash "$S/ff-collect.sh" --run r1 --id rp --repo "$REPO" --schema --repair >/dev/null 2>&1; RPRC=$?
  180. [ "$RPRC" = "10" ] && ok "collect: --repair exits 10 when corrected output invalid" || bad "collect: --repair rc=$RPRC (want 10)"
  181. [ -f "$REPO/.fleetflow/r1/rp.invalid.txt" ] && ok "collect: --repair saved <id>.invalid.txt" || bad "collect: invalid.txt missing"
  182. NREP="$(jq -r 'select(.type=="result" and .id=="rp-repair")|.id' "$REPO/.fleetflow/r1/journal.jsonl" | wc -l | tr -d ' ')"
  183. [ "$NREP" -ge 1 ] && ok "collect: --repair respawned rp-repair lane" || bad "collect: no repair lane spawned"
  184. grep -q 'corrected JSON' "$REPO/.fleetflow/r1/rp-repair.prompt-src.txt" 2>/dev/null \
  185. && ok "collect: --repair lane got the corrective prompt" || bad "collect: repair prompt missing"
  186. # --- FF_VERSION in journal (feature 4) -----------------------------------------
  187. grep -q '"v":"1.1.0"' "$REPO/.fleetflow/r1/journal.jsonl" && ok "journal records FF_VERSION 1.1.0" || bad "journal missing FF_VERSION"
  188. # every operational script pins the same version (version-skew spine)
  189. VS=0
  190. for s in ff-spawn.sh ff-collect.sh ff-status.sh ff-doctor.sh ff-run.sh ff-clean.sh ff-import.sh; do
  191. grep -q '^FF_VERSION="1.1.0"$' "$S/$s" || VS=1
  192. done
  193. [ "$VS" = "0" ] && ok "all scripts pin FF_VERSION=1.1.0" || bad "version skew across scripts"
  194. # --- effort lever (feature 5): effort is part of the cache key ------------------
  195. EP="$TMP/effort.txt"; echo "effort test. FINAL REPLY: e" > "$EP"
  196. check "spawn: effort lane first run -> 0" 0 bash "$S/ff-spawn.sh" --run r3 --id e --brain sonnet --prompt-file "$EP" --repo "$REPO" --dry-run
  197. check "spawn: effort lane identical -> cached" 3 bash "$S/ff-spawn.sh" --run r3 --id e --brain sonnet --prompt-file "$EP" --repo "$REPO" --dry-run
  198. # changing ONLY the effort must bust the cache (effort is baked into the OPTS key)
  199. check "spawn: effort change -> cache miss" 0 bash "$S/ff-spawn.sh" --run r3 --id e --brain sonnet --prompt-file "$EP" --repo "$REPO" --dry-run --effort high
  200. jq -e '.packets[]|select(.id=="e")|.effort=="high"' "$REPO/.fleetflow/r3/manifest.json" >/dev/null \
  201. && ok "manifest records effort=high" || bad "manifest effort field wrong"
  202. # --- cache/tmp redirect (feature 7) --------------------------------------------
  203. CRT="$TMP/ffcache"; CDP="$TMP/cdp.txt"; echo "cache test. FINAL REPLY: c" > "$CDP"
  204. FLEETFLOW_CACHE_ROOT="$CRT" bash "$S/ff-spawn.sh" --run r4 --id c --brain sonnet --prompt-file "$CDP" --repo "$REPO" --dry-run >/dev/null 2>&1
  205. [ -d "$CRT/r4-c" ] && ok "spawn: cache dir created under FLEETFLOW_CACHE_ROOT" || bad "spawn: cache dir not redirected to FLEETFLOW_CACHE_ROOT"
  206. # --- ff-clean.sh (feature 8): autoclean lanes + cache --------------------------
  207. check "ff-clean: usage -> 2" 2 bash "$S/ff-clean.sh"
  208. check "ff-clean: no such run -> 2" 2 bash "$S/ff-clean.sh" --run ghost --repo "$REPO"
  209. # fresh run, cache redirected, three DISTINCT-prompt worktree lanes (distinct so
  210. # they don't cache-hit and skip worktree creation)
  211. CLEANROOT="$TMP/cleancache"
  212. for lid in cleanlane keeplane dirtlane; do
  213. echo "clean-$lid task. FINAL REPLY: $lid" > "$TMP/clean-$lid.txt"
  214. FLEETFLOW_CACHE_ROOT="$CLEANROOT" bash "$S/ff-spawn.sh" --run rc --id "$lid" --brain sonnet \
  215. --prompt-file "$TMP/clean-$lid.txt" --repo "$REPO" --dry-run --worktree >/dev/null 2>&1
  216. done
  217. # keeplane gets a real commit (must survive every clean); dirtlane gets untracked junk
  218. ( cd "$REPO/.fleetflow/rc/wt-keeplane" && git -c user.email=t@t -c user.name=t commit -q --allow-empty -m "real work" )
  219. echo "junk" > "$REPO/.fleetflow/rc/wt-dirtlane/junk.txt"
  220. [ -d "$CLEANROOT/rc-cleanlane" ] && ok "ff-clean: setup created cache dir" || bad "ff-clean: setup cache dir missing"
  221. # no --force: cleanlane removed, dirtlane kept (dirty), keeplane kept (1 commit).
  222. # pass the SAME FLEETFLOW_CACHE_ROOT to ff-clean so it finds the redirected dirs.
  223. CL="$(FLEETFLOW_CACHE_ROOT="$CLEANROOT" bash "$S/ff-clean.sh" --run rc --repo "$REPO" 2>/dev/null)"
  224. printf '%s' "$CL" | awk -F'\t' '$1=="cleanlane"&&$2=="removed"{f=1} END{exit !f}' && ok "ff-clean: removes zero-commit clean lane" || bad "ff-clean: clean lane not removed"
  225. printf '%s' "$CL" | awk -F'\t' '$1=="dirtlane"&&$2=="kept"&&$3~/dirty/{f=1} END{exit !f}' && ok "ff-clean: keeps dirty zero-commit lane (no --force)" || bad "ff-clean: dirty lane mishandled"
  226. printf '%s' "$CL" | awk -F'\t' '$1=="keeplane"&&$2=="kept"&&$3~/1 commits/{f=1} END{exit !f}' && ok "ff-clean: keeps committed lane" || bad "ff-clean: committed lane mishandled"
  227. [ -d "$REPO/.fleetflow/rc/wt-cleanlane" ] && bad "ff-clean: clean worktree dir remains" || ok "ff-clean: clean worktree removed"
  228. git -C "$REPO" show-ref --verify --quiet refs/heads/fleetflow/rc/keeplane && ok "ff-clean: committed branch preserved" || bad "ff-clean: keeper branch deleted"
  229. # --force: dirtlane now removed; keeplane STILL kept (committed lanes are never force-removed)
  230. CL2="$(FLEETFLOW_CACHE_ROOT="$CLEANROOT" bash "$S/ff-clean.sh" --run rc --repo "$REPO" --force 2>/dev/null)"
  231. printf '%s' "$CL2" | awk -F'\t' '$1=="dirtlane"&&$2=="removed"{f=1} END{exit !f}' && ok "ff-clean: --force removes dirty zero-commit lane" || bad "ff-clean: --force dirty mishandled"
  232. printf '%s' "$CL2" | awk -F'\t' '$1=="keeplane"&&$2=="kept"{f=1} END{exit !f}' && ok "ff-clean: --force still keeps committed lane" || bad "ff-clean: --force removed committed lane!"
  233. [ -d "$CLEANROOT/rc-cleanlane" ] || [ -d "$CLEANROOT/rc-dirtlane" ] || [ -d "$CLEANROOT/rc-keeplane" ] \
  234. && bad "ff-clean: cache dir remains" || ok "ff-clean: cache dirs removed"
  235. # --- state derivation (feature C): last journal record wins -----------------------
  236. # a respawn appends "started" AFTER an old "result" -> the lane is running again,
  237. # NOT done/failed (the last-result-wins bug this fixes).
  238. RD5="$REPO/.fleetflow/r5"; mkdir -p "$RD5"
  239. : > "$RD5/z.prompt.txt" # mtime source for elapsed
  240. printf '%s\n' \
  241. '{"type":"started","key":"v2:z","id":"z","brain":"sonnet","phase":"build","v":"1.1.0"}' \
  242. '{"type":"result","key":"v2:z","id":"z","brain":"sonnet","rc":0,"artifact":"x"}' \
  243. '{"type":"started","key":"v2:z","id":"z","brain":"sonnet","phase":"build","v":"1.1.0"}' \
  244. > "$RD5/journal.jsonl"
  245. bash "$S/ff-status.sh" --run r5 --repo "$REPO" 2>/dev/null \
  246. | jq -e '.lanes[]|select(.id=="z")|.state=="running"' >/dev/null \
  247. && ok "status: respawned lane (started,result,started) is running" || bad "status: respawned lane state wrong"
  248. # regression guard: same lane with result-last is still done (common path unchanged)
  249. printf '%s\n' \
  250. '{"type":"started","key":"v2:z","id":"z","brain":"sonnet","phase":"build","v":"1.1.0"}' \
  251. '{"type":"result","key":"v2:z","id":"z","brain":"sonnet","rc":0,"artifact":"x"}' \
  252. > "$RD5/journal.jsonl"
  253. bash "$S/ff-status.sh" --run r5 --repo "$REPO" 2>/dev/null \
  254. | jq -e '.lanes[]|select(.id=="z")|.state=="done"' >/dev/null \
  255. && ok "status: result-last lane is still done (no regression)" || bad "status: result-last state wrong"
  256. # --- ff-import.sh (feature B): native Workflow run import ------------------------
  257. # build a synthetic native wf_ dir: journal.jsonl (started/result keyed by
  258. # agentId) + two agent transcripts (one string content, one content-array).
  259. WFD="$TMP/wf_ab12cd34-ef"; mkdir -p "$WFD"
  260. printf '%s\n' \
  261. '{"type":"started","key":"v2:aaaa","agentId":"a01cb5f01fadf5610"}' \
  262. '{"type":"result","key":"v2:aaaa","agentId":"a01cb5f01fadf5610","result":{"verdict":"ok","score":7}}' \
  263. '{"type":"started","key":"v2:bbbb","agentId":"a02deadbeef00000"}' \
  264. > "$WFD/journal.jsonl"
  265. jq -nc '{type:"user",message:{role:"user",content:"Refute the claim that X is safe."}}' \
  266. > "$WFD/agent-a01cb5f01fadf5610.jsonl"
  267. jq -nc '{type:"user",message:{role:"user",content:[{type:"text",text:"Find any bugs in module Y."}]}}' \
  268. > "$WFD/agent-a02deadbeef00000.jsonl"
  269. IMP="$(bash "$S/ff-import.sh" --wf "$WFD" --run imp1 --repo "$REPO" 2>/dev/null)"; IRC=$?
  270. [ "$IRC" = "0" ] && ok "ff-import: exits 0 on import" || bad "ff-import: rc=$IRC"
  271. printf '%s' "$IMP" | awk -F'\t' '$1=="a01cb5f01fadf5610"&&$2=="imported"{f=1} END{exit !f}' \
  272. && ok "ff-import: completed agent reported imported" || bad "ff-import: imported TSV wrong"
  273. printf '%s' "$IMP" | awk -F'\t' '$1=="a02deadbeef00000"&&$2=="incomplete"{f=1} END{exit !f}' \
  274. && ok "ff-import: started-only agent reported incomplete" || bad "ff-import: incomplete TSV wrong"
  275. PC="$(printf '%s' "$IMP" | awk -F'\t' '$1=="a01cb5f01fadf5610"{print $3}')"
  276. [ -n "$PC" ] && [ "$PC" -gt 0 ] 2>/dev/null && ok "ff-import: prompt_chars > 0" || bad "ff-import: prompt_chars wrong ($PC)"
  277. IRD="$REPO/.fleetflow/imp1"
  278. [ -f "$IRD/a01cb5f01fadf5610.prompt.txt" ] && ok "ff-import: wrote prompt.txt (completed)" || bad "ff-import: prompt.txt missing"
  279. grep -q "Refute the claim" "$IRD/a01cb5f01fadf5610.prompt.txt" && ok "ff-import: prompt extracted (string content)" || bad "ff-import: string-content prompt wrong"
  280. grep -q "Find any bugs" "$IRD/a02deadbeef00000.prompt.txt" && ok "ff-import: prompt extracted (content array)" || bad "ff-import: array-content prompt wrong"
  281. [ -f "$IRD/a01cb5f01fadf5610.result.json" ] && ok "ff-import: wrote result.json (completed)" || bad "ff-import: result.json missing"
  282. [ ! -f "$IRD/a02deadbeef00000.result.json" ] && ok "ff-import: incomplete agent has no result.json" || bad "ff-import: incomplete got result.json"
  283. jq -e '.is_error==false and (.result|fromjson|.verdict=="ok" and .score==7)' "$IRD/a01cb5f01fadf5610.result.json" >/dev/null \
  284. && ok "ff-import: result.json wraps native result (tojson)" || bad "ff-import: result.json shape wrong"
  285. NJ="$IRD/journal.jsonl"
  286. [ "$(jq -r 'select(.type=="result" and .id=="a01cb5f01fadf5610")|.brain' "$NJ")" = "native" ] \
  287. && ok "ff-import: journal result brain=native" || bad "ff-import: journal brain wrong"
  288. # phase lives on the started record (same convention as ff-spawn), not the result
  289. [ "$(jq -r 'select(.type=="started" and .id=="a01cb5f01fadf5610")|.phase' "$NJ")" = "imported" ] \
  290. && ok "ff-import: journal phase=imported" || bad "ff-import: journal phase wrong"
  291. [ -z "$(jq -r 'select(.type=="result" and .id=="a02deadbeef00000")' "$NJ")" ] \
  292. && ok "ff-import: incomplete agent has no result record" || bad "ff-import: incomplete got a result record"
  293. jq -e --arg wf "$WFD" '.packets[]|select(.id=="a01cb5f01fadf5610")|.brain=="native" and .imported_from==$wf' "$IRD/manifest.json" >/dev/null \
  294. && ok "ff-import: manifest packet brain=native + imported_from" || bad "ff-import: manifest packet wrong"
  295. # nothing to import -> exit 3
  296. WFE="$TMP/wf_empty"; mkdir -p "$WFE"; : > "$WFE/journal.jsonl"
  297. check "ff-import: empty wf journal -> 3" 3 bash "$S/ff-import.sh" --wf "$WFE" --run imp2 --repo "$REPO"
  298. # ff-run resume SKIPS imported native packets (terminal, not replayable)
  299. RRN="$(bash "$S/ff-run.sh" resume --run imp1 --repo "$REPO" 2>/dev/null)"; RCRR=$?
  300. [ "$RCRR" = "0" ] && ok "ff-run: resume skips native packets (exit 0)" || bad "ff-run: resume on imported run rc=$RCRR"
  301. printf '%s' "$RRN" | jq -e 'any(.[]; .id=="a01cb5f01fadf5610" and .status=="imported")' >/dev/null \
  302. && ok "ff-run: native packet reported imported (skipped)" || bad "ff-run: native packet not skipped"
  303. echo "=== $PASS passed, $FAILN failed ==="
  304. [ "$FAILN" = 0 ] || exit 1
  305. exit 0