@animastor/orchestration 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/package.json +51 -0
  4. package/src/host/host-bindings.js +138 -0
  5. package/src/index.js +99 -0
  6. package/src/orchestration/index.js +40 -0
  7. package/src/orchestration/orchestrator.js +630 -0
  8. package/src/orchestration/runtime-result-consumer.js +54 -0
  9. package/src/orchestration/scene-callbacks.js +542 -0
  10. package/src/orchestration/scene-orchestrator.js +601 -0
  11. package/src/orchestration/scene-restoration.js +129 -0
  12. package/src/orchestration/scene-utils.js +36 -0
  13. package/src/runtime/active-scenes-index.js +164 -0
  14. package/src/runtime/audio-fsm-port.js +187 -0
  15. package/src/runtime/circuit-breaker.js +588 -0
  16. package/src/runtime/counter-reconciliation.js +308 -0
  17. package/src/runtime/dispatch-engine.js +1768 -0
  18. package/src/runtime/failure-taxonomy.js +125 -0
  19. package/src/runtime/hub-cancel-port.js +122 -0
  20. package/src/runtime/layer-config-port.js +136 -0
  21. package/src/runtime/lease-manager.js +607 -0
  22. package/src/runtime/persistence-port.js +162 -0
  23. package/src/runtime/placeholder-audio-port.js +117 -0
  24. package/src/runtime/progress-events-port.js +121 -0
  25. package/src/runtime/reconciliation-engine.js +2405 -0
  26. package/src/runtime/retry-budget-manager.js +229 -0
  27. package/src/runtime/runtime-metrics.js +427 -0
  28. package/src/runtime/runtime-result-emitter.js +123 -0
  29. package/src/runtime/runtime-scheduler.js +629 -0
  30. package/src/runtime/scene-data-port.js +114 -0
  31. package/src/runtime/scene-window.js +827 -0
  32. package/src/runtime/video-fsm-port.js +189 -0
  33. package/src/runtime/worker-health.js +270 -0
  34. package/test/counter-reconciliation.test.js +164 -0
  35. package/test/mocks/redis-mock.js +221 -0
  36. package/test/test-bindings.js +60 -0
@@ -0,0 +1,601 @@
1
+ const { lazyHostBinding } = require('../host/host-bindings');
2
+ // §32.30: former host requires ('../state', '../audio', '../image', '../video')
3
+ // resolve lazily through the composition-root host-bindings seam — usage
4
+ // below stays verbatim.
5
+ const state = lazyHostBinding('state');
6
+ const media = lazyHostBinding('media');
7
+ const audio = lazyHostBinding('media.audio');
8
+ const image = lazyHostBinding('media.image');
9
+ const video = lazyHostBinding('media.video');
10
+ const segments = lazyHostBinding('media.audio.segments');
11
+ const chunks = lazyHostBinding('media.audio.chunks');
12
+ // S-3: Generation → GPU transport rides the provider seam only. The
13
+ // orchestrator never requires gpu-dispatcher or the workflow connector.
14
+ const provider = require('@animastor/generation').comfyuiProvider;
15
+ const runtimeScheduler = require('../runtime/runtime-scheduler');
16
+ // O-2: persistence arrives ONLY through the PersistencePort (host adapter:
17
+ // storage/runtime-persistence-adapter) — the scene-assets repository left
18
+ // this file. SQL lives host-side.
19
+ const persist = require('../runtime/persistence-port').persist;
20
+ // O-3: scene content arrives ONLY through the SceneDataPort (host adapter:
21
+ // storage/scene-data-adapter) — the Book Model facade (../book →
22
+ // @animastor/vbook-runtime) left this file. Aliased `sceneDataPort` because
23
+ // the per-stage legs bind their result to a local `sceneData` variable.
24
+ const sceneDataPort = require('../runtime/scene-data-port').sceneDataOp;
25
+ // O-7: the audio scene FSM is driven ONLY through the AudioFsmPort (host
26
+ // adapter: storage/audio-fsm-adapter) — the audio-orchestrator host
27
+ // service left this file. The video FSM remains on its host service until
28
+ // its own seam step.
29
+ const audioFsmOp = require('../runtime/audio-fsm-port').audioFsmOp;
30
+ const audioFsmPhases = require('../runtime/audio-fsm-port').audioFsmPhases;
31
+ // O-10: per-book layer config arrives ONLY through the LayerConfigPort
32
+ // (host adapter: storage/layer-config-adapter) — the layer-config host
33
+ // service (../services/layer-config) left this file.
34
+ const layerConfigOp = require('../runtime/layer-config-port').layerConfigOp;
35
+ const { log, warn, logEvent } = require('./scene-utils');
36
+ const { handleAudioCompleted, handleImageCompleted, handleVideoCompleted } = require('./scene-callbacks');
37
+ const { restoreSceneChunkStatus } = require('./scene-restoration');
38
+ // §32.30: setScenePending/setSceneGenerating are LAZY getters on the facade
39
+ // (stateOps host binding) — destructure at call time, not at load time.
40
+ const orchestratorModule = require('./orchestrator');
41
+ const completeStage = (...a) => orchestratorModule.completeStage(...a);
42
+ const failStage = (...a) => orchestratorModule.failStage(...a);
43
+ const setScenePending = (...a) => orchestratorModule.setScenePending(...a);
44
+ const setSceneGenerating = (...a) => orchestratorModule.setSceneGenerating(...a);
45
+ // Полный модуль нужен для fast-track video-orch (completeGroup принимает
46
+ // deps.orchestrator) — деструктуризация выше не покрывает этот кейс.
47
+ const orchestrator = require('./orchestrator');
48
+ // S-2: media registry for executor dispatch
49
+ const mediaRegistry = require('@animastor/generation').mediaRegistry;
50
+
51
+ // ======================================================
52
+ // SCENE ORCHESTRATOR
53
+ // ======================================================
54
+ // Pure dispatch execution: no linear state machine, no stage decisions.
55
+ // The dispatch-engine passes overrideStage — the orchestrator just executes.
56
+ // Per-asset states (AssetState) are the source of truth.
57
+
58
+ // S-2: Executor dispatch map — derived from registry + concrete executors.
59
+ // This replaces the hardcoded if/else chain in dispatchStage().
60
+ const EXECUTORS = {
61
+ audio: executeAudioDispatch,
62
+ image: executeImageDispatch,
63
+ video: executeVideoDispatch,
64
+ };
65
+
66
+ async function startScene(redis, scene, loadedBook, buildId) {
67
+ const bookId = scene.book_id;
68
+ const chapterId = scene.chapter_id;
69
+ const sceneId = scene.scene_id;
70
+
71
+ log(`START SCENE: ${bookId}/${chapterId}/${sceneId}`);
72
+
73
+ await logEvent(redis, scene, 'SCENE_STARTED', 'audio_pending', {
74
+ buildId, bookId, chapterId, sceneId
75
+ });
76
+
77
+ // T7: через фасад — единый владелец PENDING
78
+ await setScenePending(redis, bookId, chapterId, sceneId, 'audio', buildId);
79
+ await runtimeScheduler.addSceneToActiveIndex(redis, bookId, chapterId, sceneId);
80
+ log(`ADDED TO ACTIVE: ${bookId}/${chapterId}/${sceneId}`);
81
+
82
+ return { success: true };
83
+ }
84
+
85
+ // ── STAGE DISPATCH LIFECYCLE GUARD ───────────────────
86
+ // Fix: docs/runtime-audits/video-retry-targeted-investigation-2026-08-26.md
87
+ // Частичный SCENE_RESET может оставить ассет в NEW при готовых остальных
88
+ // (image=READY, video=NEW): startScene() пропускается (не все три NEW),
89
+ // после чего setSceneGenerating(new→generating) отклоняется state-машиной,
90
+ // rejection проглатывался — и job уходил в GPU Hub из невалидного состояния.
91
+ // Успешный результат затем отклонялся handleVideoCompleted
92
+ // ('invalid_asset_state'), превращался в FAILURE, сжигал retry budget и
93
+ // открывал circuit breaker.
94
+ //
95
+ // Контракт: перед отправкой любого GPU job ассет обязан пройти
96
+ // NEW → PENDING (здесь) → GENERATING (сразу ниже),
97
+ // либо уже находиться в состоянии, из которого GENERATING достижим.
98
+ // Если переход невозможен — dispatch ПРЕРЫВАЕТСЯ ДО отправки job.
99
+ async function ensureStageDispatchable(redis, bookId, chapterId, sceneId, stage) {
100
+ const states = await state.getAssetStates(redis, bookId, chapterId, sceneId);
101
+ if (states?.[stage] === state.AssetState.NEW) {
102
+ const init = await setScenePending(redis, bookId, chapterId, sceneId, stage);
103
+ if (!init.changed) {
104
+ return { ok: false, reason: `pending_init_failed:${init.reason}` };
105
+ }
106
+ log(`STAGE_INIT: ${bookId}/${chapterId}/${sceneId} ${stage}: NEW → PENDING`);
107
+ }
108
+ const gen = await setSceneGenerating(redis, bookId, chapterId, sceneId, stage);
109
+ if (!gen.changed) {
110
+ return { ok: false, reason: `generating_transition_failed:${gen.reason}` };
111
+ }
112
+ return { ok: true };
113
+ }
114
+
115
+ // T3: Каждый executor возвращает { dispatched: true/false, jobs, reason }
116
+ async function executeAudioDispatch(redis, scene, loadedBook, buildId, dispatchId) {
117
+ const bookId = scene.book_id;
118
+ const chapterId = scene.chapter_id;
119
+ const sceneId = scene.scene_id;
120
+ const dbgStart = Date.now();
121
+
122
+ log(`AUDIO_DISPATCH: ${bookId}/${chapterId}/${sceneId}`);
123
+
124
+ await logEvent(redis, scene, 'AUDIO_DISPATCHED', 'audio_generating', {
125
+ buildId, dispatchType: 'orchestrator'
126
+ });
127
+
128
+ // T7: через фасад — единый владелец GENERATING (audio)
129
+ // Lifecycle guard: abort BEFORE any GPU job if the transition chain fails.
130
+ const dispatchable = await ensureStageDispatchable(redis, bookId, chapterId, sceneId, 'audio');
131
+ if (!dispatchable.ok) {
132
+ warn(`AUDIO_DISPATCH: ${bookId}/${chapterId}/${sceneId}: state not dispatchable (${dispatchable.reason}) — aborting before any GPU job`);
133
+ return { dispatched: false, jobs: 0, reason: dispatchable.reason };
134
+ }
135
+
136
+ // Fallback to disk load when runtime doesn't pass loadedBook
137
+ const bookData = loadedBook || sceneDataPort('loadBook')(bookId);
138
+ const sceneData = sceneDataPort('findSceneRuntimeData')(bookData, chapterId, sceneId);
139
+
140
+ if (!sceneData) {
141
+ warn(`AUDIO_DISPATCH: sceneData not found for ${bookId}/${chapterId}/${sceneId}`);
142
+ // scene_not_found: remove from active index so scheduler stops retrying.
143
+ // Lease will expire on its own TTL — no need for active cleanup.
144
+ try {
145
+ await runtimeScheduler.removeSceneFromActiveIndex(redis, bookId, chapterId, sceneId);
146
+ } catch (cleanupErr) {
147
+ warn(`AUDIO_DISPATCH: cleanup error for ${bookId}/${chapterId}/${sceneId}: ${cleanupErr.message}`);
148
+ }
149
+ return { dispatched: false, jobs: 0, completed: true, reason: 'scene_not_found' };
150
+ }
151
+
152
+ // 🔧 AUDIO-ORCH: Transition PLACEHOLDER_READY → GENERATING
153
+ // O-7: через AudioFsmPort (host adapter: storage/audio-fsm-adapter).
154
+
155
+ // ── PHASE CHECK: DONE → skip, иначе продолжаем ──
156
+ // WAITING_CHUNKS/MERGING guard удалён (см. AUDIO_ORCH_ARCHITECTURAL_FIXES.md §3):
157
+ // dispatch-engine.acquireStageLease + A2 timeout formula гарантируют, что
158
+ // LEASE_TTL (≈31 мин при GPU_TIMEOUT=10мин) > STALL (30 мин) > GPU_TIMEOUT (10 мин).
159
+ // Если сцена в WAITING_CHUNKS, lease ещё активен → dispatch-engine не допустит
160
+ // re-dispatch. Если lease истёк → STALL уже перевёл сцену в FAILED.
161
+ const orchState = await audioFsmOp('getState')(redis, bookId, chapterId, sceneId);
162
+ if (orchState) {
163
+ if (orchState.phase === audioFsmPhases().DONE) {
164
+ log(`AUDIO_ALREADY_DONE: ${bookId}/${chapterId}/${sceneId} — skipping dispatch`);
165
+ return { dispatched: false, jobs: 0, completed: true, reason: 'already_done' };
166
+ }
167
+ }
168
+
169
+ let transResult = await audioFsmOp('setGenerating')(redis, bookId, chapterId, sceneId);
170
+
171
+ // 🔧 FIX: Если стейт не существует (no_state), инициализируем PLACEHOLDER_READY
172
+ // и повторяем переход.
173
+ if (!transResult.success && transResult.reason === 'no_state') {
174
+ log(` 🔧 AUDIO_ORCH: state missing for ${bookId}/${chapterId}/${sceneId} — initializing PLACEHOLDER_READY first`);
175
+ const segList = segments.buildSegments(sceneData);
176
+ await audioFsmOp('initPlaceholderReady')(redis, bookId, chapterId, sceneId, buildId, segList.length);
177
+ log(` 🔧 AUDIO_ORCH: initialized PLACEHOLDER_READY with ${segList.length} expected segments`);
178
+ transResult = await audioFsmOp('setGenerating')(redis, bookId, chapterId, sceneId);
179
+ }
180
+
181
+ // 🔧 FIX: Stale phase recovery — DONE и WAITING_CHUNKS/MERGING уже отсечены выше.
182
+ // FAILED → можно переинициализировать, GENERATING → race condition (быстрый completeChunk)
183
+ if (!transResult.success && transResult.reason === 'invalid_transition') {
184
+ const stalePhase = transResult.from;
185
+ // DONE — дополнительный safety net (выше уже должен был отсечься)
186
+ if (stalePhase === audioFsmPhases().DONE) {
187
+ warn(`AUDIO_ORCH: DONE guard prevented stale reset for ${bookId}/${chapterId}/${sceneId}`);
188
+ return { dispatched: false, jobs: 0, completed: true, reason: 'already_done' };
189
+ }
190
+ // WAITING_CHUNKS / MERGING (stale recovery): с A2 timeout formula lease
191
+ // переживает watchdog — если дошли до stale recovery, значит lease истёк
192
+ // и watchdog уже перевёл сцену в FAILED. WAITING_CHUNKS/MERGING в stale
193
+ // recovery больше не возникает (бывший phase guard удалён).
194
+ // Проверка оставлена как assertion-style safety net для логирования.
195
+ if (stalePhase === audioFsmPhases().WAITING_CHUNKS || stalePhase === audioFsmPhases().MERGING) {
196
+ log(` 🔧 AUDIO_ORCH: unexpected WAITING_CHUNKS/MERGING in stale recovery — lease outpaced watchdog (phase=${stalePhase})`);
197
+ // 🔧 FIX: Прежде чем reset'ить, проверяем — есть ли чанки на диске?
198
+ // Если все чанки на месте, зовём completeChunk для завершения merge.
199
+ const chunksUtil = chunks;
200
+ const presentChunks = chunksUtil.findExistingSceneChunks(bookId, chapterId, sceneId, buildId, null);
201
+ if (presentChunks.length > 0) {
202
+ log(` 🔧 AUDIO_ORCH: ${presentChunks.length} chunks on disk for ${bookId}/${chapterId}/${sceneId} — calling completeChunk instead of reset`);
203
+ try {
204
+ await audioFsmOp('completeChunk')(redis, bookId, chapterId, sceneId, 'recovery', buildId, {
205
+ audio,
206
+ orchestrator: require('./orchestrator'),
207
+ dispatchId: 'stale-recovery',
208
+ });
209
+ return { dispatched: false, jobs: 0, completed: true, reason: 'recovered_via_completeChunk' };
210
+ } catch (recoverErr) {
211
+ warn(` 🔧 AUDIO_ORCH: completeChunk recovery failed: ${recoverErr.message} — falling through to reset`);
212
+ }
213
+ }
214
+ }
215
+ log(` 🔧 AUDIO_ORCH: stale phase ${stalePhase} for ${bookId}/${chapterId}/${sceneId} — resetting to PLACEHOLDER_READY`);
216
+ await audioFsmOp('deleteState')(redis, bookId, chapterId, sceneId);
217
+ const segList = segments.buildSegments(sceneData);
218
+ await audioFsmOp('initPlaceholderReady')(redis, bookId, chapterId, sceneId, buildId, segList.length);
219
+ log(` 🔧 AUDIO_ORCH: reset stale phase, initialized PLACEHOLDER_READY with ${segList.length} expected segments`);
220
+ transResult = await audioFsmOp('setGenerating')(redis, bookId, chapterId, sceneId);
221
+ }
222
+
223
+ if (!transResult.success) {
224
+ warn(`AUDIO_ORCH: cannot set GENERATING for ${bookId}/${chapterId}/${sceneId}: ${transResult.reason}`);
225
+ // T3: transition rejected — считаем что dispatch не выполнен
226
+ return { dispatched: false, jobs: 0, reason: transResult.reason || 'phase_transition_rejected' };
227
+ }
228
+
229
+ // 🔧 FIX (race condition): WAITING_CHUNKS должна быть установлена ДО отправки
230
+ // jobs, иначе первый чанк может вернуться быстрее чем setWaitingChunks выполнится,
231
+ // и completeChunk увидит фазу GENERATING → early return → чанк на диске есть,
232
+ // но merge не запускается → retry exhausting → failStage → re-dispatch → цикл.
233
+ await audioFsmOp('setWaitingChunks')(redis, bookId, chapterId, sceneId);
234
+
235
+ const result = await audio.generateSceneAudio(redis, sceneData, bookData, buildId, bookId, dispatchId);
236
+
237
+ log(`AUDIO_DISPATCH generateSceneAudio: ${bookId}/${chapterId}/${sceneId} generated=${result?.generated} chunks=${result?.chunks}`);
238
+
239
+ if (result && result.generated) {
240
+ log(`AUDIO_DISPATCHED: ${bookId}/${chapterId}/${sceneId} (${result.chunks || 0} chunks sent)`);
241
+ return { dispatched: true, jobs: result.chunks || 1, reason: null };
242
+ } else if (result && result.reason === 'already_ready') {
243
+ // Audio already on disk — fast-track WAITING_CHUNKS→MERGING→DONE
244
+ await audioFsmOp('setMerging')(redis, bookId, chapterId, sceneId);
245
+ await audioFsmOp('setDone')(redis, bookId, chapterId, sceneId);
246
+ log(`AUDIO_DISPATCH: ${bookId}/${chapterId}/${sceneId} — already ready`);
247
+ const completion = await completeStage(redis, bookId, chapterId, sceneId, 'audio', buildId, dispatchId);
248
+ return { dispatched: false, jobs: 0, completed: completion.completed, reason: 'already_ready' };
249
+ } else if (result && result.expectedChunkCount === 0) {
250
+ // 0 segments = nothing to generate (scene has no TTS content).
251
+ // Fast-track WAITING_CHUNKS→MERGING→DONE to break the infinite
252
+ // dispatch→pending→re-dispatch loop that occurs when buildSegments
253
+ // returns 0 (invalid units, missing audio data, etc).
254
+ log(`AUDIO_DISPATCH: ${bookId}/${chapterId}/${sceneId} — 0 segments, nothing to generate, marking DONE`);
255
+ await audioFsmOp('setMerging')(redis, bookId, chapterId, sceneId);
256
+ await audioFsmOp('setDone')(redis, bookId, chapterId, sceneId);
257
+ const completion = await completeStage(redis, bookId, chapterId, sceneId, 'audio', buildId, dispatchId);
258
+ return { dispatched: false, jobs: 0, completed: completion.completed, reason: 'no_segments' };
259
+ } else {
260
+ warn(`AUDIO_DISPATCH: generateSceneAudio did not send jobs for ${bookId}/${chapterId}/${sceneId}`);
261
+ return { dispatched: false, jobs: 0, reason: 'no_jobs_generated' };
262
+ }
263
+ }
264
+
265
+ async function executeImageDispatch(redis, scene, loadedBook, buildId, dispatchId) {
266
+ const bookId = scene.book_id;
267
+ const chapterId = scene.chapter_id;
268
+ const sceneId = scene.scene_id;
269
+
270
+ log(`IMAGE_DISPATCH: ${bookId}/${chapterId}/${sceneId}`);
271
+
272
+ await logEvent(redis, scene, 'IMAGE_DISPATCHED', 'image_generating', {
273
+ buildId, dispatchType: 'orchestrator'
274
+ });
275
+
276
+ // T8: через фасад — единый владелец GENERATING
277
+ // Lifecycle guard: abort BEFORE any GPU job if the transition chain fails.
278
+ const dispatchable = await ensureStageDispatchable(redis, bookId, chapterId, sceneId, 'image');
279
+ if (!dispatchable.ok) {
280
+ warn(`IMAGE_DISPATCH: ${bookId}/${chapterId}/${sceneId}: state not dispatchable (${dispatchable.reason}) — aborting before any GPU job`);
281
+ return { dispatched: false, jobs: 0, reason: dispatchable.reason };
282
+ }
283
+
284
+ const bookData = loadedBook || sceneDataPort('loadBook')(bookId);
285
+ const sceneData = sceneDataPort('findSceneRuntimeData')(bookData, chapterId, sceneId);
286
+
287
+ if (!sceneData) {
288
+ warn(`IMAGE_DISPATCH: sceneData not found for ${bookId}/${chapterId}/${sceneId}`);
289
+ // scene_not_found: remove from active index so scheduler stops retrying.
290
+ try {
291
+ await runtimeScheduler.removeSceneFromActiveIndex(redis, bookId, chapterId, sceneId);
292
+ } catch (cleanupErr) {
293
+ warn(`IMAGE_DISPATCH: cleanup error for ${bookId}/${chapterId}/${sceneId}: ${cleanupErr.message}`);
294
+ }
295
+ return { dispatched: false, jobs: 0, completed: true, reason: 'scene_not_found' };
296
+ }
297
+
298
+ // Read dirty unit IDs from PG (O-2: via PersistencePort)
299
+ let dirtyUnitIds = new Set();
300
+ try {
301
+ const ids = await persist('sceneAssets.getDirtyUnitIds')(bookId, chapterId, sceneId);
302
+ if (ids && ids.length > 0) {
303
+ dirtyUnitIds = new Set(ids);
304
+ log(`[DIRTY-UNITS] ${bookId}/${chapterId}/${sceneId}: ${ids.length} dirty unit(s) from PG`);
305
+ }
306
+ } catch (err) {
307
+ warn(`[DIRTY-UNITS] PG read failed for ${bookId}/${chapterId}/${sceneId}: ${err.message}`);
308
+ }
309
+
310
+ // T3: generateSceneIUImages возвращает количество отправленных jobs
311
+ let jobsSent = 0;
312
+ try {
313
+ const genResult = await image.generateSceneIUImages(
314
+ redis,
315
+ sceneData,
316
+ bookData,
317
+ buildId,
318
+ bookId,
319
+ dirtyUnitIds,
320
+ dispatchId
321
+ );
322
+ jobsSent = (genResult && genResult.sentCount) || 0;
323
+ if (jobsSent === 0 && genResult.total > 0 && genResult.cachedCount === genResult.total) {
324
+ const completion = await completeStage(redis, bookId, chapterId, sceneId, 'image', buildId, dispatchId);
325
+ return { dispatched: false, jobs: 0, completed: completion.completed, reason: 'cache_hit' };
326
+ }
327
+ } catch (genErr) {
328
+ warn(`IMAGE_DISPATCH: generateSceneIUImages error for ${bookId}/${chapterId}/${sceneId}: ${genErr.message}`);
329
+ return { dispatched: false, jobs: 0, reason: 'generation_error' };
330
+ }
331
+
332
+ if (jobsSent > 0) {
333
+ log(`IMAGE_DISPATCHED: ${bookId}/${chapterId}/${sceneId} (${jobsSent} IU job(s) sent)`);
334
+ return { dispatched: true, jobs: jobsSent, reason: null };
335
+ }
336
+
337
+ log(`IMAGE_DISPATCH: ${bookId}/${chapterId}/${sceneId} — no jobs sent (cache hit or empty units)`);
338
+ return { dispatched: false, jobs: 0, reason: 'no_jobs_sent' };
339
+ }
340
+
341
+ async function executeVideoDispatch(redis, scene, loadedBook, buildId, dispatchId) {
342
+ const bookId = scene.book_id;
343
+ const chapterId = scene.chapter_id;
344
+ const sceneId = scene.scene_id;
345
+
346
+ log(`VIDEO_DISPATCH: ${bookId}/${chapterId}/${sceneId}`);
347
+
348
+ await logEvent(redis, scene, 'VIDEO_DISPATCHED', 'video_generating', {
349
+ buildId, dispatchType: 'orchestrator'
350
+ });
351
+
352
+ // T8: через фасад — единый владелец GENERATING
353
+ // Lifecycle guard: abort BEFORE any GPU job if the transition chain fails
354
+ // (root cause of the 2026-08-26 video re-dispatch incident).
355
+ const dispatchable = await ensureStageDispatchable(redis, bookId, chapterId, sceneId, 'video');
356
+ if (!dispatchable.ok) {
357
+ warn(`VIDEO_DISPATCH: ${bookId}/${chapterId}/${sceneId}: state not dispatchable (${dispatchable.reason}) — aborting before any GPU job`);
358
+ return { dispatched: false, jobs: 0, reason: dispatchable.reason };
359
+ }
360
+
361
+ const bookData = loadedBook || sceneDataPort('loadBook')(bookId);
362
+ const sceneData = sceneDataPort('findSceneRuntimeData')(bookData, chapterId, sceneId);
363
+
364
+ if (!sceneData) {
365
+ warn(`VIDEO_DISPATCH: sceneData not found for ${bookId}/${chapterId}/${sceneId}`);
366
+ // scene_not_found: remove from active index so scheduler stops retrying.
367
+ try {
368
+ await runtimeScheduler.removeSceneFromActiveIndex(redis, bookId, chapterId, sceneId);
369
+ } catch (cleanupErr) {
370
+ warn(`VIDEO_DISPATCH: cleanup error for ${bookId}/${chapterId}/${sceneId}: ${cleanupErr.message}`);
371
+ }
372
+ return { dispatched: false, jobs: 0, completed: true, reason: 'scene_not_found' };
373
+ }
374
+
375
+ // Read per-type timeout from layer-config
376
+ // O-10: through the LayerConfigPort (host adapter:
377
+ // storage/layer-config-adapter). The leg's pre-O-10 try/catch
378
+ // tolerance is preserved exactly — the port call replaces the
379
+ // direct service call inside the same `try { … } catch (_) {}`.
380
+ let videoTimeoutMs;
381
+ try {
382
+ const cfg = await layerConfigOp('get')(redis, bookId);
383
+ if (cfg && cfg.video_timeout_minutes > 0) {
384
+ videoTimeoutMs = cfg.video_timeout_minutes * 60 * 1000;
385
+ log(`VIDEO_DISPATCH: ${bookId}/${chapterId}/${sceneId}: using timeout=${cfg.video_timeout_minutes} min from layer-config`);
386
+ }
387
+ } catch (_) {}
388
+
389
+ const videoResult = await video.generateVideoAnimation(sceneData, bookData, buildId, provider.listWorkflows(), dispatchId);
390
+
391
+ if (!videoResult.success) {
392
+ warn(`VIDEO_GENERATION: ${bookId}/${chapterId}/${sceneId} failed: ${videoResult.reason || 'unknown'}`);
393
+ return { dispatched: false, jobs: 0, reason: videoResult.reason || 'generation_failed' };
394
+ }
395
+
396
+ const jobSpecs = videoResult.jobSpecs || [];
397
+ if (jobSpecs.length === 0) {
398
+ warn(`VIDEO_GENERATION: ${bookId}/${chapterId}/${sceneId}: no jobSpecs returned`);
399
+ return { dispatched: false, jobs: 0, reason: 'no_job_specs' };
400
+ }
401
+
402
+ // ── VIDEO-ORCH: init state machine с группами (suffix + unit_ids) ──
403
+ // Группа → { suffix: '_gN', unit_ids: [...] }. Маппинг юнитов на группу
404
+ // хранится в state для точечной dirty-регенерации: при повторном
405
+ // dispatch перегенерируются только группы, содержащие dirty-юниты.
406
+ // O-8: через VideoFsmPort (host adapter: storage/video-fsm-adapter).
407
+ const videoOrch = require('../runtime/video-fsm-port').videoFsm();
408
+ const jobSchema = require('@animastor/contracts').jobProtocolV2;
409
+ const groups = jobSpecs.map(js => {
410
+ const parsed = jobSchema.parseJobId(js.job_id);
411
+ return {
412
+ suffix: parsed?.groupSuffix || '',
413
+ unit_ids: Array.isArray(js.unit_ids) ? js.unit_ids : [],
414
+ };
415
+ });
416
+ log(`VIDEO_DISPATCH: ${bookId}/${chapterId}/${sceneId} groups=[${groups.map(g => `'${g.suffix || '(base)'}'(${g.unit_ids.length}u)`).join(', ')}]`);
417
+
418
+ // ── OLD-STATE SNAPSHOT ДО initState ──
419
+ // initState перезаписывает state, поэтому состав групп прошлого диспатча
420
+ // нужно запомнить ДО него. Используется для валидации cache-hit: группа
421
+ // считается готовой только если её unit_ids ТОЧНО совпадают с прошлой
422
+ // группой того же суффикса (границы групп могут сдвинуться при изменении
423
+ // длительностей юнитов — тогда старый файл устарел, даже если нет dirty).
424
+ const prevState = await videoOrch.getState(redis, bookId, chapterId, sceneId);
425
+ const prevGroupsBySuffix = new Map();
426
+ if (prevState && Array.isArray(prevState.groups)) {
427
+ for (const g of prevState.groups) {
428
+ prevGroupsBySuffix.set(g.suffix || '', g);
429
+ }
430
+ }
431
+ await videoOrch.initState(redis, bookId, chapterId, sceneId, buildId, groups);
432
+
433
+ // ── DIRTY-UNITS: читаем из PG (как в executeImageDispatch) — O-2: via PersistencePort ──
434
+ let dirtyUnitIds = new Set();
435
+ try {
436
+ const ids = await persist('sceneAssets.getDirtyUnitIds')(bookId, chapterId, sceneId);
437
+ if (ids && ids.length > 0) {
438
+ dirtyUnitIds = new Set(ids);
439
+ log(`[DIRTY-UNITS] video ${bookId}/${chapterId}/${sceneId}: ${ids.length} dirty unit(s) from PG`);
440
+ }
441
+ } catch (err) {
442
+ warn(`[DIRTY-UNITS] video PG read failed for ${bookId}/${chapterId}/${sceneId}: ${err.message}`);
443
+ }
444
+
445
+ // ── ЧАСТИЧНЫЙ RE-DISPATCH: пропускаем группы, чьи файлы уже готовы ──
446
+ // Группа считается готовой (cache-hit), если:
447
+ // 1. её файл валиден на диске, И
448
+ // 2. ни один unit группы не dirty (dirty → перегенерация группы).
449
+ // Пропущенные группы сразу помечаем done в video-orch state.
450
+ const jobsToSend = [];
451
+ let cachedGroups = 0;
452
+ for (let i = 0; i < jobSpecs.length; i++) {
453
+ const js = jobSpecs[i];
454
+ const grp = groups[i];
455
+ const hasDirty = grp.unit_ids.some(u => dirtyUnitIds.has(u));
456
+ const fileOk = videoOrch.isGroupFileValid(buildId, bookId, chapterId, sceneId, grp.suffix);
457
+ // Cache-hit только при ТОЧНОМ совпадении unit_ids с прошлой группой того
458
+ // же суффикса: если границы групп сдвинулись (длительности изменились),
459
+ // старый файл содержит другие юниты → перегенерируем, даже без dirty.
460
+ const prev = prevGroupsBySuffix.get(grp.suffix || '');
461
+ const sameComposition = !!(prev && Array.isArray(prev.unit_ids)
462
+ && prev.unit_ids.length === grp.unit_ids.length
463
+ && grp.unit_ids.every(u => prev.unit_ids.includes(u)));
464
+ if (!hasDirty && fileOk && sameComposition) {
465
+ await videoOrch.markGroupDone(redis, bookId, chapterId, sceneId, grp.suffix);
466
+ cachedGroups++;
467
+ log(`VIDEO_DISPATCH: ${bookId}/${chapterId}/${sceneId} group '${grp.suffix || '(base)'}' cached on disk — skipped`);
468
+ } else {
469
+ // Группа перегенерируется: старый файл удаляем, чтобы при
470
+ // сбое re-dispatch не остался stale-чанк, посчитанный валидным.
471
+ if (fileOk) {
472
+ try {
473
+ const stalePath = videoOrch.groupFilePath(buildId, bookId, chapterId, sceneId, grp.suffix);
474
+ require('fs').unlinkSync(stalePath);
475
+ log(`VIDEO_DISPATCH: removed stale group file ${grp.suffix || '(base)'} for re-generation`);
476
+ } catch (_) {}
477
+ }
478
+ jobsToSend.push(js);
479
+ }
480
+ }
481
+
482
+ // ── ВСЕ ГРУППЫ УЖЕ ГОТОВЫ (полный cache-hit) → fast-track ──
483
+ if (jobsToSend.length === 0 && cachedGroups > 0) {
484
+ log(`VIDEO_DISPATCH: ${bookId}/${chapterId}/${sceneId} — all ${cachedGroups} group(s) cached, fast-track merge`);
485
+ const result = await videoOrch.completeGroup(redis, bookId, chapterId, sceneId, '', buildId, {
486
+ orchestrator,
487
+ dispatchId,
488
+ });
489
+ if (result && result.completed) {
490
+ return { dispatched: false, jobs: 0, completed: true, reason: 'cache_hit' };
491
+ }
492
+ // Провал fast-track склейки (в т.ч. merge in progress): job'ы не
493
+ // отправлялись и отправлять нечего. Не возвращаем send_failed —
494
+ // scheduler передиспатчит сцену, а merge-ретрай сделает completeGroup.
495
+ warn(`VIDEO_DISPATCH: fast-track merge did not complete for ${bookId}/${chapterId}/${sceneId} (${result?.reason || 'unknown'}) — returning merge_retry`);
496
+ return { dispatched: false, jobs: 0, reason: result?.reason === 'merging' ? 'merge_in_progress' : 'merge_retry' };
497
+ }
498
+
499
+ // Add per-type timeout to each job spec
500
+ for (const jobSpec of jobsToSend) {
501
+ if (videoTimeoutMs) {
502
+ jobSpec.timeout_ms = videoTimeoutMs;
503
+ }
504
+ }
505
+
506
+ // ── WAITING_CHUNKS ставится ДО отправки job'ов (race condition,
507
+ // аналогично аудио): первый результат не должен попасть в GENERATING.
508
+ await videoOrch.setWaitingChunks(redis, bookId, chapterId, sceneId);
509
+
510
+ let sentCount = 0;
511
+ for (const jobSpec of jobsToSend) {
512
+ try {
513
+ const sendResult = await provider.generate(jobSpec);
514
+ if (sendResult.sent) {
515
+ sentCount++;
516
+ } else {
517
+ warn(`VIDEO_DISPATCH: Hub rejected job ${jobSpec.job_id}: ${sendResult.error || 'unknown'}`);
518
+ }
519
+ } catch (sendErr) {
520
+ warn(`VIDEO_DISPATCH: provider generate failed for job ${jobSpec.job_id}: ${sendErr.message}`);
521
+ }
522
+ }
523
+
524
+ if (sentCount > 0) {
525
+ log(`VIDEO_DISPATCHED: ${bookId}/${chapterId}/${sceneId} (${sentCount}/${jobsToSend.length} job(s) sent, ${cachedGroups} cached)`);
526
+ return { dispatched: true, jobs: sentCount, reason: null };
527
+ }
528
+
529
+ warn(`VIDEO_DISPATCH: ${bookId}/${chapterId}/${sceneId}: all provider.generate calls failed`);
530
+ return { dispatched: false, jobs: 0, reason: 'send_failed' };
531
+ }
532
+
533
+ // T4: dispatchStage принимает dispatchId (из dispatch-engine) и передаёт executors
534
+ async function dispatchStage(redis, scene, loadedBook, buildId, overrideStage, dispatchId) {
535
+ const bookId = scene.book_id;
536
+ const chapterId = scene.chapter_id;
537
+ const sceneId = scene.scene_id;
538
+
539
+ log(`DISPATCH: ${bookId}/${chapterId}/${sceneId} (stage=${overrideStage}, dispatch=${dispatchId ? dispatchId.slice(0, 20) : 'none'}...)`);
540
+
541
+ const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
542
+ const isNewScene = !assetStates || (
543
+ assetStates.audio === state.AssetState.NEW &&
544
+ assetStates.image === state.AssetState.NEW &&
545
+ assetStates.video === state.AssetState.NEW
546
+ );
547
+ if (isNewScene) {
548
+ log(`NEW SCENE: ${bookId}/${chapterId}/${sceneId} (asset-state)`);
549
+ await startScene(redis, scene, loadedBook, buildId);
550
+ }
551
+
552
+ if (!overrideStage) {
553
+ log(`DECISION: ${bookId}/${chapterId}/${sceneId} -> skip (no override stage)`);
554
+ return { dispatched: false, reason: 'no_override' };
555
+ }
556
+
557
+ // S-2: dispatch through registry-driven executor map
558
+ const executor = EXECUTORS[overrideStage];
559
+ if (!executor) {
560
+ if (!mediaRegistry.hasMediaType(overrideStage)) {
561
+ warn(`DISPATCH: Unknown stage ${overrideStage} for ${bookId}/${chapterId}/${sceneId}`);
562
+ } else {
563
+ warn(`DISPATCH: No executor registered for stage ${overrideStage} for ${bookId}/${chapterId}/${sceneId}`);
564
+ }
565
+ return { dispatched: false, reason: 'unknown_stage' };
566
+ }
567
+ result = await executor(redis, scene, loadedBook, buildId, dispatchId);
568
+
569
+ result = result || { dispatched: false, reason: 'no_result', jobs: 0 };
570
+ if (!result.dispatched && !result.completed) {
571
+ // FSM-валидный rollback (GENERATING → DIRTY → PENDING). Прямой
572
+ // generating→pending запрещён FSM; раньше здесь вызывался setScenePending,
573
+ // rejection молча проглатывался — и сцена навсегда оставалась в GENERATING
574
+ // (ghost, forensic audit 6929ba5: no_jobs_sent → cancelled → вечный skip).
575
+ const rollback = await orchestrator.rollbackStageToPending(
576
+ redis, bookId, chapterId, sceneId, overrideStage, buildId,
577
+ result.reason || 'dispatch_cancelled'
578
+ );
579
+ if (!rollback.changed && !rollback.alreadyPending && rollback.reason !== 'already_ready') {
580
+ // Ошибка rollback не глотается: явный лог + флаг в результате
581
+ // (метрику/journal пишет сам rollbackStageToPending).
582
+ warn(`DISPATCH_ROLLBACK_FAILED: ${bookId}/${chapterId}/${sceneId} ${overrideStage}: ${rollback.reason} — stage may be stuck in GENERATING`);
583
+ result.rollbackFailed = true;
584
+ result.rollbackReason = rollback.reason;
585
+ }
586
+ }
587
+ result.dispatchId = dispatchId; // T4: привязываем dispatchId к результату
588
+ log(`DISPATCH_RESULT: ${bookId}/${chapterId}/${sceneId} ${overrideStage}: dispatched=${result.dispatched}, jobs=${result.jobs}, reason=${result.reason || 'ok'}`);
589
+ return result;
590
+ }
591
+
592
+ module.exports = {
593
+ ensureStageDispatchable,
594
+ dispatchStage,
595
+ restoreSceneChunkStatus,
596
+ handleAudioCompleted,
597
+ handleImageCompleted,
598
+ handleVideoCompleted,
599
+ completeStage,
600
+ failStage,
601
+ };