Skills › Compound Engineering › ce-brainstorm › elevation-dispatch.sh
elevation-dispatch.sh
288 lines · skills/ce-brainstorm/scripts/elevation-dispatch.sh at 3449230
1#!/usr/bin/env bash
2# elevation-dispatch.sh — off-host model-elevation worker for ce-plan / ce-brainstorm.
3#
4# Runs one reasoning-heavy step on a user-chosen model via the Claude CLI, as a
5# detached job supervised by peer-job-runner.py. Streams NDJSON so the idle
6# window observes genuine progress, not just liveness — a buffered format would
7# make a healthy long run byte-identical to a wedged one. See
8# docs/solutions/skill-design/cli-output-buffering-for-progress-detection.md.
9#
10# Read-only posture: the CLI is allowlisted to Read/Glob/Grep plus
11# WebSearch/WebFetch, so writes, shell, skills, and MCP are unavailable; the
12# model reads the repo and web to verify its brief and returns prose.
13#
14# Usage:
15# elevation-dispatch.sh <model> <prompt-file> <result-path>
16# elevation-dispatch.sh --emit-adapter <model> # print argv, no model call (test hook)
17#
18# NOTE ON THE FUNCTION NAMED run_codex_cmd: it is NOT codex-specific here. It is
19# the $PEERLOG byte-growth idle loop that implements the primary stall-supervision
20# signal (run_timeout_cmd, hard-cap-only, would leave a stalled run undetected).
21# It keeps that name because the shared heartbeat-parity regex in
22# tests/peer-job-runner-parity.test.ts uses `run_codex_cmd()` as the terminator
23# that forces BOTH heartbeat functions into the byte-compared kernel; renaming it
24# would weaken that cross-skill guard.
26set -uo pipefail
27trap '' HUP
29ACTIVE_PEER_PID=""
30RUN_SUCCEEDED=false
32log() { printf '[elevation] %s\n' "$*" >&2; }
34EFFORT="high" # settled: elevation runs at high effort
36# Read-only tool posture: the available built-in set, not a denylist. The
37# elevated step reads the repo (Read/Glob/Grep) and may check current facts on
38# the web (WebSearch/WebFetch) while authoring; it never needs Write/Bash/Task or
39# any mutating tool. Its output is returned prose, not a file write.
40ALLOWED=(Read Glob Grep WebSearch WebFetch)
42build_cmd() { # <model> <handoff-dir> -> sets CMD array (claude CLI, streaming, read-only)
43 # --safe-mode suppresses the user environment's hooks, plugins, and MCP
44 # servers; --disable-slash-commands blocks skills. --tools RESTRICTS the
45 # available built-in set to this list — Write/Edit/Bash are not present at all.
46 # This is the real read-only boundary: --allowedTools ALONE only pre-approves
47 # (verified — it leaves every other tool available), so --allowedTools here
48 # just lets --permission-mode dontAsk run these five without a prompt instead
49 # of denying them.
50 local csv; csv="$(IFS=,; printf '%s' "${ALLOWED[*]}")"
51 # Grant read access to ONLY the single per-run handoff dir ($2, where the
52 # orchestrator co-located the prompt and evidence), which sits outside the
53 # launch dir. Claude's file access defaults to the launch dir and is extended
54 # via --add-dir. Adding the whole OS temp root ($TMPDIR / /tmp) instead would
55 # expose every other same-user scratch file and credential to the elevated
56 # model; the scoped dir does not. Read-only (only Read/Glob/Grep available).
57 local add_dirs=()
58 [ -n "${2:-}" ] && add_dirs=(--add-dir "$2")
59 # --no-session-persistence: this is a one-shot background model call, so the
60 # prompt and scratch-file references must not be saved as a resumable session
61 # on disk (matches the other scripted Claude peer routes in this repo).
62 CMD=(claude -p --model "$1" --effort "$EFFORT"
63 --output-format stream-json --verbose
64 --safe-mode --no-session-persistence --disable-slash-commands --strict-mcp-config
65 --permission-mode dontAsk
66 "${add_dirs[@]}"
67 --tools "$csv" --allowedTools "${ALLOWED[@]}"
68 --max-turns "${ELEVATION_MAX_TURNS:-30}")
69}
71# Test hook: print the argv the worker would exec, without calling a model.
72# Accepts an optional handoff dir ($3) so the emitted argv shows the scoped
73# --add-dir; without it the flag is omitted (no dir to grant).
74if [ "${1:-}" = "--emit-adapter" ]; then
75 [ -n "${2:-}" ] || { log "--emit-adapter requires <model>"; exit 2; }
76 build_cmd "$2" "${3:-}"
77 printf '%s\0' "${CMD[@]}"
78 exit 0
79fi
81MODEL="${1:?model required}"
82PROMPT_FILE="${2:?prompt-file required}"
83RESULT_PATH="${3:?result-path required}"
84[ -f "$PROMPT_FILE" ] || { log "prompt file not found: $PROMPT_FILE"; exit 2; }
86# The orchestrator co-locates the prompt and every evidence file in one private
87# per-run dir; grant the elevated model read access to just that dir (resolved
88# to an absolute path), never the whole OS temp root. Pure-bash dirname (no
89# external `dirname`): strip the last /component, defaulting to cwd if none.
90HANDOFF_DIR="${PROMPT_FILE%/*}"
91[ "$HANDOFF_DIR" = "$PROMPT_FILE" ] && HANDOFF_DIR="."
92HANDOFF_DIR="$(cd "$HANDOFF_DIR" 2>/dev/null && pwd || printf '%s' "$HANDOFF_DIR")"
94# jq builds every result envelope; it is only an optional capability (ce-setup),
95# so preflight it here rather than spending the CLI call and failing to parse.
96# Exit 0 with a failure envelope, NOT nonzero: the runner classifies a nonzero
97# exit as `failed`, and its `result` command then refuses to emit the artifact,
98# so the recovery flow could never read this envelope. Exit 0 makes the job
99# `done`, the envelope's status:failed is read, and it degrades to inline.
100if ! command -v jq >/dev/null 2>&1; then
101 log "jq not found on PATH; cannot parse the elevated result — degrading to inline"
102 printf '{"status":"failed","requested_model":"%s","evidence":"jq unavailable on PATH"}' "$MODEL" > "$RESULT_PATH" 2>/dev/null || true
103 exit 0
104fi
106PEERLOG="$(mktemp "${TMPDIR:-/tmp}/elevation-peer-XXXXXX")"
108# Idle window is the primary stall signal; the hard cap is a raised backstop.
109# Keep this inner cap >= the runner's CE_PEER_HARD_SECS so it never reaps a
110# healthy run before the outer supervisor's own raised backstop.
111IDLE_SECS="${CE_ELEVATION_IDLE_SECS:-180}"
112HARD_SECS="${CE_ELEVATION_HARD_SECS:-5400}"
113POLL_SECS="${CE_ELEVATION_POLL_SECS:-5}" # $PEERLOG growth poll interval
115reap() {
116 local pid="$1" grp
117 if kill -TERM -- -"$pid" 2>/dev/null; then grp=1; else kill -TERM "$pid" 2>/dev/null; grp=0; fi
118 for _ in 1 2 3 4 5; do
119 if [ "$grp" = 1 ]; then kill -0 -- -"$pid" 2>/dev/null || return 0
120 else kill -0 "$pid" 2>/dev/null || return 0; fi
121 sleep 1
122 done
123 if [ "$grp" = 1 ]; then kill -KILL -- -"$pid" 2>/dev/null; else kill -KILL "$pid" 2>/dev/null; fi
124}
126on_term() {
127 if [ -n "${_HEARTBEAT_PID:-}" ]; then
128 stop_heartbeat
129 fi
130 if [ -n "${ACTIVE_PEER_PID:-}" ]; then
131 log "received TERM/INT; reaping peer process group $ACTIVE_PEER_PID"
132 reap "$ACTIVE_PEER_PID" 2>/dev/null || true
133 ACTIVE_PEER_PID=""
134 fi
135 exit 0
136}
137trap 'on_term' TERM INT
139write_result() { # <json-string> -> atomic publish to RESULT_PATH
140 local tmp="${RESULT_PATH}.tmp.$$"
141 printf '%s' "$1" > "$tmp" && mv -f "$tmp" "$RESULT_PATH"
142}
144# Bounded stderr/stdout tail for a failed run. tail -c avoids the macOS bash
145# negative-slice bug that erased sub-300-char evidence in the review worker.
146bounded_failure_evidence() { tail -c 800 "$PEERLOG" 2>/dev/null || true; }
148# Expected served-id prefix for a requested model alias, or empty if unknown.
149model_prefix() { # <requested> -> prefix | ""
150 case "$1" in
151 fable) printf 'claude-fable-' ;;
152 opus) printf 'claude-opus-' ;;
153 sonnet) printf 'claude-sonnet-' ;;
154 haiku) printf 'claude-haiku-' ;;
155 claude-*) printf '%s' "$1" ;;
156 esac
157}
159# Requested family vs served id. matched | mismatch | unverified.
160classify_receipt() { # <requested> <served>
161 local served="$2" prefix
162 { [ -z "$served" ] || [ "$served" = "unverified" ]; } && { printf 'unverified'; return; }
163 prefix="$(model_prefix "$1")"
164 [ -z "$prefix" ] && { printf 'unverified'; return; }
165 case "$served" in
166 "$prefix"*) printf 'matched' ;;
167 *) printf 'mismatch' ;;
168 esac
169}
171# --- liveness heartbeat -----------------------------------------------------
172# Emits one stderr line every CROSS_MODEL_HEARTBEAT_SECS so the OUTER
173# peer-job-runner idle window (out.log byte-growth) sees the supervising script
174# as alive during a long model call. It writes to stderr, NOT $PEERLOG, so it
175# never masks this worker's OWN $PEERLOG idle detection (run_codex_cmd below) —
176# a stalled model still stops growing $PEERLOG and is reaped. This block is
177# byte-identical across all peer workers (kernel parity, tests/peer-job-runner-parity.test.ts).
178_HEARTBEAT_PID=""
179start_heartbeat() {
180 local every="${CROSS_MODEL_HEARTBEAT_SECS:-60}" parent_pid="$$"
181 # Floor to 1s: a non-numeric or 0 value would make `sleep` return instantly and
182 # spin the loop, flooding out.log into the runner's byte cap.
183 case "$every" in ''|*[!0-9]*) every=60 ;; esac; [ "$every" -lt 1 ] && every=1
184 _HEARTBEAT_READY=0
185 trap '_HEARTBEAT_READY=1' USR1
186 # Callers restore set +m after launching the peer, so without this the
187 # heartbeat inherits the worker pgid and kill -- -PID cannot reach the sleep.
188 local prev_m; case "$-" in *m*) prev_m=1;; *) prev_m=0;; esac
189 set -m
190 ( local t0 n sleeper=""
191 trap 'kill "${sleeper:-}" 2>/dev/null || true; exit 0' TERM INT
192 kill -USR1 "$parent_pid"
193 t0="$(date +%s)"
194 while kill -0 "$parent_pid" 2>/dev/null; do
195 sleep "$every" & sleeper=$!
196 wait "$sleeper" 2>/dev/null || exit 0
197 sleeper=""
198 kill -0 "$parent_pid" 2>/dev/null || break
199 n="$(date +%s)"; log "peer alive ($(( n - t0 ))s elapsed)"
200 done ) &
201 _HEARTBEAT_PID=$!
202 [ "$prev_m" = 0 ] && set +m
203 while [ "$_HEARTBEAT_READY" != 1 ] && kill -0 "$_HEARTBEAT_PID" 2>/dev/null; do sleep 0.01 || true; done
204 trap - USR1
205}
206stop_heartbeat() {
207 if [ -n "$_HEARTBEAT_PID" ]; then
208 # Leader-only TERM is deferred until the inner `wait $sleeper` returns, so
209 # the default 60s interval would block this wait. Signal the process group.
210 kill -- -"$_HEARTBEAT_PID" 2>/dev/null || kill "$_HEARTBEAT_PID" 2>/dev/null || true
211 wait "$_HEARTBEAT_PID" 2>/dev/null || true
212 fi
213 _HEARTBEAT_PID=""
214}
216run_codex_cmd() {
217 RUN_SUCCEEDED=false
218 local prev; case "$-" in *m*) prev=1;; *) prev=0;; esac
219 set -m
220 command "${CMD[@]}" < "$PROMPT_FILE" > "$PEERLOG" 2>&1 &
221 local pid=$!
222 ACTIVE_PEER_PID="$pid"
223 [ "$prev" = 0 ] && set +m
224 start_heartbeat
225 local start last=-1 lastchg now size
226 start="$(date +%s)"; lastchg="$start"
227 while kill -0 "$pid" 2>/dev/null; do
228 sleep "$POLL_SECS"; now="$(date +%s)"; size="$(wc -c <"$PEERLOG" 2>/dev/null || echo 0)"
229 [ "$size" != "$last" ] && { last="$size"; lastchg="$now"; }
230 if [ $(( now - lastchg )) -ge "$IDLE_SECS" ]; then
231 log "elevated call idle ${IDLE_SECS}s; reaping"; reap "$pid"; break
232 fi
233 if [ $(( now - start )) -ge "$HARD_SECS" ]; then
234 log "elevated call exceeded hard cap ${HARD_SECS}s; reaping"; reap "$pid"; break
235 fi
236 done
237 if wait "$pid" 2>/dev/null; then RUN_SUCCEEDED=true
238 else log "elevated call exited non-zero or was reaped"; fi
239 reap "$pid" 2>/dev/null || true
240 stop_heartbeat
241 ACTIVE_PEER_PID=""
242}
244# --- main -------------------------------------------------------------------
245build_cmd "$MODEL" "$HANDOFF_DIR"
246run_codex_cmd
248# The stream-json terminal event is the LAST line whose type is "result". Match
249# on it rather than `tail -1`, so a diagnostic written to stderr after the result
250# (an update notice, wrapper output) does not become the "result" we parse.
251EVENT="$(grep -a '"type":"result"' "$PEERLOG" 2>/dev/null | tail -1 || true)"
252PREFIX="$(model_prefix "$MODEL")"
253# jq `keys` is sorted, so keys[0] is not necessarily the served model when
254# modelUsage carries an auxiliary model too; prefer the requested family's key.
255SERVED="$(printf '%s' "$EVENT" | jq -r --arg p "$PREFIX" \
256 '(.modelUsage // {} | keys) as $k
257 | (if $p != "" then first($k[] | select(startswith($p))) else empty end) // $k[0] // "unverified"' \
258 2>/dev/null || printf 'unverified')"
259# Ship "ok" only on a clean success — a terminal event carries .result even when
260# truncated/errored (subtype error_*, is_error true). HAS_OUTPUT is a tiny jq
261# flag, so the plan text is never loaded into a shell variable or an argv.
262SUBTYPE="$(printf '%s' "$EVENT" | jq -r '.subtype // empty' 2>/dev/null || true)"
263IS_ERROR="$(printf '%s' "$EVENT" | jq -r '.is_error // false' 2>/dev/null || printf 'true')"
264HAS_OUTPUT="$(printf '%s' "$EVENT" | jq -r 'if (.result // "") == "" then "no" else "yes" end' 2>/dev/null || printf 'no')"
266if [ "$RUN_SUCCEEDED" = true ] && [ "$HAS_OUTPUT" = "yes" ] \
267 && [ "$SUBTYPE" = "success" ] && [ "$IS_ERROR" != "true" ]; then
268 RECEIPT="$(classify_receipt "$MODEL" "$SERVED")"
269 # Build the envelope by piping the event THROUGH jq, which reads .result
270 # internally — never pass the plan text as an argv --arg, which would exceed
271 # ARG_MAX for a large Deep plan.
272 tmp="${RESULT_PATH}.tmp.$$"
273 if printf '%s' "$EVENT" | jq --arg m "$MODEL" --arg s "$SERVED" --arg r "$RECEIPT" \
274 '{status:"ok", requested_model:$m, served_model:$s, receipt:$r, output:.result}' \
275 > "$tmp" 2>/dev/null; then
276 mv -f "$tmp" "$RESULT_PATH"
277 log "elevated step complete: requested=$MODEL served=$SERVED receipt=$RECEIPT"
278 else
279 rm -f "$tmp"
280 write_result "$(jq -n --arg m "$MODEL" '{status:"failed", requested_model:$m, evidence:"result envelope build failed"}')"
281 log "elevated step: result envelope build failed"
282 fi
283else
284 write_result "$(jq -n --arg m "$MODEL" --arg e "$(bounded_failure_evidence)" \
285 '{status:"failed", requested_model:$m, evidence:$e}')"
286 log "elevated step failed; wrote failure envelope"
287fi
288rm -f "$PEERLOG"