Agent Manager Claude Opus 5 commited on
Commit
ce3c3bd
·
1 Parent(s): 16717ea

Don't fail the boot when rsync reports vanished source files

Browse files

agent-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 CHANGED
@@ -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 rsync -a --update "$@" "$durable/" "$live/"; then
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 ! rsync -a -r --from0 --files-from="$list" --delay-updates \
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
server/state-checkpoint.test.mjs CHANGED
@@ -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 {