Spaces:
Running
Don't fail the boot when rsync reports vanished source files
Browse filesagent-manager refused to start with "agent-state restore was incomplete;
refusing to start agents against partial state". The restore's rsync had
exited 24 -- "partial transfer due to vanished source files" -- because
Claude pruned tool-results scratch under /data/state/claude while the
9m38s bucket walk was still reading the listing that named them.
24 is a warning, not an error: the files that vanished are the harness's
own scratch, and the rest of the tree copied fine. Treating it as a
failure took the whole Space down.
Both tree copies get the same treatment -- a checkpoint copies live trees
the harnesses are still writing, so it is exposed to the same race. The
single-file SQLite copies stay strict: there, a vanished source is
exactly the file we asked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- scripts/agent-state.sh +17 -2
- server/state-checkpoint.test.mjs +41 -0
|
@@ -53,6 +53,21 @@ mark_checkpoint_floor() {
|
|
| 53 |
touch -d '2 seconds ago' "$1" 2>/dev/null || touch "$1"
|
| 54 |
}
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
restore_tree() {
|
| 57 |
key="$1" durable="$2" live="$3"; shift 3
|
| 58 |
mkdir -p "$durable" "$live"
|
|
@@ -63,7 +78,7 @@ restore_tree() {
|
|
| 63 |
# --update matters for hot/dev restarts: local disk survives those and can be
|
| 64 |
# newer than the last completed bucket checkpoint. A fresh container starts
|
| 65 |
# with an empty destination, so the same command performs a full restore.
|
| 66 |
-
if
|
| 67 |
mkdir -p "$stamp_dir"
|
| 68 |
if [ ! -e "$stamp" ]; then
|
| 69 |
if [ "$had_local" = true ]; then
|
|
@@ -102,7 +117,7 @@ checkpoint_tree() {
|
|
| 102 |
# the remote tree every 15 seconds — a critical property on the bucket
|
| 103 |
# mount. Destination temporaries close before rename, retaining the prior
|
| 104 |
# object if this process dies during transfer.
|
| 105 |
-
if !
|
| 106 |
"$@" "$live/" "$durable/"; then
|
| 107 |
rm -f "$next" "$list"
|
| 108 |
return 1
|
|
|
|
| 53 |
touch -d '2 seconds ago' "$1" 2>/dev/null || touch "$1"
|
| 54 |
}
|
| 55 |
|
| 56 |
+
# rsync exit 24 is "partial transfer due to vanished source files": between
|
| 57 |
+
# building its file list and reading them, something deleted files the listing
|
| 58 |
+
# still named. Both tree copies are exposed to it — a restore walks the bucket
|
| 59 |
+
# for minutes while a harness prunes transcript scratch, and a checkpoint copies
|
| 60 |
+
# a live tree the harnesses are still writing. What vanishes is the scratch
|
| 61 |
+
# itself, so nothing durable is lost and the copy is otherwise complete.
|
| 62 |
+
# Treating it as a failure once refused to boot the Space at all.
|
| 63 |
+
# Single-file copies below keep the strict test: there, a vanished source is
|
| 64 |
+
# exactly the file we were asked for.
|
| 65 |
+
rsync_tree() {
|
| 66 |
+
rsync "$@"; _rs_rc=$?
|
| 67 |
+
[ "$_rs_rc" -eq 24 ] && _rs_rc=0
|
| 68 |
+
return "$_rs_rc"
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
restore_tree() {
|
| 72 |
key="$1" durable="$2" live="$3"; shift 3
|
| 73 |
mkdir -p "$durable" "$live"
|
|
|
|
| 78 |
# --update matters for hot/dev restarts: local disk survives those and can be
|
| 79 |
# newer than the last completed bucket checkpoint. A fresh container starts
|
| 80 |
# with an empty destination, so the same command performs a full restore.
|
| 81 |
+
if rsync_tree -a --update "$@" "$durable/" "$live/"; then
|
| 82 |
mkdir -p "$stamp_dir"
|
| 83 |
if [ ! -e "$stamp" ]; then
|
| 84 |
if [ "$had_local" = true ]; then
|
|
|
|
| 117 |
# the remote tree every 15 seconds — a critical property on the bucket
|
| 118 |
# mount. Destination temporaries close before rename, retaining the prior
|
| 119 |
# object if this process dies during transfer.
|
| 120 |
+
if ! rsync_tree -a -r --from0 --files-from="$list" --delay-updates \
|
| 121 |
"$@" "$live/" "$durable/"; then
|
| 122 |
rm -f "$next" "$list"
|
| 123 |
return 1
|
|
@@ -191,6 +191,47 @@ try {
|
|
| 191 |
check('SQLite snapshot restores without stale WAL state',
|
| 192 |
sql(path.join(env.OPENCODE_LIVE, 'opencode.db'), 'SELECT body FROM message ORDER BY id;') === openRowsBeforeCorruption
|
| 193 |
&& !fs.existsSync(path.join(env.OPENCODE_LIVE, 'opencode.db-wal')));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
} catch (error) {
|
| 195 |
check('no unexpected exception', false, error?.stack || String(error));
|
| 196 |
} finally {
|
|
|
|
| 191 |
check('SQLite snapshot restores without stale WAL state',
|
| 192 |
sql(path.join(env.OPENCODE_LIVE, 'opencode.db'), 'SELECT body FROM message ORDER BY id;') === openRowsBeforeCorruption
|
| 193 |
&& !fs.existsSync(path.join(env.OPENCODE_LIVE, 'opencode.db-wal')));
|
| 194 |
+
// rsync exit 24 = "partial transfer due to vanished source files". A restore
|
| 195 |
+
// walks the bucket for minutes while a harness prunes its transcript scratch,
|
| 196 |
+
// so the listing names files that are gone by the time they are read. This
|
| 197 |
+
// once aborted the boot outright. Exit code handling is pinned with a stub so
|
| 198 |
+
// the check does not depend on winning a race against a real transfer; a
|
| 199 |
+
// fresh DATA_DIR keeps the strict single-file SQLite copies out of the way.
|
| 200 |
+
const vanishRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'am-agent-state-24-'));
|
| 201 |
+
const stubDir = path.join(vanishRoot, 'bin');
|
| 202 |
+
const stubRsync = (code) => {
|
| 203 |
+
fs.mkdirSync(stubDir, { recursive: true });
|
| 204 |
+
fs.writeFileSync(path.join(stubDir, 'rsync'), [
|
| 205 |
+
'#!/bin/sh',
|
| 206 |
+
'echo \'file has vanished: "tool-results/toolu_01.txt"\' >&2',
|
| 207 |
+
`echo 'rsync warning: vanished/error stub (code ${code})' >&2`,
|
| 208 |
+
`exit ${code}`,
|
| 209 |
+
].join('\n'));
|
| 210 |
+
fs.chmodSync(path.join(stubDir, 'rsync'), 0o755);
|
| 211 |
+
};
|
| 212 |
+
const restoreStatus = (code) => {
|
| 213 |
+
stubRsync(code);
|
| 214 |
+
const stubEnv = { ...env, PATH: `${stubDir}:${env.PATH}` };
|
| 215 |
+
for (const [key, value] of Object.entries(stubEnv)) {
|
| 216 |
+
if (typeof value === 'string' && value.startsWith(root)) {
|
| 217 |
+
stubEnv[key] = value.replace(root, vanishRoot);
|
| 218 |
+
}
|
| 219 |
+
}
|
| 220 |
+
try {
|
| 221 |
+
execFileSync('sh', [script, 'restore'], { env: stubEnv, encoding: 'utf8', stdio: 'pipe' });
|
| 222 |
+
return { status: 0, local: stubEnv.AM_LOCAL };
|
| 223 |
+
} catch (error) {
|
| 224 |
+
return { status: error.status ?? 1, local: stubEnv.AM_LOCAL };
|
| 225 |
+
}
|
| 226 |
+
};
|
| 227 |
+
|
| 228 |
+
const vanished = restoreStatus(24);
|
| 229 |
+
check('restore survives rsync vanished-source warning (exit 24)', vanished.status === 0);
|
| 230 |
+
check('a tolerated exit 24 still takes the restore success path',
|
| 231 |
+
fs.existsSync(path.join(vanished.local, 'agent-state-stamps/claude')));
|
| 232 |
+
check('restore still fails on a genuine rsync error (exit 23)',
|
| 233 |
+
restoreStatus(23).status === 1);
|
| 234 |
+
fs.rmSync(vanishRoot, { recursive: true, force: true });
|
| 235 |
} catch (error) {
|
| 236 |
check('no unexpected exception', false, error?.stack || String(error));
|
| 237 |
} finally {
|