Files
CASAN/packages/casan-harness/scripts/bash/loop-run.sh
T
2026-07-19 12:14:12 +07:00

169 lines
6.9 KiB
Bash

#!/usr/bin/env bash
set -uo pipefail
# CASAN Loop Orchestrator (Plan-17 Track 6, harness-owned).
#
# Drives one governed agent loop. Every turn runs the loop primitives in order
# (17.20): loop-gate (verify) -> loop-governor (budget) -> loop-convergence
# (progress) -> loop-trace (record). The loop stops on the FIRST stop-condition:
# DONE gate PASS on the artifact (success-criteria proven),
# HALT governor budget exceeded (loop-breaker) or gate DENY (security),
# ESCALATE convergence STALLED/OSCILLATING (or gate ESCALATE).
#
# Secure-by-default (17.20): governance is ON. In profile=prod, disabling it
# (CASAN_LOOP_GOVERNANCE=off) is refused unless an explicit, audited opt-out
# reason is given (CASAN_LOOP_OPTOUT_REASON) — reversing the ARCH-03 lesson.
#
# Between-turn context compaction (17.21) uses context-compress.py + must-keep
# when --context is supplied, so the loop's growing context is kept bounded.
#
# Usage:
# loop-run.sh --run-id R --artifact FILE [--success-criteria FILE] \
# [--profile prod|dev] [--delegation-level L2] [--project okr] \
# [--max-steps N] [--tokens-per-step T] [--cost-per-step C] \
# [--context FILE]
#
# Exit codes: 0 DONE · 3 HALT/ESCALATE/DENY (governance stopped the loop) ·
# 4 opt-out refused (prod) · 2 usage error.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_BIN="${CASAN_PYTHON_BIN:-}"
if [[ -z "$PYTHON_BIN" ]]; then
for candidate in /usr/bin/python3 python3 python; do
if command -v "$candidate" >/dev/null 2>&1 && "$candidate" --version >/dev/null 2>&1; then
PYTHON_BIN="$candidate"
break
fi
done
fi
if [[ -z "$PYTHON_BIN" ]]; then
echo "LOOP_RUNTIME_UNAVAILABLE: working Python 3 interpreter not found" >&2
exit 69
fi
GATE="$SCRIPT_DIR/loop-gate.py"
GOV="$SCRIPT_DIR/loop-governor.py"
CONV="$SCRIPT_DIR/loop-convergence.py"
TRACE="$SCRIPT_DIR/loop-trace.py"
COMPRESS="$SCRIPT_DIR/context-compress.py"
RUN_ID=""; ARTIFACT=""; CRIT=""; PROFILE=""; DLEVEL=""; PROJECT=""
MAX_STEPS=25; TOKENS_PER_STEP=1000; COST_PER_STEP="0.01"; CONTEXT=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
--run-id) RUN_ID="${2:-}"; shift 2 ;;
--artifact) ARTIFACT="${2:-}"; shift 2 ;;
--success-criteria) CRIT="${2:-}"; shift 2 ;;
--profile) PROFILE="${2:-}"; shift 2 ;;
--delegation-level) DLEVEL="${2:-}"; shift 2 ;;
--project) PROJECT="${2:-}"; shift 2 ;;
--max-steps) MAX_STEPS="${2:-}"; shift 2 ;;
--tokens-per-step) TOKENS_PER_STEP="${2:-}"; shift 2 ;;
--cost-per-step) COST_PER_STEP="${2:-}"; shift 2 ;;
--context) CONTEXT="${2:-}"; shift 2 ;;
*) echo "loop-run: unknown arg $1" >&2; exit 2 ;;
esac
done
if [[ -z "$RUN_ID" || -z "$ARTIFACT" ]]; then
echo "Usage: loop-run.sh --run-id R --artifact FILE [--success-criteria FILE] ..." >&2
exit 2
fi
PROFILE="${PROFILE:-${CASAN_PROFILE:-dev}}"
prof_args=(--profile "$PROFILE")
[[ -n "$DLEVEL" ]] && prof_args+=(--delegation-level "$DLEVEL")
[[ -n "$PROJECT" ]] && prof_args+=(--project "$PROJECT")
sha_of() {
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" 2>/dev/null | awk '{print $1}'
else shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'; fi
}
json_field() { sed -n "s/.*\"$1\": \"\\([A-Za-z_]*\\)\".*/\\1/p" | head -1; }
# --- secure-by-default governance gate (17.20) ------------------------------
GOVERNANCE="${CASAN_LOOP_GOVERNANCE:-on}"
if [[ "$GOVERNANCE" == "off" ]]; then
if [[ "$PROFILE" == "prod" && -z "${CASAN_LOOP_OPTOUT_REASON:-}" ]]; then
echo "LOOP_REFUSE opt-out of loop governance requires CASAN_LOOP_OPTOUT_REASON in prod" >&2
exit 4
fi
# An allowed opt-out is always audited (never silent).
PYTHONPATH="$SCRIPT_DIR" "$PYTHON_BIN" - "$RUN_ID" "${CASAN_LOOP_OPTOUT_REASON:-dev-optout}" <<'PY'
import sys, loop_common as lc
lc.append_audit({"kind": "loop_governance_optout", "run_id": sys.argv[1], "reason": sys.argv[2]})
PY
fi
echo "===== loop-run run_id=$RUN_ID profile=$PROFILE governance=$GOVERNANCE ====="
FINAL="DONE"; RC=0
CUM_TOKENS=0
COST_ACC="0"
for (( step=1; step<=MAX_STEPS; step++ )); do
CUM_TOKENS=$(( CUM_TOKENS + TOKENS_PER_STEP ))
COST_ACC="$("$PYTHON_BIN" -c "print(round($COST_ACC + $COST_PER_STEP, 6))")"
DECISION="CONTINUE"; VERDICT=""; PROGRESS="0"
if [[ "$GOVERNANCE" == "on" ]]; then
# 1) Governor: cumulative budget check (loop-breaker) BEFORE more work.
set +e
"$PYTHON_BIN" "$GOV" check --run-id "$RUN_ID" --step "$step" \
--tokens "$CUM_TOKENS" --cost "$COST_ACC" "${prof_args[@]}" >/dev/null 2>&1
grc=$?
set -e 2>/dev/null || true
if [[ "$grc" -eq 3 ]]; then FINAL="HALT"; DECISION="HALT"; RC=3; fi
if [[ "$grc" -eq 4 ]]; then FINAL="ESCALATE"; DECISION="ESCALATE"; RC=3; fi
if [[ "$DECISION" == "CONTINUE" ]]; then
# 2) Gate: per-iteration verify contract.
set +e
GATE_OUT="$("$PYTHON_BIN" "$GATE" verify --run-id "$RUN_ID" --step "$step" \
--artifact "$ARTIFACT" ${CRIT:+--success-criteria "$CRIT"} "${prof_args[@]}" 2>/dev/null)"
set -e 2>/dev/null || true
VERDICT="$(printf '%s' "$GATE_OUT" | json_field verdict)"
case "$VERDICT" in
PASS) FINAL="DONE"; DECISION="DONE"; PROGRESS="1"; RC=0 ;;
DENY) FINAL="HALT"; DECISION="HALT"; RC=3 ;;
ESCALATE) FINAL="ESCALATE"; DECISION="ESCALATE"; RC=3 ;;
*) DECISION="CONTINUE"; PROGRESS="0" ;; # FAIL -> keep correcting
esac
fi
# 3) Convergence: observe + verdict (no-progress / oscillation breaker).
if [[ "$DECISION" == "CONTINUE" ]]; then
AH="$(sha_of "$ARTIFACT")"; AH="${AH:0:16}"
"$PYTHON_BIN" "$CONV" observe --run-id "$RUN_ID" --step "$step" \
--action-hash "$AH" --progress "$PROGRESS" >/dev/null 2>&1
set +e
"$PYTHON_BIN" "$CONV" verdict --run-id "$RUN_ID" "${prof_args[@]}" >/dev/null 2>&1
cvrc=$?
set -e 2>/dev/null || true
if [[ "$cvrc" -eq 3 ]]; then FINAL="HALT"; DECISION="HALT"; RC=3; fi
if [[ "$cvrc" -eq 4 ]]; then FINAL="ESCALATE"; DECISION="ESCALATE"; RC=3; fi
fi
else
# Governance opted out: record turns only, terminate at max-steps as DONE.
DECISION="CONTINUE"
fi
# 4) Trace: append the immutable iteration record.
"$PYTHON_BIN" "$TRACE" record --run-id "$RUN_ID" --step "$step" \
--intent "turn-$step" --action verify --tool loop-run \
${VERDICT:+--gate-verdict "$VERDICT"} --decision "$DECISION" --progress "$PROGRESS" \
--artifact "$ARTIFACT" ${CRIT:+--success-criteria "$CRIT"} \
--budget-snapshot "{\"steps\":$step,\"tokens\":$CUM_TOKENS,\"cost_usd\":$COST_ACC}" >/dev/null 2>&1
# 5) Between-turn context compaction (17.21).
if [[ -n "$CONTEXT" && -f "$CONTEXT" ]]; then
"$PYTHON_BIN" "$COMPRESS" --mode structural --input "$CONTEXT" \
> "$(dirname "$ARTIFACT")/.loop-context-compacted.txt" 2>/dev/null || true
fi
[[ "$DECISION" == "CONTINUE" ]] || break
done
echo "LOOP_RESULT run_id=$RUN_ID final=$FINAL last_step=$step tokens=$CUM_TOKENS cost=$COST_ACC"
exit "$RC"