ff-collect.sh 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. #!/usr/bin/env bash
  2. # ff-collect.sh - gate one fleetflow lane's result, or run the escape guard.
  3. #
  4. # Per-brain success semantics: Claude-harness brains (glm/sonnet/opus/haiku/
  5. # fable) gate on the JSON envelope's is_error; codex gates on a non-empty
  6. # last-message (plus JSON validity when a schema was used); grok gates on a
  7. # parseable envelope with non-empty .text (its envelope has no is_error - a
  8. # failed grok run exits nonzero, which ff-spawn already caught). The escape guard
  9. # compares the main checkout's status against the baseline snapshotted at
  10. # first spawn — new entries mean a worker wrote outside its lane.
  11. # stdout: the worker's final text (data). stderr: chatter.
  12. #
  13. # Exit codes: 0 pass | 2 usage | 3 artifact missing | 10 gate failed
  14. # 12 escape detected
  15. set -u
  16. . "$(dirname "${BASH_SOURCE[0]}")/_env.sh"
  17. FF_VERSION="1.1.0"
  18. usage() {
  19. cat <<'EOF'
  20. Usage: ff-collect.sh --run NAME --id ID [--repo PATH] [--schema] [--repair]
  21. ff-collect.sh --check-main-clean [--repo PATH] [--run NAME]
  22. --run NAME run name
  23. --id ID lane id to gate
  24. --repo PATH repo root (default: git toplevel of cwd)
  25. --schema the lane used a JSON schema: require the final text to
  26. parse as JSON (markdown code fences are stripped first)
  27. --repair on --schema failure: save the bad output to
  28. <run>/<id>.invalid.txt and respawn a <id>-repair lane
  29. (one attempt); print the repaired text on success
  30. --check-main-clean escape guard - compare the MAIN checkout's git status
  31. against the run's baseline; exit 12 on new entries
  32. EXAMPLES
  33. ff-collect.sh --run audit --id ts-refresh
  34. ff-collect.sh --run audit --id dissent-1 --schema
  35. ff-collect.sh --run audit --id verdict --schema --repair
  36. ff-collect.sh --check-main-clean --run audit
  37. EOF
  38. }
  39. err() { echo "ff-collect: $*" >&2; }
  40. RUN="" ID="" REPO="" SCHEMA=0 CHECK_CLEAN=0 REPAIR=0 JQERR=""
  41. while [ $# -gt 0 ]; do
  42. case "$1" in
  43. --run) RUN="${2:-}"; shift 2 ;;
  44. --id) ID="${2:-}"; shift 2 ;;
  45. --repo) REPO="${2:-}"; shift 2 ;;
  46. --schema) SCHEMA=1; shift ;;
  47. --repair) REPAIR=1; shift ;;
  48. --check-main-clean) CHECK_CLEAN=1; shift ;;
  49. -h|--help) usage; exit 0 ;;
  50. *) err "unknown argument: $1"; usage >&2; exit 2 ;;
  51. esac
  52. done
  53. command -v jq >/dev/null || { err "jq required"; exit 2; }
  54. [ -n "$REPO" ] || REPO="$(git rev-parse --show-toplevel 2>/dev/null)" || true
  55. [ -n "$REPO" ] && [ -d "$REPO" ] || { err "not in a git repo (or --repo invalid)"; exit 2; }
  56. HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  57. SPAWN="$HERE/ff-spawn.sh"
  58. COLLECT="$HERE/ff-collect.sh"
  59. # strip markdown code fences (```json ... ```) so schema gating tolerates fenced replies
  60. strip_fences() { sed -E '/^[[:space:]]*```[[:alnum:]]*[[:space:]]*$/d'; }
  61. # stdin = text; returns 0 if valid JSON else 1; sets global JQERR to the parse error
  62. json_ok() {
  63. local t; t="$(cat)"
  64. JQERR="$(printf '%s' "$t" | jq empty 2>&1 1>/dev/null | head -1)"
  65. printf '%s' "$t" | jq empty >/dev/null 2>&1
  66. }
  67. # do_repair <bad_text> <jq_error>: save bad output, respawn a <id>-repair lane
  68. # (same brain, --max-turns 3, no worktree), gate it, print its text on success.
  69. # One attempt only. Exits 0 (repaired) or 10 (repair also failed). FLEETFLOW_REPAIR_DRYRUN
  70. # forces the respawn to --dry-run (test/offline seam).
  71. do_repair() {
  72. local bad="$1" jqerr="$2" rid="${ID}-repair" pf spawn_rc=0 rout rc2
  73. pf="$RUNDIR/$rid.prompt-src.txt"
  74. printf '%s' "$bad" > "$RUNDIR/$ID.invalid.txt"
  75. err "schema validation failed (--repair): saved $RUNDIR/$ID.invalid.txt; respawning lane $rid"
  76. printf 'Your previous FINAL REPLY failed JSON validation. Error: %s. Previous output: %s. Reply with ONLY the corrected JSON object, nothing else.' \
  77. "$jqerr" "$bad" > "$pf"
  78. if [ -n "${FLEETFLOW_REPAIR_DRYRUN:-}" ]; then
  79. bash "$SPAWN" --run "$RUN" --id "$rid" --brain "$BRAIN" --max-turns 3 \
  80. --repo "$REPO" --prompt-file "$pf" --dry-run >/dev/null 2>"$RUNDIR/$rid.spawn.err" || spawn_rc=$?
  81. else
  82. bash "$SPAWN" --run "$RUN" --id "$rid" --brain "$BRAIN" --max-turns 3 \
  83. --repo "$REPO" --prompt-file "$pf" >/dev/null 2>"$RUNDIR/$rid.spawn.err" || spawn_rc=$?
  84. fi
  85. case "$spawn_rc" in 0|3) ;; *) err "repair spawn failed (rc=$spawn_rc; see $RUNDIR/$rid.spawn.err)"; exit 10 ;; esac
  86. # gate the repair lane WITHOUT --repair (one attempt); its stdout is the repaired text
  87. rout="$(bash "$COLLECT" --run "$RUN" --id "$rid" --repo "$REPO" --schema 2>"$RUNDIR/$rid.collect.err")"; rc2=$?
  88. if [ "$rc2" = 0 ]; then printf '%s\n' "$rout"; exit 0; fi
  89. err "repair lane also failed validation (see $RUNDIR/$rid.collect.err)"; exit 10
  90. }
  91. # --- escape guard -------------------------------------------------------------
  92. if [ "$CHECK_CLEAN" = 1 ]; then
  93. BASELINE=""
  94. if [ -n "$RUN" ] && [ -f "$REPO/.fleetflow/$RUN/main-baseline.txt" ]; then
  95. BASELINE="$REPO/.fleetflow/$RUN/main-baseline.txt"
  96. else
  97. # fall back to the most recent run's baseline
  98. BASELINE="$(ls -t "$REPO/.fleetflow"/*/main-baseline.txt 2>/dev/null | head -1)"
  99. fi
  100. [ -n "$BASELINE" ] && [ -f "$BASELINE" ] || { err "no baseline found - run ff-spawn first"; exit 2; }
  101. NOW="$(git -C "$REPO" status --porcelain 2>/dev/null)"
  102. NEW="$(comm -13 <(sort "$BASELINE") <(printf '%s\n' "$NOW" | sort) | grep -v '^\s*$' || true)"
  103. if [ -n "$NEW" ]; then
  104. err "ESCAPE DETECTED - main checkout changed since baseline:"
  105. printf '%s\n' "$NEW"
  106. err "salvage: git -C \"$REPO\" stash push -u -- <path>; then apply in the lane"
  107. exit 12
  108. fi
  109. echo "main-clean"
  110. exit 0
  111. fi
  112. # --- lane gate ------------------------------------------------------------
  113. [ -n "$RUN" ] && [ -n "$ID" ] || { err "--run and --id required"; usage >&2; exit 2; }
  114. RUNDIR="$REPO/.fleetflow/$RUN"
  115. JOURNAL="$RUNDIR/journal.jsonl"
  116. BRAIN=""
  117. [ -f "$JOURNAL" ] && \
  118. BRAIN="$(jq -r --arg id "$ID" 'select(.type=="result" and .id==$id) | .brain' "$JOURNAL" 2>/dev/null | tail -1)"
  119. if [ "$BRAIN" = "grok" ]; then
  120. # grok's headless envelope is {text, stopReason, sessionId, ...} with NO
  121. # is_error field. rc was already gated by ff-spawn (a failed run exits nonzero
  122. # and writes stderr), so the content gate here is: envelope parses AND produced
  123. # non-empty output. stopReason is informational (EndTurn = a clean turn); we do
  124. # NOT hard-gate on it, because an agentic tool-turn can end on other reasons.
  125. ART="$RUNDIR/$ID.result.json"
  126. [ -s "$ART" ] || { err "grok result missing/empty: $ART"; exit 3; }
  127. jq -e 'type=="object"' "$ART" >/dev/null 2>&1 || { err "grok envelope unparseable: $ART"; exit 10; }
  128. if [ "$SCHEMA" = 1 ]; then
  129. # --json-schema makes grok emit an already-parsed .structuredOutput - prefer it
  130. # over re-parsing .text (grok did the validation server-side).
  131. SO="$(jq -c 'if has("structuredOutput") then .structuredOutput else empty end' "$ART" 2>/dev/null)"
  132. if [ -n "$SO" ]; then printf '%s\n' "$SO"; exit 0; fi
  133. # fallback: no structuredOutput (schema not passed natively) - .text may hold fenced JSON
  134. CLEAN="$(jq -r '.text // empty' "$ART" | strip_fences)"
  135. if ! printf '%s' "$CLEAN" | json_ok; then
  136. [ "$REPAIR" = 1 ] && do_repair "$CLEAN" "$JQERR"
  137. err "grok final output not valid JSON (schema lane): $JQERR"; exit 10
  138. fi
  139. printf '%s\n' "$CLEAN"
  140. exit 0
  141. fi
  142. TEXT="$(jq -r '.text // empty' "$ART")"
  143. [ -n "$TEXT" ] || { err "grok produced empty text"; exit 10; }
  144. printf '%s\n' "$TEXT"
  145. exit 0
  146. fi
  147. if [ "$BRAIN" = "codex" ] || { [ -z "$BRAIN" ] && [ -f "$RUNDIR/$ID.last.txt" ]; }; then
  148. ART="$RUNDIR/$ID.last.txt"
  149. [ -s "$ART" ] || { err "codex last-message missing/empty: $ART"; exit 3; }
  150. if [ "$SCHEMA" = 1 ]; then
  151. CLEAN="$(strip_fences < "$ART")"
  152. if ! printf '%s' "$CLEAN" | json_ok; then
  153. [ "$REPAIR" = 1 ] && do_repair "$CLEAN" "$JQERR"
  154. err "final message is not valid JSON (schema lane): $JQERR"; exit 10
  155. fi
  156. printf '%s\n' "$CLEAN"
  157. exit 0
  158. fi
  159. cat "$ART"
  160. exit 0
  161. fi
  162. ART="$RUNDIR/$ID.result.json"
  163. [ -s "$ART" ] || { err "result missing/empty: $ART"; exit 3; }
  164. # NB: jq's // treats boolean false as absent, so probe with has()
  165. IS_ERR="$(jq -r 'if has("is_error") then (.is_error|tostring) else "missing" end' "$ART" 2>/dev/null)"
  166. [ -n "$IS_ERR" ] && [ "$IS_ERR" != "missing" ] || { err "unparseable result envelope: $ART"; exit 10; }
  167. if [ "$IS_ERR" != "false" ]; then
  168. err "worker reported is_error=$IS_ERR"
  169. jq -r '.result // empty' "$ART" | head -5 >&2
  170. exit 10
  171. fi
  172. TEXT="$(jq -r '.result // empty' "$ART")"
  173. if [ "$SCHEMA" = 1 ]; then
  174. CLEAN="$(printf '%s' "$TEXT" | strip_fences)"
  175. if ! printf '%s' "$CLEAN" | json_ok; then
  176. [ "$REPAIR" = 1 ] && do_repair "$CLEAN" "$JQERR"
  177. err "final text is not valid JSON (schema lane): $JQERR"; exit 10
  178. fi
  179. printf '%s\n' "$CLEAN"
  180. exit 0
  181. fi
  182. printf '%s\n' "$TEXT"
  183. exit 0