Files
OpenShell/scripts/agents/runtime/supervisor.sh
John T. MyersandJohn Myers dfd5238d0d fix(gator): make supervised lifecycle sandbox-native (#3343)
* fix(gator): run supervisor as sandbox main process

Signed-off-by: John Myers <johntmyers@users.noreply.github.com>

* feat(gator): persist supervised state history

Signed-off-by: John Myers <johntmyers@users.noreply.github.com>

* refactor(gator): remove obsolete background launch mode

Signed-off-by: John Myers <johntmyers@users.noreply.github.com>

---------

Signed-off-by: John Myers <johntmyers@users.noreply.github.com>
Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
2026-09-15 19:04:13 +00:00

399 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
set -euo pipefail
require_env() {
local name="$1"
[[ -n "${!name:-}" ]] || { echo "missing required env: $name" >&2; exit 1; }
}
require_env OPENSHELL_AGENT_HARNESS
RUNTIME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PAYLOAD_DIR="$(cd "$RUNTIME_DIR/.." && pwd)"
PROMPT_FILE="$PAYLOAD_DIR/agent-prompt.md"
ADAPTER="$PAYLOAD_DIR/runtime/harnesses/$OPENSHELL_AGENT_HARNESS/exec.sh"
RUN_MODE="${OPENSHELL_AGENT_RUN_MODE:-once}"
POLL_INTERVAL_SECONDS="${OPENSHELL_AGENT_POLL_INTERVAL_SECONDS:-900}"
MAX_TRANSIENT_FAILURES="${OPENSHELL_AGENT_MAX_TRANSIENT_FAILURES:-5}"
HEARTBEAT_SECONDS="${OPENSHELL_AGENT_HEARTBEAT_SECONDS:-60}"
STATE_DIR="${OPENSHELL_AGENT_STATE_DIR:-/sandbox/.openshell-agent}"
STATE_HISTORY_LIMIT="${OPENSHELL_AGENT_STATE_HISTORY_LIMIT:-100}"
MAX_NOTES_LENGTH="${OPENSHELL_AGENT_MAX_NOTES_LENGTH:-2048}"
MAX_SLEEP_SECONDS=86400
[[ -f "$PROMPT_FILE" ]] || { echo "missing agent prompt: $PROMPT_FILE" >&2; exit 1; }
[[ -x "$ADAPTER" ]] || { echo "missing harness adapter: $ADAPTER" >&2; exit 1; }
case "$RUN_MODE" in
once|watch) ;;
*) echo "unsupported agent run mode: $RUN_MODE" >&2; exit 2 ;;
esac
[[ "$POLL_INTERVAL_SECONDS" =~ ^[0-9]+$ ]] || { echo "OPENSHELL_AGENT_POLL_INTERVAL_SECONDS must be an integer" >&2; exit 2; }
[[ "$MAX_TRANSIENT_FAILURES" =~ ^[0-9]+$ ]] || { echo "OPENSHELL_AGENT_MAX_TRANSIENT_FAILURES must be an integer" >&2; exit 2; }
[[ "$HEARTBEAT_SECONDS" =~ ^[0-9]+$ ]] || { echo "OPENSHELL_AGENT_HEARTBEAT_SECONDS must be an integer" >&2; exit 2; }
[[ "$STATE_HISTORY_LIMIT" =~ ^[0-9]+$ ]] || { echo "OPENSHELL_AGENT_STATE_HISTORY_LIMIT must be an integer" >&2; exit 2; }
[[ "$MAX_NOTES_LENGTH" =~ ^[0-9]+$ ]] || { echo "OPENSHELL_AGENT_MAX_NOTES_LENGTH must be an integer" >&2; exit 2; }
[[ "$POLL_INTERVAL_SECONDS" -gt 0 ]] || { echo "OPENSHELL_AGENT_POLL_INTERVAL_SECONDS must be greater than zero" >&2; exit 2; }
[[ "$STATE_HISTORY_LIMIT" -gt 0 ]] || { echo "OPENSHELL_AGENT_STATE_HISTORY_LIMIT must be greater than zero" >&2; exit 2; }
[[ "$MAX_NOTES_LENGTH" -gt 0 ]] || { echo "OPENSHELL_AGENT_MAX_NOTES_LENGTH must be greater than zero" >&2; exit 2; }
json_string_field() {
local json="$1"
local key="$2"
printf '%s' "$json" | sed -nE "s/.*\"$key\"[[:space:]]*:[[:space:]]*\"([^\"]*)\".*/\1/p"
}
json_number_field() {
local json="$1"
local key="$2"
printf '%s' "$json" | sed -nE "s/.*\"$key\"[[:space:]]*:[[:space:]]*([0-9]+).*/\1/p"
}
valid_result_json() {
local json="$1"
if command -v jq >/dev/null 2>&1; then
printf '%s' "$json" | jq -e 'type == "object"' >/dev/null 2>&1
return
fi
if command -v python3 >/dev/null 2>&1; then
printf '%s' "$json" | python3 -c '
import json
import sys
try:
value = json.load(sys.stdin)
except Exception:
sys.exit(1)
sys.exit(0 if isinstance(value, dict) else 1)
' >/dev/null 2>&1
return
fi
return 1
}
normalize_result_json() {
local json="$1"
if command -v jq >/dev/null 2>&1; then
printf '%s' "$json" | jq -c --argjson max "$MAX_NOTES_LENGTH" '
.notes = if (.notes | type) == "string" then .notes[:$max]
else "No cycle notes were provided by the agent." end
'
return
fi
printf '%s' "$json" | python3 -c '
import json
import sys
maximum = int(sys.argv[1])
value = json.load(sys.stdin)
notes = value.get("notes")
value["notes"] = notes[:maximum] if isinstance(notes, str) else "No cycle notes were provided by the agent."
print(json.dumps(value, separators=(",", ":")))
' "$MAX_NOTES_LENGTH"
}
state_record_json() {
local supervisor_state="$1"
local harness_status="$2"
local result_json="$3"
local recorded_at
recorded_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
if command -v jq >/dev/null 2>&1; then
jq -cn \
--arg recorded_at "$recorded_at" \
--arg agent_id "${OPENSHELL_AGENT_ID:-unknown}" \
--arg harness "$OPENSHELL_AGENT_HARNESS" \
--arg run_mode "$RUN_MODE" \
--argjson cycle "$cycle" \
--arg supervisor_state "$supervisor_state" \
--arg harness_status "$harness_status" \
--argjson result "$result_json" \
'{schema_version:1, recorded_at:$recorded_at, agent_id:$agent_id,
harness:$harness, run_mode:$run_mode, cycle:$cycle,
supervisor_state:$supervisor_state,
harness_exit_code:(if $harness_status == "" then null else ($harness_status | tonumber) end),
result:$result}'
return
fi
python3 -c '
import json
import sys
recorded_at, agent_id, harness, run_mode, cycle, state, harness_status, result = sys.argv[1:]
print(json.dumps({
"schema_version": 1,
"recorded_at": recorded_at,
"agent_id": agent_id,
"harness": harness,
"run_mode": run_mode,
"cycle": int(cycle),
"supervisor_state": state,
"harness_exit_code": int(harness_status) if harness_status else None,
"result": json.loads(result),
}, separators=(",", ":")))
' "$recorded_at" "${OPENSHELL_AGENT_ID:-unknown}" "$OPENSHELL_AGENT_HARNESS" \
"$RUN_MODE" "$cycle" "$supervisor_state" "$harness_status" "$result_json"
}
persist_state() {
local supervisor_state="$1"
local harness_status="${2:-}"
local result_json="${3:-}"
local record snapshot_tmp history_tmp
[[ -n "$result_json" ]] || result_json='{}'
mkdir -p "$STATE_DIR"
record="$(state_record_json "$supervisor_state" "$harness_status" "$result_json")"
snapshot_tmp="$(mktemp "$STATE_DIR/.status.XXXXXX")"
printf '%s\n' "$record" > "$snapshot_tmp"
mv "$snapshot_tmp" "$STATE_DIR/status.json"
printf '%s\n' "$record" >> "$STATE_DIR/history.jsonl"
history_tmp="$(mktemp "$STATE_DIR/.history.XXXXXX")"
tail -n "$STATE_HISTORY_LIMIT" "$STATE_DIR/history.jsonl" > "$history_tmp"
mv "$history_tmp" "$STATE_DIR/history.jsonl"
}
diagnostic_result_json() {
local status="$1"
local reason="$2"
local notes="$3"
if command -v jq >/dev/null 2>&1; then
jq -cn --arg status "$status" --arg reason "$reason" --arg notes "$notes" \
'{status:$status, reason:$reason, notes:$notes}'
return
fi
python3 -c '
import json
import sys
print(json.dumps({"status": sys.argv[1], "reason": sys.argv[2], "notes": sys.argv[3]}, separators=(",", ":")))
' "$status" "$reason" "$notes"
}
classify_transient_failure() {
local output_file="$1"
grep -Eiq 'stream disconnected before completion|failed to connect to websocket|Reconnecting\.\.\.|Broken pipe|Connection to sandbox closed by remote host|peer closed connection without sending TLS close_notify' "$output_file"
}
safe_sleep_seconds() {
local value="$1"
if [[ ! "$value" =~ ^[0-9]+$ ]] || [[ "$value" -le 0 ]]; then
printf '%s\n' "$POLL_INTERVAL_SECONDS"
return
fi
if [[ "$value" -gt "$MAX_SLEEP_SECONDS" ]]; then
printf '%s\n' "$MAX_SLEEP_SECONDS"
return
fi
printf '%s\n' "$value"
}
sleep_with_heartbeat() {
local total_seconds="$1"
local reason="$2"
local remaining="$total_seconds"
if [[ "$HEARTBEAT_SECONDS" -le 0 ]]; then
sleep "$remaining"
return
fi
while [[ "$remaining" -gt 0 ]]; do
local chunk="$remaining"
if [[ "$chunk" -gt "$HEARTBEAT_SECONDS" ]]; then
chunk="$HEARTBEAT_SECONDS"
fi
sleep "$chunk"
remaining=$((remaining - chunk))
if [[ "$remaining" -gt 0 ]]; then
echo "openshell-agent: still waiting ($reason); next cycle in ${remaining}s" >&2
fi
done
}
active_cycle_heartbeat() {
local active_cycle="$1"
local elapsed=0
local sleep_pid=""
trap 'if [[ -n "${sleep_pid:-}" ]]; then kill "$sleep_pid" 2>/dev/null || true; wait "$sleep_pid" 2>/dev/null || true; fi; exit 0' TERM INT EXIT
while true; do
sleep "$HEARTBEAT_SECONDS" &
sleep_pid=$!
wait "$sleep_pid" || exit 0
sleep_pid=""
elapsed=$((elapsed + HEARTBEAT_SECONDS))
echo "openshell-agent: still running $RUN_MODE cycle $active_cycle with harness $OPENSHELL_AGENT_HARNESS after ${elapsed}s" >&2
done
}
retry_watch_cycle() {
local reason="$1"
local retry_seconds="${2:-$transient_backoff_seconds}"
local advance_backoff="${3:-true}"
transient_failures=$((transient_failures + 1))
if [[ "$MAX_TRANSIENT_FAILURES" -gt 0 ]]; then
if [[ $((transient_failures % MAX_TRANSIENT_FAILURES)) -eq 0 ]]; then
echo "openshell-agent: transient watch failure $transient_failures ($reason); still retrying in ${retry_seconds}s" >&2
else
echo "openshell-agent: transient watch failure $transient_failures ($reason); retrying in ${retry_seconds}s" >&2
fi
else
echo "openshell-agent: transient watch failure $transient_failures ($reason); retrying in ${retry_seconds}s" >&2
fi
sleep_with_heartbeat "$retry_seconds" "$reason"
if [[ "$advance_backoff" == "true" ]]; then
transient_backoff_seconds=$((transient_backoff_seconds * 2))
cap_transient_backoff
fi
}
cap_transient_backoff() {
if [[ "$transient_backoff_seconds" -gt "$POLL_INTERVAL_SECONDS" ]]; then
transient_backoff_seconds="$POLL_INTERVAL_SECONDS"
fi
if [[ "$transient_backoff_seconds" -gt "$MAX_SLEEP_SECONDS" ]]; then
transient_backoff_seconds="$MAX_SLEEP_SECONDS"
fi
}
run_cycle() {
local output_file="$1"
local heartbeat_pid=""
if [[ "$HEARTBEAT_SECONDS" -gt 0 ]]; then
active_cycle_heartbeat "$cycle" &
heartbeat_pid=$!
fi
set +e
bash "$ADAPTER" "$PROMPT_FILE" 2>&1 | tee "$output_file"
local status=${PIPESTATUS[0]}
set -e
if [[ -n "$heartbeat_pid" ]]; then
kill "$heartbeat_pid" 2>/dev/null || true
wait "$heartbeat_pid" 2>/dev/null || true
fi
return "$status"
}
cycle=0
transient_failures=0
transient_backoff_seconds=30
cap_transient_backoff
while true; do
cycle=$((cycle + 1))
echo "openshell-agent: starting $RUN_MODE cycle $cycle with harness $OPENSHELL_AGENT_HARNESS" >&2
persist_state "running"
output_file="$(mktemp /tmp/openshell-agent-cycle.XXXXXX)"
if run_cycle "$output_file"; then
harness_status=0
else
harness_status=$?
fi
result_line="$(grep -E '^OPENSHELL_AGENT_RESULT[[:space:]]+' "$output_file" | tail -n 1 || true)"
result_json="${result_line#OPENSHELL_AGENT_RESULT }"
if [[ -z "$result_line" ]]; then
retry_reason="missing OPENSHELL_AGENT_RESULT after harness exit $harness_status"
if classify_transient_failure "$output_file"; then
retry_reason="$retry_reason; upstream transport failure detected"
fi
diagnostic_json="$(diagnostic_result_json transient_failure missing_agent_result "$retry_reason")"
persist_state "$([[ "$RUN_MODE" == "once" ]] && printf terminal || printf sleeping)" "$harness_status" "$diagnostic_json"
if [[ "$RUN_MODE" == "once" ]]; then
rm -f "$output_file"
if [[ "$harness_status" -ne 0 ]]; then
exit "$harness_status"
fi
exit 1
fi
rm -f "$output_file"
retry_watch_cycle "$retry_reason"
continue
fi
if ! valid_result_json "$result_json"; then
diagnostic_json="$(diagnostic_result_json transient_failure malformed_agent_result "The harness returned malformed result JSON; the supervisor will retry.")"
persist_state "$([[ "$RUN_MODE" == "once" ]] && printf terminal || printf sleeping)" "$harness_status" "$diagnostic_json"
rm -f "$output_file"
if [[ "$RUN_MODE" == "once" ]]; then
echo "openshell-agent: malformed OPENSHELL_AGENT_RESULT JSON" >&2
exit 1
fi
retry_watch_cycle "malformed OPENSHELL_AGENT_RESULT JSON"
continue
fi
result_json="$(normalize_result_json "$result_json")"
status="$(json_string_field "$result_json" status)"
reason="$(json_string_field "$result_json" reason)"
next_poll_seconds="$(json_number_field "$result_json" next_poll_seconds)"
next_poll_seconds="$(safe_sleep_seconds "$next_poll_seconds")"
[[ -n "$reason" ]] || reason="unspecified"
rm -f "$output_file"
case "$status" in
complete)
persist_state "terminal" "$harness_status" "$result_json"
echo "openshell-agent: complete ($reason)" >&2
exit 0
;;
waiting|blocked)
persist_state "$([[ "$RUN_MODE" == "once" ]] && printf terminal || printf sleeping)" "$harness_status" "$result_json"
if [[ "$RUN_MODE" == "once" ]]; then
echo "openshell-agent: $status ($reason)" >&2
exit 0
fi
transient_failures=0
transient_backoff_seconds=30
echo "openshell-agent: $status ($reason); sleeping ${next_poll_seconds}s outside harness" >&2
sleep_with_heartbeat "$next_poll_seconds" "$reason"
;;
transient_failure)
persist_state "$([[ "$RUN_MODE" == "once" ]] && printf terminal || printf sleeping)" "$harness_status" "$result_json"
if [[ "$RUN_MODE" == "once" ]]; then
echo "openshell-agent: transient failure ($reason)" >&2
exit 1
fi
retry_watch_cycle "$reason" "$next_poll_seconds" false
;;
terminal_failure)
persist_state "terminal" "$harness_status" "$result_json"
echo "openshell-agent: terminal failure ($reason)" >&2
exit 1
;;
*)
persist_state "$([[ "$RUN_MODE" == "once" ]] && printf terminal || printf sleeping)" "$harness_status" "$result_json"
if [[ "$RUN_MODE" == "once" ]]; then
echo "openshell-agent: invalid OPENSHELL_AGENT_RESULT status: ${status:-<missing>}" >&2
exit 1
fi
retry_watch_cycle "invalid OPENSHELL_AGENT_RESULT status: ${status:-<missing>}"
;;
esac
done