test(sandbox): prove #455 end to end against a live Daytona sandbox - #459
test(sandbox): prove #455 end to end against a live Daytona sandbox#459khaliqgant wants to merge 8 commits into
Conversation
…ete reconcile `relayfile-mount --once` returned as soon as one reconcile finished. A single reconcile mirrors at most defaultBootstrapMaxFilesPerCycle (2000) files, then persists a resume cursor and yields with traversal_complete=false while markSyncSuccess still stamps lastSuccessfulReconcileAt. So on any workspace larger than that budget `--once` exited 0 with a non-null `bootstrap` block still in .relay/state.json. AgentWorkforce/sandbox reads exactly that field as the initial-sync readiness barrier and exits 75 (TEMPFAIL, "relayfile initial sync paused before complete readiness"). That is the mechanism behind #455's 100% JIT-provision failure rate: it is structural, not the race hypothesised from #412 -- the sandbox runs this binary, which has a single .relay/state.json writer. --once now resumes the persisted traversal checkpoint until the bootstrap completes. The loop is bounded by root-context cancellation, a terminal error, and a no-progress guard keyed on every resumable coordinate the public bootstrap block exposes (filesSynced alone can be flat while the directory queue advances). Cancellation returns nil so the exit code keeps its historical meaning and the downstream guard still reports a resumable TEMPFAIL, and a cycle that *failed* is never retried -- that keeps one transient cloud error from escalating into a bootstrap stall. Recreate-then-verify: TestInitialSyncOnceSatisfiesSandboxReadinessGuard drives the same path with a 5-file/cycle budget and applies the sandbox's own guard verbatim. It fails with "state.bootstrap != null" on the pre-fix code and passes here. Refs #455 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sDWHJzWsSYwv9fCtzhZrj Session-Id: 8817afd3-0a74-42ab-8841-5e358ded0641
…state `<localDir>/.relay/state.json` has two writers in one process emitting disjoint schemas to the same path, roughly once per second: mountsync's savePublicState and the CLI's writeMirrorStateFile. The mirror writer serialized its own struct over the whole file, so whichever writer ran last decided which half of the schema existed -- consumers saw a document with no `providers` (mountsync won) or no `files`/`counters`/`states`/`circuit` (the CLI won), and every guard keyed on the missing half silently failed open. The mirror writer now merges: it clears the keys it owns -- so a cleared field (a drained stallReason, a resolved lastError) is not resurrected -- then overlays its snapshot, leaving mountsync's fields intact. mirrorStateOwnedKeys is pinned to the syncStateFile JSON surface by a reflection test so it cannot drift. The merged write is compact, matching the mountsync writer, because the document now carries the per-file map and this write fires on every local-change batch. Two consequences of the same file that this also closes: - writeMirrorStateFile stamped lastReconcileAt = now unconditionally, making the document fresh by construction, so no consumer could ever observe a stale mount. It now only stamps when the mount reported no reconcile time. - The CLI daemon's mountsync.NewSyncer call omitted Interval, so the syncer's interval was the zero value and its public state advertised intervalMs: 0 -- which makes every consumer's staleness check early-return "fresh" forever. Recreate-then-verify: TestMirrorStateWriteKeepsMountsyncFields runs a CLI mount cycle and asserts both writers' fields survive in one document. On the pre-fix code it reports localRoot, syncMode, states, files, counters and staleAfter all clobbered. Not addressed here, both on other surfaces: the SDK's isMountStateReady fail-open on an absent `providers` array, and the failedWritebacks lost update across the two writers. Refs #412 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sDWHJzWsSYwv9fCtzhZrj Session-Id: 8817afd3-0a74-42ab-8841-5e358ded0641
…s rules #412 named this file as a documented public contract that three guides tell agents to read, but it did not say what the two in-process writers guarantee, and #455 turned on a readiness rule the contract never stated. Record both: a write from either writer must not remove the other's keys, an absent key means "not reported" rather than "the other writer won", and a non-null `bootstrap` block means the mount is not ready however fresh `lastSuccessfulReconcileAt` looks. Refs #412, #455 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sDWHJzWsSYwv9fCtzhZrj Session-Id: 8817afd3-0a74-42ab-8841-5e358ded0641
Codex flagged on PR #457 that the previous commit fixed only one direction: the CLI mirror writer stopped deleting mountsync's keys, but mountsync still serialized publicState over the whole document and deleted the CLI's. The path it named is real. In the CLI mount daemon's timer loop, a healthy websocket plus watcher takes the non-reconcile branch, which calls RefreshRealtimeStateWithContext -> saveStateWithoutLocalScan -> savePublicState with no following writeSnapshot. Every such refresh removed `providers`, `daemon` and `guards` from the published document until some later snapshot happened to run. Both writers now go through internal/mountstate, declaring the keys they own: - Merge/MergeFunc replace exactly those keys and preserve every other key, including ones the build does not know about. - One process-wide lock replaces the arrangement #412 documented, where one writer's mutex appeared to synchronize against a writer that could not take it. - The document holds json.RawMessage values, so merging a public state that carries a multi-megabyte per-file map does not deep-decode it on a path that runs on every reconcile and every local-change batch. That lock also closes #412's lost update. Both writers now read `failedWritebacks` inside the same lock hold as the write that carries it forward, and incrementFailedWritebacksInState became mountstate.Increment. TestIncrementSurvivesConcurrentMerges lands all 200 increments against a concurrently republishing writer under -race; before, increments were lost in 5 of 6 runs. Removes the now-dead failedWritebacksStateMu, readPersistedFailedWritebacksUnlocked, uint64FromJSONValue and readPublicFailedWritebacks. Recreate-then-verify: TestSavePublicStateKeepsCLIMirrorFields reports `providers`, `daemon` and `guards` clobbered when only the savePublicState merge is reverted. Refs #412 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sDWHJzWsSYwv9fCtzhZrj Session-Id: 8817afd3-0a74-42ab-8841-5e358ded0641
…cellation cubic flagged on PR #457 that TestInitialSyncOnceStopsWhenRootContextEnds could pass by finishing normally: nothing stopped the bootstrap completing inside the window, and the test never asserted that cancellation had fired, so it could not catch a regression in the bound it was named for. That is right. Measured on this machine the run did cancel -- 200 files at 2 per cycle takes longer than 2s because each cycle re-lists the tree and rescans locally -- but nothing guaranteed it, and a faster machine would have turned the test green for the wrong reason. The workspace is now sized so it cannot finish (400 files, 2 per cycle, each read delayed), and the test asserts both that the context was cancelled and that the bootstrap is still in progress afterwards -- so a run that completes normally fails instead of passing silently. Timing should not be what pins a branch, so each bound also gets a deterministic unit test against finishInitialBootstrap directly: - an already-cancelled context returns without running a cycle; - a failed cycle is not retried, keeping --once's single-attempt behavior so one transient cloud error cannot escalate into a bootstrap stall; - a checkpoint that stops advancing stops after onceBootstrapStableCycleLimit cycles rather than spinning to the ceiling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sDWHJzWsSYwv9fCtzhZrj Session-Id: 8817afd3-0a74-42ab-8841-5e358ded0641
…read cubic flagged on PR #457 that readDocumentLocked treated every read failure as an empty document. A merge writes back everything it read, so a transient EIO or a permissions change would have deleted the other writer's keys — the exact clobber this package exists to prevent, reached by the code meant to prevent it. Read failures other than "not found" now propagate, and the write does not happen. Three cases, each pinned by a test: - missing file: not an error, the first write has to start somewhere; - unreadable file: error, nothing is written, the previous document survives intact; - unparseable file: still overwritten on purpose. Writes go through an atomic rename so a torn document should be unreachable, and refusing to rewrite a corrupt one would strand the mount with it forever. `Read` keeps returning an empty document on any failure: a reader has nothing to destroy, and its callers have no error path. Also closes the coverage gap cubic found in the resume-loop tests. TestFinishInitialBootstrapDoesNotRetryAFailedCycle returned an error from the first `lastCycleErr` call, which is the pre-loop gate — so the in-loop failed-resume-cycle branch was never executed. Split into two tests: the pre-loop gate, and a new one where the first cycle succeeds and a resume cycle then fails, asserting exactly one cycle ran. Refs #412 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sDWHJzWsSYwv9fCtzhZrj Session-Id: 8817afd3-0a74-42ab-8841-5e358ded0641
PR #457's evidence was a Go transcription of the sandbox readiness guard. The transcription is faithful, but no sandbox had ever provisioned with this build, so the claim was untested on the real path. This adds a two-arm proof that runs against a real Daytona sandbox and imports the readiness guard from @agent-relay/sandbox 0.1.14 — the version cloud pins — rather than reimplementing it. Both arms run the identical generated command against the identical remote subtree and differ only in which relayfile-mount is first on PATH. Result on /github/repos/AgentWorkforce/relay (5,685 files): arm A, snapshot-baked binary: guard exit 75, bootstrap non-null at filesSynced 2000, lastSuccessfulReconcileAt stamped, status "bootstrapping" arm B, built from 4e3c110: guard exit 0, bootstrap key absent, status "ready", 5,685 files across three resume cycles Both arms' first cycles are numerically identical (entries_seen=3986 files_seen=2000 bytes_seen=26734874, both yielding at entry 170 with 887 directories pending); they diverge only in what happens after. The claim is CONFIRMED. The harness treats a must-fail control that does not fail as UNKNOWN rather than green: if arm A passes, the fixture never crossed the 2000-file budget and neither arm proves anything. A timeout is UNKNOWN, never a pass. Raw .relay/state.json bytes from both arms are included, with the sha256 taken inside the sandbox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R22c8M6BAYQykoDjVEmMtv Session-Id: 6123871c-ba3b-49ec-8855-2064a56321d9
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c52d5ae8ff
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Relayfile Eval ReviewRun: Passed: 4 | Needs human: 0 | Reviewable: 0 | Missing output: 0 | Failed: 0 | Skipped: 0 Human Review CasesNo reviewable human-review cases captured Relayfile output. |
There was a problem hiding this comment.
All reported issues were addressed across 9 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Review found twelve issues on the harness itself. Because this harness gates future merges, a weak check here becomes a false green for everyone later, so all of them are addressed. Correctness of the verdict: * arm A exiting 75 is no longer accepted as a valid must-fail control on its own. The guard returns 75 for ANY incomplete-or-unreadable state, including an auth failure before a single file syncs — exactly what happened during the original run (403 missing required scope: fs:read, filesSynced 0, exit 75). The control now additionally requires a non-null bootstrap block at filesSynced >= 2000 and a log reporting both "bootstrap file budget reached" and traversal_complete=false. * infrastructure failure is UNKNOWN, never REFUTED. validateEnv() and module loading previously threw outside the try, so a HarnessUnknown escaped as an unhandled rejection and node exited 1 — the code that means "the fix does not work". Everything is inside the try now. * credential failure in either arm is UNKNOWN, and each arm mints its own token, so a one-hour credential cannot expire during the second sequential 45-minute arm and score an interrupted run as REFUTED. * the scored state file is derived from the package's own resolveRelayfileMountExactLayout, the same computation the guard uses, so the harness cannot drift from the guard. Resolvability: * the import specifier @agent-relay/sandbox/relayfile/mount-script.js did not exist; the package exposes these through /core. Verified: ERR_MODULE_NOT_FOUND before the fix. * createRequire().resolve() cannot be used either — it resolves under the CJS "require" condition and the package exports only import/types (ERR_PACKAGE_PATH_NOT_EXPORTED). Resolution now goes through a throwaway ESM shim written into RELAYFILE_PROOF_MODULE_BASE, which uses Node's real resolver with the right conditions and no hardcoded package internals. * all credentials are validated before mkdtemp and the go build, so a missing variable costs nothing and leaves no litter in os.tmpdir(). Evidence: * SHA256SUMS is now verifiable with `sha256sum -c` against the committed .gz files, with the uncompressed hashes and byte counts in comments. * METHODOLOGY no longer claims the two arms' generated shell strings are byte-identical. They are not — localDir, stateDir, credsFilePath and runId necessarily differ per arm. What makes it a control is that every input governing the mechanism is the same. Adds a red-check for the verdict logic itself, built from the real recorded data. Against the previous logic all five adversarial cases scored wrong — three of them reporting PROOF HELD off a false control. The verdict is unchanged: arm A exit 75 at 2000 files, arm B exit 0 at 5685 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R22c8M6BAYQykoDjVEmMtv Session-Id: 6123871c-ba3b-49ec-8855-2064a56321d9
There was a problem hiding this comment.
3 issues found across 5 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="scripts/test-sandbox-initial-sync-readiness-e2e.verdict.test.mjs">
<violation number="1" location="scripts/test-sandbox-initial-sync-readiness-e2e.verdict.test.mjs:58">
P2: The red-check never guards a false green where arm B 'passes' without mirroring anything. `verdict()` returns PROOF_HELD whenever `B.guardExit === 0 && B.bootstrapNull` — it never requires `B.mirroredFiles > 0`. Because the control arm A has already proven the subtree holds >= 2000 files, an arm B that exits 0 with zero (or a handful of) mirrored files is evidence the candidate silently failed to mount, yet the harness would report PROOF HELD and gate the merge. Add a red-check case (and a `B.mirroredFiles > 0` requirement in `verdict`) so a candidate that produced no mirror cannot be read as convergence.</violation>
</file>
<file name="scripts/test-sandbox-initial-sync-readiness-e2e.mjs">
<violation number="1" location="scripts/test-sandbox-initial-sync-readiness-e2e.mjs:370">
P1: When arm B encounters a non-auth network or server error, the harness reports REFUTED instead of UNKNOWN. Require a successful candidate traversal with no infrastructure/error signal before classifying non-convergence as a refutation.</violation>
<violation number="2" location="scripts/test-sandbox-initial-sync-readiness-e2e.mjs:393">
P2: When `RELAYFILE_PROOF_MODULE_BASE` contains a different sandbox package, this harness silently tests its guard as if it were the pinned production guard. Assert the expected package version or resolve dependencies from the pinned lockfile before running the proof.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| if (B.stateMissing) { | ||
| return [EXIT_UNKNOWN, `UNKNOWN: arm B left no state file at ${B.stateFile}`]; | ||
| } | ||
| if (B.guardExit !== 0 || !B.bootstrapNull) { |
There was a problem hiding this comment.
P1: When arm B encounters a non-auth network or server error, the harness reports REFUTED instead of UNKNOWN. Require a successful candidate traversal with no infrastructure/error signal before classifying non-convergence as a refutation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/test-sandbox-initial-sync-readiness-e2e.mjs, line 370:
<comment>When arm B encounters a non-auth network or server error, the harness reports REFUTED instead of UNKNOWN. Require a successful candidate traversal with no infrastructure/error signal before classifying non-convergence as a refutation.</comment>
<file context>
@@ -187,38 +309,116 @@ function summarize(r) {
+ if (B.stateMissing) {
+ return [EXIT_UNKNOWN, `UNKNOWN: arm B left no state file at ${B.stateFile}`];
+ }
+ if (B.guardExit !== 0 || !B.bootstrapNull) {
+ return [EXIT_PROOF_REFUTED,
+ `REFUTED: arm B exited ${B.guardExit} with bootstrap${B.bootstrapNull ? " null" : " still non-null"} `
</file context>
| const A = (o = {}) => ({ ...goodControl, ...o }); | ||
| const B = (o = {}) => ({ ...goodCandidate, ...o }); | ||
|
|
||
| test("must-fire: the real observed run is a pass", () => { |
There was a problem hiding this comment.
P2: The red-check never guards a false green where arm B 'passes' without mirroring anything. verdict() returns PROOF_HELD whenever B.guardExit === 0 && B.bootstrapNull — it never requires B.mirroredFiles > 0. Because the control arm A has already proven the subtree holds >= 2000 files, an arm B that exits 0 with zero (or a handful of) mirrored files is evidence the candidate silently failed to mount, yet the harness would report PROOF HELD and gate the merge. Add a red-check case (and a B.mirroredFiles > 0 requirement in verdict) so a candidate that produced no mirror cannot be read as convergence.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/test-sandbox-initial-sync-readiness-e2e.verdict.test.mjs, line 58:
<comment>The red-check never guards a false green where arm B 'passes' without mirroring anything. `verdict()` returns PROOF_HELD whenever `B.guardExit === 0 && B.bootstrapNull` — it never requires `B.mirroredFiles > 0`. Because the control arm A has already proven the subtree holds >= 2000 files, an arm B that exits 0 with zero (or a handful of) mirrored files is evidence the candidate silently failed to mount, yet the harness would report PROOF HELD and gate the merge. Add a red-check case (and a `B.mirroredFiles > 0` requirement in `verdict`) so a candidate that produced no mirror cannot be read as convergence.</comment>
<file context>
@@ -0,0 +1,134 @@
+const A = (o = {}) => ({ ...goodControl, ...o });
+const B = (o = {}) => ({ ...goodCandidate, ...o });
+
+test("must-fire: the real observed run is a pass", () => {
+ const [code, msg] = verdict(A(), B());
+ assert.equal(code, EXIT_PROOF_HELD);
</file context>
| const loader = await externalLoader(); | ||
| disposeResolver = loader.dispose; | ||
| return Promise.all([ | ||
| loader.load("@agent-relay/sandbox/core"), |
There was a problem hiding this comment.
P2: When RELAYFILE_PROOF_MODULE_BASE contains a different sandbox package, this harness silently tests its guard as if it were the pinned production guard. Assert the expected package version or resolve dependencies from the pinned lockfile before running the proof.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At scripts/test-sandbox-initial-sync-readiness-e2e.mjs, line 393:
<comment>When `RELAYFILE_PROOF_MODULE_BASE` contains a different sandbox package, this harness silently tests its guard as if it were the pinned production guard. Assert the expected package version or resolve dependencies from the pinned lockfile before running the proof.</comment>
<file context>
@@ -187,38 +309,116 @@ function summarize(r) {
+ const loader = await externalLoader();
+ disposeResolver = loader.dispose;
+ return Promise.all([
+ loader.load("@agent-relay/sandbox/core"),
+ loader.load("@daytonaio/sdk"),
+ loader.load("@cloud/core/relayfile/client.js"),
</file context>
Verification lane for #457. I did not write that PR and this does not change it — this is the missing evidence that it works on the real path.
Verdict: CONFIRMED. Based on
fix/455-state-json-single-writerso the diff here is only the proof.Why this exists
#457's evidence is
sandboxInitialSyncGuard, a Go transcription of the sandbox readiness guard. The transcription is faithful — but no sandbox had ever provisioned with this build, so the claim was untested where it actually fails. This closes that gap.The guard here is imported from
@agent-relay/sandboxatorigin/maincfdf801(v0.1.14, the versioncloud/packages/corepins), not reimplemented, and invoked throughbuildRelayfileMountInitialSyncBackgroundShell— the detached launcher, because Daytona's exec proxy read-times-out around 120s and cannot host a real initial sync in the foreground.Both exit codes
Sandbox
8a7d6049-ca63-447b-abdf-f750f121c994, remote root/github/repos/AgentWorkforce/relay, 5,685 files. Both arms run the identical generated command against the identical subtree and differ only in whichrelayfile-mountis first onPATH.4e3c1105bootstrapfilesSynced: 2000lastSuccessfulReconcileAt2026-09-02T12:18:24.303486294Z2026-09-02T12:47:48.742036968Zstatusbootstrappingready3d59ae71dff2…37dc1d4339329c7c14f5…14dad359Raw
.relay/state.jsonbytes for both arms are committed underdocs/evidence/daytona-455-initial-sync-readiness-20260902/raw/, with the sha256 taken inside the sandbox.All four claimed steps in one run (arm A):
And the fix (arm B): 2,000 → 4,000 → 5,685 across three resume cycles, then
initial sync: bootstrap complete.The control is tight. Both arms' first cycles are numerically identical —
list_calls=6 entries_seen=3986 files_seen=2000 directories_seen=1986 bytes_seen=26734874, both yielding at entry 170 with 887 directories pending. Same fixture, same budget, same stopping point; they diverge only in what happens next.The harness fails closed
scripts/test-sandbox-initial-sync-readiness-e2e.mjsexits 2 (UNKNOWN), not 0, when it cannot provision, when an arm times out, or when arm A passes — a must-fail control that does not fail means the fixture never crossed the 2,000-file budget and neither arm proves anything.Three things found on the way that are not in the claim
Publishing fix(mount): make --once reach a complete reconcile, and stop the two state.json writers clobbering each other #457 will not reach any sandbox. The mount binary is baked into the Daytona snapshot, not resolved at provision time — snapshot names encode it, and the newest snapshot built 2026-09-02 still carries v0.10.50. A snapshot rebuild and a fleet pin bump are required on top of the merge.
The snapshot's label disagrees with its contents. Named
v0.10.50, but@relayfile/mount-linux-x64inside is 0.10.51, and three differentrelayfile-mountbinaries with three different sha256s are present. Which one runs depends onPATH.tokenIngress: 'env'in@agent-relay/sandboxis broken. It rendersRELAYFILE_MOUNT_TOKEN=, butcmd/relayfile-mount/main.goreads onlyRELAYFILE_TOKEN/RELAYFILE_MOUNT_CREDS_FILE. No build — including this PR's head — reads it. Observed directly:token is required (--token, RELAYFILE_TOKEN, or --creds-file), exit 1, before the guard ran. These runs used'creds-file', which is what fleet uses.One caveat on the fix
The fixed build removes the
bootstrapkey rather than setting it tonull. The guard testsstate.bootstrap != null, and loose!=treatsundefinedas null, so it passes. A strict!== nullwould have failed it. Guard and writer agree today only by virtue of a loose comparison — worth pinning deliberately.Not merging, and nothing was pushed to
fix/455-state-json-single-writer. Khaliq holds the merge gate.🤖 Generated with Claude Code
https://claude.ai/code/session_01R22c8M6BAYQykoDjVEmMtv