Skip to content

fix: merge storyboard scene writes against fresh state so sibling renders don't clobber job ids (#3400) - #3411

Merged
atomantic merged 1 commit into
mainfrom
claim/issue-3400
Aug 3, 2026
Merged

fix: merge storyboard scene writes against fresh state so sibling renders don't clobber job ids (#3400)#3411
atomantic merged 1 commit into
mainfrom
claim/issue-3400

Conversation

@atomantic

Copy link
Copy Markdown
Owner

Summary

The audit finding was marked UNCERTAIN pending "verify updateStage's internal serialization first." The race is realupdateStage serializes the writes but does not merge them, so serialization never helped here.

updateStage(issueId, stageId, patch) is just updateStageWithLatest(issueId, stageId, () => patch), and that does queue on the per-series write tail. But all three storyboard write paths read stages.storyboards.scenes at entry, mutated a copy, and passed the whole pre-read array as the patch — a value computed outside the lock. The tail then shallow-merges that stale array over the freshest stage, so the second of two concurrent enqueues reverts the first scene's freshly-stamped job id. The media job keeps running with nothing left pointing at it, and the filename hook (which only attaches a completed render when the target slot already carries the job id) silently drops the result.

Fixed by routing all three through a shared patchStoryboardScene(issueId, index, mutate) that hands updateStageWithLatest a mutator, keeping the read-modify-write of scenes inside the write region so only the targeted index is replaced. This mirrors the existing persistComicPageSlot treatment in comicPages.js and renderComicCoverLike in covers.js — the same fix, already applied to the comic-page surface.

Paths changed:

  • enqueueStoryboardSceneVideoscenes[idx].sceneVideoJobId
  • enqueueStoryboardShotStartFramescenes[sIdx].shots[tIdx].startFrameJobId
  • refineStoryboardScenePromptscenes[idx].description. Beyond the issue's stated scope, included deliberately: it is the identical bug in the same file, and with an LLM round-trip sitting between the read and the write it has by far the widest clobber window of the three. Leaving it would have left the fix half-applied.

Single-call behavior is unchanged. One deliberate behavior change: a scene or shot that disappeared between the caller's read and the write now 404s (PIPELINE_SCENE_NOT_FOUND / PIPELINE_SHOT_NOT_FOUND) instead of resurrecting itself into the persisted array — validating against the stale snapshot was the only reason that ever "worked."

Per the trust model this is a same-process re-entrancy race from one user's pipeline run (two render buttons, or a fan-out over scenes), not a cross-human race — squarely the "serializing two write paths that mutate the same record" case CLAUDE.md calls expected.

Test plan

  • cd server && NODE_ENV=test npm test24297 passed, 211 skipped, 0 failed. (A first run also flagged dataManager.categories.test.js with ENOENT … server/test-data; it passes in isolation and on re-run — a parallel-run race over a gitignored runtime dir in a fresh worktree, unrelated to this change.)
  • New describe('concurrent storyboard scene enqueues (#3400)') in server/services/pipeline/visualStages.test.js installs a genuinely stateful serialized write tail (one shared persisted stage, one mutex, computeFn evaluated inside the lock) and drives enqueues concurrently via Promise.all:
    • two scene-video enqueues for different scenes → both sceneVideoJobIds survive
    • two shot start-frame enqueues on different scenes → both startFrameJobIds survive
    • a scene-video enqueue concurrent with a sibling-scene prompt refine → neither reverts the other
    • a scene that vanished before the write landed → 404 instead of resurrection
  • Falsification check: reverted storyboards.js to the pre-fix version and re-ran — all 4 new tests fail with exactly the clobber symptom (expected undefined to be 'job-scene-0'), plus the 3 existing happy-path assertions that now pin the mutator path. Restored, all 126 pass.
  • Test-harness note: the shared updateStageWithLatest mock now reads from a per-test persistedStages (reset in beforeEach) so the outer getIssue read and the write tail agree on state, and beforeEach restores mock implementations rather than only clearing call logs — otherwise the stateful tail installed by these tests would leak into the rest of this 1500-line file.

Closes #3400

…ders don't clobber job ids (#3400)

enqueueStoryboardSceneVideo, enqueueStoryboardShotStartFrame, and
refineStoryboardScenePrompt each read stages.storyboards.scenes at entry and
wrote the whole pre-read array back through updateStage. updateStage does
serialize on the series write tail, but it shallow-merges the `scenes` value
the caller built OUTSIDE that lock — so two enqueues for different scenes that
both read before either wrote would revert each other's freshly-stamped
sceneVideoJobId / startFrameJobId, and the losing media job would run to
completion with nothing referencing it.

All three now go through a shared patchStoryboardScene() that passes a mutator
to updateStageWithLatest, keeping the read-modify-write of `scenes` inside the
write region so only the targeted index is replaced. Mirrors the existing
persistComicPageSlot treatment in comicPages.js. A scene (or shot) that
disappeared between the caller's read and the write now 404s rather than
resurrecting itself into the persisted array.

The audit finding was marked UNCERTAIN pending "does updateStage already merge
serially" — it serializes but does not merge, so the race was real.
@atomantic

Copy link
Copy Markdown
Owner Author

Review gate — codex gpt-5.6-terra, 1 round

Two findings, both legitimate, both deferred to #3413 rather than applied here. Reasoning:

1. Index-based targeting can retarget onto the wrong scene under a concurrent reorder. Correct, and the deeper issue — but storyboard scenes carry no durable id at all (issuesShared.js:395 is a bare scenes.slice(0, 200)), and the whole surface is index-addressed: the route param, the job owner string …:storyboards:scene<idx>, and storyboardsFilenameHook.js's completion routing. Fixing it means stamping ids, a migration for every existing install's persisted scenes, and a legacy-owner fallback for in-flight jobs. Out of scope for a targeted race fix.

Importantly this is not a regression from this PR: pre-fix, the same reorder was clobbered wholesale by the stale array being written back. Index-targeting within the fresh array is strictly better than what it replaces.

2. A media job is orphaned when the locked write rejects. Also correct — enqueueJob runs before patchStoryboardScene, so a vanished scene now 404s with the job already queued. This PR does newly expose that path (pre-fix the write couldn't 404; it silently resurrected the deleted scene into the array instead — worse). comicPages.js#persistComicPageSlot and covers.js#renderComicCoverLike have the identical enqueue-then-persist shape, so compensating only here would leave the three render surfaces inconsistent. #3413 covers all three together, using the exported cancelJob (mediaJobQueue/index.js:965).

No findings applied; no code change since the review. Worktree verified clean afterward (codex made no edits). Full server suite green at 24297 passed.

@atomantic
atomantic merged commit 82cdc5c into main Aug 3, 2026
6 checks passed
@atomantic
atomantic deleted the claim/issue-3400 branch August 3, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Guard storyboards.js scene enqueues against read-modify-write clobbering

1 participant