fix(runtime): a source retry withdraws the prompts rendered from the withdrawn expansion - #1220
Merged
Merged
Conversation
…withdrawn expansion
`resume --retry-failed` on a dynamic source whose verifier failed withdraws
the published expansion generation into `dynamic-expansion-history/`, so the
group expands again from the source's new output. The archive moved only the
generation-owned attempts. It left the published `prompt.rendered.md` of every
planned task whose prompt waits on the group, and that prompt was rendered
from the withdrawn children.
When the re-run source planned different items, the next render of such a
task, for example a custom join whose prompt uses `{{artifact_path:<group>}}`,
differed from the file on disk. The re-expansion then threw `runtime rendered
prompt changed for <attempt>`, and so did every later render and lifecycle
admission check, so the run stranded until the file was deleted by hand. The
stock topologies are not affected, because their deferred prompts reach the
goal groups only through authority selectors, which render the same bytes
whatever the children are.
The archive now also moves the published prompt of each sealed base task whose
`deferredPromptGroups` names a withdrawn group. It is validated with the rest
of the attempt state before the Smithers reset, the next expansion renders it
afresh against the new generation, and `retry.json` lists it with the other
archived paths. A prompt that was never rendered is skipped.
The re-derivation test gives the fixture's join a deferred prompt that names
the group's children and re-runs the source with a different item after the
retry. It checks that the re-expansion renders the join's prompt from the new
child, that the archive holds the withdrawn prompt, and that `retry.json`
lists it. With origin/main's retry module it fails at the re-expansion with
`runtime rendered prompt changed for join`. The lifecycle retry test checks
the same through a real `resume --retry-failed` of a launched run, whose
compiled base tasks mark the join's prompt as deferred: the prompt is in the
archive after the resume and is rendered again by the re-expansion. With
origin/main's retry module it fails because the join's prompt is still in
place after the resume.
Split out of draft #1216, whose change to how drifted published prompts are
handled this fix does not depend on.
Refs #1141
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…n prompt The retry skipped a waiting task's prompt that `fs.existsSync` could not see. `existsSync` follows symlinks, so a dangling link at `artifacts/<attempt>/prompt.rendered.md` counted as a prompt that was never rendered: it stayed in place and was left out of `retry.json`. The generation's own entries in the same function, and the resume path's retained-prompt restore (`runEntryExists` in smithers.ts), already count a dangling link as present so that it reaches the fail-closed checks. The check now uses `lstat`. Only a prompt that does not exist is skipped. A dangling link reaches `assertRegularFileInside` and refuses the retry before the Smithers reset, as a live one already did. Any other failed lookup, such as an attempt path that is a regular file, now also refuses at planning. It was skipped before, and the re-expansion then failed on that path after the reset. A new test pins each refusal: a live symlink, a dangling symlink, and a directory at the prompt path, and a symlinked attempt directory. In each case planning throws an `ArtifactPathError`, the manifests stay published, and no history directory is created. With the previous check the dangling case plans the retry. With origin/main's retry module every case does. Refs #1141 Co-Authored-By: Claude Opus 5.5 <[email protected]>
Comment on lines
+279
to
+280
| assertNoSymlinkComponents(runRoot, source, `dynamic retry prompt for ${task.attemptId}`); | ||
| if (fs.lstatSync(source, { throwIfNoEntry: false }) === undefined) continue; |
There was a problem hiding this comment.
Dangling attempt link gets skipped If
artifacts/<attemptId> is a dangling symlink, the path check treats it as missing, and the prompt lookup skips it. The retry then resets Smithers and archives the manifests rather than refusing the invalid directory before reset. The next expansion cannot create the prompt there, leaving the run in need of manual repair. Check parent directories with lstat before treating the prompt as absent.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/runtime/src/dynamic-expansion-retry.ts
Line: 279-280
Comment:
**Dangling attempt link gets skipped** If `artifacts/<attemptId>` is a dangling symlink, the path check treats it as missing, and the prompt lookup skips it. The retry then resets Smithers and archives the manifests rather than refusing the invalid directory before reset. The next expansion cannot create the prompt there, leaving the run in need of manual repair. Check parent directories with `lstat` before treating the prompt as absent.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
This was referenced Sep 29, 2026
aviggiano
added a commit
that referenced
this pull request
Sep 30, 2026
An attempt's artifacts/<attempt>/prompt.rendered.md is now the prompt it receives on every verb, and nothing hashes, compares or seals it after launch. Prompt tamper detection cost more than it bought: an operator's edit of a task that had not run, or an upgrade that renders templates differently (#1176, #1195), stranded the run, and a static prompt had three different sources depending on the verb. Deleted: - the runtime byte comparison of published prompts, and the verify-mode "missing" throw (renderReadyRuntimePrompts renders only a missing file and otherwise adopts it; admission renders nothing); - the group-template digest gate (DYNAMIC_TEMPLATE_CHANGED) and its now-unused templatePath input; the manifest compatibility rows, which compare launch values with launch values, stay; - the prompt entries of the control seal and execution snapshot (controls/rendered-prompts/, controls/prompt-snapshots/), the workflow's sealed-copy read, and the --refresh-controller retained binding with its digest checks; - the digest gate on restoring a missing static prompt; - the dead dynamicRuntimePromptDigest export. Added: - a prompt problem fails only its task: a runtime prompt that cannot be rendered is returned as promptRenderFailures instead of thrown, a missing prompt file reads as empty in the render, and assert-task-inputs fails that task with the renderer's message or a "is missing; ultrafuzz resume restores ..." message. Every other task, status and sync keep working; - a missing static prompt is restored from prompt-snapshots/ before every engine start (resume, replay and fork), never over an existing file; - the #1220 retry archive moves the withdrawn generation's attempt state before it renames the manifests, so an interrupted archive leaves the published generation in place instead of a withdrawn item's prompt beside a new manifest. The docs say what is no longer checked and how to change a prompt of a running campaign; the CHANGELOG entry replaces the #1176/#1195 upgrade note, whose failure this removes. Runs launched before this change keep their launch engine's behaviour until they are resumed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
aviggiano
added a commit
that referenced
this pull request
Sep 30, 2026
An attempt's artifacts/<attempt>/prompt.rendered.md is now the prompt it receives on every verb, and nothing hashes, compares or seals it after launch. Prompt tamper detection cost more than it bought: an operator's edit of a task that had not run, or an upgrade that renders templates differently (#1176, #1195), stranded the run, and a static prompt had three different sources depending on the verb. Deleted: - the runtime byte comparison of published prompts, and the verify-mode "missing" throw (renderReadyRuntimePrompts renders only a missing file and otherwise adopts it; admission renders nothing); - the group-template digest gate (DYNAMIC_TEMPLATE_CHANGED) and its now-unused templatePath input; the manifest compatibility rows, which compare launch values with launch values, stay; - the prompt entries of the control seal and execution snapshot (controls/rendered-prompts/, controls/prompt-snapshots/), the workflow's sealed-copy read, and the --refresh-controller retained binding with its digest checks; - the digest gate on restoring a missing static prompt; - the dead dynamicRuntimePromptDigest export. Added: - a prompt problem fails only its task: a runtime prompt that cannot be rendered is returned as promptRenderFailures instead of thrown, a missing prompt file reads as empty in the render, and assert-task-inputs fails that task with the renderer's message or a "is missing; ultrafuzz resume restores ..." message. Every other task, status and sync keep working; - a missing static prompt is restored from prompt-snapshots/ before every engine start (resume, replay and fork), never over an existing file; - the #1220 retry archive moves the withdrawn generation's attempt state before it renames the manifests, so an interrupted archive leaves the published generation in place instead of a withdrawn item's prompt beside a new manifest. The docs say what is no longer checked and how to change a prompt of a running campaign; the CHANGELOG entry replaces the #1176/#1195 upgrade note, whose failure this removes. Runs launched before this change keep their launch engine's behaviour until they are resumed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
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.
This PR was split out of draft #1216 so it can ship in v0.1.2. It ports #1216's second commit (1621598) to main on its own, with its comments and tests adapted to main's semantics. #1216's main change, keeping a published runtime prompt that the current build renders differently, is contentious and deferred to v0.1.3. This fix does not depend on it. On this branch a prompt that renders differently still fails the render as it does on main, so the tests check that the stale prompt is gone before the next render, not that it gets adopted.
Problem
resume --retry-failedon a dynamic source whose verifier failed withdraws the published expansion generation, so the group expands again from the source's new output. If the re-run source plans different items, a task that waits on the group and whose prompt names the group's children cannot render again. A custom join whose prompt uses{{artifact_path:<group>}}is one example. The re-expansion throwsruntime rendered prompt changed for <join>. It publishes the new manifest before it renders, so every later render, and every lifecycle admission check that re-derives the dynamic controls, renders the same prompt again and throws the same error. The run stays stuck until someone deletes the join'sartifacts/<attempt>/prompt.rendered.mdby hand and resumes it.The packaged topologies are not affected. Their deferred prompts belong to the nodes behind the goal groups: dedupe, triage, severity classification, test aggregation and the final report. Those prompts name only static nodes' directories, and they reach the goal children only through authority selectors. Authority selectors render the same bytes whatever the children are.
Root cause
archiveDynamicExpansionsForRetry(packages/runtime/src/dynamic-expansion-retry.ts) moves the manifests and the attempt state the generation owns (artifacts/<storage-id>, invariant snapshots, verification records) intodynamic-expansion-history/. It did not moveartifacts/<attempt>/prompt.rendered.mdfor the sealed base tasks whose prompt waits on the group (deferredPromptGroups), although that prompt was rendered from the withdrawn children.renderReadyRuntimePromptspublishes a prompt that is missing but refuses one on disk whose bytes differ from a fresh render. So the stale file blocks the new expansion.Change
collectAttemptStateMovesalso moves the publishedprompt.rendered.mdof every sealed base task whosedeferredPromptGroupsnames a withdrawn group.lstat, so a dangling symlink is not mistaken for a prompt that was never rendered.planDynamicExpansionRetryArchivenow reads the sealed runtime base before collecting the moves. These prompts are therefore validated with the rest of the attempt state before the Smitherstimetravelreset, just as the generation's own entries already were.retry.jsonlists each moved prompt inarchived_attempt_pathswith the other archived paths. The next expansion renders it afresh from the new generation.docs/reference/topology-yaml.mdnow says that the generated children's artifacts, and the rendered prompts of later nodes that wait on the group, move with the manifests.The first commit is the port. The second commit came out of review. It replaces the port's
existsSynccheck, which follows symlinks and so skipped a dangling link, withlstat, and it adds the refusal test below.Verification
The branch is based on origin/main 13af837. To run the tests against main, I copied origin/main's
packages/runtime/src/dynamic-expansion-retry.tsover this branch's version, left the tests as they are, and recompiled.dynamic-expansion.test.ts, "explicit source retry re-derives the base runtime controls after archiving an expansion". The fixture's join now has a deferred prompt,Join {{artifact_path:fanout}}., withdeferredPromptGroups: ["fanout"]. After the retry, the source plans a different item and the group expands again. The test checks four things: the join's prompt names the new child and not the withdrawn one; admission re-derives the expanded controls; the archive holds the withdrawn prompt byte for byte; andretry.jsonlists exactly the generation's artifact directory and the join's prompt.Error: runtime rendered prompt changed for join, thrown fromrenderReadyRuntimePrompts.dynamic-expansion.test.ts, "explicit source retry refuses a withdrawn prompt that is not a regular file before anything moves" (new). After the expansion, each case damages the join's published prompt in one way: a live symlink, a dangling symlink, a directory at the prompt path, or a symlinked attempt directory. In each caseplanDynamicExpansionRetryArchivemust throw anArtifactPathErrorwith the expected code, the manifest must stay indynamic-expansions/, and nodynamic-expansion-history/may exist.Missing expected exception: symlink, because main never checks these prompts.existsSynccheck): fails withMissing expected exception: dangling-symlink.dynamic-lifecycle.test.ts, "explicit source retry prunes the withdrawn generation from run state and keeps observers admitted". This test runs a realresumeRun({ retryFailed: true })on a launched run whose compiled base tasks mark the join's prompt as deferred. It checks that the join's prompt is in the archive after the resume, that the re-expansion renders it again, and that observers stay admitted.AssertionError: Expected values to be strictly equal(true !== false), because the join's prompt is still in place after the resume.Summarize completed work in {{artifact_path}}/report.md.) does not name the children. It re-renders to the same bytes, so this test cannot reproduce the throw. The unit test above does.resume --retry-failed, I wrote a differing prompt back at the join's path, which is the state v0.1.1 leaves.runtime rendered prompt changed for strict-join, and the new manifest stayed published. Strict admission failed with the same message.resume --retry-failedcreated no new archive and left the stale prompt in place, because no verifier had failed.dynamic-expansion.test.ts17/17 anddynamic-lifecycle.test.ts19/19. The retry subset ofruntime.test.ts(--test-name-pattern='resume retries|retry-failed|retryFailed|source retry|retry') passes 16/16. Three Bun adapter tests in that subset are skipped because Bun is not installed here.npx prettier --checkandnpx eslinton the changed files,CI=1 ESLINT_PLUGIN_DIFF_COMMIT=origin/main pnpm -w lint:strict:ci(exit 0),pnpm -w lint,pnpm --filter @ultrafuzz/runtime typecheck,pnpm -w knipandnode scripts/docs-check.mjsall pass.Risk
Narrow path. The change only runs when
resume --retry-failedresets a producer that owns the published expansion, which is the path that already withdraws the generation. It reads the sealed runtime base (smithers/runtime-base-tasks.json), which every run with a dynamic group launched on v0.1.0 or later has. With no sealed base, nothing new moves.Unchanged prompts move too. Every deferred prompt that waits on a withdrawn group moves, including one that would re-render to the same bytes, like the packaged final report. The cost is one rename and one
retry.jsonentry. Smithers resets the task with the source's dependents, and the next expansion renders its prompt again before the task runs.New refusals before reset. Planning now refuses the retry, before any
timetravel, in these cases for such a task:prompt.rendered.md, live or dangling;prompt.rendered.mdthat is not a regular file;In each case the operator removes the entry and retries. The new test covers every case except the last. ultrafuzz itself never creates a symlink under a run's
artifacts/.Not atomic. The prompt moves join a window that already was not atomic. It opens after the Smithers reset and the manifest rename, and closes once the controls are re-derived, the run state is pruned and
retry.jsonis written.retry.json, as the generation's own moves already can on main. I tested the throwing case by forcingEACCESon the join prompt in a throwaway test.persisted dynamic runtime task plan does not match its sealed templates and manifests, and the join's prompt stays in place. A re-expansion that plans other items then fails as it does on main today, so this is no worse than main.Stuck runs are not repaired. Upgrading does not unstick a run that is already stuck on v0.1.1. The archive only runs for a failed verifier's producer, and such a run failed at render time instead. The changelog entry gives the manual step.
The join's other artifacts, such as outputs of an earlier join attempt, stay where they are, as on main.
Changelog entry
resume --retry-failedretries a dynamic source whose verifier failed, it now also withdraws the rendered prompts of later nodes that wait on the group, so they render again from the new expansion. Before, if the retried source planned different items, a node whose prompt names the group's children (for example with{{artifact_path:<group>}}) failed every render and lifecycle admission check withruntime rendered prompt changed for <attempt>, and the run could not continue. The packaged topologies were not affected. Upgrading does not repair a run that is already stuck this way: delete the staleartifacts/<attempt>/prompt.rendered.mdby hand and resume the run, and the next render publishes it again.Refs #1141
Greptile follow-up
artifacts/directory is treated as absent by the attempt-state root check (comment). That check predates this PR; itsexistsSyncuse is noted under Risk. A run whoseartifacts/root is a dangling link is already corrupt: every other command that reads run artifacts fails on it, and repairing it is manual either way. This PR's own new paths, the per-attempt prompt and its attempt directory, uselstatand refuse a dangling link before the Smithers reset. The new test covers that.🤖 Generated with Claude Code