Repository navigation
fix(modal): record the sandbox exit code the overseer already probes - #303
Conversation
Three Aave v4 runs have now lost their sandbox at `stateful-invariant-setup` -- R45 and R46 at 20 succeeded nodes, roughly $400 and two hours each -- and none of them can be diagnosed, because the one field that would explain it is computed and then thrown away. `probeModalSandbox` (`runner.ts:1180-1194`) already gets the code from `sandbox.poll()`, and the foreground `launchOrResumeModel` path forwards it. But on an unattended run the overseer is what observes these deaths, and its reservation force-closes the dying attempt's lifecycle row with every diagnostic hardcoded to `"unknown"` (`launch-state.ts:1147-1160`). R46's three lifecycle rows all read `worker_exit_code: "unknown"`, including the attempt whose sandbox died mid-node. 137 would say OOM. 0 would say clean exit. A signal would say eviction. "unknown" says nothing, and learning nothing again costs another relaunch. `launchModalRecoveryWorker` now probes the outgoing sandbox before reserving the replacement -- it already holds both the Modal client and the outgoing `sandbox_id`, so this is one call, not a new plumbing path -- and threads the result through `reserveModalLaunchAttempt`. The regression pins three cases, because two of them are easy to get wrong: - an observed 137 is recorded - an ABSENT observation still records "unknown" rather than inventing a code, so a caller that could not probe stays distinguishable from one that saw a clean exit - an observed 0 survives as 0; a falsy check here would silently discard the most interesting observation of the three Proven red before the fix. packages/modal 310/310. Fixes #302 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… of re-probing
Both reviews landed on the same objection, and the second found the better design:
my first version added a `probeModalSandbox` call to the RECOVERY RELAUNCH path --
the code that rescues a run whose sandbox just died -- purely to improve logging.
`probeModalSandbox` rethrows anything that is not `NotFoundError`, and
`sandbox.poll()` is a real gRPC call, so an exhausted retry budget would propagate
out of the overseer tick; ten consecutive ticks abandon the job. Adding an abort
point to the rescue path in order to observe failures better is the wrong
dependency direction at any probability.
The value did not need a new probe at all. `runningRecoverySandbox` already polls
the outgoing sandbox during ownership resolution and threw the exit code away:
const exitCode = await sandbox.poll();
if (exitCode === null) return sandbox; // non-null discarded
It now returns `{ sandbox }` while live and `{ exitCode }` once exited, the
observation rides out on `ResolvedModalRecoveryOwner.launchExitCode`, and
`launchModalRecoveryWorker` records it. No new network call, no new throw site.
The second probe was also strictly worse at capturing the value: between ownership
resolution and the reservation, the tick creates and terminates an inspector
sandbox and copies credentials, so the id can be reaped in between and a later
probe can only return a worse answer than the earlier one.
Also from review: the runner half was completely unpinned -- deleting it left the
suite green, the same defect class found in #286 and #298. `runningRecoverySandbox`
is now exported and tested across all four outcomes: exited with 137, exited
cleanly with 0 (which must survive rather than be coerced away, since a clean exit
at a node that was supposed to keep working is the most surprising answer of all),
still live, and vanished. Proven red by mutation.
packages/modal 311/311.
Fixes #302
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both reviews rejected the first version, and the second found a better design than mine. Reworked in
|
Integrates origin/main (fa8be64): PR #287's invariant-suite handoff deletion channel and durable provenance, plus #282, #284, #290, #294, #296, #298, #300, #303, #305 and #308. CHANGELOG keeps both Unreleased sections; the modal runner test import list keeps both new symbols. Fixes the review finding on #165 (issue #212). Compiled task specs can only name singular `test/...` output roots, because they are built before the worktree exists. Targets are not consistent: the Aave v4 target at 6959e321 has no `test/` at all, only plural `tests/`. The generated workflow created the singular tree regardless, so such a repository grew empty `test/recon`, `test/chimera` and `test/invariants` directories, which setup.md tells the model to read as the repository's convention, and retry cleanup then scrubbed that invented tree while leaving the real `tests/` output behind. Workspace output roots are now re-anchored at execution time onto the test root the repository actually uses, restoring the semantics of main's `invariantTestRoots` helper that this branch dropped in merge 2bbecba, at the layer this branch's retry preparation uses. Three review findings on the first cut of that fix are addressed here: - Exactly ONE root is selected, never the union of those present. Anchoring under every existing root would have recreated the same defect from the other side: once an ancestor stage introduces the other root, each invariant stage would create an empty duplicate tree beside the real one. Preference follows INVARIANT_TEST_ROOT_NAMES, so a repository that has `test/` behaves exactly as it did before this helper existed and only a repository without it is redirected. - The resolution is pinned per attempt at preparation time. Rediscovering per call was unsafe: prepareTaskWorkspaceOutputRoots finishes by replaying ancestor workspace patches, and an ancestor stage can introduce the other test root as a side effect, so a second independent discovery could select a root whose subdirectories were never created. taskWorkspaceOutputRoots would then lstat a missing path and throw a bare ENOENT instead of the typed artifact-contract failure, killing a retries:0 node on exactly the restart and resume paths this machinery exists to survive. - Only ENOENT and ENOTDIR mean "this root is absent". Swallowing EACCES or ELOOP would silently fall back to the singular default against a root that does exist. Test changes, also from review: - The non-directory case was vacuous: with only a regular file named `test` present, "rejected it" and "fell back to the default" both read `test`. It now runs against a repository that also has a real `tests/`, so the outputs differ; verified by mutation (dropping the isDirectory() guard fails the test). - taskWorkspaceOutputRoots' use of the helper is now pinned in the generated-source assertions. Reverting only that call site previously left every test green while every task on a `tests/`-only target died on a bare ENOENT. - Adds coverage for the pinning behaviour, for per-attempt isolation of the pin, and for surfacing an unreadable candidate root. Also repairs a merge-integration gap the new #217 harness could not have anticipated: it lifts resetTaskArtifactsForRetry, which on this branch calls prepareAnchoredDirectory (branch-only, absent from main), so the harness now supplies that collaborator and drops the dead invariantTestRoots stub. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
stateful-invariant-setupand none can be diagnosed, because the field that would explain it is computed and then discardedWhat is undiagnosable, and why
stateful-invariant-setupsandbox-exitedstateful-invariant-setupsandbox-exitedNeither recorded a
last_error— the sandbox went away rather than the agent reporting a failure — andworker.logon the volume is a heartbeat only (worker-started/operation-started/operation-finished), carrying no agent stderr. The obvious next question is the sandbox's exit code, and it is missing.probeModalSandbox(runner.ts:1180-1194) already obtains it:and the foreground
launchOrResumeModelpath forwards it (runner.ts:680,:696). But on an unattended run — which is every benchmark run — the overseer is what observes these terminations, and its reservation force-closes the dying attempt's lifecycle row with every diagnostic hardcoded (launch-state.ts:1147-1160):R46's durable state, all three rows:
{"attempt": 2, "terminal_reason": "operational-failure", "worker_exit_code": "unknown", "model_work_started": true, "node_counts_after": {"succeeded": 20, "failed": 3, "remaining": 9}}The fix
launchModalRecoveryWorkerprobes the outgoing sandbox before reserving its replacement. It already holds the Modal client and the outgoingsandbox_id, so this is one call rather than a new plumbing path, and the value is threaded throughreserveModalLaunchAttemptinto the lifecycle row.The test pins three cases, two of which are easy to get wrong
137is recorded"unknown"rather than inventing a code, so a caller that could not probe stays distinguishable from one that saw a clean exit0survives as0— a falsy check here would silently discard the most interesting observation of the three, and a clean exit at this node would be a genuinely surprising result worth seeingProven red before the fix.
Relationship to #286
Same shape, different field. #286 threads
observedModelWorkStartedthrough this exact reservation for the same reason: the overseer path defaults a diagnostic to"unknown"and nobody notices. Both reviewers of that PR independently recommended making the parameter required so omission becomes a compile error. That is the right end state and would subsume both changes — I have kept this one optional to match the existing shape rather than conflict with #286, and #302 records the suggestion that they be unified.Validation
packages/modal310 tests / 310 passWhat this does not do
It does not fix the sandbox deaths. It makes the next one legible. Given that each relaunch of the mystery costs roughly $400 and two hours, and three runs have now paid that, one recorded integer is worth the change.
Greptile Summary
The PR preserves the sandbox exit code already observed by Modal recovery ownership resolution and records it when force-closing the replaced launch attempt.
Confidence Score: 5/5
The PR appears safe to merge.
No blocking failure remains within the eligible follow-up review scope.
Important Files Changed
Sequence Diagram
Reviews (2): Last reviewed commit: "fix(modal): thread the exit code the ove..." | Re-trigger Greptile
Context used: