fix(runtime): published dynamic expansions are the lock-free authority for fan-out membership - #1163
Merged
Merged
Conversation
…y for fan-out membership A published dynamic-expansions/<group>.json is the durable record of which generated nodes a group has, but loadOrCreateDynamicExpansion re-proved it on every workflow render and every strict lifecycle admission: it re-read and re-hashed the live source artifact and required it to match source.output_sha256, all under an O_EXCL lock that was never reclaimed. Both are self-inflicted stops: - A reset that re-runs the source (a --reset-node, or a Smithers timetravel that sweeps the source into its dependent set) empties the source's canonical artifact directory when its agent attempt starts. Every render then threw "dynamic source artifact does not exist" and, once the source wrote a different plan, DYNAMIC_EXPANSION_CHANGED, with no way back. The same check made strict admission refuse cancel, pause, why, fork and replay. - A controller killed (SIGKILL, OOM) or an observer interrupted while holding .expansion.lock left every later render and admission busy-waiting 5 s and then throwing DYNAMIC_EXPANSION_LOCKED. An existing manifest is now validated (the manifest set plus the group's static identity: run, group, source node, attempt and path, JSON and key paths, node-ID template, prompt template digest and fingerprint, limit) and returned without touching the source. The source is read only to create the manifest, and its digest is still recorded as provenance. The lock is deleted: reads need none, creation happens in the workflow render, which Smithers runs for one live driver per run, publishFileDurableExclusive never replaces a published file, and the existing re-read after publication refuses an inconsistent set. Semantic change: after a reset re-runs a source, the fan-out keeps its originally published items instead of failing. resume --retry-failed of a failed source verifier still archives the manifests (#1064) and re-expands. The tests that encoded the old behaviour (a changed source must throw, the lock must never be reclaimed, admission must fail while the source is absent) are replaced by tests that a re-run source keeps the published fan-out for renders and admission, and that a leftover lock does not block expansion. Refs #1142 Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ource retry planDynamicExpansionRetryArchive refuses a manifest directory that holds anything but the published manifests, so the .expansion.lock an older build left behind after a crash (the failure the previous commit removes) made `resume --retry-failed` of the dynamic source fail with DYNAMIC_RETRY_EXPANSION_INVALID until an operator deleted it by hand. The temporary file of an interrupted publishFileDurableExclusive has the same effect. Dot entries are never manifests (readExpansionManifests already skips them) and the directory rename archives them along with everything else, so the refusal now ignores them. Any other unrecognized entry is still refused; the existing test for that case now uses a non-dot file. Refs #1142 Co-Authored-By: Claude Opus 5.5 <[email protected]>
| templateDigest: input.templateDigest, | ||
| templateFingerprint: input.templateFingerprint, | ||
| maxDynamicNodes: input.maxDynamicNodes, | ||
| sequence: priorManifests.length, |
There was a problem hiding this comment.
Concurrent groups duplicate sequences
If two renderers of the same run create different groups at the same time, both can read the same manifest count and publish different files with that count as their sequence. The check after publication rejects the duplicate, but both files are already durable. Later renders and strict lifecycle admission then keep rejecting the manifest set until an operator repairs it.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/runtime/src/dynamic-expansion.ts
Line: 309
Comment:
**Concurrent groups duplicate sequences**
If two renderers of the same run create different groups at the same time, both can read the same manifest count and publish different files with that count as their `sequence`. The check after publication rejects the duplicate, but both files are already durable. Later renders and strict lifecycle admission then keep rejecting the manifest set until an operator repairs it.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
This was referenced Sep 28, 2026
Merged
…t a race leaves behind The comment that replaced withDynamicExpansionLock said the re-read after publication "refuses" a set a racing publisher made inconsistent. It does, but only after both manifests are durable: two renderers that create different groups at once can take the same sequence, and every later render and strict admission then fails with DYNAMIC_MANIFEST_SET_INVALID until the set is repaired by hand. The comment now says so, and says that creation assumes one renderer per run (Smithers refuses to resume a run whose driver is live before it renders), that renderers which load the same outputs publish identical bytes, and that on a filesystem without hard links a concurrent reader can catch a manifest mid-publication. The retry-archive docstring still justified the archive by the render needing the source artifact, which this branch made untrue. Its remaining job is to let the group expand again from the retried source's new output. Refs #1142 Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ies on With the source no longer re-hashed, assertCompatibleManifest is the only check between a changed group definition and silently reusing the published items, and the source-change block this branch deleted was the only test that reached DYNAMIC_EXPANSION_CHANGED. The prompt-template and limit test now also changes the source JSON path, key path, node-ID template and template fingerprint of a published group and expects DYNAMIC_EXPANSION_CHANGED for each. It passes on main and on this branch, and fails when that call is made a no-op. The lifecycle test's rewritten source now carries a goal with a different key instead of the published plan plus a newline, so it exercises a different plan rather than only different bytes. The source re-run test and the base-controls retry test built the same sealed run by hand; they now share sealedDynamicRun. Refs #1142 Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every pull request in this batch inserts its entry at the same place in CHANGELOG.md, so each merge would conflict with the next. The entries are collected into one changelog update instead. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This was referenced Sep 29, 2026
aviggiano
added a commit
that referenced
this pull request
Sep 29, 2026
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
Two bookkeeping failures in dynamic fan-out can stop a campaign permanently (#1142).
resetTaskArtifactsForRetryinworkflow.tsx). From then on every workflow render throwsdynamic source artifact does not exist. Once the source writes a plan with different bytes, every render throwsDYNAMIC_EXPANSION_CHANGEDinstead, and that never clears. Two resets can do this:--reset-nodeon the source, or a--retry-failedreset whose dependent set includes the source (Recovery does not reopen descendants after prerequisites succeed #1141 reports that Smithers builds that set by start time; I did not reproduce that part). The same check runs inside strict lifecycle admission (readLinkedWorkflowEvidence), socancel,pause,why, timeline, snapshots,node,forkandreplayare refused withWORKFLOW_CONTROL_EVIDENCE_INVALID.dynamic-expansions/.expansion.lock, which was never reclaimed by design ("never steal a lock based on age"). Observers took it too. So a controller that was SIGKILLed or OOM-killed, or an observer interrupted with Ctrl-C, while holding the lock left every later render and admission check busy-waiting 5 s (Atomics.waiton the event loop) and then throwingDYNAMIC_EXPANSION_LOCKED. A leftover lock also maderesume --retry-failedof the source refuse withDYNAMIC_RETRY_EXPANSION_INVALID("unrecognized retry state").Root cause
loadOrCreateDynamicExpansiontreated the published manifest as a cache to re-prove on every call instead of as the record of membership. It re-read and re-hashed the live source artifact and required the bytes to matchsource.output_sha256, and it did all of this under a lock that nothing could release after a crash.Change
loadOrCreateDynamicExpansion(dynamic-expansion.ts): if the group already has a manifest, it validates the manifest set and the group's static identity (run, group, source node/attempt, source path, JSON and key paths, node-ID template, prompt template digest and fingerprint, limit) and returns the manifest. It does not read the source. The source is read, hashed and planned only when the manifest is created.source.output_sha256is still recorded as provenance.withDynamicExpansionLockand its two error helpers are deleted.publishFileDurableExclusivenever replaces a published file (a byte-identical duplicate is accepted), and the existing re-read after publication rejects an inconsistent set. That re-read detects a cross-group race but cannot undo it; see Risk. The comment at the call site now says so.planDynamicExpansionRetryArchive(dynamic-expansion-retry.ts) no longer counts dot entries as unrecognized state.readExpansionManifestsalready skips them, and the directory rename archives them with everything else. That covers a lock file from an older build and the temporary file of an interrupted publication. Other unknown entries are still refused. Its docstring no longer justifies the archive by the render needing the source; the archive's remaining job is to let the group expand again from the retried source's new output.docs/reference/topology-yaml.mdno longer says resume rejects changes to the source bytes, and it describes the new behaviour.Most of the
dynamic-expansion.tsdiff is re-indentation from unwrapping the lock callback.git diff -wshows the real change: +15/-65 in that file.Semantic change to review
After a reset re-runs a source, the fan-out keeps its originally published items. Before this change the run failed at every render instead. An operator who resets a source to get a new plan therefore does not get a new fan-out. The same should hold for
forkandreplay, which run against the same run root and its published manifests (from readingsubmitLifecycleAction; not run). The only path that re-expands isresume --retry-failedon a source whose verifier failed: the #1064 archive moves the manifests todynamic-expansion-history/before the source runs again. That path is unchanged.Deliberately not built (and why)
dynamic-expansions/<group>.jsonalready is the durable membership record. The bug was re-validating it against mutable state.dynamic-expansion-retry.ts) is still needed now that membership is frozen. Deleting the archive would also change--retry-failedsemantics, so that is the owner's call for a separate PR.Verification
Discriminating evidence. I compiled this PR's tests against
main'sdynamic-expansion.tsanddynamic-expansion-retry.ts(fbbcf6c) into a separate out dir and ran them, then ran them at the PR head.maindynamic-expansion.test.ts› a re-run dynamic source keeps the published fan-out for renders and admission (renders with the source removed, then rewritten with a different plan, then ready again, plusverifyDynamicRuntimeMaterialization)ArtifactPathError missing-file: dynamic source artifact does not existdynamic-expansion.test.ts› a lock file left by a killed materializer blocks neither expansion nor a source retryDYNAMIC_EXPANSION_LOCKEDdynamic-lifecycle.test.ts› a re-running dynamic source keeps published controls admissible and observable (strict evidence pluseventsquery/watch with the planner artifact removed, then strict evidence with a plan whose goal has a different key)WORKFLOW_CONTROL_EVIDENCE_INVALID … published dynamic runtime controls no longer re-derive from their sealed base: dynamic source artifact does not existOn
mainthe first assertion of each source test already fails, so their rewritten-source halves are never reached there. To check that those halves test something, I ran both tests against a partial fix: in the compileddist-test, the reuse branch skipped a missing source but still compared the digest of a present one. Both fail on it: the unit test withDYNAMIC_EXPANSION_CHANGED, and the lifecycle test withWORKFLOW_CONTROL_EVIDENCE_INVALID.The lock-file test also checks commit 2 separately: run against commit 1's retry source it fails with
DYNAMIC_RETRY_EXPANSION_INVALID, and it passes with commit 2.Coverage for the check this PR now relies on: once the source is no longer re-hashed, the
assertCompatibleManifestcall in the reuse branch is all that stops a changed group definition from silently reusing the published items. "persisted expansion rejects prompt-template, topology-contract, and dynamic-limit changes" now changes the source JSON path, key path, node-ID template and template fingerprint of a published group and expectsDYNAMIC_EXPANSION_CHANGEDfor each. The behaviour is unchanged, so it passes onmainand on this PR. It fails (Missing expected exception) when that call is made a no-op.Tests removed or rewritten because they encoded the old behaviour:
DYNAMIC_EXPANSION_CHANGED);fs.openSyncandDate.now);notes.txt), which is still refused.The source re-run test and "explicit source retry re-derives the base runtime controls after archiving an expansion" now share one
sealedDynamicRunfixture instead of two hand-built copies.What I ran at the head:
node --test dist-test/test/dynamic-expansion.test.js: 15/15 passnode --test dist-test/test/dynamic-lifecycle.test.js, whole file (run together with the file above): 18/19. The failure was in fixture setup, before any dynamic expansion:WORKFLOW_SUBMISSION_FAILED … dependencies/packages/000003/v4/locales/ka.d.cts changed while reading, anode_modulesfile read while the fixture snapshots its execution dependencies. That test ("explicit source retry prunes the withdrawn generation from run state and keeps observers admitted") passed when rerun alone. A reviewer hit the same setup failure on a loaded host.dynamic-workflow.test.js2/2;cloud-worker-handoff.test.js, whole file, 14/14 (these fixtures render the real generatedworkflow.tsxagainst the rebuilt runtimedist, which callsmaterializeDynamicRuntime); and--test-name-pattern='resume retries a failed artifact verifier from its agent producer and dependent closure|resume retries stalled nodes alongside failed ones' dist-test/test/runtime.test.js2/2 (the Archive dynamic expansion generations before producer retry #1064 archive throughresumeRun). The follow-up commits change only comments insrc, plus the two test files above, so I did not re-run these.npx prettier --checkandnpx eslinton the changed files;CI=1 ESLINT_PLUGIN_DIFF_COMMIT=<merge base> pnpm -w lint:strict:ci;pnpm --filter @ultrafuzz/runtime typecheck;pnpm -w knip;node scripts/docs-check.mjs. The strict lint is diffed against the merge base fbbcf6c. Diffed against the currentorigin/mainit also lintspackages/cli/src/commands/report/bundle.ts, whichmainchanged after this branch point (fix(cli): record skipped files in the report bundle manifest #1161), and reportsmax-linesthere. This PR does not touch that file, and CI diffs the merge ref against the PR base, so it lints only this PR's files.Not run: the full runtime and CLI suites, a real Smithers campaign that resets a dynamic source, the two-process race, and an upgrade of a run already stranded by a lock. The lifecycle tests use the repository's fake Smithers inspect and events fixtures.
CI:
External static analysisfails here, as it does on every PR that editsCHANGELOG.md. Super-Linter lints the whole file with MD013 at 400 characters and reports every long entry, about 45 existing ones plus this one, so shortening this entry would not help. #1184 turns MD013 off. Becauserelease-validationneeds that job, the runtime lanes have not run in CI for this PR yet; the local runs above are the evidence until they do.Risk / compatibility
sequence. The post-publication re-read then rejects the set, but both files are already durable, so every later render and strict admission fails withDYNAMIC_MANIFEST_SET_INVALIDuntil someone repairs the set by hand.dist. With different ready sets every trial stranded the set (onmainthe lock serialized them and none did). With identical ready sets none did, because identical bytes dedupe. I did not run that experiment myself.ultrafuzz resumeserializes on the run's lifecycle lock and inspects the run first (Ordinary resume preflights an active run instead of attaching #968), and Smithers 0.35 refuses to resume a run whose driver is live, before its detached preflight render (findLiveDriverErrorruns beforepreflightDetachedLaunchin@smthrs/cli/src/index.js;--forcedoes not bypass it, and ultrafuzz never passes--steal-ownership). These are launch-time checks, not exclusion held by the running driver, so they narrow the race rather than rule it out.fork/replaypath: neither ultrafuzz'ssubmitLifecycleActionnor the Smithersfork/replaycommand handlers check the parent run's driver (I did not read@smthrs/time-travel). Forking or replaying a run whose driver is still live would add a second renderer, which would share the whole run root with the live driver, not only the manifests.publishFileDurableExclusivecreates the final manifest name and then writes into it, so without the lock a concurrent observer can read a manifest mid-publication and fail that one admission withDYNAMIC_MANIFEST_INVALID. From readingsafe-paths.ts; not run..expansion.lock: strict admission (cancel,pause,why, …) and the source-retry planner run in the installed CLI, so they stop tripping on it once this build is installed.resumepoints the controller at the installed runtime (ULTRAFUZZ_RUNTIME_MODULEinsubmitSmithersContinuation), so its renders should stop too.forkandreplaycontrollers load the runtime sealed into the run's execution snapshot, which for a run launched on an older build still takes the lock. I read this in the code; I did not run an upgrade. A process on an older build still takes the lock, and this build ignores it.DYNAMIC_EXPANSION_LOCKEDandDYNAMIC_EXPANSION_LOCK_INVALIDare no longer raised. No docs or code outside the deleted tests referenced them. For a published group,DYNAMIC_EXPANSION_CHANGEDstill covers a changed source node, attempt or artifact path, JSON path, key path, node-ID template or template fingerprint. A changed limit fails asDYNAMIC_MANIFEST_SET_INVALIDand a changed template file asDYNAMIC_TEMPLATE_CHANGED, as before.Refs #1142. This covers its fan-out membership part: a re-run source or a stale lock no longer changes or blocks the published membership. It does not make a consumer's admitted authority immutable mid-attempt, and a failed replacement can still destroy the prior verified generation (the source's agent attempt still empties its artifacts before the new attempt succeeds), so it should not close the issue.
🤖 Generated with Claude Code
The PR does not appear safe to merge while concurrent renderers can durably publish conflicting expansion sequences.
Fix with agent prompt
Summary
The PR makes published dynamic-expansion manifests authoritative for fan-out membership, removes the unreclaimed expansion lock, permits legacy dot entries during source-retry archiving, and updates documentation and tests.
Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Materialize dynamic group] --> B{Published manifest exists?} B -->|Yes| C[Validate manifest set and group identity] C --> D[Reuse published membership] B -->|No| E[Read source artifact and plan expansion] E --> F[Validate candidate set] F --> G[Publish manifest exclusively] G --> H[Re-read and validate published set]Reviews (3) · Last reviewed commit: "Merge remote-tracking branch 'origin/mai..."