Spaces:
Running
Running
| # Durable state bridge for the agent harnesses. | |
| # | |
| # Active state always lives on the container's POSIX filesystem. The mounted | |
| # bucket only receives short-lived, closed writes made by rsync (ordinary file | |
| # trees) or SQLite's online backup API (live databases). This is intentional: | |
| # hf-mount's streaming writer holds an open file in memory until close, while | |
| # copying a database and its WAL independently can produce a torn backup. | |
| set -u | |
| MODE="${1:-}" | |
| case "$MODE" in | |
| restore|checkpoint|checkpoint-final) ;; | |
| *) echo "usage: $0 restore|checkpoint|checkpoint-final" >&2; exit 2 ;; | |
| esac | |
| : "${DATA_DIR:?DATA_DIR is required}" | |
| : "${AM_LOCAL:?AM_LOCAL is required}" | |
| CODEX_HOME="${CODEX_HOME:-$AM_LOCAL/codex-home}" | |
| CODEX_DURABLE="${CODEX_DURABLE:-$DATA_DIR/state/codex}" | |
| CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$AM_LOCAL/agent-state/claude}" | |
| CLAUDE_DURABLE="${CLAUDE_DURABLE:-$DATA_DIR/state/claude}" | |
| GEMINI_CLI_HOME="${GEMINI_CLI_HOME:-$AM_LOCAL/agent-state/gemini-home}" | |
| GEMINI_LIVE="${GEMINI_LIVE:-$GEMINI_CLI_HOME/.gemini}" | |
| GEMINI_DURABLE="${GEMINI_DURABLE:-$DATA_DIR/state/gemini}" | |
| OPENCLAW_STATE_DIR="${OPENCLAW_STATE_DIR:-$AM_LOCAL/oc-home/.openclaw}" | |
| OPENCLAW_DURABLE="${OPENCLAW_DURABLE:-$DATA_DIR/state/openclaw-backup}" | |
| OPENCODE_LIVE="${OPENCODE_LIVE:-$AM_LOCAL/opencode-share}" | |
| OPENCODE_DURABLE="${OPENCODE_DURABLE:-$DATA_DIR/state/opencode}" | |
| HERMES_LIVE="${HERMES_LIVE:-$AM_LOCAL/hermes}" | |
| HERMES_DURABLE="${HERMES_DURABLE:-$DATA_DIR/state/hermes}" | |
| LOCK="$AM_LOCAL/agent-state-checkpoint.lock" | |
| mkdir -p "$AM_LOCAL" | |
| exec 9>"$LOCK" | |
| if [ "$MODE" = restore ] || [ "$MODE" = checkpoint-final ]; then | |
| # A dev-mode restart may overlap the previous app's final checkpoint. Never | |
| # restore an older durable view while that checkpoint is still being made. | |
| flock 9 | |
| else | |
| # Timer and shutdown checkpoints can race; the one already holding the lock | |
| # is sufficient, and a later timer/shutdown will catch subsequent writes. | |
| flock -n 9 || exit 0 | |
| fi | |
| # `find -newer` is a strict comparison. A file written in the same filesystem | |
| # timestamp tick as a checkpoint marker would otherwise be skipped forever. | |
| # Keep a small overlap at every successful boundary; an occasional repeat copy | |
| # is harmless, while a missed transcript/config is not. | |
| mark_checkpoint_floor() { | |
| touch -d '2 seconds ago' "$1" 2>/dev/null || touch "$1" | |
| } | |
| # rsync exit 24 is "partial transfer due to vanished source files": between | |
| # building its file list and reading them, something deleted files the listing | |
| # still named. Both tree copies are exposed to it — a restore walks the bucket | |
| # for minutes while a harness prunes transcript scratch, and a checkpoint copies | |
| # a live tree the harnesses are still writing. What vanishes is the scratch | |
| # itself, so nothing durable is lost and the copy is otherwise complete. | |
| # Treating it as a failure once refused to boot the Space at all. | |
| # Single-file copies below keep the strict test: there, a vanished source is | |
| # exactly the file we were asked for. | |
| rsync_tree() { | |
| rsync "$@"; _rs_rc=$? | |
| [ "$_rs_rc" -eq 24 ] && _rs_rc=0 | |
| return "$_rs_rc" | |
| } | |
| restore_tree() { | |
| key="$1" durable="$2" live="$3"; shift 3 | |
| mkdir -p "$durable" "$live" | |
| stamp_dir="$AM_LOCAL/agent-state-stamps" | |
| stamp="$stamp_dir/$key" | |
| had_local=false | |
| [ -n "$(find "$live" -type f -print -quit 2>/dev/null)" ] && had_local=true | |
| # --update matters for hot/dev restarts: local disk survives those and can be | |
| # newer than the last completed bucket checkpoint. A fresh container starts | |
| # with an empty destination, so the same command performs a full restore. | |
| if rsync_tree -a --update "$@" "$durable/" "$live/"; then | |
| mkdir -p "$stamp_dir" | |
| if [ ! -e "$stamp" ]; then | |
| if [ "$had_local" = true ]; then | |
| # First deployment over an already-populated local tree: force one | |
| # checkpoint so newer local files skipped by --update become durable. | |
| touch -t 197001010000 "$stamp" | |
| else | |
| # Fresh container: every local byte came from this durable restore. | |
| mark_checkpoint_floor "$stamp" | |
| fi | |
| fi | |
| else | |
| return 1 | |
| fi | |
| } | |
| checkpoint_tree() { | |
| key="$1" live="$2" durable="$3"; shift 3 | |
| [ -d "$live" ] || return 0 | |
| stamp_dir="$AM_LOCAL/agent-state-stamps" | |
| mkdir -p "$durable" "$stamp_dir" | |
| stamp="$stamp_dir/$key" | |
| if [ ! -e "$stamp" ]; then | |
| touch -t 197001010000 "$stamp" | |
| fi | |
| # Mark the START of this checkpoint. Any file changed during/after its copy | |
| # is newer than `next` and will therefore be selected again next time. | |
| next="$stamp.next.$$" | |
| list="$stamp.files.$$" | |
| mark_checkpoint_floor "$next" | |
| (cd "$live" && find . -type f -newer "$stamp" -print0) > "$list" | |
| if [ -s "$list" ]; then | |
| # --files-from means rsync walks only changed LOCAL paths. It does not scan | |
| # the remote tree every 15 seconds — a critical property on the bucket | |
| # mount. Destination temporaries close before rename, retaining the prior | |
| # object if this process dies during transfer. | |
| if ! rsync_tree -a -r --from0 --files-from="$list" --delay-updates \ | |
| "$@" "$live/" "$durable/"; then | |
| rm -f "$next" "$list" | |
| return 1 | |
| fi | |
| fi | |
| mv "$next" "$stamp" | |
| rm -f "$list" | |
| } | |
| restore_sqlite_tree() { | |
| durable="$1" live="$2" db_name="$3" checkpoint_name="$4" | |
| mkdir -p "$durable" "$live" | |
| if ! restore_tree "$checkpoint_name-files" "$durable" "$live" \ | |
| --exclude 'checkpoints' --exclude '*.db' --exclude '*.db-*' \ | |
| --exclude '*.sqlite*' --exclude '*-wal' --exclude '*-shm'; then | |
| return 1 | |
| fi | |
| checkpoint="$durable/checkpoints/$checkpoint_name" | |
| target="$live/$db_name" | |
| if [ -s "$target" ] && [ "$(sqlite3 "$target" 'PRAGMA quick_check;' 2>/dev/null)" = ok ]; then | |
| # Local disk survives in-container/dev restarts. Its database can be newer | |
| # than the last checkpoint (including committed rows still in its WAL), so | |
| # a valid live database is always the restore authority on a hot restart. | |
| return 0 | |
| fi | |
| # Retain an invalid local set for diagnosis, then recover from the last | |
| # known-good checkpoint. These are explicit ephemeral paths, never bucket | |
| # objects. | |
| had_invalid=false | |
| if [ -e "$target" ] || [ -e "$target-wal" ] || [ -e "$target-shm" ]; then | |
| had_invalid=true | |
| invalid_dir="$AM_LOCAL/agent-state-invalid/$checkpoint_name.$(date -u +%Y%m%dT%H%M%SZ).$$" | |
| mkdir -p "$invalid_dir" | |
| [ -e "$target" ] && mv "$target" "$invalid_dir/$db_name" | |
| [ -e "$target-wal" ] && mv "$target-wal" "$invalid_dir/$db_name-wal" | |
| [ -e "$target-shm" ] && mv "$target-shm" "$invalid_dir/$db_name-shm" | |
| fi | |
| if [ -s "$checkpoint" ]; then | |
| # WAL/SHM are ephemeral coordination files, never part of a restored | |
| # checkpoint. Removing these explicit local paths cannot touch bucket data. | |
| rm -f "$target-wal" "$target-shm" | |
| if ! rsync -a "$checkpoint" "$target"; then return 1; fi | |
| elif [ -s "$durable/$db_name" ]; then | |
| # One-release compatibility path for state written by the old raw-rsync | |
| # mechanism. Copy the legacy DB and any WAL so SQLite can recover it; the | |
| # first successful checkpoint replaces this path as the restore authority. | |
| if ! rsync -a "$durable/$db_name" "$target"; then return 1; fi | |
| if [ -f "$durable/$db_name-wal" ] && ! rsync -a "$durable/$db_name-wal" "$target-wal"; then return 1; fi | |
| if [ -f "$durable/$db_name-shm" ] && ! rsync -a "$durable/$db_name-shm" "$target-shm"; then return 1; fi | |
| fi | |
| if [ "$had_invalid" = true ] && [ ! -s "$target" ]; then | |
| echo "agent-state: invalid local SQLite state has no durable recovery for $target" >&2 | |
| return 1 | |
| fi | |
| if [ -s "$target" ] && [ "$(sqlite3 "$target" 'PRAGMA quick_check;' 2>/dev/null)" != ok ]; then | |
| echo "agent-state: restored SQLite state is invalid for $target" >&2 | |
| return 1 | |
| fi | |
| } | |
| checkpoint_sqlite_tree() { | |
| live="$1" durable="$2" db_name="$3" checkpoint_name="$4" | |
| source="$live/$db_name" | |
| # A harness can write ordinary state before it creates its database (Hermes | |
| # setup files are a real example). Always publish that file tree first. The | |
| # database backup is optional until the database itself exists. | |
| if ! checkpoint_tree "$checkpoint_name-files" "$live" "$durable" \ | |
| --exclude 'checkpoints' --exclude '*.db' --exclude '*.db-*' \ | |
| --exclude '*.sqlite*' --exclude '*-wal' --exclude '*-shm'; then | |
| return 1 | |
| fi | |
| [ -s "$source" ] || return 0 | |
| sqlite_stamp_dir="$AM_LOCAL/agent-state-stamps" | |
| sqlite_stamp="$sqlite_stamp_dir/$checkpoint_name-sqlite" | |
| mkdir -p "$sqlite_stamp_dir" | |
| # Timestamp-only detection has an equal-tick hole, while deliberately | |
| # overlapping the marker would rewrite an idle database on rapid successive | |
| # checkpoints. Record the exact local DB/WAL metadata observed BEFORE the | |
| # backup instead. A concurrent commit changes the next signature and is | |
| # therefore picked up by the following checkpoint. | |
| sqlite_next="$sqlite_stamp.next.$$" | |
| { | |
| stat -c 'db|%s|%y|%z' "$source" | |
| if [ -e "$source-wal" ]; then | |
| stat -c 'wal|%s|%y|%z' "$source-wal" | |
| else | |
| echo 'wal|absent' | |
| fi | |
| } > "$sqlite_next" | |
| if cmp -s "$sqlite_next" "$sqlite_stamp"; then | |
| rm -f "$sqlite_next" | |
| return 0 | |
| fi | |
| stage_dir="$AM_LOCAL/agent-state-snapshots" | |
| mkdir -p "$stage_dir" "$durable/checkpoints" | |
| staged="$stage_dir/$checkpoint_name.$$.tmp" | |
| rm -f "$staged" | |
| escaped=$(printf '%s' "$staged" | sed "s/'/''/g") | |
| # .backup observes the main DB and WAL through one consistent SQLite read | |
| # transaction. The staged file is ordinary local storage and is closed before | |
| # rsync hands it to the bucket. | |
| if ! sqlite3 "$source" ".timeout 5000" ".backup '$escaped'"; then | |
| rm -f "$staged" "$sqlite_next" | |
| return 1 | |
| fi | |
| if [ "$(sqlite3 "$staged" 'PRAGMA quick_check;' 2>/dev/null)" != ok ]; then | |
| echo "agent-state: refusing invalid SQLite checkpoint for $source" >&2 | |
| rm -f "$staged" "$sqlite_next" | |
| return 1 | |
| fi | |
| if ! rsync -a --delay-updates "$staged" "$durable/checkpoints/$checkpoint_name"; then | |
| rm -f "$staged" "$sqlite_next" | |
| return 1 | |
| fi | |
| rm -f "$staged" | |
| mv "$sqlite_next" "$sqlite_stamp" | |
| } | |
| failures=0 | |
| if [ "$MODE" = restore ]; then | |
| restore_tree codex "$CODEX_DURABLE" "$CODEX_HOME" \ | |
| --exclude '*.sqlite*' --exclude '*.db' --exclude '*.db-*' \ | |
| --exclude '*-wal' --exclude '*-shm' --exclude 'db-backups' \ | |
| --exclude 'cache' --exclude '.tmp' --exclude 'mcp-oauth-locks' || failures=$((failures + 1)) | |
| restore_tree claude "$CLAUDE_DURABLE" "$CLAUDE_CONFIG_DIR" || failures=$((failures + 1)) | |
| restore_tree gemini "$GEMINI_DURABLE" "$GEMINI_LIVE" || failures=$((failures + 1)) | |
| restore_tree openclaw "$OPENCLAW_DURABLE" "$OPENCLAW_STATE_DIR" || failures=$((failures + 1)) | |
| restore_sqlite_tree "$OPENCODE_DURABLE" "$OPENCODE_LIVE" opencode.db opencode.db || failures=$((failures + 1)) | |
| restore_sqlite_tree "$HERMES_DURABLE" "$HERMES_LIVE" state.db state.db || failures=$((failures + 1)) | |
| else | |
| checkpoint_tree codex "$CODEX_HOME" "$CODEX_DURABLE" \ | |
| --exclude '*.sqlite*' --exclude '*.db' --exclude '*.db-*' \ | |
| --exclude '*-wal' --exclude '*-shm' --exclude 'db-backups' \ | |
| --exclude 'cache' --exclude '.tmp' --exclude 'mcp-oauth-locks' || failures=$((failures + 1)) | |
| checkpoint_tree claude "$CLAUDE_CONFIG_DIR" "$CLAUDE_DURABLE" || failures=$((failures + 1)) | |
| checkpoint_tree gemini "$GEMINI_LIVE" "$GEMINI_DURABLE" || failures=$((failures + 1)) | |
| checkpoint_tree openclaw "$OPENCLAW_STATE_DIR" "$OPENCLAW_DURABLE" || failures=$((failures + 1)) | |
| checkpoint_sqlite_tree "$OPENCODE_LIVE" "$OPENCODE_DURABLE" opencode.db opencode.db || failures=$((failures + 1)) | |
| checkpoint_sqlite_tree "$HERMES_LIVE" "$HERMES_DURABLE" state.db state.db || failures=$((failures + 1)) | |
| fi | |
| [ "$failures" -eq 0 ] || { | |
| echo "agent-state: $MODE completed with $failures failed adapter(s)" >&2 | |
| exit 1 | |
| } | |