@animastor/orchestration 0.3.1 → 0.3.4

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,86 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.4 (2026-11-04) — 0.3.3 + «ready» аудио только при коммите GPU
4
+
5
+ Одна публикация вместо двух: 0.3.3 (уборка хаба при `/regenerate`, см. ниже) **не
6
+ публиковался** в npm отдельно и входит в 0.3.4 целиком. Основание:
7
+ `reports/2026-11-04/audio-placeholder-ready.md` (сцена `sc-4bb85e02` — тишина-заглушка
8
+ стала `ready` без GPU), интеграция: `reports/2026-11-04/integration-A-B-C.md`.
9
+ Описание инварианта: animastor-docs `02-orchestration/AUDIO_READY_SEMANTICS.md`.
10
+
11
+ ### Changed (поведение)
12
+
13
+ - `scene-orchestrator.executeAudioDispatch`: `AUDIO_ALREADY_DONE` пропускает работу только
14
+ если хост подтверждает коммит (`audio.isSceneAudioCommitted(...).ready === true`:
15
+ канонический файл без маркера заглушки **и** чанки сцены на диске). Иначе — лог
16
+ `AUDIO_ALREADY_DONE rejected … DONE without a committed audio chunk`, FSM-состояние
17
+ удаляется, стадия идёт обычным путём (dispatch на GPU). То же — в safety-net ветке
18
+ «stale `DONE`». Пробник недоступен или упал → считается «не закоммичено» (fail-closed:
19
+ лишняя генерация безопасна, пропуск — нет).
20
+ - `scene-callbacks.handleAudioCompleted`: проверка после завершения — `isSceneAudioCommitted`
21
+ (legacy `isSceneAudioReady` только если хост не дал нового пробника); в
22
+ `replacePlaceholderWithRealAudio` передаётся `{ committed: true }` — хост без этого
23
+ доказательства не поднимает запись в `ready`.
24
+
25
+ ### Host contract (новое, обратно совместимо)
26
+
27
+ - `media.audio.isSceneAudioCommitted(buildId, bookId, chapterId, sceneId, expectedCount|null)`
28
+ → `{ ready, reason, chunks, path }`. Backend ≥ интеграции A+B его даёт; на старом хосте
29
+ пакет откатывается к file-only проверке.
30
+ - `placeholderAudio.replacePlaceholderWithRealAudio(…, realDuration, options)` — 7-й аргумент
31
+ `options.committed`.
32
+
33
+ Инварианты 0.3.3 (sweep только своих dispatch-id, fail-closed book-wide sweep) не менялись;
34
+ fence, command-gate и retry-политика — тоже.
35
+
36
+ ## 0.3.3 (2026-11-04, не публиковался — вошёл в 0.3.4) — /regenerate не сносит чужие задачи на хабе
37
+
38
+ Основание: `reports/2026-11-04/gpu-generation-stall.md` §5.3, §9.1 — `/regenerate`
39
+ одной сцены (audio) закончился `clearHubDispatches([], { book_id })` →
40
+ `DELETE /queue/clear?book_id=`; хаб удалил running-запись image-задачи другой
41
+ сцены (результат отклонён 409 и потерян) и queued-задачу (восстановлена
42
+ stale-lease recovery через 7 мин). Отчёт исправления:
43
+ `reports/2026-11-04/regenerate-sweep-fix.md`. Описание: animastor-docs
44
+ `architecture/COMMAND_ONLY_GENERATION.md` §«Уборка хаба при /regenerate».
45
+
46
+ ### Changed (поведение)
47
+
48
+ - `dispatch-engine.clearHubDispatches`: book-scoped форма (пустой список id +
49
+ `book_id`) — **fail-closed**: запрос `?book_id=` уходит только при явном
50
+ `allowBookSweep: true`; иначе ни одного запроса, warn
51
+ `book-wide GPU hub sweep … REFUSED`, результат `{requested:0, cleared:0,
52
+ failed:0, refused:1}`. Per-id форма не менялась.
53
+ - `orchestrator.resetScenes` возвращает `dispatchIds` — dispatch-id,
54
+ отменённые этим reset'ом (только сбрасываемые сцена×стадия); вызывающий
55
+ повторно убирает на хабе только их.
56
+
57
+ Fence, command-gate и retry-политика не меняются.
58
+
59
+ ## 0.3.2 (2026-11-02) — эпоха dispatch в audio-orch, без ложного FAILED
60
+
61
+ Основание: `reports/2026-11-02/gpu-e2e-installer-025-logs.md` §6.1, §8 п.3, п.5 —
62
+ состояние audio-orch отменённой попытки (11:09) пережило отмену, новый
63
+ dispatch (12:25) унаследовал его `started_at`, и watchdog через 28 с после
64
+ dispatch'а пометил здоровый прогон FAILED (`chunk_stall_timeout:4619s`), затем
65
+ `[FAIL-STAGE] Rejected … missing_dispatch_id` и `[INVARIANT] 1 audio-orch violations`.
66
+ Описание: animastor-docs `architecture/COMMAND_ONLY_GENERATION.md` §«Эпоха dispatch».
67
+
68
+ ### Changed (поведение)
69
+
70
+ - `scene-orchestrator.executeAudioDispatch` открывает новую эпоху на каждом
71
+ dispatch: `setWaitingChunks(…, { dispatchId, buildId, expectedCount })`.
72
+ Хост пишет `started_at=now`, `dispatch_id`, `expected_count`,
73
+ `chunks_received=0` и убирает `fail_reason`/`failed_at`/`last_chunk_at`
74
+ прошлой попытки. Хост без поддержки 5-го аргумента ведёт себя как раньше.
75
+ - `reconciliation-engine.checkStalledAudioScenes`: запись проваливается, только
76
+ если её `dispatch_id` равен живому `dispatch-meta` сцены. Запись без
77
+ `dispatch_id` или другого dispatch — `stale_state_ignored` (одна info-строка
78
+ на запись, не FAILED). `failWaitingScene` получает `dispatchId` записи.
79
+ - `orchestrator.failStage` без `dispatchId` → `{failed:false,
80
+ reason:'stale_state_ignored'}` (info), а не `Rejected … missing_dispatch_id`.
81
+
82
+ Fence, command-gate (Stage 155) и retry-политика не меняются.
83
+
3
84
  ## 0.3.1 (2026-11-01) — результат GPU обрабатывается один раз
4
85
 
5
86
  Основание: `reports/2026-11-01/gpu-audio-image-run-analysis.md`, P1 — каждый
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animastor/orchestration",
3
- "version": "0.3.1",
3
+ "version": "0.3.4",
4
4
  "description": "Animastor Orchestration — the generation orchestration/runtime contour of the Animastor backend, physically extracted from the host (§32.30): the unified generation lifecycle owner (orchestrator facade, stage executor, scene callbacks/restoration), the dispatch engine and its lease/quota/retry managers, the reconciliation engine and counter reconciliation, the scene window/scheduler/worker-health, the runtime-result emitter/consumer, and the eight O-2..O-10 port contracts (Persistence, SceneData, PlaceholderAudio, ProgressEvents, AudioFsm, VideoFsm, HubCancel, LayerConfig). The package reaches the host (runtime config slices, Redis-domain state layer, media facades, the S-5 seam registry, the artifact root, the PW-2 workspace resolver) ONLY through the composition-root host-bindings seam — no backend/src require, no storage/book/routes/services, no Redis, no PG, no Express inside.",
5
5
  "keywords": [
6
6
  "animastor",
@@ -288,6 +288,14 @@ async function failStage(redis, bookId, chapterId, sceneId, stage, buildId, reas
288
288
  throw new Error(`orchestrator.failStage: unknown stage '${stage}'`);
289
289
  }
290
290
 
291
+ // GPU E2E 2026-11-02 (§6.1): a fail signal without dispatch_id comes
292
+ // from a legacy / stale record (no epoch) — it can never fail the live
293
+ // dispatch. A regular no-op, not a reject.
294
+ if (!dispatchId || typeof dispatchId !== 'string') {
295
+ log(`[FAIL-STAGE] stale_state_ignored ${bookId}/${chapterId}/${sceneId} ${stage}: no dispatch_id (reason=${reason})`);
296
+ return { failed: false, reason: 'stale_state_ignored' };
297
+ }
298
+
291
299
  const identity = await dispatchEngine.verifyDispatchIdentity(
292
300
  redis,
293
301
  bookId,
@@ -491,7 +499,7 @@ async function resetScenes(redis, bookId, buildId, scenes, layerCfg, options = {
491
499
 
492
500
  if (!scenes || scenes.length === 0) {
493
501
  log('[RESET-SCENES] No scenes to reset');
494
- return { marked: 0, reset_scenes: 0 };
502
+ return { marked: 0, reset_scenes: 0, dispatchIds: [] };
495
503
  }
496
504
 
497
505
  const scopeDisplay = `${scope}${chapterId ? '/' + chapterId : ''}${sceneId ? '/' + sceneId : ''}`;
@@ -627,7 +635,10 @@ async function resetScenes(redis, bookId, buildId, scenes, layerCfg, options = {
627
635
  { scenes_count: scenes.length, marked: marked.marked }
628
636
  ).catch(() => {});
629
637
 
630
- return { ...marked, reset_scenes: scenes.length };
638
+ // 0.3.3 (reports/2026-11-04/regenerate-sweep-fix.md): the dispatch ids
639
+ // cancelled by THIS reset — the only hub entries a caller may sweep again
640
+ // (a book-wide sweep would remove other scenes' in-flight jobs).
641
+ return { ...marked, reset_scenes: scenes.length, dispatchIds: [...new Set(cancellation.dispatchIds || [])] };
631
642
  }
632
643
 
633
644
  // ── S-5: pure FSM-writer ownership moved to the state layer ──
@@ -109,9 +109,18 @@ async function handleAudioCompleted(redis, bookId, chapterId, sceneId, buildId)
109
109
  return { ok: false, retryable: false, reason: 'invalid_asset_state' };
110
110
  }
111
111
 
112
- const isReady = await audio.isSceneAudioReady(buildId, bookId, chapterId, sceneId, require('music-metadata'));
112
+ // Ready means "a fenced GPU commit of this generation produced the
113
+ // chunks" — NOT "a file exists at the canonical path". A silent
114
+ // placeholder is a valid 10.85 s MP3 and passed the old file-only check,
115
+ // which is how sc-4bb85e02 reached READY with silence on 2026-11-04.
116
+ // The legacy probe remains only for hosts that have not wired the
117
+ // committed-audio probe yet.
118
+ const committedProbe = typeof audio.isSceneAudioCommitted === 'function'
119
+ ? await audio.isSceneAudioCommitted(buildId, bookId, chapterId, sceneId, null)
120
+ : { ready: await audio.isSceneAudioReady(buildId, bookId, chapterId, sceneId, require('music-metadata')), reason: 'legacy_file_check', chunks: [] };
121
+ const isReady = !!(committedProbe && committedProbe.ready === true);
113
122
  if (!isReady) {
114
- error(`Audio not ready after completion: ${bookId}/${chapterId}/${sceneId}`);
123
+ error(`Audio not ready after completion: ${bookId}/${chapterId}/${sceneId} (reason=${committedProbe && committedProbe.reason}, chunks=[${(committedProbe && committedProbe.chunks || []).join(',')}])`);
115
124
  const scene = { book_id: bookId, chapter_id: chapterId, scene_id: sceneId };
116
125
  await logEvent(redis, scene, 'AUDIO_FAILED', 'audio_ready', {
117
126
  reason: 'not_ready_validation'
@@ -149,8 +158,11 @@ async function handleAudioCompleted(redis, bookId, chapterId, sceneId, buildId)
149
158
  const metadata = await mm.parseFile(audioPath);
150
159
  realDuration = metadata.format.duration || 0;
151
160
  } catch {}
161
+ // The commit proof travels down: the placeholder service refuses to
162
+ // upgrade a record to 'ready' without it (2026-11-04 regression).
152
163
  await placeholderAudioOp('replacePlaceholderWithRealAudio')(
153
- bookId, chapterId, sceneId, buildId, audioPath, realDuration
164
+ bookId, chapterId, sceneId, buildId, audioPath, realDuration,
165
+ { committed: isReady }
154
166
  );
155
167
  } catch (err) {
156
168
  warn(`Failed to replace placeholder audio: ${err.message}`);
@@ -53,6 +53,28 @@ const orchestrator = require('./orchestrator');
53
53
  // S-2: media registry for executor dispatch
54
54
  const mediaRegistry = require('@animastor/generation').mediaRegistry;
55
55
 
56
+ /**
57
+ * Fenced-commit probe for the audio FSM guards.
58
+ *
59
+ * A DONE record is only authoritative when a real GPU commit backs it. On
60
+ * 2026-11-04 the already_ready fast-track wrote DONE for a scene that had no
61
+ * committed chunk at all, and AUDIO_ALREADY_DONE then swallowed every later
62
+ * "audio" command. Returns false when the probe is unavailable (fail closed
63
+ * for the guard — regenerating is safe, skipping is not).
64
+ */
65
+ async function hasCommittedAudio(buildId, bookId, chapterId, sceneId, expectedCount) {
66
+ try {
67
+ if (typeof audio.isSceneAudioCommitted === 'function') {
68
+ const probe = await audio.isSceneAudioCommitted(buildId, bookId, chapterId, sceneId, expectedCount || null);
69
+ return !!(probe && probe.ready === true);
70
+ }
71
+ return await audio.isSceneAudioReady(buildId, bookId, chapterId, sceneId);
72
+ } catch (err) {
73
+ warn(`AUDIO_READY_PROBE failed for ${bookId}/${chapterId}/${sceneId}: ${err.message}`);
74
+ return false;
75
+ }
76
+ }
77
+
56
78
  // ======================================================
57
79
  // SCENE ORCHESTRATOR
58
80
  // ======================================================
@@ -211,8 +233,16 @@ async function executeAudioDispatch(redis, scene, loadedBook, buildId, dispatchI
211
233
  const orchState = await audioFsmOp('getState')(redis, bookId, chapterId, sceneId);
212
234
  if (orchState) {
213
235
  if (orchState.phase === audioFsmPhases().DONE) {
214
- log(`AUDIO_ALREADY_DONE: ${bookId}/${chapterId}/${sceneId} — skipping dispatch`);
215
- return { dispatched: false, jobs: 0, completed: true, reason: 'already_done' };
236
+ // DONE is only authoritative with a committed chunk behind it.
237
+ if (await hasCommittedAudio(buildId, bookId, chapterId, sceneId, orchState.expected_count)) {
238
+ log(`AUDIO_ALREADY_DONE: ${bookId}/${chapterId}/${sceneId} — skipping dispatch`);
239
+ return { dispatched: false, jobs: 0, completed: true, reason: 'already_done' };
240
+ }
241
+ log(
242
+ `AUDIO_ALREADY_DONE rejected: ${bookId}/${chapterId}/${sceneId} — DONE without a committed audio chunk ` +
243
+ `(expected=${orchState.expected_count || '?'}), resetting state to regenerate`
244
+ );
245
+ await audioFsmOp('deleteState')(redis, bookId, chapterId, sceneId);
216
246
  }
217
247
  }
218
248
 
@@ -234,8 +264,11 @@ async function executeAudioDispatch(redis, scene, loadedBook, buildId, dispatchI
234
264
  const stalePhase = transResult.from;
235
265
  // DONE — дополнительный safety net (выше уже должен был отсечься)
236
266
  if (stalePhase === audioFsmPhases().DONE) {
237
- warn(`AUDIO_ORCH: DONE guard prevented stale reset for ${bookId}/${chapterId}/${sceneId}`);
238
- return { dispatched: false, jobs: 0, completed: true, reason: 'already_done' };
267
+ if (await hasCommittedAudio(buildId, bookId, chapterId, sceneId, orchState && orchState.expected_count)) {
268
+ warn(`AUDIO_ORCH: DONE guard prevented stale reset for ${bookId}/${chapterId}/${sceneId}`);
269
+ return { dispatched: false, jobs: 0, completed: true, reason: 'already_done' };
270
+ }
271
+ warn(`AUDIO_ORCH: stale DONE without a committed chunk for ${bookId}/${chapterId}/${sceneId} — resetting to PLACEHOLDER_READY`);
239
272
  }
240
273
  // WAITING_CHUNKS / MERGING (stale recovery): с A2 timeout formula lease
241
274
  // переживает watchdog — если дошли до stale recovery, значит lease истёк
@@ -280,7 +313,16 @@ async function executeAudioDispatch(redis, scene, loadedBook, buildId, dispatchI
280
313
  // jobs, иначе первый чанк может вернуться быстрее чем setWaitingChunks выполнится,
281
314
  // и completeChunk увидит фазу GENERATING → early return → чанк на диске есть,
282
315
  // но merge не запускается → retry exhausting → failStage → re-dispatch → цикл.
283
- await audioFsmOp('setWaitingChunks')(redis, bookId, chapterId, sceneId);
316
+ //
317
+ // GPU E2E 2026-11-02 (§6.1): every dispatch opens a NEW audio-orch epoch
318
+ // (started_at=now, dispatch_id, expected_count; fail_reason/failed_at/
319
+ // last_chunk_at of an earlier or cancelled attempt are dropped). The stall
320
+ // watchdog compares this dispatch_id with the live dispatch metadata.
321
+ await audioFsmOp('setWaitingChunks')(redis, bookId, chapterId, sceneId, {
322
+ dispatchId,
323
+ buildId,
324
+ expectedCount: segments.buildSegments(sceneData).length,
325
+ });
284
326
 
285
327
  const result = await audio.generateSceneAudio(redis, sceneData, bookData, buildId, bookId, dispatchId);
286
328
 
@@ -1623,8 +1623,11 @@ async function clearLeasesForScenes(redis, bookId, scenes) {
1623
1623
  *
1624
1624
  * The contract now is:
1625
1625
  * - ids present → one `?dispatch_id=` DELETE per id (unchanged);
1626
- * - no ids + book_id → exactly ONE `?book_id=` DELETE (the whole-book sweep
1626
+ * - no ids + book_id + `allowBookSweep: true`
1627
+ * → exactly ONE `?book_id=` DELETE (the whole-book sweep
1627
1628
  * the hub already supports and cache/delete paths use);
1629
+ * - no ids + book_id WITHOUT `allowBookSweep` (0.3.3, fail-closed)
1630
+ * → NO request, a warning, `{requested:0, refused:1}`;
1628
1631
  * - neither → NO request at all (never a fake HTTP call).
1629
1632
  *
1630
1633
  * A book-scoped sweep is checked against the hub's echoed `scope.book_id`:
@@ -1659,6 +1662,19 @@ async function clearHubDispatches(dispatchIds, options = {}) {
1659
1662
  // ── Book-scoped sweep (no explicit dispatch ids) ──────────────────────
1660
1663
  // Exactly one request; the hub filters by record.book_id so only this
1661
1664
  // book's queue/running/result entries are removed.
1665
+ //
1666
+ // 0.3.3 FAIL-CLOSED (reports/2026-11-04/regenerate-sweep-fix.md): a
1667
+ // book-wide sweep removes EVERY in-flight job of the book — incident
1668
+ // 2026-11-04: a one-scene audio /regenerate swept a running image job
1669
+ // of another scene (result rejected 409, lost) and a queued one (back
1670
+ // only after 7 min of stale-lease recovery). It is issued ONLY when the
1671
+ // caller states it explicitly (`allowBookSweep: true` — an explicit
1672
+ // cancellation / full restart of the whole book / book deletion).
1673
+ // Otherwise: no request at all, a warning, `refused: 1`.
1674
+ if (ids.length === 0 && bookId && options.allowBookSweep !== true) {
1675
+ reportWarning(`${context}: book-wide GPU hub sweep for ${bookId} REFUSED (no dispatch ids, allowBookSweep not set) — nothing swept`);
1676
+ return { requested: 0, cleared: 0, failed: 0, refused: 1 };
1677
+ }
1662
1678
  if (ids.length === 0 && bookId) {
1663
1679
  try {
1664
1680
  const response = await fetchImpl(
@@ -473,6 +473,33 @@ async function checkPartialBuilds(redis, bookId, chapterId, sceneId) {
473
473
  // Важно: watchdog НЕ конкурирует с gpu-hub timeout — они ловят
474
474
  // разные сценарии (стоп прогресса vs мёртвый воркер).
475
475
 
476
+ // GPU E2E 2026-11-02 (§6.1) — dispatch epoch of an audio-orch record.
477
+ // current=true only when the record carries a dispatch_id AND it equals the
478
+ // live dispatch metadata of the scene's audio stage. A metadata read error is
479
+ // "cannot prove" → not current (the watchdog never fails on a guess).
480
+ async function _audioDispatchEpoch(redis, bookId, chapterId, sceneId, orchState) {
481
+ if (!orchState || !orchState.dispatch_id) return { current: false, reason: 'no_dispatch_id' };
482
+ let meta = null;
483
+ try {
484
+ meta = await require('./dispatch-engine').getDispatchMetadata(redis, bookId, chapterId, sceneId, 'audio');
485
+ } catch (_) {
486
+ return { current: false, reason: 'dispatch_metadata_unreadable' };
487
+ }
488
+ if (!meta || !meta.dispatch_id) return { current: false, reason: 'no_active_dispatch' };
489
+ if (meta.dispatch_id !== orchState.dispatch_id) return { current: false, reason: 'other_dispatch' };
490
+ return { current: true, reason: 'current_dispatch' };
491
+ }
492
+
493
+ // One info line per stale record (scene + its epoch), not one per cycle.
494
+ const _staleAudioOrchLogged = new Set();
495
+ function _logStaleAudioOrchOnce(sceneRef, orchState, reason) {
496
+ const k = `${sceneRef}|${orchState.dispatch_id || ''}|${orchState.started_at || ''}`;
497
+ if (_staleAudioOrchLogged.has(k)) return;
498
+ if (_staleAudioOrchLogged.size > 5000) _staleAudioOrchLogged.clear();
499
+ _staleAudioOrchLogged.add(k);
500
+ log(`[STALLED-AUDIO] ${sceneRef} — stale_state_ignored (${reason}): the record is not of the live dispatch, not failing it`);
501
+ }
502
+
476
503
  async function checkStalledAudioScenes(redis, deps) {
477
504
  // O-7: the audio FSM is consumed through the AudioFsmPort (host adapter:
478
505
  // storage/audio-fsm-adapter). The pre-O-7 optional-load semantics are
@@ -513,12 +540,24 @@ async function checkStalledAudioScenes(redis, deps) {
513
540
  const age = Date.now() - threshold;
514
541
  if (age < AUDIO_CHUNK_STALL_MS) continue;
515
542
 
543
+ // GPU E2E 2026-11-02 (§6.1): the record must belong to the LIVE
544
+ // dispatch. A record without dispatch_id (legacy / pre-epoch) or
545
+ // of another dispatch is a stale epoch — failing it marked a
546
+ // healthy run FAILED (`chunk_stall_timeout:4619s`). It is ignored
547
+ // (one info line per record), never failed.
548
+ const epoch = await _audioDispatchEpoch(redis, bookId, chapterId, sceneId, orchState);
549
+ if (!epoch.current) {
550
+ _logStaleAudioOrchOnce(`${bookId}/${chapterId}/${sceneId}`, orchState, epoch.reason);
551
+ continue;
552
+ }
553
+
516
554
  const reason = `chunk_stall_timeout:${Math.round(age / 1000)}s`;
517
- warn(`[STALLED-AUDIO] ${bookId}/${chapterId}/${sceneId} — ${reason} (last_chunk_at=${!!lastChunkAt} started_at=${!!startedAt})`);
555
+ warn(`[STALLED-AUDIO] ${bookId}/${chapterId}/${sceneId} — ${reason} (last_chunk_at=${!!lastChunkAt} started_at=${!!startedAt} dispatch=${String(orchState.dispatch_id).slice(0, 24)})`);
518
556
 
519
557
  try {
520
558
  await audioOrch.failWaitingScene(redis, bookId, chapterId, sceneId, buildId, reason, {
521
- orchestrator: deps.orchestrator
559
+ orchestrator: deps.orchestrator,
560
+ dispatchId: orchState.dispatch_id,
522
561
  });
523
562
  stalled++;
524
563
  } catch (err) {
@@ -551,7 +590,7 @@ async function checkStalledAudioScenes(redis, deps) {
551
590
  warn(`[STALLED-AUDIO] completeChunk recovery failed: ${err.message}`);
552
591
  try {
553
592
  await audioOrch.failWaitingScene(redis, bookId, chapterId, sceneId, buildId,
554
- `recovery_fallback:${err.message}`, { orchestrator: deps.orchestrator });
593
+ `recovery_fallback:${err.message}`, { orchestrator: deps.orchestrator, dispatchId: orchState.dispatch_id });
555
594
  stalled++;
556
595
  } catch (fsErr) {
557
596
  warn(`[STALLED-AUDIO] failWaitingScene fallback failed: ${fsErr.message}`);
@@ -561,7 +600,7 @@ async function checkStalledAudioScenes(redis, deps) {
561
600
  warn(`[STALLED-AUDIO] ${bookId}/${chapterId}/${sceneId} — ${presentCount}/${expectedCount} on disk, no last_chunk_at. Partial chunks, failing scene.`);
562
601
  try {
563
602
  await audioOrch.failWaitingScene(redis, bookId, chapterId, sceneId, buildId,
564
- `partial_chunks:${presentCount}/${expectedCount}_no_timestamps`, { orchestrator: deps.orchestrator });
603
+ `partial_chunks:${presentCount}/${expectedCount}_no_timestamps`, { orchestrator: deps.orchestrator, dispatchId: orchState.dispatch_id });
565
604
  stalled++;
566
605
  } catch (err) {
567
606
  warn(`[STALLED-AUDIO] failWaitingScene failed: ${err.message}`);