Repository navigation
fix(modal): treat a run with no linked durable run as not started - #381
Conversation
A generation-0 failure between `eval run` creating the eval run directory and a durable run being linked left every later recovery generation unable to start. `hasExistingEvalWorkspace` routed on "the eval runs directory is non-empty", while `locateModalResumeWorkspace` requires exactly one *linked durable run*. Between those two moments the worker chose the resume path and then threw `resume requires exactly one linked durable run, found 0`, in ~92 seconds, every generation, until the no-progress budget ran out. Route on the condition the resume path actually requires. `findModalResumeWorkspace` reports `not-started` when nothing was ever linked, and the worker then builds the workspace through the pre-model path that already handles a populated WORK_ROOT. Zero and many stay asymmetric: zero linked runs means nothing was recorded, so restarting loses nothing by construction, while more than one is corruption and remains a hard failure. Refs #378.
Both reviews of the first attempt were right: it was a no-op. Eval run ids are deterministic, so routing to the pre-model path hit EVAL_RUN_ALREADY_EXISTS and wedged exactly as before — and because `modelWorkStarted` is set before `runEval`, it also reset the pre-model retry streak, turning a loop bounded at 3 attempts into an unbounded one. The premise was wrong too. `runs.jsonl` is appended only after the launcher returns, and `startRun` creates the run root, writes state.json, compiles and submits before that. R54's volume held a durable run with 9 succeeded nodes and no link. Restarting would have abandoned that work, and would have failed anyway on RUN_ALREADY_EXISTS, since row run ids are deterministic too. So treat the journal as a cache rather than the source of truth: when it holds no link, consult the durable run directory. A run on disk is resumable and its link is written back on finalize; only a genuinely absent run is a fresh start, and then the stale eval run directory is cleared so the runner can reuse the id. Also from review: classify a missing journal instead of crashing ENOENT, keep non-ENOENT readdir faults loud, and apply the symlink assertion at every candidate count. Refs #378.
|
Reworked after both reviews. Thank you — both findings were correct and the first version was a no-op. What changed. The fix no longer restarts. It repairs the missing link and resumes: when the journal On the premise. "Restarting loses nothing by construction" is withdrawn. It was false in general and Also fixed from the reviews: a missing On test level — the point that all three tests only exercised the newly added function was the sharpest Re-review requested on the new approach, particularly the link write-back in |
…nest Second round of review found the repair moved the wedge rather than removing it. Three fixes: The repaired journal row now carries `ultrafuzz_run_root` and `status: "launched"`. Cloning the failed launch row and stamping a link on it produced a row `scoreEvalRun` cannot resolve a report for: it falls back to a path built from `row.run_id`, which is not the bounded run id on disk. The run would drive to success and then die in scoring. A run root counts as resumable only when `run-metadata.json` carries the workflow link. `state.json` is written long before compilation, so requiring only that called runs resumable which `resume` refuses forever with WORKFLOW_RUN_ID_MISSING. Unresumable roots are now named for clearing, because a restart would otherwise trip RUN_ALREADY_EXISTS on their deterministic id. `modelWorkStarted` is claimed only once the durable run state is readable. An attempt reporting model work resets the streak the pre-model retry limit counts, so claiming it earlier traded a 3-attempt failure for a full budget burn. Refs #378.
|
Second round applied. Both re-reviews were blocking and both were right; three fixes pushed. 1. The repaired row was not scoreable. Confirmed: cloning the failed-launch row and stamping a link on 2. 3. Claims withdrawn, both flagged as overstated:
One correction to the re-review, on the causal story. The suggestion that R54's 9 succeeded nodes imply Not changed, deliberately: the Tests: 19, including the two states that produced the blocking findings — an unlinked run root, and the |
…unlinked run Round 3 of review found the workflow-link predicate read `run-metadata.json`, a filename the runtime never writes — it writes `run.json` (`run-layout.ts:166`). Every real run root therefore classified as unlinked, which left #378 unfixed AND pointed the new deletion at exactly the roots it was added to protect. The test fixture invented the same filename, so all 19 tests passed while agreeing with the bug. The path is now derived from `layoutForRunRoot`, in both the source and the fixture, and pinned by a test. Two further wedges of the same class, both found by review: The zero-candidate branch named nothing to clear, but `runEvalSuite` refuses an id whose DIRECTORY exists and creates that directory before writing `eval.json`. A kill in that window wedged on EVAL_RUN_ALREADY_EXISTS exactly as before. Eval run directories are now tracked separately from candidates. `orphaned` was a disjunction, so a root carrying a workflow link but missing its state was named for deletion. Since the link is written before submission, that is a root a workflow may have run from. Orphaned is now a positive determination — metadata read successfully, no link — and linked-but-stateless is reported as damage. Read faults are no longer swallowed on the path that gates deletion: only ENOENT/ENOTDIR count as absence. Refs #378.
…filename Round 4 came back with no blocking findings. These are its non-blocking ones, all of the same class the earlier rounds were. The docstring that motivates the whole workflow-link predicate still named `run-metadata.json` — the exact wrong filename that caused round 3's defect, sitting thirty-nine lines above the comment explaining why the path is deliberately never restated. It now points at `layoutForRunRoot`. Run roots that could not be classified were silently skipped, but `planRun` refuses on `existsSync`, which follows symlinks and does not care whether a name is layout-addressable. Anything dropped there would still block a restart, invisibly. They are now reported. A stray dot-prefixed directory would also have thrown out of `layoutForRunRoot` on every generation with nothing able to clear it; addressability is now asked of the helper rather than restated as a regex. Damage no longer preempts a good resume: it is only refused on the branch that would restart, since that is the only branch it blocks. The one-candidate branch named only the candidate for clearing while the zero-candidate branch named every directory. `runEvalSuite` refuses any id whose directory exists, so both branches now name all of them. Malformed run metadata throws with the file path instead of a bare SyntaxError. Refs #378.
Round 5 approved with two comment-level notes and one symmetry gap; this closes all three. The eval-run loop still had the bare `continue` the run-root loop had just replaced. `runEvalSuite` refuses on `existsSync`, which follows symlinks and ignores entry type, so an entry skipped there would block the restart with nothing able to clear it. Non-directory entries are now refused rather than dropped -- the conservative half of the same rule: never delete what cannot be classified. The deferral comment claimed damage cannot preempt a good resume. That is true of the `damaged` list but not of a read fault raised while classifying another root, which still propagates. Fail-closed and correct, but the comment was promising more than it delivered. Refs #378.
Fixes #378.
What is broken
R54 lost its entire no-progress budget without doing any work. Generation 0 failed during workflow
submission (an unrelated npm publish race, #377). Generations 1 and 2 then each died in ~92 seconds having
written no events at all, so the node-error channel and the volume event log were both silent. The
cause existed only in the sandbox's own stderr:
runs.jsonlis the journal linking an evaluation row to the durable run executing it, and that link isappended only after the launcher returns.
startRuncreates the run root, writesstate.json,compiles the workflow and submits it before returning. So a real durable run can exist with nothing in the
journal pointing at it. The worker rejected that outright and, because the state lives on a durable volume,
did so again every generation. Recovery could never succeed at any budget.
The fix
Treat the journal as a cache, not the source of truth. When it holds no link, consult the run directory:
repairModalEvalRunRecordwrites it back when the run finalizes, so the next generation does not have torediscover it. The repaired row is the row a successful launch would have written — including
ultrafuzz_run_rootandstatus: "launched", without whichscoreEvalRuncannot resolve the report.The workflow link is the dividing line, and it is meaningful:
persistSmithersEvidencewrites it rightafter compilation and before submission, and
readLinkedWorkflowEvidencerefuses any run without it.So "compiled, then failed to submit" is resumable (the R54 case), while "died during compilation" never can
be — calling the latter resumable only relocates the wedge.
The destructive step, which is the most review-worthy part
Because both ids involved are deterministic, a restart trips over leftovers:
runEvalSuiterefuses an evalrun id whose directory exists (
EVAL_RUN_ALREADY_EXISTS), andplanRunrefuses an existing run root(
RUN_ALREADY_EXISTS). The not-started branch therefore removes:staleEvalRunIds— eval run directories undercontrol/, including ones with noeval.json, since thedirectory is created before that file is written;
staleRunRootIds— run roots undertarget/that carry no workflow link.What bounds the second one:
orphanedis a positive determination — the metadata was read successfullyand carries no link — not merely "not resumable". A root that is linked but is missing its state is
reported as damage and never deleted, because the link is written before submission and such a root is one
a workflow may have run from. Read faults (EACCES, EIO, ESTALE) are not swallowed on this path; only
ENOENT/ENOTDIR count as absence.
A malformed journal line still throws, deliberately:
appendJsonLinedoes not fsync (#382), so a torn lineis possible, and the torn line may be the one carrying the link.
Tests
23 in
packages/modal/test/resume.test.ts, covering each finding across the three rounds: an unlinked runroot, a linked root with no state, an eval run directory with no
eval.json, a linked run with noevaluation to attach it to, a journal that was never written, several durable runs on disk, a symlinked
controlat zero candidates, the repaired row'sultrafuzz_run_rootandstatus, and a journal pointingat a different durable run.
Two couplings are pinned directly rather than restated, because restating them is what caused two of the
three rejections:
staleEvalRunIdsmust name exactlyevalRunRoot(control, id), the pathrunEvalSuiterefuses to reuse;layoutForRunRoot(root).runMetadataPath, and the fixture builds itthrough the same helper. Reverting that one line fails 5 tests.
Verification
packages/modal/test/resume.test.ts— 23/23.npx vitest run packages/modal/test/ packages/evals/test/— 523 pass.ci-config.test.tsfails 17/17with and without this change (verified by stashing): in this sandbox it resolves the repo root to
/home. Pre-existing and environmental.tsc --noEmit,eslint,prettierclean.Known limitation, stated rather than hidden
modelWorkStartedis still set beforeeval runon the not-started path, so a pre-model failure therereports model work and resets the streak that
MODAL_PRE_MODEL_RETRY_LIMITcounts. It was moved below thedurable-state check on the resume path, but the bound is narrowed, not restored. The real fix is the
corroborateModelWorkpattern the public worker already has (#320); the private worker has no equivalent.Out of scope here, and worth its own issue.
Note also that gating the flag on
counts.succeeded > 0would be wrong: reference nodes are markedsucceeded at plan time (
plan-run.ts:689), before any model runs.Rejected approaches, and why
Attempt 1 — restart instead of resume. A no-op: eval run ids are deterministic, so it hit
EVAL_RUN_ALREADY_EXISTSand wedged identically. Worse, it reported model work and so disabled the3-attempt pre-model bound. Its premise — "zero linked runs means nothing was recorded, so restarting loses
nothing by construction" — was false, and false for R54, whose volume held a durable run with 9 succeeded
nodes. I had verified three downstream consumer claims about the repair; all three were correct and all
three were moot, because the path never executed.
Attempt 2 — resume, but repair the row badly. The repaired row lacked
ultrafuzz_run_root, so the runwould resume, succeed, write the link, and then die in scoring. And
state.jsonwas used as the test forresumability, though it is written long before compilation.
Attempt 3 — the right predicate, read from the wrong file.
hasLinkedWorkflowreadrun-metadata.json; the runtime writesrun.json. Every real run root classified as unlinked, so #378 wasstill unfixed and the new deletion pointed at precisely the roots it was meant to protect. The fixture
invented the same filename, so the suite agreed with the bug and 19/19 passed.
The common thread in all three: a claim about another module asserted rather than derived. The two pinned
couplings above exist to stop the fourth instance.
Greptile Summary
The PR makes Modal recovery distinguish resumable durable runs from attempts that never reached workflow linkage.
Confidence Score: 5/5
The PR appears safe to merge.
No blocking failure remains.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Inspect eval journal and durable roots] --> B{Exactly one workflow-linked run?} B -->|Yes| C[Resume durable run] C --> D[Repair missing journal link] B -->|No| E{Only positively unlinked leftovers?} E -->|Yes| F[Remove stale deterministic paths] F --> G[Start evaluation normally] E -->|No: linked, damaged, or ambiguous| H[Refuse destructive recovery]Reviews (6): Last reviewed commit: "fix(modal): classify eval run entries th..." | Re-trigger Greptile
Context used: