@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 +81 -0
- package/package.json +1 -1
- package/src/orchestration/orchestrator.js +13 -2
- package/src/orchestration/scene-callbacks.js +15 -3
- package/src/orchestration/scene-orchestrator.js +47 -5
- package/src/runtime/dispatch-engine.js +17 -1
- package/src/runtime/reconciliation-engine.js +43 -4
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
|
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}`);
|