agent-manager-template / scripts /agent-state.sh
Agent Manager
Don't fail the boot when rsync reports vanished source files
ce3c3bd
Raw
History Blame Contribute Delete
11.8 kB
#!/bin/sh
# 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
}