Repository navigation
Rewritten published artifact strands runs: verified dependency digest mismatch fails fan-in forever with zero failed nodes #275
Description
Summary
A durable Modal run whose workflow backend reports terminal failed with zero failed nodes and a pending, ready-to-dispatch fan-in node cannot be resumed. Every resume is accepted, dispatches nothing, and the run re-finalizes as failed. This is the mechanism behind #272; that issue described the symptom, this one records the verified mechanism, because the recovery direction attempted in #273 is provably not it.
What is verified
All against the vendored smithers-orchestrator@0.31.0 sources in node_modules.
up --resumealready un-terminalizes the run.activateRunForResume(@smithers-orchestrator/engine/src/engine.js:7152-7175) patches the claimed run withstatus: "running",finishedAtMs: null,errorJson: null. And"failed"is inRESUMABLE_RUN_STATUSES(engine.js:2968-2978), so a failed run is resumable without any pre-step.- Rewinding to the latest frame is a complete no-op.
jumpToFramereturns early with zero mutation whentargetFrameNo === latestFrame.frameNo(@smithers-orchestrator/time-travel/src/jumpToFrame.js:811-824); only the non-latest path callsupdateRun({status:"running", errorJson:null, finishedAtMs:null})(:1029-1038). - Therefore a rewind contributes nothing to un-terminalizing a run that is about to be resumed. Its only differential effect is to un-complete whatever changed in the newest frame — i.e. it destroys completed work.
DEPENDENCY_DEADLOCKreproduces the exact observed signature.WorkflowDriverreachingFinishedwithlastDeferredDepsnon-empty returnsstatus: "failed"with no node marked failed and the deferred tasks leftpending(@smithers-orchestrator/driver/src/WorkflowDriver.js:510-518).- The resume-hydration patch restores a node as
finishedonly when a matching output row exists.SMITHERS_ENGINE_RESUME_HYDRATION_PATCHinpackages/runtime/src/smithers.ts:64-88skips any node wheretypeof node.outputTable !== "string", or where no row indurableOutputs[node.outputTable]matches bothnodeIdanditeration. Unrestored nodes are notfinishedfor dependency resolution.
Leading hypothesis
Any succeeded node whose output row is not matched by that predicate is not restored as finished on resume. property-specification-fanin depends on all eight property-specification-* lens nodes, so a single unrestored lens leaves its dependencies permanently unsatisfiable: the driver defers it, reaches Finished with deferred deps, and fails the run with DEPENDENCY_DEADLOCK — zero failed nodes, fan-in still pending. Each subsequent resume repeats it deterministically.
This is a hypothesis, not yet verified. What would confirm it: capture data.run.error (code and message) from smithers inspect --format json on the failed run, and log restoredTaskCount from the hydration patch against the durable succeeded-node count. If restoredTaskCount is less than the number of succeeded nodes, this is confirmed.
R43 evidence
Run aave-v4-v0012-main-issue270-r43-invariant-only on main 4af20bf9b9e5833cc2e35e74a1080a46697779c6.
- Generation 1 / attempt 1 terminal diagnostic:
dependency-unreachable,resume-required, counts21 succeeded / 0 failed / 11 remaining.
Note this label is misleading:dependency-unreachableis whatwaitForTerminalRunraises viaOperationalDispositionError("unreachable")(packages/modal/src/worker.ts:281) when it never observesmodalDurableRunAdvanced. It is not a network condition. - Attempts 2, 3, 4:
terminal-run-non-resumable, counts unchanged at21 / 0 / 11,property-specification-faninpending/wait_reason: ready/next_eligible_action: dispatch, every node'slast_error: null. Runtime ~108 s each — the resume ran, the run re-finalizedfailed, andrepairModalEvalRunRecordrefused to finalize (packages/modal/src/resume.ts:130-133). - No occurrence of
WORKFLOW_RENDER_FAILEDanywhere in the preserved evidence, so the pre-existing render-failure recovery branch was never involved. - Recovery budget exhausted after 3 no-progress generations at
2026-08-06T09:43:53Z. ~83.3 M tokens / ~$482 spent before the stall.
Preserved: r43-private-inspection-20260806T0909Z.json, ...0919Z.json, r43-recovery-state.json, r43-detached-overseer.log, status-r43-20260806T090131Z.json.
Expected fix
- Add a regression that resumes a run whose durable nodes are
finishedbut whose output rows are unmatched, and assert the dependent fan-in node is dispatched rather than the run failing with zero failed nodes. - Make the hydration predicate restore terminal state from the durable node row itself, or repair the missing output linkage, so dependency resolution cannot silently regress a succeeded node.
- Capture
run.error.codeinto recovery evidence so this class is diagnosable without source archaeology. - Rename or re-scope
dependency-unreachable, which currently reports "resume never advanced" as a dependency/network condition.
Note on #273
#273 attempted recovery by rewinding to the preceding timeline frame and, failing that, transferring to a fresh workflow lineage. Points 1-3 above show the rewind cannot help and is destructive, and the fresh-lineage path is unreachable for a run with a long frame history while risking full re-execution where it does fire. It should not be merged as a fix for #272.
Activity
aviggiano commentedon Aug 6, 2026
Root cause confirmed empirically — my hypothesis above was wrong
I read R43's workflow log and durable volume directly. The hydration hypothesis is refuted: the log shows
restoredTaskCount=36on all three resume attempts, so terminal-task restoration works. There is noDEPENDENCY_DEADLOCKeither. The actual failure is an artifact-contract poison-pill.The real failure
From
.smithers/logs/ultrafuzz-…-trial-1.logon R43's volume, identically on every resume:That is
assertVerifiedDependency→ theartifactSha !== entry.sha256check inpackages/runtime/src/templates/smithers/workflows/workflow.tsx:1253-1256.Verified byte-level evidence
Verification marker
<runRoot>/.ultrafuzz-verification/property-specification-crytic.json, compared against the artifacts on the volume:properties/crytic.md97b050b0…053897b050b0…0538findings.json37517e5f…b57037517e5f…b570properties/crytic.json8cfef182…132819d3976b…3e9d(44704 bytes)The marker is otherwise entirely well-formed: correct
schema_version, correctattempt_id, artifact set exactly matching the declared outputs. Itspublications[]array records the same old sha8cfef182…forproperties/crytic.json.A full walk of the run root finds exactly one copy of that artifact (
artifacts/property-specification-crytic/properties/crytic.json). So this is not a canonical-vs-attempt copy divergence — a published artifact that is supposed to be immutable was rewritten in place after its verification marker was written, and neither the marker'sartifacts[]nor itspublications[]was updated.Why this is unrecoverable
prepare:property-specification-faninfails deterministically on every attempt. Because the failure is in the prepare wrapper rather than the node body, Ultrafuzz's durable state recordsproperty-specification-faninaspendingwithlast_error: nulland zero failed nodes, while the workflow run isfailed. Recovery then cannot make progress:modalDurableRunNeedsResumeseesremaining: 11 > 0and resumes;repairModalEvalRunRecordrefuses to finalize (packages/modal/src/resume.ts:130-133) and the sandbox exitsterminal-run-non-resumable.R43 burned three recovery generations this way (~108 s each) before the budget exhausted, having already spent ~83.3 M tokens / ~$482. No retry-failed path helps, because there is no failed node to reset.
This is why the #273 approach cannot work: neither rewinding a frame nor transferring to a fresh lineage changes the on-disk sha of
properties/crytic.json.Expected fix
Two distinct defects, and both need addressing:
properties/crytic.jsonbypassed the publication immutability guards (assertPublishedFileReplacementAllowed,assertPublishedBytes). That write path is the primary bug. Note the digest comparison itself is correct and was tightened by fix(runtime): block artifact handoffs after failed verification #252 — fix(runtime): block artifact handoffs after failed verification #252 is not at fault; it surfaced a pre-existing corruption that previously passed silently.Regression tests should cover: a dependency whose published artifact sha no longer matches its marker must either be re-verified or surface as a failed node that
--retry-failedcan reset — never as an unrecoverable terminal run with zero failed nodes.Also observed in the same log, likely unrelated but worth a separate look:
[smithers] worktree sync: git rebase origin/ultrafuzz-pinned failed (exit 128): fatal: invalid upstream 'origin/ultrafuzz-pinned'Evidence preserved at
r43-workflow-log.txtalongside the R43 inspections. R43 was never stopped or restarted.[-]Resume does not restore succeeded nodes whose output rows are unmatched, deadlocking fan-in[/-][+]Rewritten published artifact strands runs: verified dependency digest mismatch fails fan-in forever with zero failed nodes[/+]aviggiano commentedon Aug 6, 2026
Narrowing: both publication guards are intact, so the rewrite bypassed them
Further evidence, and it rules out the obvious suspects.
The stranded artifact is legitimate, not corrupt. I pulled
properties/crytic.jsonoff R43's volume and validated it against its declared contract:properties/crytic.json— 44704 bytes, valid JSON,ultrafuzz/property-lens@1validation passes, 102 properties.properties/certora.jsonfrom the same run is 52790 bytes with 113 properties.So the file the run is refusing to accept is a complete, contract-valid lens output. The marker's sha is stale; the artifact is fine.
The marker could not have been stale when it was written. Tracing the verify path in
packages/runtime/src/templates/smithers/workflows/workflow.tsx:publishVerifiedArtifacts(:3610-3614) writes every publication throughpublishFileDurableExclusive.publishFileDurableExclusive(packages/artifacts/src/safe-paths.ts:188-204) callsassertPublishedBytes(root, destination, bytes)when the destination already exists, so a pre-existing file with different bytes throws rather than being silently kept.writeArtifactVerificationMarker(:3665-3704) computes itspublications[]shas from the very same buffers, and runs after publication.Therefore at marker-write time, disk bytes and marker bytes were necessarily identical. Combined with the divergence now on disk, the artifact was rewritten after its verify task completed.
Reconciliation is not the writer either.
reconcileTargets(packages/runtime/src/artifact-reconciliation.ts:99-120) skips any target whose destination already exists, andcopyRegularFileExclusivere-checks and returns false if the destination exists. It cannot overwrite a published artifact.So the writer is still unidentified, and it bypasses both the exclusive-publish guard and the exclusive-copy guard. I am deliberately not shipping a "prevent the rewrite" patch while that is true — a fix aimed at the wrong writer would be worse than no fix, which is the lesson of #273.
Revised fix plan
prepare:wrapper of the dependent, so Ultrafuzz records zero failed nodes and--retry-failedhas nothing to reset. A marker/artifact divergence should instead mark the dependency node failed, so the existing timetravel-reset machinery re-runs it and re-verifies it from scratch. That preserves verification strength — the artifact is re-verified by the real verifier, not waved through.Additional detail for whoever picks this up: the durable node timestamps for all eight
property-specification-*nodes were rewritten into a 13-second window (08:59:39Z-08:59:52Z) with ~1.4 s durations, i.e. a burst re-finalization at the moment the last lens finished — a plausible window for the rewrite and a good place to point the instrumentation first.aviggiano commentedon Aug 6, 2026
Task timeline from R43's workflow DB — verify runs ~43 min after the node, and the verified bytes no longer exist
I pulled R43's
smithers.dboff the volume (18.5 MB, preserved) and dumped the attempt timeline. This is the most useful artifact yet.The schedule is prepare-all → run-all → verify-all.
verify:property-specification-cryticran 43 minutes afternode:property-specification-cryticfinished, and in that window the six later lens agents ran in the same workspace. That is a large window for the sharedproperties/directory to drift.Node rows confirm the asymmetry that makes this unrecoverable:
node:property-specification-cryticverify:property-specification-cryticprepare:property-specification-faninnode:property-specification-faninverify:property-specification-faninOnly the
prepare:wrapper is failed. Nonode:is failed, which is exactly why Ultrafuzz reports zero failed nodes and--retry-failedhas nothing useful to reset.The verified bytes are gone
I walked the entire volume subtree for the run. There is exactly one
crytic.jsonanywhere, and it holds19d3976b…— not the marker's8cfef182…. No workspace mirror copy survives. So the verified content exists nowhere on the volume and R43 cannot be repaired by restoring it; the crytic node would have to re-run.Also relevant: the run has 41 timeline frames, which independently confirms that #273's fresh-lineage fallback was dead code for this run (
previousSmithersTimelineFramealways returns a value).Writers eliminated so far
publishFileDurableExclusive— callsassertPublishedByteson an existing destination, so it throws rather than silently keeping differing bytes (packages/artifacts/src/safe-paths.ts:201-204).reconcileTargets/copyRegularFileExclusive— both skip when the destination exists (packages/runtime/src/artifact-reconciliation.ts:99-120).fetch+rebase origin/<base>inside a separate worktree, and R43's rebase failed non-destructively (invalid upstream 'origin/ultrafuzz-pinned').workspace-handoffpatches —.ultrafuzzandartifactsare inSENSITIVE_SEGMENTSand excluded (packages/runtime/src/workspace-handoff.ts:11-21).Since disk necessarily equalled the marker at
08:59:42(publication asserts it) and differs now, the rewrite happened after the verify burst, i.e. across the sandbox exit at ~09:01 and the three restore/restart cycles beforeprepare:faninran at 09:41. The restore/staging path is the remaining candidate and is where instrumentation should go first.Recommended fix, now better founded
verify:<node>immediately afternode:<node>rather than batching all verifies at the end, so a verified artifact is sealed while its workspace is still quiescent.node:<dependency>andverify:<dependency>so the dependency re-runs and is genuinely re-verified — one node re-run (~9 min for crytic) instead of stranding 21 completed nodes. Note this also requires clearing the now-inconsistent marker and artifact, sincepublishFileDurableExclusivewould otherwise throw on re-publish.Evidence preserved:
r43-smithers-db-snapshot-20260806T1105Z.db,r43-workflow-log.txt,r43-crytic-json-ondisk-19d3976b.json.fix(runtime): gate dependency recovery on a real on-disk divergence
fix(runtime): repair a diverged dependency by re-verifying it
aviggiano commentedon Aug 6, 2026
ROOT CAUSE IDENTIFIED: the writer is our own lens sanitizer, and this is a regression from #269
The writer I could not find earlier is
sanitizeLensReferenceExpectationAuthorityinpackages/runtime/src/artifact-gates.ts:1570.lensPathissafeResolveInside(artifactDir, lensOutput.path, "property lens output")— the canonical published artifact, i.e. exactlyartifacts/property-specification-crytic/properties/crytic.json.Why every guard missed it
It writes with
writeJsonDurable, notpublishFileDurableExclusive. SoassertPublishedBytes— the guard that makes published artifacts immutable — is never consulted. That is why the earlier elimination of the publish path, artifact reconciliation, worktree sync and workspace handoff all came back clean: none of them was the writer.Call path, and why it lands after verification
sanitizeLensReferenceExpectationAuthority←verifyRequiredArtifactsForAttempt(artifact-gates.ts:184) ←workflow-sync.ts:2058, i.e. the runtime sync path, which runs after the workflow'sverify:<node>task has already published the artifact and written its marker. The sanitizer then edits the bytes and leaves the marker untouched, somarker.sha256and the file disagree from that moment on.It only writes when
removed > 0, which is exactly why a single lens diverged whilecrytic.mdandfindings.jsonstayed consistent.This is a regression from #269
2b00c48is an ancestor of4af20bf, the main commit R43 was built from. That explains why R43 hit this and earlier runs did not.Correct fix is prevention, not recovery
The sanitizer is a trusted runtime gate deliberately removing unauthorized reference-expectation IDs — unlike a model rewriting its own output, so keeping the marker consistent with its edit is legitimate rather than a tamper bypass. Options, in preference order:
sha256for that path in bothartifacts[]andpublications[]of<runRoot>/.ultrafuzz-verification/<attemptId>.json.A regression test should assert that after the sanitizer rewrites a lens, the dependent's
assertVerifiedDependencystill passes — i.e. marker and bytes agree.Consequence for PR #278
#278 is recovery-only and does not address this. Adversarial review also found it converts the stall into a terminal run loss for the five invariant-suite nodes:
rememberInvariantSuitePublications(workflow.tsx:3096-3098) readsinvariantSuitePublicationSnapshots, a module-level in-process Map populated only whilenode:<D>executes (workflow.tsx:2400viaartifactAwareAgent.generate). Resetting onlyverify:<D>leaves the node memoized, so on a resumed run the Map is empty and the verifier throwsinvariant suite publication snapshot is unavailable— afterclearArtifactVerificationMarkerhas already deleted the marker, withretries={0}. The recovery's ownverify:<D>finished guard then blocks it from firing again. Same class atverifyInvariantLedgerSourceEvidence(workflow.tsx:3421,:3468-3481), which reads the live worktree.So #278 is being parked as a draft, and the fix should target the writer above. Separately, the verifier arguably ought to be a pure function of on-disk state — rebuilding the invariant-suite snapshot from
invariant-suite-manifest.jsoninstead of trusting in-process memory — which would make standalone re-verification viable in general.fix(runtime): report a lens marker digest that drifted from disk
fix(runtime): keep the marker consistent when sanitizing a lens
fix(runtime): report a lens marker digest that drifted from disk
fix(runtime): re-seal the verification marker after normalizing a pro…