mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-04 00:23:53 +08:00
* 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>
399 lines
14 KiB
Bash
Executable File
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
|