Repository navigation
fix(runtime): surface a terminal artifact-preparation failure as a failed durable node - #288
Merged
Merged
Conversation
…nodes A terminal workflow failure whose failing smithers task is a `prepare:` wrapper left the durable run indistinguishable from an idle one: the run went terminal `failed` while every node stayed `pending` with `wait_reason: ready`, `next_eligible_action: dispatch` and `last_error: null`. Nothing was resettable, so every resume was accepted, dispatched nothing and re-finalized identically until the recovery budget was exhausted. The `prepare:<attemptId>` wrapper owns no durable node. `synchronizeTasks` only merged evidence for `node:<attemptId>` and `verify:<attemptId>`, and `completionEvidenceForTask` returned `undefined` whenever the agent task had no evidence — which is exactly the state a failed preparation leaves behind, because the agent task it gates never runs. Every throw site in `prepareArtifactMirror` (retries: 0) therefore produced the same unattributable terminal state. Attribute a terminally failed preparation wrapper back to its attempt: the node records `failed` with the wrapper's error, an `artifact-contract` failure category and `causal_task_id` naming the wrapper, so the failure is both visible and resettable. A wrapper failure superseded by a later successful attempt is ignored, and a `skipped` wrapper is left to the existing dependency-cascade path. The wrapper id is derived from the attempt id rather than added to tasks.json, because `parseStoredTask` drops tasks missing a required field — making it required would discard the manifests of exactly the already-stranded runs this is meant to recover. As a structural backstop for any future workflow task that owns no durable node, a terminal failed workflow with no failed durable node now emits a typed `WORKFLOW_TERMINAL_WITHOUT_FAILED_NODE` diagnostic naming the workflow tasks the failure was charged to. Refs #272 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-firing it Remediates the three independent adversarial reviews of the #272 change. One finding was major. The `WORKFLOW_TERMINAL_WITHOUT_FAILED_NODE` backstop is now durable. It was returned in the in-memory diagnostics array only, and both automated consumers discard that: the eval runner's default row sync ignores `result.diagnostics` whenever `result.ok`, and the Modal worker explicitly drains the CLI child's stdout without persisting it. For the residual class the backstop exists to cover, the archived run root was therefore still byte-identical to the black hole #272 describes. It now appends a `workflow-failure-unattributed` run event carrying the workflow run id, the workflow state and the failing workflow task ids — ids only, no error text — deduplicated against what is already recorded, because synchronization re-derives it on every `status`/`inspect`. The backstop also decided "no failed durable node" from the in-pass status map, which `synchronizeTasks` does not populate for a task whose graph node or evidence was missing that pass. It now unions those with the durable statuses in state.json, which is the source of truth for whether a failure is already attributed. A cancelled or stalled preparation wrapper is no longer adopted as a genuine failure. `statusFromWorkflowState` collapses `cancelled`/`canceled`/`stuck` onto `failed`, so the previous gate could not tell a real preparation failure from a run the operator — or the deadline path's own `requestSmithersCancel` — cancelled while the wrapper was in flight. Every node whose wrapper was running at cancel time was durably recorded, and publicly reported, as an `artifact-contract` violation it never committed. Attribution now requires the wrapper's raw workflow state to be a genuine terminal failure. A node that recovers no longer keeps the failure attribution from the attempt that failed. `--retry-failed` resets a failed `prepare:` wrapper off `failedChildKeys`, so "recorded failed, then retried and succeeded" is the intended recovery for this very issue — and `dependencyCascadeFailure` returns the first dependency carrying a `failure` record, so the stale one silently re-attributed every later skipped dependent to `prepare:<attemptId>` / `artifact-contract` and shipped that category in the public eval row. Tests: four new cases, all four red against the previous commit and three of the four red against pre-#272 main. Each of the five clauses was mutated in turn and every mutation killed exactly one test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aviggiano
force-pushed
the
fix/prepare-verify-wrapper-failure-surface
branch
from
August 6, 2026 23:23
9c33184 to
c4a3849
Compare
This was referenced Aug 7, 2026
aviggiano
added a commit
that referenced
this pull request
Aug 7, 2026
…and #278 (#338) Recovers the four pieces of #273/#278 that survived review, onto current main, without their refuted premises. - Adopt the deterministic replacement lineage when a fresh submission reports RUN_EXISTS. An earlier recovery generation can create the replacement run and die before the new lineage is persisted; every later generation then recomputes the same id and wedges on exit 4. - Drop the rewind to the latest timeline frame. jumpToFrame returns early with zero mutation when the target equals the latest frame (time-travel/src/jumpToFrame.js:812-824) and up --resume already un-terminalizes a failed run, so the timeline read and the rewind were two subprocesses per generation that changed nothing. - Fall back to a top-level data.state / data.status in smithersSnapshotRunState, for snapshot shape variants that carry no runState or run wrapper. - Add smithersSnapshotUnverifiedDependencies, which reads a dependent prepare: failure off the run row. Proven against R43's real error_json; the recovery built on it is #288's job. The refuted premises are deliberately left out: no rewind to an earlier frame, no verifier reset, no artifact quarantine. Co-authored-by: Claude <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md
This was referenced Aug 7, 2026
Comment on lines
+3097
to
+3098
| if ([...nodeStatuses.values()].some((status) => ["failed", "timed-out", "skipped", "invalidated"].includes(status))) { | ||
| return undefined; |
There was a problem hiding this comment.
Skipped nodes suppress unrelated failures
When a terminal workflow-level task such as ultrafuzz-agent-tasks fails while any durable node is already skipped, this early return treats that dependency-cascade outcome as attribution for the unrelated failure. WORKFLOW_TERMINAL_WITHOUT_FAILED_NODE is therefore not emitted and no workflow-failure-unattributed event is appended, leaving the terminal failure absent from the archived run evidence.
Knowledge Base Used: Runtime: Run Orchestration Lifecycle
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/runtime/src/workflow-sync.ts
Line: 3097-3098
Comment:
**Skipped nodes suppress unrelated failures**
When a terminal workflow-level task such as `ultrafuzz-agent-tasks` fails while any durable node is already `skipped`, this early return treats that dependency-cascade outcome as attribution for the unrelated failure. `WORKFLOW_TERMINAL_WITHOUT_FAILED_NODE` is therefore not emitted and no `workflow-failure-unattributed` event is appended, leaving the terminal failure absent from the archived run evidence.
**Knowledge Base Used:** [Runtime: Run Orchestration Lifecycle](https://app.greptile.com/monad-foudnation/-/custom-context/knowledge-base/monad-developers/ultrafuzz/-/docs/runtime.md)
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
aviggiano
added a commit
that referenced
this pull request
Sep 29, 2026
…led node, and pin same-id recovery (#1172) * refactor(runtime): delete the unused smithersSnapshotUnverifiedDependencies helper The helper regex-scanned a Smithers inspect snapshot for the generated workflow's "artifact dependency has not passed verification" message so a resume could name the dependency behind a failed `prepare:` wrapper (the R43 shape behind #272). Nothing in src calls it: prepare-wrapper failures are now attributed to their durable node (#288), and the lens sanitizer that caused R43 was deleted (#558). Its only caller was its own unit test, which goes too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(runtime): name the runner error when a workflow fails with no failed node, and pin same-id recovery A Smithers run can end `failed` without failing any task. That is a run-level error such as WORKFLOW_RENDER_FAILED, which the runner raises when the generated workflow throws while rendering, and it leaves durable work pending. WORKFLOW_TERMINAL_WITHOUT_FAILED_NODE could then only say "failing workflow task(s): unreported", because parseCurrentSmithersInspect accepted data.run.error and dropped it, and that error is the only record of why the run stopped. parseCurrentSmithersInspect now returns an optional runError read loosely from data.run.error. It takes the cause's message (the runner records what was thrown as `cause` under its own summary and `smithers up` recovery advice), falling back to the message, redacted and scrubbed like other runner text and capped at 1,000 characters. An unexpected shape yields nothing and never fails the parse. The WORKFLOW_TERMINAL_WITHOUT_FAILED_NODE message appends "; workflow run error <code>: <message>". Its details, and so the durable workflow-failure-unattributed event payload, are unchanged: that record stays ids-only and needs no schema change. A new integration test drives the pinned runner and Ultrafuzz's own resumeRun (the `resume --force --retry-failed` entry) through this shape. While the render-time cause persists, the resume is refused with WORKFLOW_LIFECYCLE_FAILED and the run is left exactly as it was. Once the cause is removed, the same run ID resumes and runs only the pending task, and the finished producer is not run again. The test also checks the new reader against the runner's real error JSON. No rewind, fork or replacement lineage is added: each would re-render the same program and inputs and hit the same cause (#272). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(runtime): read the runner's clean summary and bound run-error redaction Review follow-ups on the run-level error reader added for #272. - Prefer the runner's structured `summary` over `message` when the run error has no `cause`. The runner appends a docs link to every SmithersError message and, for a failed run, raw `smithers up ... --resume true` / `smithers replay ...` commands. Those commands bypass the attested operator controller and contradict the documented "fix the cause, then resume" recovery. `summary` carries neither, so no text matching is needed. A plain Error without a cause (no `summary`) still falls back to `message`. - Redact only a prefix eight times the kept length. The runner stores error text untruncated and the redactor's cost grows with the square of one long token, so a 200,000-character hex token took about 7 s per parse, paid by every sync, status and resume of a failed run. It now takes about 13 ms. Eight times (not four) keeps a 4096-bit RSA PEM block that starts in the kept 1,000 characters whole: with a 4,000-character prefix a probe leaked two of its body lines. - Apply the same redaction, runner-name scrub and a 100-character cap to `code`, which was copied verbatim. - Reword the reader comment (it only guarantees the parse never fails) and the CLI reference, which now names both shapes the diagnostic covers and scopes the resume advice to run-level errors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: let CHANGELOG entries exceed the markdownlint line length Super-linter lints a changed Markdown file whole, and dozens of existing CHANGELOG.md entries are single lines longer than its 400-character MD013 limit. So a PR that adds an entry fails "External static analysis", which fails `release-gates` and skips every release validation lane, including the runtime lanes this PR's new test runs in. This is the same two-line hunk #1169 adds, so whichever merges second applies it cleanly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
A terminally failed workflow whose failing smithers task was the artifact preparation wrapper left no trace on any durable node.
packages/runtime/src/workflow-sync.tsbuilt its per-attempt evidence map from exactly two smithers node ids —node:<attemptId>andverify:<attemptId>.prepare:<attemptId>was never looked up. A failedprepare:wrapper is precisely the state in which the agent task it gates never starts, socompletionEvidenceForTasksawagentEvidence === undefined, returnedundefined, and the callercontinued without touching the node.The result is the R43 durable state: run
failed, zero failed nodes, and a node still"status":"pending","wait_reason":"ready","next_eligible_action":"dispatch". Nothing is resettable, so every resume re-finalizes identically.prepareArtifactMirrorthrows from ~8 sites and is rendered withretries={0}, so this is reachable from any artifact-contract violation during preparation.R43 primary evidence, identical across all three recovery generations:
Two corrections to the original triage, both confirmed in code.
parseStoredTaskdropping records with nologicalNodeId. That is not the mechanism:parseStoredTaskparses<runRoot>/smithers/tasks.json, the ultrafuzz compiled task manifest (one record per agentic attempt), and wrapper tasks were never rows in it. The drop happens one layer down, insynchronizeTasks' evidence lookup.main. When the agent succeeds andverify:fails,completionEvidenceForTaskalready returns the verifier evidence and the node is finalizedfailed. Only the prepare half was missing. This PR preserves the verify behaviour byte-for-byte — sameARTIFACT_VERIFIER_FAILEDcode, same message text — and adds the prepare half.The fix
Entirely inside
packages/runtime/src/workflow-sync.ts.synchronizeTasksnow also merges evidence forprepare:<attemptId>.completionEvidenceForTaskgained a leading branch: when the preparation wrapper ended terminally failed and the agent task is not itself terminal, the attempt is finalized from the wrapper's evidence withsource: "preparation".finalizeTerminalTaskgeneralizes its verifier branch to "wrapper" — categoryartifact-contract(an existing member of the closedPUBLIC_EVAL_FAILURE_CATEGORIESenum, so public eval diagnostics keep working with no cross-package schema change), diagnosticARTIFACT_PREPARATION_FAILED, andcausal_task_idnaming the wrapper. The non-succeeded branch is pure computation with no filesystem effects: nothing is reset, deleted or re-verified.The wrapper id is derived (
prepare:${attemptId}) rather than made a required field oftasks.json, becauseparseStoredTaskdiscards any record missing a required field — making it required would discard the manifest of exactly the already-stranded runs this is meant to recover. An assertion pinning the compiled"preparationId": "prepare:project-discovery"shape was added to the existing compile test so the two sides cannot drift.As a structural backstop for the "or a typed diagnosable outcome" clause, a terminal failed/timed-out workflow that still records zero terminal-failed durable nodes emits
WORKFLOW_TERMINAL_WITHOUT_FAILED_NODEnaming the failing workflow tasks fromfailedChildKeysplus failed steps. Cancelled and paused states are excluded on purpose.Explicitly not done, per the two banned approaches: no reset of
verify:/node:tasks, no reads of the in-process invariant-suiteMap, no marker deletion or re-sealing, and no rewind/timetravel/fork anywhere in the resume path.packages/runtime/src/smithers.tsis untouched.What the review round changed
The backstop is now durable — this was the major finding. It only pushed onto the returned in-memory diagnostics array. Unlike the adjacent node-synced / workflow-synced paths it never called
appendEventand never touchedstate.json, and both automated consumers throw diagnostics away: the eval runner'sdefaultRowSyncreturns without recording wheneverresult.ok, and the Modal worker'srunCheckedexplicitly drains the CLI child's stdout and stderr without persisting them ("Drain child output without persisting provider responses or benchmark contents"). So for the residual class the backstop exists to cover — a terminal failure charged to a task that is neitherprepare:norverify:, such as theultrafuzz-agent-tasksparent that appears in the R43 log — a Modal generation still ended withstate.jsonbyte-identical to the pre-fix black hole. Only a human runningultrafuzz status/inspectand reading the JSON would ever have seen it.It now appends a durable
workflow-failure-unattributedrun event with the workflow run id, the workflow state, the failing workflow task ids and the distinct durable node statuses. Ids only — no error text, so the no-credential-surface property holds. Because synchronization re-derives the backstop on everystatus/inspect, the append is deduplicated against what is already inevents.jsonlon the(workflow_run_id, workflow_state, failed_workflow_tasks)triple: a repeatedly re-observed terminal failure records one event, not one per pass.The backstop no longer reads only the in-pass status map. It was handed
syncResult.nodeStatuses, whichsynchronizeTaskspopulates only for tasks that reach the bottom of its loop — itcontinues without recording anything when the graph node or the attempt's evidence is missing that pass. So on a resume where a previously-recorded failed node had no synchronizable evidence (inspect steps absent for it,smithers eventshaving taken theWORKFLOW_EVENTS_FAILEDpath), the backstop reported "no failed durable node" as a severity-errordiagnostic even thoughstate.jsonattributed the failure. It now unions the in-pass map withreadRunState(layout).nodes, which is the durable source of truth a crash-restart reader should trust.A cancelled or stalled wrapper is no longer adopted as a genuine failure — all three reviewers raised this independently.
PREPARATION_FAILURE_STATUSESwas checked against the already-normalisedNodeStatus, andstatusFromWorkflowStatemapscancelled/canceled/stuckontofailed. This is reachable without an operator cancel: sync's own deadline path callsrequestSmithersCancel, and any latergetRunStatus/inspectRunre-syncs. So every node whose preparation wrapper was in flight at cancel time — up to max-concurrency of them — was durably written asstatus: failed,last_error: "artifact preparation ended with status failed",provenance.failure.category: "artifact-contract", plus anARTIFACT_PREPARATION_FAILEDerror diagnostic, andpublicEvalFailedNodesthen published that node withfailure_category: artifact-contract. A cancelled or deadline-killed run was recorded, durably and publicly, as an artifact-contract violation it never committed — and becauseneedsFinalizationonly re-fires on a status change, the record was never revised. The same mapping made a controller-lossstuckwrapper look like a terminal contract failure.Attribution now additionally requires the wrapper's raw workflow state to be a genuine terminal failure (
failed,error,timeout,timed-out,timedout,heartbeat-timeout). Cancellation and stalls fall through to the existing pending/cascade handling, exactly as onmain.That allow-list is exhaustive rather than approximate:
statusFromWorkflowStateproducesNodeStatus"failed"from exactly{failed, error, cancelled, canceled, stuck}and"timed-out"from exactly{timeout, timed-out, timedout, heartbeat-timeout}, so the new gate is precisely the complement of{cancelled, canceled, stuck}within the states that can reachPREPARATION_FAILURE_STATUSES. No genuine failure shape is lost, and both event-sourced signatures the R43 recovery depends on —NodeFailed(failed) andTaskHeartbeatTimeout(timeout) — pass it.A node that recovers no longer keeps a failure attribution it has outlived. The succeeded return of
finalizeTerminalTasksetsprovenancewithout afailurekey, and the caller merged it overwithoutTerminalDisposition(previous?.provenance), which strips onlyterminal_disposition. So on the intended recovery path for this very issue — sync 1 records node Afailedwithfailure.causal_task_id: "prepare:A",resume.tsresumes with--retry-failed,smithersSnapshotFailedTasksfindsprepare:A::0infailedChildKeysand timetravels it, prepare succeeds and A succeeds — node A endedsucceededwhile still carryingprovenance.failure = {category: "artifact-contract", causal_task_id: "prepare:A"}.dependencyCascadeFailurewalkstask.dependenciesand returns the first dependency that has afailurerecord, so any later node depending on A that is skipped for an unrelated reason was durably attributed toprepare:A/artifact-contractinstead of its actual cause, and that category is what shipped in the public eval row. The mechanism pre-dates this change, but this change makes retry-then-succeed the expected recovery for prepare wrappers. The merge now drops a supersededfailurewhen the node reaches a terminal success and the current finalization records no failure of its own.Regression tests and red/green evidence
Six cases in
packages/runtime/test/runtime.test.tsdrivesyncRunend to end against a fake smithers CLI.Against pre-#272
main(77da78a): 5 of 6 fail.Test 73's assertion message is the R43 signature verbatim:
terminal failed run recorded no failed node: {"actors-flows":{… "status":"pending" … "wait_reason":"ready","next_eligible_action":"dispatch" …}, "project-discovery":{… "status":"succeeded" …}}. Test 75 failed withexpected a typed diagnostic, got [].Against the first commit of this PR (
79a99c5), all four remediation tests fail:syncRun leaves a cancelled preparation wrapper unattributedis green on pre-#272main— of course it is,mainnever consults the wrapper, so the node simply stayspending. It is proved by mutation instead.Every clause was mutated in turn, and each mutation killed exactly one test:
syncRun leaves a cancelled preparation wrapper unattributedfailureclearing reverted towithoutTerminalDispositionalonesyncRun clears a preparation failure attribution once the node succeedssyncRun does not report an unattributed failure that state.json already attributesworkflow-failure-unattributedappend removedsyncRun records an unattributed terminal workflow failure durably and only onceif (true))Two further cases are honest guards, not regression tests, and passed before and after:
syncRun keeps a preparation failure superseded by a later successful attempt(guards this change against over-firing) and the"preparationId": "prepare:project-discovery"assertion added to the existing compile test (a contract pin against drift).Review findings
9 actionable findings across three lenses (correctness-regression, blast-radius-compatibility, durability-crash-restart); 1 major. Several were raised by more than one reviewer.
Addressed in code (6 distinct findings, covering 7 reports):
workflow-failure-unattributedevent, ids only, deduplicated. A reviewer also raised the unpersisted half as a separate minor under the correctness lens; same fix.artifact-contractfailure of a node that never started → attribution now requires the wrapper's raw workflow state to be a genuine terminal failure.provenance.failuresurvives a successful retry of the same node and then misdirects every later dependency-cascade attribution → cleared on terminal success.state.json→ the two are unioned.Accepted as documentation rather than a code change (2 reports of 1 finding):
minor ×2 (correctness + durability) — "wrapper-attributed failures flip the Modal terminal disposition from
incompletetooperational-failure, which a publication gate treats differently." The reviewers are right, and the original PR's claim that this is "strictly better than the previous silentincomplete" was not verified. Becauseprovenance.workflow.task_idbecomesprepare:<attemptId>,hasExactTaskBinding(packages/modal/src/terminal-disposition.ts) fails and the node lands inbindingFailures— but it is nowfailedrather thanpending, soincompletedrops to 0. Verified on the reviewers' fixture:classifyTerminalDispositionreturns{kind:"incomplete",operationalFailures:3}pre-fix and{kind:"operational-failure",operationalFailures:1}post-fix whenever no node is left non-terminal.publicEvalDiagnosticsRowIsFailedDatapointtreatsoperational-failurerows as failed datapoints, which drops theworkflow-not-scoreable/final-status-not-scoreable/terminal-disposition-not-scoreablereason codes and puts the row intofailedDatapointRowsinassertSmokeFindingFloor— precisely the set exempted from "smoke benchmark row must report at least one normalized finding". Concretely: a smoke run whose final node's prepare wrapper fails, with every other node terminal, previously blew up the bundle gate with zero findings and is now accepted as a legitimate failed datapoint. This does not bite the R43 shape itself (11 nodes stay pending there, so it remainsincomplete).Kept as-is deliberately, and one reviewer explicitly offered this resolution. The alternative both reviewers floated — keep
provenance.workflow.task_id = node:<attemptId>and carry the wrapper id only incausal_task_id— is worse: it satisfieshasExactTaskBinding, so an artifact-preparation infrastructure failure would be classified as a genuine task failure and scored as a real model outcome. Mis-attributing infrastructure to model quality is the exact class of error this workstream exists to remove. Verify-wrapper failures already behave this way onmain, so this is a new trigger for an existing class rather than a new class, and the correct fix is the named follow-up: teachhasExactTaskBinding/hasCompletedTaskEvidenceaboutprepare:/verify:ids and give wrapper failures their own non-scoreable kind. That belongs inpackages/modaland is deliberately out of scope for the narrow runtime change.No finding was silently dropped. Nothing was rejected as incorrect.
One reviewer finding was explicitly marked non-actionable and is recorded here as requested: the pending→failed flip counts as recovery progress and resets the Modal no-progress counter once.
observedModalRecoveryProgressdeclares progress when the count of nodes in a terminal status increases across a generation, andrecovery.tsresetsno_progress_generationsto 0 whenever progress is observed. So an already-stranded run resumed after this ships spends up to one extra Modal generation plus backoff before write-off. This cannot repeat unboundedly, and #122's livelock stays closed: once the node isfailedit staysfailed(a re-running prepare yieldsrunning/pending evidence, which returnsundefinedand leaves the node untouched), so each further reset of the counter requires a genuinely new node to reach a terminal status. The durable resume path is also unchanged —--retry-failedalready reset failed wrapper tasks offfailedChildKeysregardless of ultrafuzz node status, so this PR newly causes no timetravel reset.Residual risk
operational-failure, which is a scoreable failed datapoint and therefore exempt from the smoke findings floor.repairModalEvalRunRecordstill declines to finalize the eval row, becauseprovenance.workflow.task_idisprepare:<attemptId>andhasExactTaskBindingrequiresnode:<attemptId>. Identical to the pre-existing behaviour for verify-wrapper failures, and strictly better than the previous silentincomplete, but it is not a full end-to-end recovery.WORKFLOW_TERMINAL_WITHOUT_FAILED_NODEandworkflow-failure-unattributedare a new diagnostic code and a new event type on the sync path. No existing test or consumer keys off diagnostic-array length or event-type counts, and neither changesokor the run status — but a downstream tool that treats every error-severity sync diagnostic as fatal now sees one more code.timed-outis retained inPREPARATION_FAILURE_STATUSES. BecauseevidenceFromEventsflips status totimed-outon any error text matching/timeout|timed out|heartbeat/i, a prepare failure whose underlying fs/git error merely mentions a timeout makesfinalRunStatusreturntimed-outrather thanfailed(thetimed-outbranch is checked first), changing the eval record'sfinal_status. A genuinely timed-out preparation is a real failure worth surfacing, so this is kept; it mirrors the pre-existing verify-wrapper behaviour.<Worktree>execution branch. The cloud-sandbox branch callsprepareArtifactMirrorinside the sandbox with noprepare:smithers task, so for cloud tasks the new lookup finds no step and no events and is a no-op. Cloud failures are unchanged by this work.replayEvents(layout), i.e. a full events-file scan — but only on a pass that actually has an unattributed terminal failure, which is rare by construction.replayEventsapplies its default replay limit, so on a run with an extremely long event log the earlier record could fall outside the window and one duplicate record be appended. Bounded and cosmetic; the alternative (a new field on the run-state schema) would be a cross-package schema change for a diagnostic.Validation
Ran from
/home/ubuntu/uf-fix-272;df -h /checked before every gate (164–171G free on/, 66–67% used).pnpm -w run ci→ exit 0 (format:check,lint,buildacross all 13 projects,validate:release).pnpm -w test→ exit 0, all 13 packages green.pnpm -w typecheck→ exit 0, all 13 workspace projects.pnpm -w lint,prettier --checkon the touched files,git diff --check→ all clean.node scripts/run-tests.mjs, all 14 files) → 298 tests, 0 fail, 15 skipped.node --test dist-test/test/runtime.test.js→ 142 tests, 127 pass, 0 fail, 15 skipped (was 122 pass / 5 fail against pre-Resume terminal failed workflows with pending ready nodes #272main).workflow-sync.tswas restored byte-for-byte after each.Merge order
Merge #287 (
fix/invariant-suite-handoff-durability, Fixes #217 and #219 (both already merged)) before this PR. Both are runtime fixes and both are inpackages/runtime; #287 rewrites large parts ofpackages/runtime/src/templates/smithers/workflows/workflow.tsx, while this PR is the narrower change and touches onlypackages/runtime/src/workflow-sync.tsplus its test and the changelog. Landing the broader template change first and rebasing this onto it keeps the conflict surface to the changelog.Relates to #272 — this surfaces the strand as a diagnosable failed node, but is not a full end-to-end recovery (see Residual risk). #272 stays open until a live resume confirms the failed node dispatches under
--retry-failed.Greptile Summary
The PR reconciles failed artifact-preparation wrappers into durable node failures and adds a durable fallback event for terminal workflow failures that cannot be attributed to a node. It also avoids attributing cancellations as artifact failures and clears stale failure provenance after successful recovery.
Confidence Score: 4/5
The PR is not yet safe to merge because a terminal workflow-level failure can still remain absent from durable evidence whenever an unrelated node is skipped.
The new persistence backstop returns early for any skipped durable node without establishing that the skipped node attributes the workflow task that actually failed, so the archived run can still omit the terminal failure the fix is intended to preserve.
Files Needing Attention: packages/runtime/src/workflow-sync.ts
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Inspect Smithers workflow] --> B[Merge agent, verifier, and preparation evidence] B --> C{Preparation genuinely failed?} C -->|Yes| D[Finalize durable node as failed] C -->|No| E[Retain existing node outcome] D --> F[Read durable and in-pass node statuses] E --> F F --> G{Terminal workflow failure lacks matching node attribution?} G -->|Yes| H[Emit diagnostic and append durable unattributed-failure event] G -->|No| I[Complete synchronization]Prompt To Fix All With AI
Reviews (3): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile
Context used: