ff-spawn.sh 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. #!/usr/bin/env bash
  2. # ff-spawn.sh - spawn one fleetflow worker lane (GLM / Codex / Anthropic brain).
  3. #
  4. # Creates the run dir + optional worktree lane, injects the guard preamble,
  5. # journals a hash-keyed started/result pair (native-Workflow-style replay
  6. # cache), launches the brain-appropriate process, and writes its artifacts.
  7. # stdout: the artifact path (data). stderr: progress chatter.
  8. #
  9. # Exit codes: 0 ok | 2 usage | 3 cache hit (cached artifact path on stdout)
  10. # 5 missing dependency | 10 worker failed
  11. set -u
  12. . "$(dirname "${BASH_SOURCE[0]}")/_env.sh"
  13. FF_VERSION="1.1.0"
  14. usage() {
  15. cat <<'EOF'
  16. Usage: ff-spawn.sh --run NAME --id ID --brain BRAIN --prompt-file FILE
  17. [--worktree] [--base BRANCH] [--repo PATH] [--max-turns N]
  18. [--effort low|medium|high|max] [--schema FILE] [--no-guard]
  19. [--force] [--dry-run]
  20. --run NAME run name (groups lanes; [a-z0-9-]+)
  21. --id ID lane id within the run ([a-z0-9-]+)
  22. --brain BRAIN glm | codex | grok | sonnet | opus | haiku | fable
  23. --prompt-file F packet file (guard preamble is prepended unless --no-guard)
  24. --phase NAME progress-group label (default: build) - display only
  25. --worktree give the worker its own worktree lane (branch fleetflow/RUN/ID)
  26. --base BRANCH worktree base (default: main, falls back to HEAD)
  27. --repo PATH repo root (default: git toplevel of cwd)
  28. --max-turns N worker turn cap (default: 100)
  29. --effort LEVEL reasoning effort lever: low|medium|high|max (default: unset =
  30. inherit the brain's own default). GLM -> FLEET_WORKER_EFFORT;
  31. claude brains -> --settings effortLevel; codex -> model_reasoning_effort.
  32. Effort IS part of the cache key (different effort = different run).
  33. --schema FILE JSON Schema for the final answer (codex: native
  34. --output-schema; other brains: appended to the prompt)
  35. --no-guard skip the guard preamble injection
  36. --force ignore a journal cache hit and re-run
  37. --dry-run do not launch a worker; write a stub result (for tests/planning)
  38. EXAMPLES
  39. ff-spawn.sh --run audit --id ts-refresh --brain glm --worktree \
  40. --prompt-file packets/ts.txt
  41. ff-spawn.sh --run audit --id dissent-1 --brain codex --effort high \
  42. --prompt-file packets/refute.txt --schema verdict.schema.json
  43. ff-spawn.sh --run audit --id judge --brain opus --effort max --prompt-file packets/judge.txt
  44. EOF
  45. }
  46. err() { echo "ff-spawn: $*" >&2; }
  47. RUN="" ID="" BRAIN="" PROMPT_FILE="" WORKTREE=0 BASE="main" REPO=""
  48. MAX_TURNS=100 SCHEMA="" GUARD=1 FORCE=0 DRYRUN=0 PHASE="build" EFFORT=""
  49. while [ $# -gt 0 ]; do
  50. case "$1" in
  51. --run) RUN="${2:-}"; shift 2 ;;
  52. --phase) PHASE="${2:-}"; shift 2 ;;
  53. --id) ID="${2:-}"; shift 2 ;;
  54. --brain) BRAIN="${2:-}"; shift 2 ;;
  55. --prompt-file) PROMPT_FILE="${2:-}"; shift 2 ;;
  56. --worktree) WORKTREE=1; shift ;;
  57. --base) BASE="${2:-}"; shift 2 ;;
  58. --repo) REPO="${2:-}"; shift 2 ;;
  59. --max-turns) MAX_TURNS="${2:-}"; shift 2 ;;
  60. --effort) EFFORT="${2:-}"; shift 2 ;;
  61. --schema) SCHEMA="${2:-}"; shift 2 ;;
  62. --no-guard) GUARD=0; shift ;;
  63. --force) FORCE=1; shift ;;
  64. --dry-run) DRYRUN=1; shift ;;
  65. -h|--help) usage; exit 0 ;;
  66. *) err "unknown argument: $1"; usage >&2; exit 2 ;;
  67. esac
  68. done
  69. case "$BRAIN" in glm|codex|grok|sonnet|opus|haiku|fable) ;; *) err "invalid --brain '$BRAIN'"; exit 2 ;; esac
  70. case "$EFFORT" in ""|low|medium|high|max) ;; *) err "invalid --effort '$EFFORT' (low|medium|high|max)"; exit 2 ;; esac
  71. echo "$RUN" | grep -qE '^[a-z0-9-]+$' || { err "invalid --run"; exit 2; }
  72. echo "$ID" | grep -qE '^[a-z0-9-]+$' || { err "invalid --id"; exit 2; }
  73. [ -f "$PROMPT_FILE" ] || { err "prompt file not found: $PROMPT_FILE"; exit 2; }
  74. [ -z "$SCHEMA" ] || [ -f "$SCHEMA" ] || { err "schema file not found: $SCHEMA"; exit 2; }
  75. command -v jq >/dev/null || { err "jq required"; exit 5; }
  76. command -v git >/dev/null || { err "git required"; exit 5; }
  77. [ -n "$REPO" ] || REPO="$(git rev-parse --show-toplevel 2>/dev/null)" || true
  78. [ -n "$REPO" ] && [ -d "$REPO" ] || { err "not in a git repo (or --repo invalid)"; exit 2; }
  79. RUNDIR="$REPO/.fleetflow/$RUN"
  80. mkdir -p "$RUNDIR"
  81. # keep the scratch tree out of git without touching the repo's .gitignore
  82. EXCL="$(git -C "$REPO" rev-parse --absolute-git-dir)/info/exclude"
  83. mkdir -p "$(dirname "$EXCL")"
  84. grep -qs '^\.fleetflow/$' "$EXCL" 2>/dev/null || echo ".fleetflow/" >> "$EXCL"
  85. # escape-guard baseline: snapshot the main checkout's status once per run
  86. BASELINE="$RUNDIR/main-baseline.txt"
  87. [ -f "$BASELINE" ] || git -C "$REPO" status --porcelain > "$BASELINE" 2>/dev/null
  88. # --- build the effective prompt ---------------------------------------------
  89. SENT="$RUNDIR/$ID.prompt.txt"
  90. : > "$SENT"
  91. if [ "$GUARD" = 1 ]; then
  92. PRE="$(dirname "${BASH_SOURCE[0]}")/../assets/guard-preamble.txt"
  93. [ -f "$PRE" ] && { cat "$PRE" >> "$SENT"; echo >> "$SENT"; }
  94. fi
  95. cat "$PROMPT_FILE" >> "$SENT"
  96. # codex and grok take a native structured-output flag (--output-schema /
  97. # --json-schema), so their schema is passed out-of-band, not appended to the
  98. # prompt. Every other brain gets the schema embedded and validated at collect.
  99. if [ -n "$SCHEMA" ] && [ "$BRAIN" != "codex" ] && [ "$BRAIN" != "grok" ]; then
  100. { echo; echo "FINAL REPLY MUST be a single JSON object valid against this schema:";
  101. cat "$SCHEMA"; } >> "$SENT"
  102. fi
  103. # --- journal: hash-keyed replay cache (native Workflow pattern) --------------
  104. # effort is part of the key (different effort = a different run), per Wave 1.
  105. OPTS="turns=$MAX_TURNS|wt=$WORKTREE|schema=$( [ -n "$SCHEMA" ] && basename "$SCHEMA" )|effort=$EFFORT"
  106. KEY="v2:$( { printf '%s\n' "$BRAIN"; cat "$SENT"; printf '%s' "$OPTS"; } | sha256sum | cut -d' ' -f1)"
  107. JOURNAL="$RUNDIR/journal.jsonl"
  108. # --- run manifest (orchestrator-side packet metadata; ff-run replays it) ----
  109. # Created on first spawn; each spawn upserts its packet by id (idempotent).
  110. MANIFEST="$RUNDIR/manifest.json"
  111. prompt_abs() {
  112. local d
  113. d="$(cd "$(dirname "$PROMPT_FILE")" 2>/dev/null && pwd)" || { printf '%s' "$PROMPT_FILE"; return; }
  114. printf '%s/%s' "$d" "$(basename "$PROMPT_FILE")"
  115. }
  116. WT_JSON="false"; [ "$WORKTREE" = 1 ] && WT_JSON="true"
  117. MENTRY="$(jq -nc --arg id "$ID" --arg b "$BRAIN" --arg p "$PHASE" --arg pf "$(prompt_abs)" \
  118. --argjson wt "$WT_JSON" --argjson mt "$MAX_TURNS" --arg e "$EFFORT" --arg s "${SCHEMA:-}" --arg k "$KEY" \
  119. '{id:$id,brain:$b,phase:$p,prompt_file:$pf,worktree:$wt,max_turns:$mt,effort:$e,schema:$s,key:$k}')"
  120. if [ ! -s "$MANIFEST" ]; then
  121. jq -nc --arg run "$RUN" --arg base "$BASE" --arg by "ff-spawn/$FF_VERSION" \
  122. --argjson entry "$MENTRY" --arg phase "$PHASE" \
  123. '{run:$run,base:$base,created_by:$by,phases:[$phase],packets:[$entry]}' > "$MANIFEST"
  124. else
  125. jq --argjson entry "$MENTRY" --arg id "$ID" --arg phase "$PHASE" \
  126. '.packets = ((.packets // []) | map(select(.id != $id))) + [$entry]
  127. | .phases = (((.phases // []) + [$phase]) | unique)' \
  128. "$MANIFEST" > "$MANIFEST.tmp" && mv -f "$MANIFEST.tmp" "$MANIFEST"
  129. fi
  130. if [ "$FORCE" = 0 ] && [ -f "$JOURNAL" ]; then
  131. CACHED="$(jq -r --arg k "$KEY" 'select(.type=="result" and .key==$k and .rc==0) | .artifact' "$JOURNAL" 2>/dev/null | tail -1)"
  132. if [ -n "$CACHED" ] && [ -f "$CACHED" ]; then
  133. err "cache hit for $ID (unchanged packet) - use --force to re-run"
  134. echo "$CACHED"
  135. exit 3
  136. fi
  137. fi
  138. # --- worktree lane ------------------------------------------------------------
  139. WORKDIR="$REPO"
  140. if [ "$WORKTREE" = 1 ]; then
  141. WORKDIR="$RUNDIR/wt-$ID"
  142. if [ ! -d "$WORKDIR" ]; then
  143. git -C "$REPO" show-ref --verify --quiet "refs/heads/$BASE" || BASE="HEAD"
  144. git -C "$REPO" worktree add -q -b "fleetflow/$RUN/$ID" "$WORKDIR" "$BASE" \
  145. || { err "worktree add failed"; exit 10; }
  146. fi
  147. fi
  148. # phase is display metadata only - deliberately NOT part of the cache key
  149. jq -nc --arg k "$KEY" --arg id "$ID" --arg b "$BRAIN" --arg p "$PHASE" --arg v "$FF_VERSION" \
  150. '{type:"started",key:$k,id:$id,brain:$b,phase:$p,v:$v}' >> "$JOURNAL"
  151. # --- launch -------------------------------------------------------------------
  152. ART="$RUNDIR/$ID.result.json"
  153. ERRF="$RUNDIR/$ID.err"
  154. RC=0
  155. # cache/tmp redirect: worker/pytest/uv litter lands OUTSIDE repo + lanes
  156. # (a codex sandbox once left AppContainer-ACL'd pytest dirs inside a lane that
  157. # resisted unelevated deletion and blocked a repo move).
  158. CACHE_DIR="${FLEETFLOW_CACHE_ROOT:-$HOME/.fleet-worker/cache}/$RUN-$ID"
  159. mkdir -p "$CACHE_DIR"
  160. CFGD="${FLEET_WORKER_CONFIG_DIR:-$HOME/.fleet-worker/cfg-ff-$ID}"
  161. # precompute the claude --settings effortLevel JSON so ${EFFORT:+...} stays simple
  162. EFF_JSON=""
  163. [ -n "$EFFORT" ] && EFF_JSON="$(jq -nc --arg e "$EFFORT" '{"effortLevel":$e}' 2>/dev/null)"
  164. # archive the session transcript next to the artifact (best-effort, never fatal)
  165. archive_transcript() {
  166. local dest="$RUNDIR/$ID.transcript.jsonl" src="" sid enc
  167. case "$BRAIN" in
  168. glm)
  169. src="$(ls -t "$CFGD"/projects/*/*.jsonl 2>/dev/null | head -1)"
  170. ;;
  171. sonnet|opus|haiku|fable)
  172. sid="$(jq -r '.session_id // empty' "$ART" 2>/dev/null)"
  173. if [ -n "$sid" ]; then
  174. # workdir encoding: per-char [:\\/.] -> "-" (verified empirically:
  175. # C:\Users\Mack -> C--Users-Mack under ~/.claude/projects)
  176. enc="$(printf '%s' "$WORKDIR" | sed 's#[:\\/.]#-#g')"
  177. src="$HOME/.claude/projects/$enc/$sid.jsonl"
  178. [ -f "$src" ] || src="$(ls "$HOME"/.claude/projects/*/"$sid".jsonl 2>/dev/null | head -1)"
  179. fi
  180. ;;
  181. esac
  182. if [ -n "$src" ] && [ -f "$src" ]; then
  183. cp -f "$src" "$dest" 2>/dev/null && err "archived transcript -> $dest" \
  184. || err "transcript copy failed ($src), skipped"
  185. else
  186. err "transcript source not found (${src:-no session_id}), skipped (non-fatal)"
  187. fi
  188. }
  189. if [ "$DRYRUN" = 1 ]; then
  190. # the stub must match the brain's real envelope so collect can gate it:
  191. # grok emits {text,stopReason,...} (no is_error); every other brain here
  192. # collects via the claude-style {is_error,result} envelope.
  193. case "$BRAIN" in
  194. grok) jq -nc '{text:"DRYRUN",stopReason:"EndTurn"}' > "$ART" ;;
  195. *) jq -nc '{is_error:false,result:"DRYRUN"}' > "$ART" ;;
  196. esac
  197. else
  198. case "$BRAIN" in
  199. glm)
  200. FW="${FLEETFLOW_FLEET_WORKER:-$HOME/.claude/skills/fleet-worker/scripts/fleet-worker}"
  201. [ -f "$FW" ] || { err "fleet-worker launcher not found ($FW)"; exit 5; }
  202. ( cd "$WORKDIR" && \
  203. FLEET_WORKER_CONFIG_DIR="$CFGD" \
  204. UV_CACHE_DIR="$CACHE_DIR" TMPDIR="$CACHE_DIR" TMP="$CACHE_DIR" TEMP="$CACHE_DIR" \
  205. ${EFFORT:+FLEET_WORKER_EFFORT="$EFFORT"} \
  206. bash "$FW" --output-format json --max-turns "$MAX_TURNS" "$(cat "$SENT")" \
  207. ) > "$ART" 2> "$ERRF" || RC=$?
  208. ;;
  209. codex)
  210. command -v codex >/dev/null || { err "codex CLI not found"; exit 5; }
  211. ART="$RUNDIR/$ID.last.txt"
  212. # a worktree's git metadata lives in the MAIN repo's .git - outside the
  213. # codex sandbox's writable root - so git commit fails without this carve-out
  214. GITDIR=""; [ "$WORKTREE" = 1 ] && GITDIR="$(git -C "$REPO" rev-parse --absolute-git-dir)"
  215. ( cd "$WORKDIR" && \
  216. UV_CACHE_DIR="$CACHE_DIR" TMPDIR="$CACHE_DIR" TMP="$CACHE_DIR" TEMP="$CACHE_DIR" \
  217. codex exec --full-auto --ephemeral --color never --json \
  218. ${GITDIR:+--add-dir "$GITDIR"} \
  219. ${FLEETFLOW_CODEX_MODEL:+-m "$FLEETFLOW_CODEX_MODEL"} \
  220. ${EFFORT:+-c "model_reasoning_effort=$EFFORT"} \
  221. ${SCHEMA:+--output-schema "$SCHEMA"} \
  222. -o "$ART" - < "$SENT" \
  223. ) > "$RUNDIR/$ID.events.jsonl" 2> "$ERRF" || RC=$?
  224. ;;
  225. grok)
  226. # xAI's Grok Build CLI - a NON-Anthropic worker (own binary + protocol,
  227. # NOT a claude -p wrapper). Auth is the GROK_DEPLOYMENT_KEY env var, read
  228. # from the inherited environment - never written to disk or args.
  229. GROK="${FLEETFLOW_GROK_BIN:-grok}"
  230. command -v "$GROK" >/dev/null || { err "grok CLI not found ($GROK)"; exit 5; }
  231. # --always-approve = codex --full-auto analog: autonomous tool use, headless.
  232. # --json-schema takes the schema STRING (not a path) and implies json output,
  233. # surfacing an already-parsed .structuredOutput field (see ff-collect).
  234. ( cd "$WORKDIR" && \
  235. UV_CACHE_DIR="$CACHE_DIR" TMPDIR="$CACHE_DIR" TMP="$CACHE_DIR" TEMP="$CACHE_DIR" \
  236. "$GROK" --prompt-file "$SENT" --output-format json --always-approve \
  237. --max-turns "$MAX_TURNS" \
  238. ${FLEETFLOW_GROK_MODEL:+-m "$FLEETFLOW_GROK_MODEL"} \
  239. ${EFFORT:+--reasoning-effort "$EFFORT"} \
  240. ${SCHEMA:+--json-schema "$(cat "$SCHEMA")"} \
  241. ) > "$ART" 2> "$ERRF" || RC=$?
  242. ;;
  243. sonnet|opus|haiku|fable)
  244. command -v claude >/dev/null || { err "claude CLI not found"; exit 5; }
  245. MODEL="$BRAIN"; [ "$BRAIN" = "fable" ] && MODEL="claude-fable-5"
  246. ( cd "$WORKDIR" && \
  247. UV_CACHE_DIR="$CACHE_DIR" TMPDIR="$CACHE_DIR" TMP="$CACHE_DIR" TEMP="$CACHE_DIR" \
  248. claude -p --model "$MODEL" --output-format json --max-turns "$MAX_TURNS" \
  249. --permission-mode "${FLEETFLOW_PERMISSION_MODE:-bypassPermissions}" \
  250. ${EFFORT:+--settings "$EFF_JSON"} \
  251. < "$SENT" \
  252. ) > "$ART" 2> "$ERRF" || RC=$?
  253. ;;
  254. esac
  255. archive_transcript
  256. fi
  257. jq -nc --arg k "$KEY" --arg id "$ID" --arg b "$BRAIN" --arg a "$ART" --argjson rc "$RC" \
  258. '{type:"result",key:$k,id:$id,brain:$b,rc:$rc,artifact:$a}' >> "$JOURNAL"
  259. echo "$ART"
  260. if [ "$RC" -ne 0 ]; then err "worker exited rc=$RC (see $ERRF)"; exit 10; fi
  261. exit 0