← Files Compound EngineeringARCHIVED FILE

skills/ce-plan/scripts/elevation-dispatch.sh

13.2 KB · Oct 2, 2026 · 00:33 UTC

↓ Download file

#!/usr/bin/env bash
# elevation-dispatch.sh — off-host model-elevation worker for ce-plan / ce-brainstorm.
#
# Runs one reasoning-heavy step on a user-chosen model via the Claude CLI, as a
# detached job supervised by peer-job-runner.py. Streams NDJSON so the idle
# window observes genuine progress, not just liveness — a buffered format would
# make a healthy long run byte-identical to a wedged one. See
# docs/solutions/skill-design/cli-output-buffering-for-progress-detection.md.
#
# Read-only posture (R7): the CLI is allowlisted to Read/Glob/Grep plus
# WebSearch/WebFetch, so writes, shell, skills, and MCP are unavailable; the
# model reads the repo and web to verify its brief and returns prose.
#
# Usage:
#   elevation-dispatch.sh <model> <prompt-file> <result-path>
#   elevation-dispatch.sh --emit-adapter <model>   # print argv, no model call (test hook)
#
# NOTE ON THE FUNCTION NAMED run_codex_cmd: it is NOT codex-specific here. It is
# the $PEERLOG byte-growth idle loop that implements R11's primary supervision
# signal (run_timeout_cmd, hard-cap-only, would leave a stalled run undetected).
# It keeps that name because the shared heartbeat-parity regex in
# tests/peer-job-runner-parity.test.ts uses `run_codex_cmd()` as the terminator
# that forces BOTH heartbeat functions into the byte-compared kernel; renaming it
# would weaken that cross-skill guard.

set -uo pipefail
trap '' HUP

ACTIVE_PEER_PID=""
RUN_SUCCEEDED=false

log() { printf '[elevation] %s\n' "$*" >&2; }

EFFORT="high"   # settled: elevation runs at high effort

# Read-only tool posture (R7): the available built-in set, not a denylist. The
# elevated step reads the repo (Read/Glob/Grep) and may check current facts on
# the web (WebSearch/WebFetch) while authoring; it never needs Write/Bash/Task or
# any mutating tool. Its output is returned prose, not a file write.
ALLOWED=(Read Glob Grep WebSearch WebFetch)

build_cmd() {   # <model> <handoff-dir> -> sets CMD array (claude CLI, streaming, read-only)
  # --safe-mode suppresses the user environment's hooks, plugins, and MCP
  # servers; --disable-slash-commands blocks skills. --tools RESTRICTS the
  # available built-in set to this list — Write/Edit/Bash are not present at all.
  # This is the real read-only boundary: --allowedTools ALONE only pre-approves
  # (verified — it leaves every other tool available), so --allowedTools here
  # just lets --permission-mode dontAsk run these five without a prompt instead
  # of denying them.
  local csv; csv="$(IFS=,; printf '%s' "${ALLOWED[*]}")"
  # Grant read access to ONLY the single per-run handoff dir ($2, where the
  # orchestrator co-located the prompt and evidence), which sits outside the
  # launch dir. Claude's file access defaults to the launch dir and is extended
  # via --add-dir. Adding the whole OS temp root ($TMPDIR / /tmp) instead would
  # expose every other same-user scratch file and credential to the elevated
  # model; the scoped dir does not. Read-only (only Read/Glob/Grep available).
  local add_dirs=()
  [ -n "${2:-}" ] && add_dirs=(--add-dir "$2")
  # --no-session-persistence: this is a one-shot background model call, so the
  # prompt and scratch-file references must not be saved as a resumable session
  # on disk (matches the other scripted Claude peer routes in this repo).
  CMD=(claude -p --model "$1" --effort "$EFFORT"
       --output-format stream-json --verbose
       --safe-mode --no-session-persistence --disable-slash-commands --strict-mcp-config
       --permission-mode dontAsk
       "${add_dirs[@]}"
       --tools "$csv" --allowedTools "${ALLOWED[@]}"
       --max-turns "${ELEVATION_MAX_TURNS:-30}")
}

# Test hook: print the argv the worker would exec, without calling a model.
# Accepts an optional handoff dir ($3) so the emitted argv shows the scoped
# --add-dir; without it the flag is omitted (no dir to grant).
if [ "${1:-}" = "--emit-adapter" ]; then
  [ -n "${2:-}" ] || { log "--emit-adapter requires <model>"; exit 2; }
  build_cmd "$2" "${3:-}"
  printf '%s\0' "${CMD[@]}"
  exit 0
fi

MODEL="${1:?model required}"
PROMPT_FILE="${2:?prompt-file required}"
RESULT_PATH="${3:?result-path required}"
[ -f "$PROMPT_FILE" ] || { log "prompt file not found: $PROMPT_FILE"; exit 2; }

# The orchestrator co-locates the prompt and every evidence file in one private
# per-run dir; grant the elevated model read access to just that dir (resolved
# to an absolute path), never the whole OS temp root. Pure-bash dirname (no
# external `dirname`): strip the last /component, defaulting to cwd if none.
HANDOFF_DIR="${PROMPT_FILE%/*}"
[ "$HANDOFF_DIR" = "$PROMPT_FILE" ] && HANDOFF_DIR="."
HANDOFF_DIR="$(cd "$HANDOFF_DIR" 2>/dev/null && pwd || printf '%s' "$HANDOFF_DIR")"

# jq builds every result envelope; it is only an optional capability (ce-setup),
# so preflight it here rather than spending the CLI call and failing to parse.
# Exit 0 with a failure envelope, NOT nonzero: the runner classifies a nonzero
# exit as `failed`, and its `result` command then refuses to emit the artifact,
# so the recovery flow could never read this envelope. Exit 0 makes the job
# `done`, the envelope's status:failed is read, and it degrades to inline.
if ! command -v jq >/dev/null 2>&1; then
  log "jq not found on PATH; cannot parse the elevated result — degrading to inline"
  printf '{"status":"failed","requested_model":"%s","evidence":"jq unavailable on PATH"}' "$MODEL" > "$RESULT_PATH" 2>/dev/null || true
  exit 0
fi

PEERLOG="$(mktemp "${TMPDIR:-/tmp}/elevation-peer-XXXXXX")"

# Idle window is the primary stall signal; the hard cap is a raised backstop (R11).
# Keep this inner cap >= the runner's CE_PEER_HARD_SECS so it never reaps a
# healthy run before the outer supervisor's own raised backstop.
IDLE_SECS="${CE_ELEVATION_IDLE_SECS:-180}"
HARD_SECS="${CE_ELEVATION_HARD_SECS:-5400}"
POLL_SECS="${CE_ELEVATION_POLL_SECS:-5}"   # $PEERLOG growth poll interval

reap() {
  local pid="$1" grp
  if kill -TERM -- -"$pid" 2>/dev/null; then grp=1; else kill -TERM "$pid" 2>/dev/null; grp=0; fi
  for _ in 1 2 3 4 5; do
    if [ "$grp" = 1 ]; then kill -0 -- -"$pid" 2>/dev/null || return 0
    else kill -0 "$pid" 2>/dev/null || return 0; fi
    sleep 1
  done
  if [ "$grp" = 1 ]; then kill -KILL -- -"$pid" 2>/dev/null; else kill -KILL "$pid" 2>/dev/null; fi
}

on_term() {
  if [ -n "${_HEARTBEAT_PID:-}" ]; then
    stop_heartbeat
  fi
  if [ -n "${ACTIVE_PEER_PID:-}" ]; then
    log "received TERM/INT; reaping peer process group $ACTIVE_PEER_PID"
    reap "$ACTIVE_PEER_PID" 2>/dev/null || true
    ACTIVE_PEER_PID=""
  fi
  exit 0
}
trap 'on_term' TERM INT

write_result() {   # <json-string> -> atomic publish to RESULT_PATH
  local tmp="${RESULT_PATH}.tmp.$$"
  printf '%s' "$1" > "$tmp" && mv -f "$tmp" "$RESULT_PATH"
}

# Bounded stderr/stdout tail for a failed run. tail -c avoids the macOS bash
# negative-slice bug that erased sub-300-char evidence in the review worker.
bounded_failure_evidence() { tail -c 800 "$PEERLOG" 2>/dev/null || true; }

# Expected served-id prefix for a requested model alias, or empty if unknown.
model_prefix() {   # <requested> -> prefix | ""
  case "$1" in
    fable)    printf 'claude-fable-' ;;
    opus)     printf 'claude-opus-' ;;
    sonnet)   printf 'claude-sonnet-' ;;
    haiku)    printf 'claude-haiku-' ;;
    claude-*) printf '%s' "$1" ;;
  esac
}

# Requested family vs served id (R6/R16). matched | mismatch | unverified.
classify_receipt() {   # <requested> <served>
  local served="$2" prefix
  { [ -z "$served" ] || [ "$served" = "unverified" ]; } && { printf 'unverified'; return; }
  prefix="$(model_prefix "$1")"
  [ -z "$prefix" ] && { printf 'unverified'; return; }
  case "$served" in
    "$prefix"*) printf 'matched' ;;
    *)          printf 'mismatch' ;;
  esac
}

# --- liveness heartbeat -----------------------------------------------------
# Emits one stderr line every CROSS_MODEL_HEARTBEAT_SECS so the OUTER
# peer-job-runner idle window (out.log byte-growth) sees the supervising script
# as alive during a long model call. It writes to stderr, NOT $PEERLOG, so it
# never masks this worker's OWN $PEERLOG idle detection (run_codex_cmd below) —
# a stalled model still stops growing $PEERLOG and is reaped. This block is
# byte-identical across all peer workers (kernel parity, tests/peer-job-runner-parity.test.ts).
_HEARTBEAT_PID=""
start_heartbeat() {
  local every="${CROSS_MODEL_HEARTBEAT_SECS:-60}" parent_pid="$$"
  # Floor to 1s: a non-numeric or 0 value would make `sleep` return instantly and
  # spin the loop, flooding out.log into the runner's byte cap.
  case "$every" in ''|*[!0-9]*) every=60 ;; esac; [ "$every" -lt 1 ] && every=1
  _HEARTBEAT_READY=0
  trap '_HEARTBEAT_READY=1' USR1
  # Callers restore set +m after launching the peer, so without this the
  # heartbeat inherits the worker pgid and kill -- -PID cannot reach the sleep.
  local prev_m; case "$-" in *m*) prev_m=1;; *) prev_m=0;; esac
  set -m
  ( local t0 n sleeper=""
    trap 'kill "${sleeper:-}" 2>/dev/null || true; exit 0' TERM INT
    kill -USR1 "$parent_pid"
    t0="$(date +%s)"
    while kill -0 "$parent_pid" 2>/dev/null; do
      sleep "$every" & sleeper=$!
      wait "$sleeper" 2>/dev/null || exit 0
      sleeper=""
      kill -0 "$parent_pid" 2>/dev/null || break
      n="$(date +%s)"; log "peer alive ($(( n - t0 ))s elapsed)"
    done ) &
  _HEARTBEAT_PID=$!
  [ "$prev_m" = 0 ] && set +m
  while [ "$_HEARTBEAT_READY" != 1 ] && kill -0 "$_HEARTBEAT_PID" 2>/dev/null; do sleep 0.01 || true; done
  trap - USR1
}
stop_heartbeat() {
  if [ -n "$_HEARTBEAT_PID" ]; then
    # Leader-only TERM is deferred until the inner `wait $sleeper` returns, so
    # the default 60s interval would block this wait. Signal the process group.
    kill -- -"$_HEARTBEAT_PID" 2>/dev/null || kill "$_HEARTBEAT_PID" 2>/dev/null || true
    wait "$_HEARTBEAT_PID" 2>/dev/null || true
  fi
  _HEARTBEAT_PID=""
}

run_codex_cmd() {
  RUN_SUCCEEDED=false
  local prev; case "$-" in *m*) prev=1;; *) prev=0;; esac
  set -m
  command "${CMD[@]}" < "$PROMPT_FILE" > "$PEERLOG" 2>&1 &
  local pid=$!
  ACTIVE_PEER_PID="$pid"
  [ "$prev" = 0 ] && set +m
  start_heartbeat
  local start last=-1 lastchg now size
  start="$(date +%s)"; lastchg="$start"
  while kill -0 "$pid" 2>/dev/null; do
    sleep "$POLL_SECS"; now="$(date +%s)"; size="$(wc -c <"$PEERLOG" 2>/dev/null || echo 0)"
    [ "$size" != "$last" ] && { last="$size"; lastchg="$now"; }
    if [ $(( now - lastchg )) -ge "$IDLE_SECS" ]; then
      log "elevated call idle ${IDLE_SECS}s; reaping"; reap "$pid"; break
    fi
    if [ $(( now - start )) -ge "$HARD_SECS" ]; then
      log "elevated call exceeded hard cap ${HARD_SECS}s; reaping"; reap "$pid"; break
    fi
  done
  if wait "$pid" 2>/dev/null; then RUN_SUCCEEDED=true
  else log "elevated call exited non-zero or was reaped"; fi
  reap "$pid" 2>/dev/null || true
  stop_heartbeat
  ACTIVE_PEER_PID=""
}

# --- main -------------------------------------------------------------------
build_cmd "$MODEL" "$HANDOFF_DIR"
run_codex_cmd

# The stream-json terminal event is the LAST line whose type is "result". Match
# on it rather than `tail -1`, so a diagnostic written to stderr after the result
# (an update notice, wrapper output) does not become the "result" we parse.
EVENT="$(grep -a '"type":"result"' "$PEERLOG" 2>/dev/null | tail -1 || true)"
PREFIX="$(model_prefix "$MODEL")"
# jq `keys` is sorted, so keys[0] is not necessarily the served model when
# modelUsage carries an auxiliary model too; prefer the requested family's key.
SERVED="$(printf '%s' "$EVENT" | jq -r --arg p "$PREFIX" \
  '(.modelUsage // {} | keys) as $k
   | (if $p != "" then first($k[] | select(startswith($p))) else empty end) // $k[0] // "unverified"' \
  2>/dev/null || printf 'unverified')"
# Ship "ok" only on a clean success — a terminal event carries .result even when
# truncated/errored (subtype error_*, is_error true). HAS_OUTPUT is a tiny jq
# flag, so the plan text is never loaded into a shell variable or an argv.
SUBTYPE="$(printf '%s' "$EVENT" | jq -r '.subtype // empty' 2>/dev/null || true)"
IS_ERROR="$(printf '%s' "$EVENT" | jq -r '.is_error // false' 2>/dev/null || printf 'true')"
HAS_OUTPUT="$(printf '%s' "$EVENT" | jq -r 'if (.result // "") == "" then "no" else "yes" end' 2>/dev/null || printf 'no')"

if [ "$RUN_SUCCEEDED" = true ] && [ "$HAS_OUTPUT" = "yes" ] \
   && [ "$SUBTYPE" = "success" ] && [ "$IS_ERROR" != "true" ]; then
  RECEIPT="$(classify_receipt "$MODEL" "$SERVED")"
  # Build the envelope by piping the event THROUGH jq, which reads .result
  # internally — never pass the plan text as an argv --arg, which would exceed
  # ARG_MAX for a large Deep plan.
  tmp="${RESULT_PATH}.tmp.$$"
  if printf '%s' "$EVENT" | jq --arg m "$MODEL" --arg s "$SERVED" --arg r "$RECEIPT" \
       '{status:"ok", requested_model:$m, served_model:$s, receipt:$r, output:.result}' \
       > "$tmp" 2>/dev/null; then
    mv -f "$tmp" "$RESULT_PATH"
    log "elevated step complete: requested=$MODEL served=$SERVED receipt=$RECEIPT"
  else
    rm -f "$tmp"
    write_result "$(jq -n --arg m "$MODEL" '{status:"failed", requested_model:$m, evidence:"result envelope build failed"}')"
    log "elevated step: result envelope build failed"
  fi
else
  write_result "$(jq -n --arg m "$MODEL" --arg e "$(bounded_failure_evidence)" \
    '{status:"failed", requested_model:$m, evidence:$e}')"
  log "elevated step failed; wrote failure envelope"
fi
rm -f "$PEERLOG"

SHA-256: ba8219c15db2909fb1c272d2137b124a78a06329af2cca0e80276f6e8918ef3d