Orbit Runner Script Template
Purpose: load this only when Orbit must generate or patch run-loop.sh. It contains the executable runner template and nothing else.
Contents
Runner Template (run-loop.sh)
Primary output of Orbit. Drives autonomous loop execution from goal to DONE.
#!/bin/bash
# nexus-autoloop runner — generated by Orbit
# Goal: {{GOAL_SUMMARY}}
# Generated: {{TIMESTAMP}}
set -euo pipefail
#--- Configuration (customize per goal) ---
LOOP_DIR="${LOOP_DIR:-$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)}"
LOOP_DIR="$(cd -- "${LOOP_DIR}" && pwd)"
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
MAX_ITERATIONS="${MAX_ITERATIONS:-20}"
RETRY_LIMIT="${RETRY_LIMIT:-3}"
RETRY_BACKOFF_BASE="${RETRY_BACKOFF_BASE:-2}"
# Executor command — see reference/executor-engines.md for engine-specific configuration
EXEC_CMD="${EXEC_CMD:-codex}"
EXEC_TIMEOUT="${ITER_TIMEOUT:-${EXEC_TIMEOUT:-600}}"
AUTOCOMMIT="${AUTOCOMMIT:-true}"
COMMIT_MSG_PREFIX="${COMMIT_MSG_PREFIX:-loop}"
NOTIFY_ENABLED="${NOTIFY_ENABLED:-false}"
MAX_LOG_SIZE="${MAX_LOG_SIZE:-5242880}"
ADAPTIVE_TIMEOUT="${ADAPTIVE_TIMEOUT:-false}"
SKIP_PREFLIGHT="${SKIP_PREFLIGHT:-false}"
BRANCH_ISOLATION="${BRANCH_ISOLATION:-true}"
SQUASH_ON_DONE="${SQUASH_ON_DONE:-true}"
SQUASH_MSG_ENGINE="${SQUASH_MSG_ENGINE:-auto}"
#--- Resilience configuration ---
CIRCUIT_BREAKER="${CIRCUIT_BREAKER:-true}"
CIRCUIT_THRESHOLD="${CIRCUIT_THRESHOLD:-3}"
CIRCUIT_COOLDOWN="${CIRCUIT_COOLDOWN:-300}"
TOOL_TIMEOUT="${TOOL_TIMEOUT:-120}"
ITER_TIMEOUT="${ITER_TIMEOUT:-${EXEC_TIMEOUT}}"
STRUCTURED_LOG="${STRUCTURED_LOG:-true}"
COST_TRACKING="${COST_TRACKING:-false}"
TOKEN_BUDGET="${TOKEN_BUDGET:-0}"
#--- External terminators (beyond MAX_ITERATIONS) ---
# Core Contract: termination MUST be enforced externally (iteration cap, timeout, budget).
LOOP_TIMEOUT="${LOOP_TIMEOUT:-0}" # total wall-clock seconds; 0 = unlimited
USD_PER_RUN_CAP="${USD_PER_RUN_CAP:-0}" # absolute USD cap per run; 0 = unlimited
USD_PER_ITER_CAP="${USD_PER_ITER_CAP:-0}" # absolute USD cap per iteration; 0 = unlimited
# Executors that know their cost append a USD float line to ${LOOP_DIR}/.cost-usd each iteration.
#--- Semantic-stall & evidence gates ---
CONVERGENCE_THRESHOLD="${CONVERGENCE_THRESHOLD:-0.85}"
CONVERGENCE_WINDOW="${CONVERGENCE_WINDOW:-3}"
PLACEHOLDER_GREP="${PLACEHOLDER_GREP:-true}"
GOAL_IMMUTABLE="${GOAL_IMMUTABLE:-true}"
#--- Portable timeout (macOS + Linux) ---
portable_timeout() {
local secs="$1"; shift
if command -v timeout >/dev/null 2>&1; then
timeout --signal=KILL "${secs}" "$@"
elif command -v gtimeout >/dev/null 2>&1; then
gtimeout --signal=KILL "${secs}" "$@"
else
perl -e '
use POSIX ":sys_wait_h";
my $timeout = shift @ARGV;
my $pid = fork // die "fork: $!";
if ($pid == 0) {
setpgrp(0, 0) or die "setpgrp: $!";
exec { $ARGV[0] } @ARGV or die "exec: $!";
}
local $SIG{ALRM} = sub {
kill 9, -$pid; kill 9, $pid;
waitpid($pid, 0); exit 124;
};
alarm $timeout;
waitpid($pid, 0);
my $status = $?;
exit(($status & 127) ? 128 + ($status & 127) : $status >> 8);
' "${secs}" "$@"
fi
}
#--- Pre-flight checks (skip with SKIP_PREFLIGHT=true) ---
preflight_check() {
if [[ "${SKIP_PREFLIGHT}" == "true" ]]; then
echo "[PREFLIGHT] Skipped (SKIP_PREFLIGHT=true)"
return 0
fi
# Disk space check (abort if < 100MB free)
local avail_kb
avail_kb=$(df -k "${LOOP_DIR}" | awk 'NR==2{print $4}')
if [[ "${avail_kb}" -lt 102400 ]]; then
echo "[PREFLIGHT:FAIL] Disk space critically low: ${avail_kb}KB available (< 100MB)"
return 1
fi
# Stale lock detection
if [[ -f "${LOOP_DIR}/.run-loop.lock" ]]; then
local lock_pid
lock_pid=$(cat "${LOOP_DIR}/.run-loop.lock")
if kill -0 "${lock_pid}" 2>/dev/null; then
echo "[PREFLIGHT:FAIL] Active lock — PID ${lock_pid} is running"
return 1
else
echo "[PREFLIGHT:WARN] Stale lock — PID ${lock_pid} dead, removing"
rm -f "${LOOP_DIR}/.run-loop.lock"
fi
fi
# Git repository health (AUTOCOMMIT only)
if [[ "${AUTOCOMMIT}" == "true" ]]; then
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "[PREFLIGHT:FAIL] Not inside a git repository (AUTOCOMMIT=true requires git)"
return 1
fi
if [[ -d "$(git rev-parse --git-path rebase-merge)" ]] || [[ -d "$(git rev-parse --git-path rebase-apply)" ]]; then
echo "[PREFLIGHT:FAIL] Git rebase in progress — resolve before running loop"
return 1
fi
fi
echo "[PREFLIGHT] All checks passed"
return 0
}
# Run pre-flight and abort on failure
if ! preflight_check; then
echo "[ABORT] Pre-flight check failed — fix issues and retry"
exit 1
fi
# Acquire the lock atomically; always release it, including unexpected errors.
if ! (set -o noclobber; echo $$ > "${LOOP_DIR}/.run-loop.lock") 2>/dev/null; then
echo "[ABORT] Another runner acquired the loop lock"
exit 1
fi
trap 'rm -f "${LOOP_DIR}/.run-loop.lock"' EXIT
#--- Log rotation ---
rotate_log() {
if [[ -f "${LOOP_DIR}/runner.log" ]]; then
local log_size
log_size=$(wc -c < "${LOOP_DIR}/runner.log" 2>/dev/null || echo 0)
if [[ "${log_size}" -gt "${MAX_LOG_SIZE}" ]]; then
mv "${LOOP_DIR}/runner.log" "${LOOP_DIR}/runner.log.prev"
echo "[LOG] Rotated runner.log (${log_size} bytes > MAX_LOG_SIZE=${MAX_LOG_SIZE})"
fi
fi
}
rotate_log
#--- Structured logging (JSON Lines) ---
json_escape() {
local value="$1" char code escaped result="" i
local LC_ALL=C
for ((i=0; i<${#value}; i++)); do
char="${value:i:1}"
case "${char}" in
'"') result+='\"' ;;
'\') result+='\\' ;;
*)
printf -v code '%d' "'${char}"
if ((code < 32)); then
printf -v escaped '\\u%04x' "${code}"
result+="${escaped}"
else
result+="${char}"
fi ;;
esac
done
printf '%s' "${result}"
}
emit_log() {
if [[ "${STRUCTURED_LOG}" != "true" ]]; then return; fi
local level="$1" event="$2"; shift 2
local ts
ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local json="{\"timestamp\":\"${ts}\",\"level\":\"$(json_escape "${level}")\",\"event\":\"$(json_escape "${event}")\",\"iteration\":${ITER:-0}"
while [[ $# -ge 2 ]]; do
json="${json},\"$(json_escape "$1")\":\"$(json_escape "$2")\""
shift 2
done
printf '%s}\n' "${json}" >> "${LOOP_DIR}/runner.jsonl"
}
#--- Circuit breaker ---
CIRCUIT_FILE="${LOOP_DIR}/.circuit-state"
CB_STATE="CLOSED"
CB_FAIL_COUNT=0
CB_LAST_SIGNATURE=""
CB_LAST_UPDATED=0
load_circuit_state() {
local key value
[[ -f "${CIRCUIT_FILE}" ]] || return 0
while IFS='=' read -r key value || [[ -n "${key}" ]]; do
case "${key}" in
CB_STATE) [[ "${value}" =~ ^(OPEN|HALF_OPEN|CLOSED)$ ]] || continue ;;
CB_FAIL_COUNT|CB_LAST_UPDATED) [[ "${value}" =~ ^[0-9]+$ ]] || continue ;;
CB_LAST_SIGNATURE) [[ "${value}" =~ ^[A-Za-z0-9_]*$ ]] || continue ;;
*) continue ;;
esac
printf -v "${key}" '%s' "${value}"
done < "${CIRCUIT_FILE}"
}
save_circuit_state() {
cat > "${CIRCUIT_FILE}" <<CB_EOF
CB_STATE=${CB_STATE:-CLOSED}
CB_FAIL_COUNT=${CB_FAIL_COUNT}
CB_LAST_SIGNATURE=${CB_LAST_SIGNATURE}
CB_LAST_UPDATED=$(date +%s)
CB_EOF
}
check_circuit() {
load_circuit_state
if [[ "${CIRCUIT_BREAKER}" != "true" ]]; then return 0; fi
local now
now=$(date +%s)
case "${CB_STATE:-CLOSED}" in
OPEN)
local elapsed=$(( now - ${CB_LAST_UPDATED:-0} ))
if [[ "${elapsed}" -ge "${CIRCUIT_COOLDOWN}" ]]; then
CB_STATE="HALF_OPEN"
emit_log "INFO" "circuit_state_change" "from" "OPEN" "to" "HALF_OPEN" "reason" "cooldown_elapsed"
save_circuit_state
return 0
fi
emit_log "WARN" "circuit_open" "remaining_cooldown" "$((CIRCUIT_COOLDOWN - elapsed))"
return 1
;;
HALF_OPEN|CLOSED)
return 0
;;
esac
}
record_circuit_failure() {
local signature="$1"
if [[ "${CIRCUIT_BREAKER}" != "true" ]]; then return; fi
if [[ "${signature}" == "${CB_LAST_SIGNATURE}" ]]; then
CB_FAIL_COUNT=$((CB_FAIL_COUNT + 1))
else
CB_FAIL_COUNT=1
CB_LAST_SIGNATURE="${signature}"
fi
if [[ "${CB_STATE:-CLOSED}" == "HALF_OPEN" ]]; then
CB_STATE="OPEN"
emit_log "WARN" "circuit_state_change" "from" "HALF_OPEN" "to" "OPEN" "signature" "${signature}"
elif [[ "${CB_FAIL_COUNT}" -ge "${CIRCUIT_THRESHOLD}" ]]; then
CB_STATE="OPEN"
emit_log "WARN" "circuit_state_change" "from" "CLOSED" "to" "OPEN" "signature" "${signature}" "count" "${CB_FAIL_COUNT}"
fi
save_circuit_state
}
record_circuit_success() {
if [[ "${CIRCUIT_BREAKER}" != "true" ]]; then return; fi
if [[ "${CB_STATE:-CLOSED}" != "CLOSED" ]]; then
emit_log "INFO" "circuit_state_change" "from" "${CB_STATE}" "to" "CLOSED" "reason" "success"
fi
CB_STATE="CLOSED"
CB_FAIL_COUNT=0
CB_LAST_SIGNATURE=""
save_circuit_state
}
#--- Budget gate (reads optional executor-emitted ${LOOP_DIR}/.cost-usd) ---
# Never run unmonitored loops without budget caps. Enforcement is a no-op only when
# both caps are 0 AND no cost signal exists; otherwise caps are hard external terminators.
cost_total_usd() {
[[ -f "${LOOP_DIR}/.cost-usd" ]] || { echo 0; return; }
awk '{s+=$1} END{printf "%.4f", s+0}' "${LOOP_DIR}/.cost-usd"
}
cost_last_usd() {
[[ -f "${LOOP_DIR}/.cost-usd" ]] || { echo 0; return; }
tail -1 "${LOOP_DIR}/.cost-usd" 2>/dev/null | awk '{printf "%.4f", $1+0}'
}
budget_exceeded() {
# returns 0 (true) when a configured cap is exceeded
local total last
total=$(cost_total_usd); last=$(cost_last_usd)
if [[ "${USD_PER_RUN_CAP}" != "0" ]] && awk "BEGIN{exit !(${total} > ${USD_PER_RUN_CAP})}"; then
echo "[BUDGET:RUN] run cost \$${total} exceeded USD_PER_RUN_CAP=\$${USD_PER_RUN_CAP}"; return 0
fi
if [[ "${USD_PER_ITER_CAP}" != "0" ]] && awk "BEGIN{exit !(${last} > ${USD_PER_ITER_CAP})}"; then
echo "[BUDGET:ITER] iter cost \$${last} exceeded USD_PER_ITER_CAP=\$${USD_PER_ITER_CAP}"; return 0
fi
return 1
}
# Pin the start commit even without branch isolation: autocommit must not hide
# changed files from completion checks. Emit NUL-delimited literal paths.
loop_changed_files() {
local base="${LOOP_BASE:-}"
[[ -n "${REPO_ROOT}" ]] || return 0
if [[ -n "${base}" ]] && git rev-parse --verify "${base}" >/dev/null 2>&1; then
git -C "${REPO_ROOT}" diff --name-only -z "${base}...HEAD" -- 2>/dev/null || true
fi
git -C "${REPO_ROOT}" diff --name-only -z HEAD -- 2>/dev/null || true
git -C "${REPO_ROOT}" ls-files --others --exclude-standard -z 2>/dev/null || true
}
#--- Placeholder gate: verify PASS alone is not DONE evidence (AP-12) ---
placeholder_clean() {
[[ "${PLACEHOLDER_GREP}" == "true" ]] || return 0
local changed path result
changed=$(mktemp "${LOOP_DIR}/placeholder-paths.XXXXXX")
loop_changed_files > "${changed}"
while IFS= read -r -d '' path; do
case "${path}" in
*.js|*.ts|*.tsx|*.jsx|*.py|*.go|*.rb|*.rs|*.java|*.kt|*.swift|*.php|*.c|*.cc|*.cpp|*.h) ;;
*) continue ;;
esac
path="${REPO_ROOT}/${path}"
[[ -f "${path}" ]] || continue # deleted source needs no scan
result=0
grep -nE 'TODO|FIXME|NotImplementedError|raise NotImplemented|^[[:space:]]*pass[[:space:]]*$|return None[[:space:]]*#|throw new Error\("not implemented"\)' -- "${path}" || result=$?
if [[ "${result}" -ne 1 ]]; then
rm -f "${changed}"
return 1 # a match or an unreadable source cannot satisfy the gate
fi
done < "${changed}"
rm -f "${changed}"
return 0
}
#--- Convergence / stuck-loop gate (semantic stall, not just exit codes) ---
# Signature = HEAD tree hash + working-tree diff hash + verify result. CONVERGENCE_WINDOW
# identical signatures in a row => CONVERGENCE_STALL (no net progress despite "success").
# Tree hash advances on every real commit, so healthy autocommit loops never false-trip.
SIG_LOG="${LOOP_DIR}/.action-sig.log"
record_signature() {
local verify="$1" tree wt sig path repo_root
repo_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
tree=$(git rev-parse 'HEAD^{tree}' 2>/dev/null || echo "no-tree")
wt=$( {
git diff HEAD 2>/dev/null || true
if [[ -n "${repo_root}" ]]; then
git -C "${repo_root}" ls-files --others --exclude-standard -z |
while IFS= read -r -d '' path; do
# Runtime logs/checkpoints change each turn but are not goal progress.
[[ "${repo_root}/${path}" == "${LOOP_DIR}/"* ]] && continue
printf '%s\0' "${path}"
if [[ -L "${repo_root}/${path}" ]]; then
readlink "${repo_root}/${path}"
elif [[ -f "${repo_root}/${path}" ]]; then
shasum -a 256 < "${repo_root}/${path}"
fi
done
fi
} | shasum -a 256 | awk '{print $1}')
sig=$(printf '%s:%s:%s' "${tree}" "${wt}" "${verify}" | shasum -a 256 | awk '{print $1}')
echo "${sig}" >> "${SIG_LOG}"
}
convergence_stalled() {
[[ -f "${SIG_LOG}" ]] || return 1
local n; n="${CONVERGENCE_WINDOW}"
local lines; lines=$(tail -n "${n}" "${SIG_LOG}")
[[ "$(echo "${lines}" | wc -l | tr -d ' ')" -lt "${n}" ]] && return 1
[[ "$(echo "${lines}" | sort -u | wc -l | tr -d ' ')" -eq 1 ]]
}
# Parse checkpoint data without evaluating shell code or assigning environment keys.
load_state() {
local key value
[[ -f "${LOOP_DIR}/state.env" ]] || return 0
while IFS='=' read -r key value || [[ -n "${key}" ]]; do
case "${key}" in
NEXT_ITERATION)
[[ "${value}" =~ ^[1-9][0-9]*$ ]] || continue ;;
LAST_STATUS)
[[ "${value}" =~ ^(READY|CONTINUE|DONE|BLOCKED)$ ]] || continue ;;
TOTAL_TOKENS|TOTAL_API_CALLS|ITER_TOKENS|ITER_API_CALLS)
[[ "${value}" =~ ^[0-9]+$ ]] || continue ;;
ESTIMATED_COST_USD)
[[ "${value}" =~ ^[0-9]+([.][0-9]+)?$ ]] || continue ;;
CONTRACT_VERSION)
[[ "${value}" =~ ^[0-9]+[.][0-9]+[.][0-9]+$ ]] || continue ;;
LAST_UPDATED_AT|ORIGIN_BRANCH|ITER_BRANCH|RECOVERED_FROM|LOOP_BASE) ;;
*) continue ;;
esac
printf -v "${key}" '%s' "${value}"
done < "${LOOP_DIR}/state.env"
}
write_state() {
local next="$1" status="$2" tmp key
tmp=$(mktemp "${LOOP_DIR}/state.env.XXXXXX")
cat > "${tmp}" <<STATE_EOF
NEXT_ITERATION=${next}
LAST_STATUS=${status}
LAST_UPDATED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
ORIGIN_BRANCH=${ORIGIN_BRANCH:-}
ITER_BRANCH=${ITER_BRANCH:-}
LOOP_BASE=${LOOP_BASE:-}
CONTRACT_VERSION=${CONTRACT_VERSION:-1.2.0}
STATE_EOF
for key in TOTAL_TOKENS TOTAL_API_CALLS ESTIMATED_COST_USD; do
if [[ -n "${!key:-}" ]]; then
printf '%s=%s\n' "${key}" "${!key}" >> "${tmp}"
fi
done
mv "${tmp}" "${LOOP_DIR}/state.env"
shasum -a 256 "${LOOP_DIR}/state.env" | awk '{print $1}' > "${LOOP_DIR}/state.env.sha256"
}
#--- Graceful shutdown (SIGINT/SIGTERM) ---
SHUTDOWN=0
cleanup() {
echo "[SIGNAL] Caught signal — initiating graceful shutdown"
SHUTDOWN=1
# Step 1: State snapshot
write_state "${ITER:-1}" CONTINUE
# Step 2: Partial results summary
{
echo ""
echo "## Iteration ${ITER} — INTERRUPTED"
echo "- Signal: graceful shutdown"
echo "- Timestamp: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
echo "- Last verify: ${VERIFY_RESULT:-N/A}"
echo "- Partial work preserved for resume"
} >> "${LOOP_DIR}/progress.md"
# Step 3: Structured log
emit_log "WARN" "graceful_shutdown" "iteration" "${ITER}" "verify" "${VERIFY_RESULT:-N/A}"
# Step 4: Cleanup
rm -f "${LOOP_DIR}/.run-loop.lock"
echo "[SIGNAL] State saved — safe to resume from iteration ${ITER}"
exit 130
}
trap cleanup SIGINT SIGTERM
#--- Validate checksum before consuming checkpoint values ---
ITER=1
STATUS="READY"
if [[ -f "${LOOP_DIR}/state.env.sha256" ]]; then
EXPECTED_SHA=$(cat "${LOOP_DIR}/state.env.sha256")
ACTUAL_SHA=""
if [[ -f "${LOOP_DIR}/state.env" ]]; then
ACTUAL_SHA=$(shasum -a 256 "${LOOP_DIR}/state.env" | awk '{print $1}')
fi
if [[ "${EXPECTED_SHA}" != "${ACTUAL_SHA}" ]]; then
echo "[WARN] state.env checksum mismatch — recovering before loading state"
if [[ -f "${LOOP_DIR}/recover.sh" ]]; then
bash "${LOOP_DIR}/recover.sh" "${LOOP_DIR}"
else
echo "[ABORT] Run recover.sh to rebuild the corrupted checkpoint"
exit 1
fi
fi
fi
load_state
ITER="${NEXT_ITERATION:-1}"
STATUS="${LAST_STATUS:-READY}"
LOOP_BASE="${LOOP_BASE:-$(git rev-parse --verify "${ORIGIN_BRANCH:-HEAD}" 2>/dev/null || true)}"
#--- Branch isolation (pre-loop) ---
if [[ "${BRANCH_ISOLATION}" == "true" ]] && [[ "${AUTOCOMMIT}" == "true" ]]; then
LOOP_NAME=$(basename "${LOOP_DIR}" | sed 's/^\.//')
ITER_BRANCH="loop/iter-${LOOP_NAME}"
SUMMARY_BRANCH="loop/summary-${LOOP_NAME}"
# Determine origin branch (resume: from state.env, first run: current HEAD)
if [[ -z "${ORIGIN_BRANCH:-}" ]]; then
ORIGIN_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "main")
fi
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
# DONE can be resumed after squash has already removed the iteration branch.
if [[ "${STATUS}" != "DONE" ]] && [[ "${CURRENT_BRANCH}" != "${ITER_BRANCH}" ]]; then
# Stash dirty worktree before branch switch
STASHED_FOR_BRANCH=false
if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then
git stash push -m "orbit-branch-isolation-pre-switch"
STASHED_FOR_BRANCH=true
fi
if git rev-parse --verify "${ITER_BRANCH}" >/dev/null 2>&1; then
git checkout "${ITER_BRANCH}"
else
git checkout -b "${ITER_BRANCH}"
fi
if [[ "${STASHED_FOR_BRANCH}" == "true" ]]; then
git stash pop --index || { echo "[ABORT] Stash restore conflict — resolve before resuming"; exit 1; }
fi
fi
fi
#--- Dirty baseline snapshot (NUL preserves spaces, quotes, and newlines) ---
if [[ "${AUTOCOMMIT}" == "true" ]]; then
{
git -C "${REPO_ROOT}" diff --name-only -z -- 2>/dev/null || true
git -C "${REPO_ROOT}" diff --cached --name-only -z -- 2>/dev/null || true
git -C "${REPO_ROOT}" ls-files --others --exclude-standard -z 2>/dev/null || true
} > "${LOOP_DIR}/dirty-start-paths.nul"
fi
#--- Adaptive timeout ---
EFFECTIVE_TIMEOUT="${EXEC_TIMEOUT}"
TIMING_LOG="${LOOP_DIR}/.iter-timings.log"
if [[ "${ADAPTIVE_TIMEOUT}" == "true" ]] && [[ -f "${TIMING_LOG}" ]]; then
# Calculate median of last 5 timings x 2, bounded by [EXEC_TIMEOUT, EXEC_TIMEOUT*3]
TIMINGS=$(tail -5 "${TIMING_LOG}" | sort -n)
COUNT=$(echo "${TIMINGS}" | wc -l | tr -d ' ')
if [[ "${COUNT}" -ge 3 ]]; then
MEDIAN=$(echo "${TIMINGS}" | sed -n "$(( (COUNT + 1) / 2 ))p")
ADAPTIVE_VAL=$((MEDIAN * 2))
MAX_TIMEOUT=$((EXEC_TIMEOUT * 3))
if [[ "${ADAPTIVE_VAL}" -lt "${EXEC_TIMEOUT}" ]]; then
EFFECTIVE_TIMEOUT="${EXEC_TIMEOUT}"
elif [[ "${ADAPTIVE_VAL}" -gt "${MAX_TIMEOUT}" ]]; then
EFFECTIVE_TIMEOUT="${MAX_TIMEOUT}"
else
EFFECTIVE_TIMEOUT="${ADAPTIVE_VAL}"
fi
echo "[ADAPTIVE] Timeout adjusted to ${EFFECTIVE_TIMEOUT}s (median=${MEDIAN}s x 2, bounds=[${EXEC_TIMEOUT},${MAX_TIMEOUT}])"
fi
fi
#--- Goal/AC immutability pin (AP-16: mid-run goal drift ABORTs) ---
GOAL_SHA_FILE="${LOOP_DIR}/.goal.sha256"
if [[ "${GOAL_IMMUTABLE}" == "true" ]] && [[ -f "${LOOP_DIR}/goal.md" ]]; then
if [[ ! -f "${GOAL_SHA_FILE}" ]]; then
shasum -a 256 "${LOOP_DIR}/goal.md" | awk '{print $1}' > "${GOAL_SHA_FILE}"
fi
fi
check_goal_immutable() {
[[ "${GOAL_IMMUTABLE}" == "true" ]] || return 0
[[ -f "${GOAL_SHA_FILE}" ]] || return 0
local now; now=$(shasum -a 256 "${LOOP_DIR}/goal.md" 2>/dev/null | awk '{print $1}')
[[ "${now}" == "$(cat "${GOAL_SHA_FILE}")" ]]
}
#--- Loop-level external terminator (wall clock) ---
LOOP_START_TS=$(date +%s)
LOOP_DEADLINE=0
[[ "${LOOP_TIMEOUT}" -gt 0 ]] && LOOP_DEADLINE=$((LOOP_START_TS + LOOP_TIMEOUT))
# Executor attempts, retry delays, and verification share this one deadline.
# A zero command limit means no local limit; the remaining loop budget still applies.
run_with_budget() {
local limit="$1" remaining; shift
if [[ "${LOOP_DEADLINE}" -gt 0 ]]; then
remaining=$((LOOP_DEADLINE - $(date +%s)))
[[ "${remaining}" -gt 0 ]] || return 124
if [[ "${limit}" -eq 0 || "${remaining}" -lt "${limit}" ]]; then
limit="${remaining}"
fi
fi
if [[ "${limit}" -gt 0 ]]; then
portable_timeout "${limit}" "$@"
else
"$@"
fi
}
loop_timeout_reached() {
[[ "${LOOP_DEADLINE}" -gt 0 ]] && [[ "$(date +%s)" -ge "${LOOP_DEADLINE}" ]]
}
write_terminal_state() {
write_state "${ITER}" "$1"
}
#--- Main loop ---
VERIFY_RESULT="N/A"
COMMIT_HASH="no-commit"
while [[ "${STATUS}" != "DONE" ]] && [[ "${ITER}" -le "${MAX_ITERATIONS}" ]]; do
[[ "${SHUTDOWN}" -eq 1 ]] && break
#--- External terminators: wall-clock timeout & budget caps ---
if [[ "${LOOP_DEADLINE}" -gt 0 ]] && [[ "$(date +%s)" -ge "${LOOP_DEADLINE}" ]]; then
echo "[TIMEOUT] LOOP_TIMEOUT=${LOOP_TIMEOUT}s exceeded — external terminator" | tee -a "${LOOP_DIR}/runner.log"
emit_log "WARN" "loop_timeout" "elapsed" "$(( $(date +%s) - LOOP_START_TS ))"
STATUS="BLOCKED"; write_terminal_state BLOCKED; break
fi
if BUDGET_MSG=$(budget_exceeded); then
echo "[ABORT] ${BUDGET_MSG} — pausing; explicit human resume required" | tee -a "${LOOP_DIR}/runner.log"
emit_log "ERROR" "budget_exceeded" "detail" "${BUDGET_MSG}"
STATUS="BLOCKED"; write_terminal_state BLOCKED; break
fi
if ! check_goal_immutable; then
echo "[ABORT] goal.md changed mid-run (GOAL_DRIFT / AP-16)" | tee -a "${LOOP_DIR}/runner.log"
emit_log "ERROR" "goal_drift" "file" "goal.md"
STATUS="BLOCKED"; write_terminal_state BLOCKED; break
fi
if convergence_stalled; then
echo "[CONVERGENCE:STALL] ${CONVERGENCE_WINDOW} identical-signature iterations — no net progress" | tee -a "${LOOP_DIR}/runner.log"
emit_log "ERROR" "convergence_stall" "window" "${CONVERGENCE_WINDOW}"
STATUS="BLOCKED"; write_terminal_state BLOCKED; break
fi
# Circuit breaker gate
if ! check_circuit; then
echo "[CIRCUIT:OPEN] Execution blocked — waiting for cooldown or manual reset" | tee -a "${LOOP_DIR}/runner.log"
STATUS="BLOCKED"
write_state "${ITER}" "BLOCKED"
emit_log "ERROR" "circuit_breaker_block" "state" "OPEN"
break
fi
ITER_START=$(date +%s)
VERIFY_RESULT="N/A"
COMMIT_HASH="no-commit"
echo ""
echo "=== Iteration ${ITER}/${MAX_ITERATIONS} ==="
#--- Iteration health check ---
HEALTH_AVAIL=$(df -k "${LOOP_DIR}" | awk 'NR==2{print $4}')
if [[ "${HEALTH_AVAIL}" -lt 51200 ]]; then
echo "[HEALTH:BLOCKED] Disk space low: ${HEALTH_AVAIL}KB (< 50MB)" | tee -a "${LOOP_DIR}/runner.log"
STATUS="BLOCKED"
write_state "${ITER}" "BLOCKED"
break
fi
if [[ "${AUTOCOMMIT}" == "true" ]]; then
if [[ -d "$(git rev-parse --git-path rebase-merge)" ]] || [[ -d "$(git rev-parse --git-path rebase-apply)" ]]; then
echo "[HEALTH:BLOCKED] Git rebase in progress" | tee -a "${LOOP_DIR}/runner.log"
STATUS="BLOCKED"
write_state "${ITER}" "BLOCKED"
break
fi
fi
rotate_log
#--- Bounded retry execution with error classification ---
EXEC_SUCCESS=false
RETRY_COUNT=0
LAST_EXIT_CODE=0
while [[ "${RETRY_COUNT}" -lt "${RETRY_LIMIT}" ]]; do
LAST_EXIT_CODE=0
# EXEC_CMD is trusted operator-authored shell configuration, never model output.
run_with_budget "${EFFECTIVE_TIMEOUT}" bash -c "${EXEC_CMD}" 2>&1 | tee -a "${LOOP_DIR}/runner.log" || LAST_EXIT_CODE=$?
if loop_timeout_reached; then
echo "[TIMEOUT] LOOP_TIMEOUT=${LOOP_TIMEOUT}s exceeded during executor" | tee -a "${LOOP_DIR}/runner.log"
STATUS="BLOCKED"; write_terminal_state BLOCKED; break 2
fi
if [[ "${LAST_EXIT_CODE}" -eq 0 ]]; then
EXEC_SUCCESS=true
record_circuit_success
emit_log "INFO" "exec_success" "attempt" "$((RETRY_COUNT + 1))"
break
fi
# Error classification: permanent errors skip retry
ERROR_SIG="exit_${LAST_EXIT_CODE}"
case "${LAST_EXIT_CODE}" in
126|127) # permission denied / command not found
emit_log "ERROR" "permanent_failure" "exit_code" "${LAST_EXIT_CODE}" "category" "PERMANENT"
record_circuit_failure "${ERROR_SIG}"
break
;;
esac
RETRY_COUNT=$((RETRY_COUNT + 1))
record_circuit_failure "${ERROR_SIG}"
emit_log "WARN" "exec_retry" "attempt" "${RETRY_COUNT}" "exit_code" "${LAST_EXIT_CODE}" "category" "TRANSIENT"
# Opening the circuit also stops this retry loop, including a failed probe.
if [[ "${CIRCUIT_BREAKER}" == "true" && "${CB_STATE}" == "OPEN" ]]; then
echo "[CIRCUIT:OPEN] Execution blocked until cooldown or manual reset"
break
fi
if [[ "${RETRY_COUNT}" -lt "${RETRY_LIMIT}" ]]; then
JITTER=$((RANDOM % (RETRY_BACKOFF_BASE ** RETRY_COUNT / 2 + 1)))
BACKOFF=$((RETRY_BACKOFF_BASE ** RETRY_COUNT + JITTER))
echo "[RETRY] Attempt ${RETRY_COUNT}/${RETRY_LIMIT} failed (exit=${LAST_EXIT_CODE}) — waiting ${BACKOFF}s"
BACKOFF_EXIT=0
run_with_budget 0 sleep "${BACKOFF}" || BACKOFF_EXIT=$?
if [[ "${BACKOFF_EXIT}" -ne 0 ]] || loop_timeout_reached; then
echo "[TIMEOUT] LOOP_TIMEOUT=${LOOP_TIMEOUT}s exhausted during retry delay" | tee -a "${LOOP_DIR}/runner.log"
STATUS="BLOCKED"; write_terminal_state BLOCKED; break 2
fi
fi
done
if [[ "${EXEC_SUCCESS}" != "true" ]]; then
echo "[BLOCKED] EXEC_CMD failed after ${RETRY_LIMIT} retries (TOOL_FAILURE)" | tee -a "${LOOP_DIR}/runner.log"
{
echo ""
echo "## Iteration ${ITER} — BLOCKED"
echo "- TOOL_FAILURE: ${EXEC_CMD} failed after ${RETRY_LIMIT} retries"
echo "- Timestamp: $(date -u +"%Y-%m-%dT%H:%M:%SZ")"
} >> "${LOOP_DIR}/progress.md"
STATUS="BLOCKED"
# Atomic state write before break (keep same iteration for retry)
write_state "${ITER}" "BLOCKED"
break
fi
#--- Verification gate ---
VERIFY_RESULT="SKIP"
if [[ -f "${LOOP_DIR}/verify.sh" ]]; then
if run_with_budget 0 bash "${LOOP_DIR}/verify.sh"; then
VERIFY_RESULT="PASS"
else
VERIFY_RESULT="FAIL"
fi
fi
# Recheck after executor/verification, including the final iteration: neither
# goal mutation nor newly emitted cost may bypass a gate by creating done.md.
if ! check_goal_immutable; then
echo "[ABORT] goal.md changed mid-run (GOAL_DRIFT / AP-16)" | tee -a "${LOOP_DIR}/runner.log"
STATUS="BLOCKED"; write_terminal_state BLOCKED; break
fi
if BUDGET_MSG=$(budget_exceeded); then
echo "[ABORT] ${BUDGET_MSG}" | tee -a "${LOOP_DIR}/runner.log"
STATUS="BLOCKED"; write_terminal_state BLOCKED; break
fi
if [[ "${LOOP_DEADLINE}" -gt 0 ]] && [[ "$(date +%s)" -ge "${LOOP_DEADLINE}" ]]; then
echo "[TIMEOUT] LOOP_TIMEOUT=${LOOP_TIMEOUT}s exceeded" | tee -a "${LOOP_DIR}/runner.log"
STATUS="BLOCKED"; write_terminal_state BLOCKED; break
fi
#--- Scoped auto-commit ---
if [[ "${AUTOCOMMIT}" == "true" ]]; then
{
git -C "${REPO_ROOT}" diff --name-only -z -- 2>/dev/null || true
git -C "${REPO_ROOT}" diff --cached --name-only -z -- 2>/dev/null || true
git -C "${REPO_ROOT}" ls-files --others --exclude-standard -z 2>/dev/null || true
} > "${LOOP_DIR}/current-changes.nul"
CANDIDATES=()
while IFS= read -r -d '' path; do
# Runtime files must never enter a source commit, even without .gitignore.
[[ "${REPO_ROOT}/${path}" == "${LOOP_DIR}/"* ]] && continue
BASELINE_PATH=false
while IFS= read -r -d '' baseline; do
if [[ "${path}" == "${baseline}" ]]; then BASELINE_PATH=true; break; fi
done < "${LOOP_DIR}/dirty-start-paths.nul"
if [[ "${BASELINE_PATH}" != "true" ]]; then
CANDIDATES+=("${path}")
fi
done < "${LOOP_DIR}/current-changes.nul"
rm -f "${LOOP_DIR}/current-changes.nul"
if [[ "${#CANDIDATES[@]}" -gt 0 ]]; then
git -C "${REPO_ROOT}" --literal-pathspecs add --all -- "${CANDIDATES[@]}"
if ! git -C "${REPO_ROOT}" --literal-pathspecs diff --cached --quiet -- "${CANDIDATES[@]}"; then
# --only leaves pre-existing staged work in the index, outside this commit.
git -C "${REPO_ROOT}" --literal-pathspecs commit --only -m "${COMMIT_MSG_PREFIX}(iter-${ITER}): auto-commit [verify=${VERIFY_RESULT}]" -- "${CANDIDATES[@]}"
COMMIT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
fi
fi
fi
#--- DONE detection (triple gate: done.md + verify + placeholder-clean) ---
if [[ -f "${LOOP_DIR}/done.md" ]]; then
if [[ "${VERIFY_RESULT}" == "PASS" || "${VERIFY_RESULT}" == "SKIP" ]]; then
if placeholder_clean; then
STATUS="DONE"
else
STATUS="CONTINUE"
echo "[INFO] done.md + verify=${VERIFY_RESULT} but placeholders found in changed src — continuing (AP-12)"
emit_log "WARN" "placeholder_block" "verify" "${VERIFY_RESULT}"
fi
else
STATUS="CONTINUE"
echo "[INFO] done.md exists but verification failed — continuing"
fi
else
STATUS="CONTINUE"
fi
#--- Record action signature for convergence detection ---
record_signature "${VERIFY_RESULT}"
#--- Atomic state write ---
NEXT_ITER=$((ITER + 1))
write_state "${NEXT_ITER}" "${STATUS}"
#--- Iteration notification (|| true — never fatal) ---
ITER_END=$(date +%s)
ITER_DURATION=$((ITER_END - ITER_START))
echo "${ITER_DURATION}" >> "${LOOP_DIR}/.iter-timings.log"
if [[ "${NOTIFY_ENABLED}" == "true" ]] && [[ -f "${LOOP_DIR}/notify.sh" ]]; then
bash "${LOOP_DIR}/notify.sh" "${ITER}" "${STATUS}" "${VERIFY_RESULT}" "${ITER_DURATION}" "${LOOP_DIR}" "${COMMIT_HASH}" || true
fi
emit_log "INFO" "iteration_complete" \
"status" "${STATUS}" "verify" "${VERIFY_RESULT}" \
"commit" "${COMMIT_HASH}" "duration" "${ITER_DURATION}" \
"retries" "${RETRY_COUNT}" "exit_code" "${LAST_EXIT_CODE}"
echo "[ITER ${ITER}] status=${STATUS} verify=${VERIFY_RESULT} commit=${COMMIT_HASH} duration=${ITER_DURATION}s"
ITER=${NEXT_ITER}
done
#--- Branch isolation (post-loop squash) ---
squash_iteration_branch() {
# Empty loops and changes reverted during the loop have nothing to summarize.
if git diff --quiet "${ORIGIN_BRANCH}" "${ITER_BRANCH}" --; then
git checkout "${ORIGIN_BRANCH}"
git branch -D "${ITER_BRANCH}"
echo "[BRANCH] No source changes to squash — returned to ${ORIGIN_BRANCH}"
return 0
fi
# Preserve prior summaries: a repeated loop name must not delete user commits.
if git show-ref --verify --quiet "refs/heads/${SUMMARY_BRANCH}"; then
echo "[ABORT] Summary branch ${SUMMARY_BRANCH} already exists — choose another loop name"
return 1
fi
# Scoped iteration commits leave prior staged work untouched. Keep that work
# out of the squash index too, then restore its original staged/unstaged state.
local stashed=false
if ! git diff --quiet || ! git diff --cached --quiet; then
git stash push -m "orbit-pre-squash"
stashed=true
fi
ITER_COMMIT_COUNT=$(git rev-list --count "${ORIGIN_BRANCH}..${ITER_BRANCH}")
echo "[BRANCH] Loop DONE — squashing ${ITER_COMMIT_COUNT} iteration commits"
git checkout "${ORIGIN_BRANCH}"
git checkout -b "${SUMMARY_BRANCH}"
# Squash merge with conflict handling
if git merge --squash "${ITER_BRANCH}"; then
#--- LLM-generated commit message (notify.sh pattern) ---
LOOP_NAME=$(basename "${LOOP_DIR}" | sed 's/^\.//')
DIFF_STAT=$(git diff --cached --stat 2>/dev/null | tail -20)
GOAL_CONTENT=$(head -30 "${LOOP_DIR}/goal.md" 2>/dev/null || echo "")
DONE_CONTENT=$(head -20 "${LOOP_DIR}/done.md" 2>/dev/null || echo "")
SQUASH_PROMPT="以下の情報を元に、conventional commit形式(type(scope): description)のコミットメッセージを生成してください。
typeはfeat/fix/refactor/test/docs/chore/perfから適切なものを選択。scopeは「${LOOP_NAME}」。
本文(body)は変更内容を簡潔に説明。日本語OK。
## Goal
${GOAL_CONTENT}
## Done
${DONE_CONTENT}
## Diff Summary (${ITER_COMMIT_COUNT} commits squashed)
${DIFF_STAT}
## Iterations: $((ITER - 1)), Verify: ${VERIFY_RESULT}
コミットメッセージのみを出力してください(他の説明不要):"
SQUASH_MSG=""
# Optional text generation is bounded and uses supported capture channels.
# agy needs real pty + artifact/sentinel capture; do not launch its interactive
# pipe mode merely to generate a commit message (_common/CLI_COMPATIBILITY.md §9.2).
if [[ "${SQUASH_MSG_ENGINE}" == "agy" ]]; then
echo "[BRANCH] agy text capture requires a dedicated wrapper — using heuristic"
fi
SQUASH_MSG_FILE=$(mktemp "${LOOP_DIR}/squash-message.XXXXXX")
if [[ "${SQUASH_MSG_ENGINE}" == "auto" || "${SQUASH_MSG_ENGINE}" == "claude" ]]; then
if command -v claude >/dev/null 2>&1; then
if run_with_budget "${TOOL_TIMEOUT}" claude -p "${SQUASH_PROMPT}" --max-turns 1 > "${SQUASH_MSG_FILE}" 2>/dev/null; then
SQUASH_MSG=$(cat "${SQUASH_MSG_FILE}")
fi
fi
fi
if [[ -z "${SQUASH_MSG}" ]] && [[ "${SQUASH_MSG_ENGINE}" == "auto" || "${SQUASH_MSG_ENGINE}" == "codex" ]]; then
if command -v codex >/dev/null 2>&1; then
: > "${SQUASH_MSG_FILE}"
if run_with_budget "${TOOL_TIMEOUT}" codex exec --full-auto -o "${SQUASH_MSG_FILE}" "${SQUASH_PROMPT}" >/dev/null 2>&1; then
[[ ! -s "${SQUASH_MSG_FILE}" ]] || SQUASH_MSG=$(cat "${SQUASH_MSG_FILE}")
fi
fi
fi
rm -f "${SQUASH_MSG_FILE}"
# Heuristic fallback when no LLM available
if [[ -z "${SQUASH_MSG}" ]]; then
GOAL_OBJECTIVE=$(sed -n '/^## Objective/,/^##/{/^##/d;/^$/d;p;}' "${LOOP_DIR}/goal.md" 2>/dev/null | head -1)
SQUASH_MSG="feat(${LOOP_NAME}): ${GOAL_OBJECTIVE:-loop completion}
Squashed from ${ITER_COMMIT_COUNT} iteration commits.
Iterations: $((ITER - 1)), Verify: ${VERIFY_RESULT}"
echo "[BRANCH] LLM unavailable — using heuristic commit message"
fi
git commit -m "${SQUASH_MSG}"
if [[ "${stashed}" == "true" ]]; then
git stash pop --index || { echo "[ABORT] Stash restore conflict — resolve before resuming"; return 1; }
fi
# Delete iteration branch
git branch -D "${ITER_BRANCH}" 2>/dev/null || true
echo "[BRANCH] Squash complete on ${SUMMARY_BRANCH}"
# Granularity hint for large loops
if [[ "${ITER_COMMIT_COUNT}" -gt 10 ]]; then
echo "[BRANCH:HINT] ${ITER_COMMIT_COUNT} commits squashed. For finer granularity:"
echo " git reset --soft ${ORIGIN_BRANCH} && create topic-based commits"
fi
else
echo "[BRANCH:CONFLICT] Squash failed — ORIGIN_BRANCH may have diverged"
echo "[BRANCH:CONFLICT] Resolve: git checkout ${SUMMARY_BRANCH} && fix conflicts && git commit"
echo "[BRANCH:CONFLICT] Or abort: git reset --merge && git checkout ${ITER_BRANCH}"
if [[ "${stashed}" == "true" ]]; then
echo "[BRANCH:CONFLICT] After resolving or aborting, restore prior work: git stash pop --index"
fi
return 1
fi
}
if [[ "${BRANCH_ISOLATION}" == "true" ]] && [[ "${AUTOCOMMIT}" == "true" ]] \
&& [[ -n "${ORIGIN_BRANCH:-}" ]] && [[ "${STATUS}" == "DONE" ]] \
&& [[ "${SQUASH_ON_DONE}" == "true" ]] \
&& git show-ref --verify --quiet "refs/heads/${ITER_BRANCH}"; then
squash_iteration_branch
elif [[ "${BRANCH_ISOLATION}" == "true" ]] && [[ "${STATUS}" != "DONE" ]] && [[ -n "${ITER_BRANCH:-}" ]]; then
echo "[BRANCH] STATUS=${STATUS} — staying on ${ITER_BRANCH} for resume"
fi
#--- Release lock ---
rm -f "${LOOP_DIR}/.run-loop.lock"
#--- Status footer ---
FINAL_STATUS="${STATUS}"
if [[ "${FINAL_STATUS}" != "DONE" ]]; then
FINAL_STATUS="CONTINUE"
fi
emit_log "INFO" "loop_complete" "final_status" "${FINAL_STATUS}" "iterations" "$((ITER - 1))" "max" "${MAX_ITERATIONS}"
echo ""
echo "NEXUS_LOOP_STATUS: ${FINAL_STATUS}"
echo "NEXUS_LOOP_SUMMARY: Iteration ${ITER}/${MAX_ITERATIONS}, status=${STATUS}, verify=${VERIFY_RESULT}"