#!/usr/bin/env bash # Graceful, project-owned evaluation stop. Never sends R1 or motor commands. set -euo pipefail PROJECT_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) RUN_DIR= ACK= ABORTED=0 usage() { echo 'Usage: bash scripts/stop_h2_policy_eval.sh [--run-dir PATH] --ack-r1 H2-R1-CONFIRMED' echo ' or: --ack-hardware-off H2-HARDWARE-POWER-OFF-CONFIRMED' echo ' or: --startup-aborted (only when runner never started)' } fail() { echo "$*" >&2; exit 1; } while (($#)); do case "$1" in --run-dir) RUN_DIR=${2:?missing run directory}; shift 2 ;; --ack-r1|--ack-hardware-off) ACK=${2:?missing acknowledgement}; shift 2 ;; --startup-aborted) ABORTED=1; shift ;; -h|--help) usage; exit 0 ;; *) usage >&2; exit 2 ;; esac done if [[ -z $RUN_DIR ]]; then [[ -f "$PROJECT_ROOT/artifacts/latest_eval_run" ]] || fail 'No evaluation run pointer in this project.' IFS= read -r RUN_DIR < "$PROJECT_ROOT/artifacts/latest_eval_run" fi [[ -d $RUN_DIR ]] || fail "Evaluation run directory does not exist: $RUN_DIR" RUN_DIR=$(cd -- "$RUN_DIR" && pwd -P) H2_ROOT= H2_IF= H2_PY= H2_SESSION= FB_SESSION= H2_EVAL_RUN_DIR= if [[ -f "$RUN_DIR/eval_owner.env" ]]; then source "$RUN_DIR/eval_owner.env" elif [[ -f "$RUN_DIR/run.env" ]]; then source "$RUN_DIR/run.env" else fail 'Missing evaluation owner metadata; nothing stopped.' fi [[ -n $H2_ROOT && -d $H2_ROOT ]] || fail 'Missing evaluation project owner; nothing stopped.' [[ $(cd -- "$H2_ROOT" && pwd -P) == "$PROJECT_ROOT" ]] \ || fail 'Evaluation run belongs to another project; nothing stopped.' SESSION=${H2_SESSION:-$FB_SESSION} [[ $SESSION =~ ^[A-Za-z0-9_.-]+$ ]] || fail 'Invalid evaluation session owner; nothing stopped.' SCORES=${H2_EVAL_RUN_DIR:-$RUN_DIR/evaluation} has_session() { tmux has-session -t "=$SESSION" 2>/dev/null; } if has_session; then # show-options uses target-pane syntax; ':' keeps the exact session scope. [[ $(tmux show-options -v -t "=$SESSION:" @h2_eval_run_dir) == "$RUN_DIR" ]] \ || fail 'Evaluation tmux owner mismatch; nothing stopped.' elif [[ -f "$RUN_DIR/eval.stopped" ]]; then echo "Evaluation already stopped. Scores: $SCORES" exit 0 fi [[ -f "$RUN_DIR/panes.tsv" ]] || fail 'Missing stable evaluation pane ownership; nothing stopped.' pane_info() { tmux display-message -p -t "$1" '#{session_name} #{pane_id} #{pane_dead}' 2>/dev/null || true } check_pane() { local pane=$1 info [[ $pane =~ ^%[0-9]+$ ]] || fail 'Invalid evaluation pane id; nothing stopped.' info=$(pane_info "$pane") [[ -z $info || $info == "$SESSION $pane 0" || $info == "$SESSION $pane 1" ]] \ || fail "Pane $pane belongs to another session; nothing stopped." } while IFS=$'\t' read -r role pane; do [[ -n $role ]] || continue case "$role" in control|runner|predictor|planner|adapter|monitor|bridge) check_pane "$pane" ;; *) fail "Unknown evaluation pane role: $role; nothing stopped." ;; esac done < "$RUN_DIR/panes.tsv" if [[ $ABORTED == 1 ]]; then [[ ! -e "$RUN_DIR/runner.started" ]] || fail 'Runner started; use confirmed R1 or power-off before stop.' elif [[ $ACK == H2-R1-CONFIRMED ]]; then [[ -n $H2_PY ]] || fail 'Missing evaluation Python; nothing stopped.' ( cd -- "$PROJECT_ROOT" "$H2_PY" - <<'PY' from runtime.h2_shm_bridge import H2SharedMemoryBridge bridge = H2SharedMemoryBridge(claim_writer=False, independent_reader=True) try: state = bridge.read_state() if not (state.stop_requested and state.emergency_stop): raise SystemExit("R1 stop/fault is not latched; no process was stopped") print("Verified stop=1 fault=1; read-only, no policy model loaded") finally: bridge.close() PY ) elif [[ $ACK != H2-HARDWARE-POWER-OFF-CONFIRMED ]]; then usage >&2 fail 'Operator R1 or confirmed physical power-off is required; nothing stopped.' fi # Serialize only the launch decision, never worker exit or journal flush. ( flock -x 9 touch "$RUN_DIR/eval.stop" if [[ $ABORTED == 1 && -e "$RUN_DIR/runner.started" ]]; then fail 'Runner started while cancelling startup; session preserved. Confirm R1 or power-off, then retry.' fi ) 9>"$RUN_DIR/eval.lifecycle.lock" role_pane() { awk -F '\t' -v role="$1" '$1 == role {print $2; exit}' "$RUN_DIR/panes.tsv"; } control=$(role_pane control) if [[ -n $control ]]; then check_pane "$control" if [[ $(pane_info "$control") == "$SESSION $control 0" ]]; then tmux send-keys -t "$control" C-c 2>/dev/null || true fi fi # A launcher already entering RUNNER must not be mistaken for an aborted start. if [[ $ABORTED == 1 && -e "$RUN_DIR/runner.started" ]]; then fail 'Runner started while cancelling startup; session preserved. Confirm R1 or power-off, then retry.' fi stop_role() { local role=$1 pane info pane=$(role_pane "$role") [[ -n $pane ]] || return 0 check_pane "$pane" info=$(pane_info "$pane") [[ $info == "$SESSION $pane 0" ]] || return 0 tmux send-keys -t "$pane" C-c 2>/dev/null || true for _ in {1..60}; do info=$(pane_info "$pane") [[ -z $info || $info == "$SESSION $pane 1" ]] && return 0 [[ $info == "$SESSION $pane 0" ]] || fail "Pane $pane ownership changed; session preserved." sleep 0.5 done fail "$role did not exit within 30 seconds; session preserved. Wait for journal flush and retry; no force kill." } for role in runner predictor planner adapter monitor bridge; do stop_role "$role" done if has_session; then tmux kill-session -t "=$SESSION" || { ! has_session || fail 'Owned session could not be closed; it was not force-killed.' } fi touch "$RUN_DIR/eval.stopped" if [[ -e /dev/shm/h2_pingpong_bridge ]]; then echo 'WARNING: /dev/shm/h2_pingpong_bridge still exists; retained, not deleted.' >&2 fi echo "Stopped only evaluation session $SESSION. Scores: $SCORES" echo 'Shared capture, other projects and network configuration were not changed.'