| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- #!/usr/bin/env bash
- # ff-spawn.sh - spawn one fleetflow worker lane (GLM / Codex / Anthropic brain).
- #
- # Creates the run dir + optional worktree lane, injects the guard preamble,
- # journals a hash-keyed started/result pair (native-Workflow-style replay
- # cache), launches the brain-appropriate process, and writes its artifacts.
- # stdout: the artifact path (data). stderr: progress chatter.
- #
- # Exit codes: 0 ok | 2 usage | 3 cache hit (cached artifact path on stdout)
- # 5 missing dependency | 10 worker failed
- set -u
- . "$(dirname "${BASH_SOURCE[0]}")/_env.sh"
- FF_VERSION="1.1.0"
- usage() {
- cat <<'EOF'
- Usage: ff-spawn.sh --run NAME --id ID --brain BRAIN --prompt-file FILE
- [--worktree] [--base BRANCH] [--repo PATH] [--max-turns N]
- [--effort low|medium|high|max] [--schema FILE] [--no-guard]
- [--force] [--dry-run]
- --run NAME run name (groups lanes; [a-z0-9-]+)
- --id ID lane id within the run ([a-z0-9-]+)
- --brain BRAIN glm | codex | grok | sonnet | opus | haiku | fable
- --prompt-file F packet file (guard preamble is prepended unless --no-guard)
- --phase NAME progress-group label (default: build) - display only
- --worktree give the worker its own worktree lane (branch fleetflow/RUN/ID)
- --base BRANCH worktree base (default: main, falls back to HEAD)
- --repo PATH repo root (default: git toplevel of cwd)
- --max-turns N worker turn cap (default: 100)
- --effort LEVEL reasoning effort lever: low|medium|high|max (default: unset =
- inherit the brain's own default). GLM -> FLEET_WORKER_EFFORT;
- claude brains -> --settings effortLevel; codex -> model_reasoning_effort.
- Effort IS part of the cache key (different effort = different run).
- --schema FILE JSON Schema for the final answer (codex: native
- --output-schema; other brains: appended to the prompt)
- --no-guard skip the guard preamble injection
- --force ignore a journal cache hit and re-run
- --dry-run do not launch a worker; write a stub result (for tests/planning)
- EXAMPLES
- ff-spawn.sh --run audit --id ts-refresh --brain glm --worktree \
- --prompt-file packets/ts.txt
- ff-spawn.sh --run audit --id dissent-1 --brain codex --effort high \
- --prompt-file packets/refute.txt --schema verdict.schema.json
- ff-spawn.sh --run audit --id judge --brain opus --effort max --prompt-file packets/judge.txt
- EOF
- }
- err() { echo "ff-spawn: $*" >&2; }
- RUN="" ID="" BRAIN="" PROMPT_FILE="" WORKTREE=0 BASE="main" REPO=""
- MAX_TURNS=100 SCHEMA="" GUARD=1 FORCE=0 DRYRUN=0 PHASE="build" EFFORT=""
- while [ $# -gt 0 ]; do
- case "$1" in
- --run) RUN="${2:-}"; shift 2 ;;
- --phase) PHASE="${2:-}"; shift 2 ;;
- --id) ID="${2:-}"; shift 2 ;;
- --brain) BRAIN="${2:-}"; shift 2 ;;
- --prompt-file) PROMPT_FILE="${2:-}"; shift 2 ;;
- --worktree) WORKTREE=1; shift ;;
- --base) BASE="${2:-}"; shift 2 ;;
- --repo) REPO="${2:-}"; shift 2 ;;
- --max-turns) MAX_TURNS="${2:-}"; shift 2 ;;
- --effort) EFFORT="${2:-}"; shift 2 ;;
- --schema) SCHEMA="${2:-}"; shift 2 ;;
- --no-guard) GUARD=0; shift ;;
- --force) FORCE=1; shift ;;
- --dry-run) DRYRUN=1; shift ;;
- -h|--help) usage; exit 0 ;;
- *) err "unknown argument: $1"; usage >&2; exit 2 ;;
- esac
- done
- case "$BRAIN" in glm|codex|grok|sonnet|opus|haiku|fable) ;; *) err "invalid --brain '$BRAIN'"; exit 2 ;; esac
- case "$EFFORT" in ""|low|medium|high|max) ;; *) err "invalid --effort '$EFFORT' (low|medium|high|max)"; exit 2 ;; esac
- echo "$RUN" | grep -qE '^[a-z0-9-]+$' || { err "invalid --run"; exit 2; }
- echo "$ID" | grep -qE '^[a-z0-9-]+$' || { err "invalid --id"; exit 2; }
- [ -f "$PROMPT_FILE" ] || { err "prompt file not found: $PROMPT_FILE"; exit 2; }
- [ -z "$SCHEMA" ] || [ -f "$SCHEMA" ] || { err "schema file not found: $SCHEMA"; exit 2; }
- command -v jq >/dev/null || { err "jq required"; exit 5; }
- command -v git >/dev/null || { err "git required"; exit 5; }
- [ -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; }
- RUNDIR="$REPO/.fleetflow/$RUN"
- mkdir -p "$RUNDIR"
- # keep the scratch tree out of git without touching the repo's .gitignore
- EXCL="$(git -C "$REPO" rev-parse --absolute-git-dir)/info/exclude"
- mkdir -p "$(dirname "$EXCL")"
- grep -qs '^\.fleetflow/$' "$EXCL" 2>/dev/null || echo ".fleetflow/" >> "$EXCL"
- # escape-guard baseline: snapshot the main checkout's status once per run
- BASELINE="$RUNDIR/main-baseline.txt"
- [ -f "$BASELINE" ] || git -C "$REPO" status --porcelain > "$BASELINE" 2>/dev/null
- # --- build the effective prompt ---------------------------------------------
- SENT="$RUNDIR/$ID.prompt.txt"
- : > "$SENT"
- if [ "$GUARD" = 1 ]; then
- PRE="$(dirname "${BASH_SOURCE[0]}")/../assets/guard-preamble.txt"
- [ -f "$PRE" ] && { cat "$PRE" >> "$SENT"; echo >> "$SENT"; }
- fi
- cat "$PROMPT_FILE" >> "$SENT"
- # codex and grok take a native structured-output flag (--output-schema /
- # --json-schema), so their schema is passed out-of-band, not appended to the
- # prompt. Every other brain gets the schema embedded and validated at collect.
- if [ -n "$SCHEMA" ] && [ "$BRAIN" != "codex" ] && [ "$BRAIN" != "grok" ]; then
- { echo; echo "FINAL REPLY MUST be a single JSON object valid against this schema:";
- cat "$SCHEMA"; } >> "$SENT"
- fi
- # --- journal: hash-keyed replay cache (native Workflow pattern) --------------
- # effort is part of the key (different effort = a different run), per Wave 1.
- OPTS="turns=$MAX_TURNS|wt=$WORKTREE|schema=$( [ -n "$SCHEMA" ] && basename "$SCHEMA" )|effort=$EFFORT"
- KEY="v2:$( { printf '%s\n' "$BRAIN"; cat "$SENT"; printf '%s' "$OPTS"; } | sha256sum | cut -d' ' -f1)"
- JOURNAL="$RUNDIR/journal.jsonl"
- # --- run manifest (orchestrator-side packet metadata; ff-run replays it) ----
- # Created on first spawn; each spawn upserts its packet by id (idempotent).
- MANIFEST="$RUNDIR/manifest.json"
- prompt_abs() {
- local d
- d="$(cd "$(dirname "$PROMPT_FILE")" 2>/dev/null && pwd)" || { printf '%s' "$PROMPT_FILE"; return; }
- printf '%s/%s' "$d" "$(basename "$PROMPT_FILE")"
- }
- WT_JSON="false"; [ "$WORKTREE" = 1 ] && WT_JSON="true"
- MENTRY="$(jq -nc --arg id "$ID" --arg b "$BRAIN" --arg p "$PHASE" --arg pf "$(prompt_abs)" \
- --argjson wt "$WT_JSON" --argjson mt "$MAX_TURNS" --arg e "$EFFORT" --arg s "${SCHEMA:-}" --arg k "$KEY" \
- '{id:$id,brain:$b,phase:$p,prompt_file:$pf,worktree:$wt,max_turns:$mt,effort:$e,schema:$s,key:$k}')"
- if [ ! -s "$MANIFEST" ]; then
- jq -nc --arg run "$RUN" --arg base "$BASE" --arg by "ff-spawn/$FF_VERSION" \
- --argjson entry "$MENTRY" --arg phase "$PHASE" \
- '{run:$run,base:$base,created_by:$by,phases:[$phase],packets:[$entry]}' > "$MANIFEST"
- else
- jq --argjson entry "$MENTRY" --arg id "$ID" --arg phase "$PHASE" \
- '.packets = ((.packets // []) | map(select(.id != $id))) + [$entry]
- | .phases = (((.phases // []) + [$phase]) | unique)' \
- "$MANIFEST" > "$MANIFEST.tmp" && mv -f "$MANIFEST.tmp" "$MANIFEST"
- fi
- if [ "$FORCE" = 0 ] && [ -f "$JOURNAL" ]; then
- CACHED="$(jq -r --arg k "$KEY" 'select(.type=="result" and .key==$k and .rc==0) | .artifact' "$JOURNAL" 2>/dev/null | tail -1)"
- if [ -n "$CACHED" ] && [ -f "$CACHED" ]; then
- err "cache hit for $ID (unchanged packet) - use --force to re-run"
- echo "$CACHED"
- exit 3
- fi
- fi
- # --- worktree lane ------------------------------------------------------------
- WORKDIR="$REPO"
- if [ "$WORKTREE" = 1 ]; then
- WORKDIR="$RUNDIR/wt-$ID"
- if [ ! -d "$WORKDIR" ]; then
- git -C "$REPO" show-ref --verify --quiet "refs/heads/$BASE" || BASE="HEAD"
- git -C "$REPO" worktree add -q -b "fleetflow/$RUN/$ID" "$WORKDIR" "$BASE" \
- || { err "worktree add failed"; exit 10; }
- fi
- fi
- # phase is display metadata only - deliberately NOT part of the cache key
- jq -nc --arg k "$KEY" --arg id "$ID" --arg b "$BRAIN" --arg p "$PHASE" --arg v "$FF_VERSION" \
- '{type:"started",key:$k,id:$id,brain:$b,phase:$p,v:$v}' >> "$JOURNAL"
- # --- launch -------------------------------------------------------------------
- ART="$RUNDIR/$ID.result.json"
- ERRF="$RUNDIR/$ID.err"
- RC=0
- # cache/tmp redirect: worker/pytest/uv litter lands OUTSIDE repo + lanes
- # (a codex sandbox once left AppContainer-ACL'd pytest dirs inside a lane that
- # resisted unelevated deletion and blocked a repo move).
- CACHE_DIR="${FLEETFLOW_CACHE_ROOT:-$HOME/.fleet-worker/cache}/$RUN-$ID"
- mkdir -p "$CACHE_DIR"
- CFGD="${FLEET_WORKER_CONFIG_DIR:-$HOME/.fleet-worker/cfg-ff-$ID}"
- # precompute the claude --settings effortLevel JSON so ${EFFORT:+...} stays simple
- EFF_JSON=""
- [ -n "$EFFORT" ] && EFF_JSON="$(jq -nc --arg e "$EFFORT" '{"effortLevel":$e}' 2>/dev/null)"
- # archive the session transcript next to the artifact (best-effort, never fatal)
- archive_transcript() {
- local dest="$RUNDIR/$ID.transcript.jsonl" src="" sid enc
- case "$BRAIN" in
- glm)
- src="$(ls -t "$CFGD"/projects/*/*.jsonl 2>/dev/null | head -1)"
- ;;
- sonnet|opus|haiku|fable)
- sid="$(jq -r '.session_id // empty' "$ART" 2>/dev/null)"
- if [ -n "$sid" ]; then
- # workdir encoding: per-char [:\\/.] -> "-" (verified empirically:
- # C:\Users\Mack -> C--Users-Mack under ~/.claude/projects)
- enc="$(printf '%s' "$WORKDIR" | sed 's#[:\\/.]#-#g')"
- src="$HOME/.claude/projects/$enc/$sid.jsonl"
- [ -f "$src" ] || src="$(ls "$HOME"/.claude/projects/*/"$sid".jsonl 2>/dev/null | head -1)"
- fi
- ;;
- esac
- if [ -n "$src" ] && [ -f "$src" ]; then
- cp -f "$src" "$dest" 2>/dev/null && err "archived transcript -> $dest" \
- || err "transcript copy failed ($src), skipped"
- else
- err "transcript source not found (${src:-no session_id}), skipped (non-fatal)"
- fi
- }
- if [ "$DRYRUN" = 1 ]; then
- # the stub must match the brain's real envelope so collect can gate it:
- # grok emits {text,stopReason,...} (no is_error); every other brain here
- # collects via the claude-style {is_error,result} envelope.
- case "$BRAIN" in
- grok) jq -nc '{text:"DRYRUN",stopReason:"EndTurn"}' > "$ART" ;;
- *) jq -nc '{is_error:false,result:"DRYRUN"}' > "$ART" ;;
- esac
- else
- case "$BRAIN" in
- glm)
- FW="${FLEETFLOW_FLEET_WORKER:-$HOME/.claude/skills/fleet-worker/scripts/fleet-worker}"
- [ -f "$FW" ] || { err "fleet-worker launcher not found ($FW)"; exit 5; }
- ( cd "$WORKDIR" && \
- FLEET_WORKER_CONFIG_DIR="$CFGD" \
- UV_CACHE_DIR="$CACHE_DIR" TMPDIR="$CACHE_DIR" TMP="$CACHE_DIR" TEMP="$CACHE_DIR" \
- ${EFFORT:+FLEET_WORKER_EFFORT="$EFFORT"} \
- bash "$FW" --output-format json --max-turns "$MAX_TURNS" "$(cat "$SENT")" \
- ) > "$ART" 2> "$ERRF" || RC=$?
- ;;
- codex)
- command -v codex >/dev/null || { err "codex CLI not found"; exit 5; }
- ART="$RUNDIR/$ID.last.txt"
- # a worktree's git metadata lives in the MAIN repo's .git - outside the
- # codex sandbox's writable root - so git commit fails without this carve-out
- GITDIR=""; [ "$WORKTREE" = 1 ] && GITDIR="$(git -C "$REPO" rev-parse --absolute-git-dir)"
- ( cd "$WORKDIR" && \
- UV_CACHE_DIR="$CACHE_DIR" TMPDIR="$CACHE_DIR" TMP="$CACHE_DIR" TEMP="$CACHE_DIR" \
- codex exec --full-auto --ephemeral --color never --json \
- ${GITDIR:+--add-dir "$GITDIR"} \
- ${FLEETFLOW_CODEX_MODEL:+-m "$FLEETFLOW_CODEX_MODEL"} \
- ${EFFORT:+-c "model_reasoning_effort=$EFFORT"} \
- ${SCHEMA:+--output-schema "$SCHEMA"} \
- -o "$ART" - < "$SENT" \
- ) > "$RUNDIR/$ID.events.jsonl" 2> "$ERRF" || RC=$?
- ;;
- grok)
- # xAI's Grok Build CLI - a NON-Anthropic worker (own binary + protocol,
- # NOT a claude -p wrapper). Auth is the GROK_DEPLOYMENT_KEY env var, read
- # from the inherited environment - never written to disk or args.
- GROK="${FLEETFLOW_GROK_BIN:-grok}"
- command -v "$GROK" >/dev/null || { err "grok CLI not found ($GROK)"; exit 5; }
- # --always-approve = codex --full-auto analog: autonomous tool use, headless.
- # --json-schema takes the schema STRING (not a path) and implies json output,
- # surfacing an already-parsed .structuredOutput field (see ff-collect).
- ( cd "$WORKDIR" && \
- UV_CACHE_DIR="$CACHE_DIR" TMPDIR="$CACHE_DIR" TMP="$CACHE_DIR" TEMP="$CACHE_DIR" \
- "$GROK" --prompt-file "$SENT" --output-format json --always-approve \
- --max-turns "$MAX_TURNS" \
- ${FLEETFLOW_GROK_MODEL:+-m "$FLEETFLOW_GROK_MODEL"} \
- ${EFFORT:+--reasoning-effort "$EFFORT"} \
- ${SCHEMA:+--json-schema "$(cat "$SCHEMA")"} \
- ) > "$ART" 2> "$ERRF" || RC=$?
- ;;
- sonnet|opus|haiku|fable)
- command -v claude >/dev/null || { err "claude CLI not found"; exit 5; }
- MODEL="$BRAIN"; [ "$BRAIN" = "fable" ] && MODEL="claude-fable-5"
- ( cd "$WORKDIR" && \
- UV_CACHE_DIR="$CACHE_DIR" TMPDIR="$CACHE_DIR" TMP="$CACHE_DIR" TEMP="$CACHE_DIR" \
- claude -p --model "$MODEL" --output-format json --max-turns "$MAX_TURNS" \
- --permission-mode "${FLEETFLOW_PERMISSION_MODE:-bypassPermissions}" \
- ${EFFORT:+--settings "$EFF_JSON"} \
- < "$SENT" \
- ) > "$ART" 2> "$ERRF" || RC=$?
- ;;
- esac
- archive_transcript
- fi
- jq -nc --arg k "$KEY" --arg id "$ID" --arg b "$BRAIN" --arg a "$ART" --argjson rc "$RC" \
- '{type:"result",key:$k,id:$id,brain:$b,rc:$rc,artifact:$a}' >> "$JOURNAL"
- echo "$ART"
- if [ "$RC" -ne 0 ]; then err "worker exited rc=$RC (see $ERRF)"; exit 10; fi
- exit 0
|