Repository navigation
fix: model fan-out runs can be synchronized repeatedly, recovered aggregates leave skipped, and private evals survive dynamic expansion - #1192
Merged
Conversation
The first synchronization of a run whose topology fans a node out over
several model profiles adds one state record per model attempt, keyed by
attempt ID, beside the concrete node that aggregates them
(ensureWorkflowTaskStateRecords). Every later read of the run's control
evidence re-derives the completeness binding, and for a static run
deriveWorkflowControlBindings required the keys of state.json to equal
the node IDs of graph.json exactly. So after one pass, syncRun failed
with WORKFLOW_CONTROL_EVIDENCE_INVALID ("run state node set does not
exactly match the sealed graph"), and status reported the controls as
diverged and skipped synchronizing the run. The other evidence readers
that do not tolerate divergence take the same path.
state.json is the synchronizer's mutable projection, not a sealed
control file, and its keys were never part of the binding:
expected_state_node_ids is derived from graph.json. The check was a
second copy of the writer's key rule, and it had drifted from the writer
once before, for archived dynamic generations (#1064). Delete it,
together with the dynamic runtime node-ID list that only fed its dynamic
branch. The run state's run ID and fingerprint checks stay.
The dynamic admission test drops its state-injection case, which
asserted the deleted rule. The retry archive's docstring said the
archived generation's records are pruned to satisfy this check; it now
gives the reason that remains.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After resume --retry-failed reruns a failed model attempt, the attempts Smithers skipped behind it reopen and can succeed, but their fan-out aggregate stayed skipped: synchronizeTasks kept a terminal aggregate status unless it went from failed to succeeded. With every attempt succeeded and the runner reporting the run succeeded, a blocking aggregate stuck at skipped made finalRunStatus report the recovered run as failed. An attempt's own record already decides when newer Smithers evidence replaces it (workflowEvidenceSupersedesPrevious), and keeps success and invalid-output dispositions immutable (immutableTerminalFinalization). The aggregate is derived from those records, so the extra guard could only make it disagree with them. Delete it: the aggregate is now always aggregateAttemptStatuses over its attempts' current records. One visible change: while a reset attempt waits or runs again, its aggregate reads pending or running instead of keeping the earlier failed, as a single-model node already does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ords The private Modal worker's model-work reader threw unless the keys of state.json equaled the node IDs of graph.json. Synchronization records model attempts under attempt IDs and generated dynamic nodes under storage IDs, neither of which the graph names, so the reader threw for every run whose dynamic group had expanded. The worker then failed after `eval run` had succeeded. This was the last reader-side copy of the key rule the previous commits delete from the workflow control check. Delete the throw and count every record the graph does not name as model work: only agentic nodes get attempt or storage records, and the worker lowers its flag only on `none`, so an unexpected record can only keep the flag raised. Also say where deriveWorkflowControlBindings returns the binding that expected_state_node_ids is the sealed graph's node set, so the run-state key comparison is not re-added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| expect(() => privateEvalModelWorkEvidence(incomplete)).toThrow(/schema-invalid/u); | ||
| }); | ||
|
|
||
| it("reads model work from records the runtime keys by model attempt ID", () => { |
There was a problem hiding this comment.
Dynamic path lacks test coverage This test adds model attempt records, but the reported private-eval failure involves records keyed by a generated dynamic node’s storage ID. Without a test using an expanded dynamic group and its storage-keyed records, the test could still pass if that part of the fix regresses.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/modal/test/private-model-work.test.ts
Line: 72
Comment:
**Dynamic path lacks test coverage** This test adds model attempt records, but the reported private-eval failure involves records keyed by a generated dynamic node’s storage ID. Without a test using an expanded dynamic group and its storage-keyed records, the test could still pass if that part of the fix regresses.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
This was referenced Sep 29, 2026
aviggiano
added a commit
that referenced
this pull request
Sep 29, 2026
…te record (#1202) The private Modal worker's model-work reader may lower its pre-raised flag only when durable state positively shows that no model-backed node began. #1192 dropped the only check that every model node of the graph has a run-state record, so a run state missing one read as proof that no model work began. The reader now answers unknown in that case, keeping the flag raised. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Sep 29, 2026
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.
Problem
Bugs 1 and 2 were found while fixing #1141 (#1169). A reviewer of this PR found bug 3. All three reproduce on the wave-1 base (
origin/integration/wave1,a48ad9c3).syncRun, the next one fails withWORKFLOW_CONTROL_EVIDENCE_INVALID: "workflow control completeness binding could not be re-derived: run state node set does not exactly match the sealed graph".status(getRunHealth) reports the same message asWORKFLOW_CONTROL_EVIDENCE_DIVERGEDand skips synchronization (WORKFLOW_STATE_SYNC_SKIPPED), so the run's local state stops advancing.stats,why,timeline,snapshots,node,replayandforkfail;inspectturns the error into warnings and omits the runner's view.resumeis not affected. An ad-hoc run (below) resumed such a run with--retry-failedafter its first sync.skippedafter its attempts reopen and succeed. When one discovery attempt fails, Smithers skips both analysis attempts that consume it. Afterresume --retry-failed(which reopens them since fix(runtime): resume --retry-failed reopens descendants skipped behind a recovered producer #1169), both run and succeed, but thesignal-analysisaggregate staysskipped. It is a blocking node, so the recovered run reportsfailed, although every attempt succeeded and the runner reports the run succeeded.eval runreturns, the private Modal worker reads the run to decide whether model work began (privateEvalModelWorkEvidence). That reader threw "private eval run state must exactly match its planned graph nodes" unless the keys ofstate.jsonequaled the node IDs ofgraph.json.dynamic-<group>-<digest>), whichgraph.jsondoes not name. So the reader threw for every run whose dynamic group had expanded.runWithPrivateModelWorkCorroborationrethrows that error, so the worker attempt fails althougheval runsucceeded. By code reading, a private launch then relaunches the worker, because model work stays flagged.Reach.
model_profileslist has one entry.defaultaudit profile, whosethreat-goalsandclass-goalslanes expand fromgoal-plan's output. So by code reading, a private eval hits it whenevergoal-planplans a goal, and has done so since Release/v0.1.0 #744 introduced storage IDs. I did not run it on Modal.Root cause
state.jsonmay hold.ensureWorkflowTaskStateRecords(workflow-sync.ts) adds onestate.jsonrecord per model attempt, keyed by attempt ID (project-discovery__model_1__attempt_1), beside the concrete node's aggregate record. A generated dynamic node's records are keyed by its storage ID.deriveWorkflowControlBindings(workflow-integrity.ts) required the keys ofstate.jsonto equal the node IDs ofgraph.jsonexactly. Its dynamic branch admitted attempt and storage IDs; the static branch did not.expected_state_node_idsis derived fromgraph.json. It was a second copy of the writer's key rule, and it had already drifted from the writer once, when Archive dynamic expansion generations before producer retry #1064 had to prune archived dynamic records fromstate.jsonto get past it.synchronizeTaskskept any terminal aggregate status unless it wentfailed→succeeded. The guard was added in feat: add strict producer-visible JSON contracts with release-lane fix #558 and widened in fix(runtime): reconcile recovered terminal run status (#578) #603.workflowEvidenceSupersedesPrevious), and success and invalid-output dispositions are immutable (immutableTerminalFinalization).private-model-work.tsgot its exact comparison in feat: add strict producer-visible JSON contracts with release-lane fix #558. Fan-out attempt IDs were never graph node IDs, and since Release/v0.1.0 #744 generated dynamic nodes are recorded under storage IDs. I found no other reader that compares the two key sets; the CLI's run-statistics reader already accepts attempt keys.Change
Source is +12/−47 lines. 4 of the added lines are a rewritten docstring and 3 are comments.
workflow-integrity.tsderiveWorkflowControlBindings, both the static and the dynamic branch.verifyWorkflowControlSnapshotcomputed only to feed the dynamic branch.verifyDynamicRuntimeMaterializationstill runs; only its unused return value is dropped.expected_state_node_idsis the sealed graph's node set and that run-state keys are deliberately not checked. The field is not renamed, because that would change the seal schema.workflow-sync.ts: deleted the aggregate terminal guard. The aggregate is now alwaysaggregateAttemptStatusesover its attempts' current records.dynamic-expansion-retry.ts: thearchiveDynamicExpansionsForRetrydocstring said the archived generation's records are pruned because the admission check refuses them. That is no longer true, so it now gives the reason that remains: the synchronizer creates a record only for an attempt that has none and never re-finalizes a successful one. Without the prune, a regenerated attempt that reuses a storage ID would start from the archived record.packages/modal/src/private-model-work.ts: deleted the exact key-set throw.runtime.test.ts, one per runtime bug.dynamic-lifecycle.test.tsloses its state-injection case, which asserted the deleted rule. It is renamed to "…rejects graph and task extensions not derived from sealed controls".packages/modal/test/private-model-work.test.tscovers a fan-out run whose attempt records sit beside the node's record. With every record pending, the reader returnsnone; with one attempt running, it returnsstarted.Deliberately not built
pruneArchivedRunStateNodes. Its original reason was this check, but it still has one. Without the prune, a regenerated attempt that reuses a storage ID would start from the archived record, and an archived success, which is immutable, would stand in for the new attempt's output.expected_state_node_idsstays derived fromgraph.json, so existing seals verify unchanged.finished_at. Fan-out aggregates carry none, on the base and on this branch: the record is created without one, and the aggregate patch passes no evidence timestamp. Modal's terminal-disposition reader requires one on a terminal aggregate. That matters only for a model fan-out run on Modal, and evals run one model. Left for a follow-up.unknownfor a missing model-node record in the Modal reader, as a reviewer suggested. The reader now treats a missing record as no evidence.resume --retry-failedor--reset-node. This is a separate, pre-existing gap inimmutableTerminalFinalization, which this PR leaves unchanged. It is recorded for its own follow-up.Verification
Discriminating runs (runtime). Each source variant was compiled into its own
dist-testcopy with the same final test files, and the tests were run by name:syncRun records model fan-out attempts independently(existing)syncRun synchronizes a model fan-out run again after recording its attempts(new)syncRun lets a model fan-out aggregate follow its reopened skipped attempts to success(new)statusreturnsWORKFLOW_CONTROL_EVIDENCE_DIVERGED("…run state node set does not exactly match the sealed graph") andWORKFLOW_STATE_SYNC_SKIPPEDsyncRunreturnsWORKFLOW_CONTROL_EVIDENCE_INVALIDwith the same messagesignal-analysisisskipped. Instrumented, the run status isfailedwhileproject-discoveryissucceededstatusstep. It failed on the base at the secondsyncRun, withWORKFLOW_CONTROL_EVIDENCE_INVALIDand the same message.resumeRun({ retryFailed: true })was called. It returned ok on all three variants and issuedtimetravel … --node-id node:project-discovery__model_1__attempt_1 … --no-deps. So resume was never affected by bug 1.Discriminating runs (Modal reader, bug 3).
noneinstead ofstarted.dynamic-lifecyclefixtures: a single-model dynamic child that succeeded, and a model fan-out dynamic group with pending attempts. This PR does not change that writer. The unfixed reader threw in both. This branch returnsstartedin both, from the planner's record.Also run on this branch:
dynamic-lifecycle,dynamic-expansion,observation-snapshot,workflow-control,terminal-report-completionandworkflow-completion-policy, 73/73 pass.dynamic-lifecycledrives the dynamic branch of the deleted check, including the Archive dynamic expansion generations before producer retry #1064 retry-and-re-expand test.dynamic-lifecycleandworkflow-controlwere re-run, 34/34 pass, and so were the three fan-out tests in the table, 3/3 pass.private-model-work,terminal-dispositionandworker-result, 63/63 pass.runtime.test.tssweep of every test whose name starts withsyncRun,resume,ordinary resume,native continuation,controller refresh,status,getRunHealth,an unobserved,observe,pauseorcancel. It ran in the 4 release shards on the previous head, which had the same runtime code apart from one comment line: 137 run, 134 pass, 3 fail, none caused by this PR.status keeps reporting runner health when run-state synchronization failsandnative continuation hands generated agents the run's TOML config, so CodexAgent keeps API-key auth. Both are wave-1 merge interactions, fixed separately.syncRun leaves a Kimi model unpriced when Moonshot does not list itfailed duringstartRunwith "workflow execution file … changed while reading". It passes when run alone on that head.npx prettier --checkandnpx eslinton the changed filesCI=1 ESLINT_PLUGIN_DIFF_COMMIT=origin/integration/wave1 pnpm -w lint:strict:cipnpm -w lint,pnpm -w knipandpnpm -w docs:checkpnpm --filter @ultrafuzz/runtime typecheckandpnpm --filter @ultrafuzz/modal typechecksynchronizeLinkedWorkflowRunat 90, which this PR does not touch. After the change,synchronizeTasksmeasures 68 andverifyWorkflowControlSnapshot49.Not run:
runtime.test.ts, the other runtime test files, the rest of the Modal suite, and the CLI suite;Risk / compatibility
state.jsonkeys are no longer refused.pendingorrunninginstead of keepingfailed, as a single-model node already does. The run status does not change: while the runner reports the run active,finalRunStatusreturnsrunningbefore it looks at node statuses.none, so an unexpected record can only keep the flag raised.state.jsonkeys equalgraph.jsonnode IDs, which is every state the old reader accepted, the result is unchanged.workflow-sync.ts: one 8-line hunk, the aggregate guard.workflow-integrity.ts: hunks only inverifyWorkflowControlSnapshotandderiveWorkflowControlBindings.runtime.test.ts: two tests inserted aftersyncRun records model fan-out attempts independently.packages/modal/src/private-model-work.tsand its test: no other wave-2 branch touches them.Changelog entry
A run whose topology fans a node out over several model profiles no longer stops synchronizing after its first pass. Before,
statusreported the run's controls as diverged and skipped synchronization, and commands that read the run strictly, such asstats,whyandtimeline, failed with "run state node set does not exactly match the sealed graph". A fan-out node whose skipped attempts reopen and succeed afterresume --retry-failednow reportssucceededinstead of stayingskipped, so the recovered run no longer endsfailed. A private eval on Modal no longer fails aftereval runreturns when its run expanded a dynamic group, as the default topology's goal lanes do. Before, the worker failed with "private eval run state must exactly match its planned graph nodes".Refs #1141.
🤖 Generated with Claude Code
The PR appears safe to merge; the remaining concern is non-blocking test coverage for expanded dynamic groups.
Fix with agent prompt
Summary
The PR removes a run-state key-set check that rejected legitimate fan-out and dynamic records, recalculates fan-out aggregates from current attempts, and lets the private-eval reader recognize records outside the planned graph’s node IDs.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart LR A[Sealed graph and task manifest] --> B[Workflow synchronization] B --> C[Run state: graph nodes, attempts, dynamic storage IDs] C --> D[Fan-out aggregate from attempt statuses] C --> E[Private-eval model-work evidence]Reviews (1) · Last reviewed commit: "fix(modal): read private-eval model work..."