Problem
Storyboard scenes are addressed purely by array index, end to end: the route takes sceneIndex/shotIndex, the media-job owner string is pipeline:<issueId>:storyboards:scene<idx>[:shot<idx>], storyboardsFilenameHook.js routes a completed render back via scenes[parsed.sceneIndex], and the write path (post-#3400) patches scenes[index] inside the write lock. The sanitizer stamps no id at all — issuesShared.js:395 is just scenes: raw.scenes.slice(0, 200).
#3400 closed the clobbering race (the whole pre-read array is no longer written back), but left two index-addressing consequences. Both surfaced in review of PR #3411.
1. A concurrent reorder/removal retargets the write onto the wrong scene. If a scene is deleted or reordered between the caller's read and the locked write, and index is still in range, the job id / refined description lands on whatever scene now occupies that slot instead of failing. The out-of-range case already 404s (PIPELINE_SCENE_NOT_FOUND); the still-in-range case silently mis-targets. Same for shots[tIdx]. The completion hook then routes the finished render by the same stale index, so the mis-targeting is consistent rather than self-correcting.
2. A media job is orphaned when the locked write rejects. enqueueStoryboardSceneVideo / enqueueStoryboardShotStartFrame call enqueueJob / enqueueImageJob before patchStoryboardScene. When the mutator throws (scene/shot vanished), the request 404s but the job is already queued and will run to completion with nothing referencing it — burning local GPU time and leaving an untracked artifact. comicPages.js#persistComicPageSlot and covers.js#renderComicCoverLike have the identical enqueue-then-persist shape, so this is a surface-wide pattern, not storyboard-specific.
Decision
Do both, in one change, scoped to storyboards first:
- Give scenes a durable
id. Stamp id in the storyboards branch of sanitizeVisualStage for any scene lacking one (shots already carry id). Ship a migration in scripts/migrations/ that backfills ids on existing installs' stages.storyboards.scenes[] — index-addressed data is live on every install, so this cannot be a read-time-only fix.
- Resolve by id inside the write region.
patchStoryboardScene takes the scene id captured during the caller's read, resolves it against the fresh array inside updateStageWithLatest, and 409s (PIPELINE_SCENE_STALE_TARGET) when it is gone — distinct from the existing 404 for a never-existed index. Keep the index as the fallback only for pre-migration records with no id.
- Update the completion path to match —
buildStoryboardsShotOwner and storyboardsFilenameHook.js resolve by id, falling back to index for legacy owner strings already sitting in the queue. Do not break in-flight jobs enqueued under the old owner format.
- Cancel the orphan on the reject path.
cancelJob is exported (mediaJobQueue/index.js:965); on a rejected stage write, cancel the job just enqueued and rethrow. Apply the same treatment to persistComicPageSlot and renderComicCoverLike so the three render surfaces stay consistent.
Acceptance
Follow-up to #3400 / PR #3411.
Problem
Storyboard scenes are addressed purely by array index, end to end: the route takes
sceneIndex/shotIndex, the media-job owner string ispipeline:<issueId>:storyboards:scene<idx>[:shot<idx>],storyboardsFilenameHook.jsroutes a completed render back viascenes[parsed.sceneIndex], and the write path (post-#3400) patchesscenes[index]inside the write lock. The sanitizer stamps no id at all —issuesShared.js:395is justscenes: raw.scenes.slice(0, 200).#3400 closed the clobbering race (the whole pre-read array is no longer written back), but left two index-addressing consequences. Both surfaced in review of PR #3411.
1. A concurrent reorder/removal retargets the write onto the wrong scene. If a scene is deleted or reordered between the caller's read and the locked write, and
indexis still in range, the job id / refined description lands on whatever scene now occupies that slot instead of failing. The out-of-range case already 404s (PIPELINE_SCENE_NOT_FOUND); the still-in-range case silently mis-targets. Same forshots[tIdx]. The completion hook then routes the finished render by the same stale index, so the mis-targeting is consistent rather than self-correcting.2. A media job is orphaned when the locked write rejects.
enqueueStoryboardSceneVideo/enqueueStoryboardShotStartFramecallenqueueJob/enqueueImageJobbeforepatchStoryboardScene. When the mutator throws (scene/shot vanished), the request 404s but the job is already queued and will run to completion with nothing referencing it — burning local GPU time and leaving an untracked artifact.comicPages.js#persistComicPageSlotandcovers.js#renderComicCoverLikehave the identical enqueue-then-persist shape, so this is a surface-wide pattern, not storyboard-specific.Decision
Do both, in one change, scoped to storyboards first:
id. Stampidin the storyboards branch ofsanitizeVisualStagefor any scene lacking one (shots already carryid). Ship a migration inscripts/migrations/that backfills ids on existing installs'stages.storyboards.scenes[]— index-addressed data is live on every install, so this cannot be a read-time-only fix.patchStoryboardScenetakes the scene id captured during the caller's read, resolves it against the fresh array insideupdateStageWithLatest, and 409s (PIPELINE_SCENE_STALE_TARGET) when it is gone — distinct from the existing 404 for a never-existed index. Keep the index as the fallback only for pre-migration records with no id.buildStoryboardsShotOwnerandstoryboardsFilenameHook.jsresolve by id, falling back to index for legacy owner strings already sitting in the queue. Do not break in-flight jobs enqueued under the old owner format.cancelJobis exported (mediaJobQueue/index.js:965); on a rejected stage write, cancel the job just enqueued and rethrow. Apply the same treatment topersistComicPageSlotandrenderComicCoverLikeso the three render surfaces stay consistent.Acceptance
concurrent storyboard scene enqueuesinvisualStages.test.js).indexin range does not move a job id onto the wrong scene.queued/runningjob for that owner (assert vialistJobs({ owner })).Follow-up to #3400 / PR #3411.