@animastor/orchestration 0.3.0 → 0.3.2
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,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.2 (2026-11-02) — эпоха dispatch в audio-orch, без ложного FAILED
|
|
4
|
+
|
|
5
|
+
Основание: `reports/2026-11-02/gpu-e2e-installer-025-logs.md` §6.1, §8 п.3, п.5 —
|
|
6
|
+
состояние audio-orch отменённой попытки (11:09) пережило отмену, новый
|
|
7
|
+
dispatch (12:25) унаследовал его `started_at`, и watchdog через 28 с после
|
|
8
|
+
dispatch'а пометил здоровый прогон FAILED (`chunk_stall_timeout:4619s`), затем
|
|
9
|
+
`[FAIL-STAGE] Rejected … missing_dispatch_id` и `[INVARIANT] 1 audio-orch violations`.
|
|
10
|
+
Описание: animastor-docs `architecture/COMMAND_ONLY_GENERATION.md` §«Эпоха dispatch».
|
|
11
|
+
|
|
12
|
+
### Changed (поведение)
|
|
13
|
+
|
|
14
|
+
- `scene-orchestrator.executeAudioDispatch` открывает новую эпоху на каждом
|
|
15
|
+
dispatch: `setWaitingChunks(…, { dispatchId, buildId, expectedCount })`.
|
|
16
|
+
Хост пишет `started_at=now`, `dispatch_id`, `expected_count`,
|
|
17
|
+
`chunks_received=0` и убирает `fail_reason`/`failed_at`/`last_chunk_at`
|
|
18
|
+
прошлой попытки. Хост без поддержки 5-го аргумента ведёт себя как раньше.
|
|
19
|
+
- `reconciliation-engine.checkStalledAudioScenes`: запись проваливается, только
|
|
20
|
+
если её `dispatch_id` равен живому `dispatch-meta` сцены. Запись без
|
|
21
|
+
`dispatch_id` или другого dispatch — `stale_state_ignored` (одна info-строка
|
|
22
|
+
на запись, не FAILED). `failWaitingScene` получает `dispatchId` записи.
|
|
23
|
+
- `orchestrator.failStage` без `dispatchId` → `{failed:false,
|
|
24
|
+
reason:'stale_state_ignored'}` (info), а не `Rejected … missing_dispatch_id`.
|
|
25
|
+
|
|
26
|
+
Fence, command-gate (Stage 155) и retry-политика не меняются.
|
|
27
|
+
|
|
28
|
+
## 0.3.1 (2026-11-01) — результат GPU обрабатывается один раз
|
|
29
|
+
|
|
30
|
+
Основание: `reports/2026-11-01/gpu-audio-image-run-analysis.md`, P1 — каждый
|
|
31
|
+
результат обрабатывался дважды (HTTP-push hub → backend, затем Phase A
|
|
32
|
+
reconcile подбирал оставшийся `animastor:result:*` мимо
|
|
33
|
+
`animastor:result-processed:*`). Описание: animastor-docs
|
|
34
|
+
`architecture/GPU_HUB_CONTRACT.md` §«Result ACK».
|
|
35
|
+
|
|
36
|
+
### Changed (поведение)
|
|
37
|
+
|
|
38
|
+
- `reconciliation-engine.recoverResultKeys` (Phase A): перед
|
|
39
|
+
`handleTaskResult` читает маркер `animastor:result-processed:{job}:{build}`.
|
|
40
|
+
Значение `done:<dispatch_id>` того же dispatch, что в result-ключе, —
|
|
41
|
+
ключ удаляется молча (без лога), handler не вызывается.
|
|
42
|
+
Маркер `'1'` (в работе / старый формат), маркер другого dispatch
|
|
43
|
+
(перегенерация audio/video в пределах TTL) или его отсутствие — как
|
|
44
|
+
раньше, через fenced handler.
|
|
45
|
+
- Терминальный отказ (`no_active_dispatch` и др.) для дубля доставленного
|
|
46
|
+
результата (маркер есть) — уровень info; для сироты (маркера нет) — warn.
|
|
47
|
+
- `orchestrator.resetScenes` вместе с `iu-progress` удаляет
|
|
48
|
+
`animastor:iu-progress-ids:*` (множество IU×fence, хост task-handler).
|
|
49
|
+
|
|
50
|
+
Fence, dedup-ключ маршрута и command-gate (Stage 155) не меняются.
|
|
51
|
+
|
|
3
52
|
## 0.3.0 (2026-10-31) — генерация только по явной команде
|
|
4
53
|
|
|
5
54
|
Правило владельца: GPU-генерация запускается только кнопками «аудио» /
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@animastor/orchestration",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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,
|
|
@@ -575,6 +583,8 @@ async function resetScenes(redis, bookId, buildId, scenes, layerCfg, options = {
|
|
|
575
583
|
|
|
576
584
|
const progKey = `animastor:iu-progress:${bookId}:${ds.chapter_id}:${ds.scene_id}:image`;
|
|
577
585
|
try { await redis.del(progKey); } catch (_) {}
|
|
586
|
+
// SYNC: backend task-handler — the IU×fence set behind the counter.
|
|
587
|
+
try { await redis.del(`animastor:iu-progress-ids:${bookId}:${ds.chapter_id}:${ds.scene_id}:image`); } catch (_) {}
|
|
578
588
|
|
|
579
589
|
try {
|
|
580
590
|
let cursor = '0';
|
|
@@ -280,7 +280,16 @@ async function executeAudioDispatch(redis, scene, loadedBook, buildId, dispatchI
|
|
|
280
280
|
// jobs, иначе первый чанк может вернуться быстрее чем setWaitingChunks выполнится,
|
|
281
281
|
// и completeChunk увидит фазу GENERATING → early return → чанк на диске есть,
|
|
282
282
|
// но merge не запускается → retry exhausting → failStage → re-dispatch → цикл.
|
|
283
|
-
|
|
283
|
+
//
|
|
284
|
+
// GPU E2E 2026-11-02 (§6.1): every dispatch opens a NEW audio-orch epoch
|
|
285
|
+
// (started_at=now, dispatch_id, expected_count; fail_reason/failed_at/
|
|
286
|
+
// last_chunk_at of an earlier or cancelled attempt are dropped). The stall
|
|
287
|
+
// watchdog compares this dispatch_id with the live dispatch metadata.
|
|
288
|
+
await audioFsmOp('setWaitingChunks')(redis, bookId, chapterId, sceneId, {
|
|
289
|
+
dispatchId,
|
|
290
|
+
buildId,
|
|
291
|
+
expectedCount: segments.buildSegments(sceneData).length,
|
|
292
|
+
});
|
|
284
293
|
|
|
285
294
|
const result = await audio.generateSceneAudio(redis, sceneData, bookData, buildId, bookId, dispatchId);
|
|
286
295
|
|
|
@@ -63,6 +63,25 @@ function error(msg) {
|
|
|
63
63
|
console.error(`${logPrefix} ❌ ${msg}`);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// SYNC: backend/src/helpers/queue-idempotency.cjs — resultDedupKey /
|
|
67
|
+
// readResultDedup (marker `'1'` = in flight, `done:<dispatch_id>` = settled).
|
|
68
|
+
// Read-only here: the result route is the only writer.
|
|
69
|
+
const RESULT_DEDUP_DONE_PREFIX = 'done:';
|
|
70
|
+
async function readResultDedup(redis, jobId, buildId) {
|
|
71
|
+
let raw = null;
|
|
72
|
+
try {
|
|
73
|
+
raw = await redis.get(`animastor:result-processed:${jobId}:${buildId}`);
|
|
74
|
+
} catch (_) {
|
|
75
|
+
return { state: 'unknown', dispatchId: null };
|
|
76
|
+
}
|
|
77
|
+
if (raw == null || raw === false) return { state: 'none', dispatchId: null };
|
|
78
|
+
const v = String(raw);
|
|
79
|
+
if (v.startsWith(RESULT_DEDUP_DONE_PREFIX)) {
|
|
80
|
+
return { state: 'settled', dispatchId: v.slice(RESULT_DEDUP_DONE_PREFIX.length) || null };
|
|
81
|
+
}
|
|
82
|
+
return { state: 'in_flight', dispatchId: null };
|
|
83
|
+
}
|
|
84
|
+
|
|
66
85
|
// ======================================================
|
|
67
86
|
// RECONCILIATION REPORT
|
|
68
87
|
// ======================================================
|
|
@@ -454,6 +473,33 @@ async function checkPartialBuilds(redis, bookId, chapterId, sceneId) {
|
|
|
454
473
|
// Важно: watchdog НЕ конкурирует с gpu-hub timeout — они ловят
|
|
455
474
|
// разные сценарии (стоп прогресса vs мёртвый воркер).
|
|
456
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
|
+
|
|
457
503
|
async function checkStalledAudioScenes(redis, deps) {
|
|
458
504
|
// O-7: the audio FSM is consumed through the AudioFsmPort (host adapter:
|
|
459
505
|
// storage/audio-fsm-adapter). The pre-O-7 optional-load semantics are
|
|
@@ -494,12 +540,24 @@ async function checkStalledAudioScenes(redis, deps) {
|
|
|
494
540
|
const age = Date.now() - threshold;
|
|
495
541
|
if (age < AUDIO_CHUNK_STALL_MS) continue;
|
|
496
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
|
+
|
|
497
554
|
const reason = `chunk_stall_timeout:${Math.round(age / 1000)}s`;
|
|
498
|
-
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)})`);
|
|
499
556
|
|
|
500
557
|
try {
|
|
501
558
|
await audioOrch.failWaitingScene(redis, bookId, chapterId, sceneId, buildId, reason, {
|
|
502
|
-
orchestrator: deps.orchestrator
|
|
559
|
+
orchestrator: deps.orchestrator,
|
|
560
|
+
dispatchId: orchState.dispatch_id,
|
|
503
561
|
});
|
|
504
562
|
stalled++;
|
|
505
563
|
} catch (err) {
|
|
@@ -532,7 +590,7 @@ async function checkStalledAudioScenes(redis, deps) {
|
|
|
532
590
|
warn(`[STALLED-AUDIO] completeChunk recovery failed: ${err.message}`);
|
|
533
591
|
try {
|
|
534
592
|
await audioOrch.failWaitingScene(redis, bookId, chapterId, sceneId, buildId,
|
|
535
|
-
`recovery_fallback:${err.message}`, { orchestrator: deps.orchestrator });
|
|
593
|
+
`recovery_fallback:${err.message}`, { orchestrator: deps.orchestrator, dispatchId: orchState.dispatch_id });
|
|
536
594
|
stalled++;
|
|
537
595
|
} catch (fsErr) {
|
|
538
596
|
warn(`[STALLED-AUDIO] failWaitingScene fallback failed: ${fsErr.message}`);
|
|
@@ -542,7 +600,7 @@ async function checkStalledAudioScenes(redis, deps) {
|
|
|
542
600
|
warn(`[STALLED-AUDIO] ${bookId}/${chapterId}/${sceneId} — ${presentCount}/${expectedCount} on disk, no last_chunk_at. Partial chunks, failing scene.`);
|
|
543
601
|
try {
|
|
544
602
|
await audioOrch.failWaitingScene(redis, bookId, chapterId, sceneId, buildId,
|
|
545
|
-
`partial_chunks:${presentCount}/${expectedCount}_no_timestamps`, { orchestrator: deps.orchestrator });
|
|
603
|
+
`partial_chunks:${presentCount}/${expectedCount}_no_timestamps`, { orchestrator: deps.orchestrator, dispatchId: orchState.dispatch_id });
|
|
546
604
|
stalled++;
|
|
547
605
|
} catch (err) {
|
|
548
606
|
warn(`[STALLED-AUDIO] failWaitingScene failed: ${err.message}`);
|
|
@@ -1947,11 +2005,34 @@ async function recoverResultKeys(redis, deps, scope) {
|
|
|
1947
2005
|
warn(`Skipping legacy result without build/dispatch identity: ${job_id}`);
|
|
1948
2006
|
continue;
|
|
1949
2007
|
}
|
|
2008
|
+
|
|
2009
|
+
// P1 (2026-11-01): the hub's fallback copy of a delivery the
|
|
2010
|
+
// result route already SETTLED is a duplicate — drop it
|
|
2011
|
+
// silently, never replay it (the replay re-ran the ready chain
|
|
2012
|
+
// and logged false no_active_dispatch warnings). Only a marker
|
|
2013
|
+
// carrying THIS dispatch counts: an in-flight / legacy '1'
|
|
2014
|
+
// marker or one from an older dispatch of the same job_id
|
|
2015
|
+
// (audio/video regeneration within the TTL) is no proof — such
|
|
2016
|
+
// a result goes through the fenced handler exactly as before.
|
|
2017
|
+
const dedup = await readResultDedup(redis, job_id, buildId);
|
|
2018
|
+
if (dedup.state === 'settled' && dedup.dispatchId === String(dispatchId)) {
|
|
2019
|
+
await redis.del(key);
|
|
2020
|
+
continue;
|
|
2021
|
+
}
|
|
1950
2022
|
try {
|
|
1951
2023
|
await taskHandler.handleTaskResult(job_id, result_base64, buildId, dispatchId);
|
|
1952
2024
|
} catch (rejectErr) {
|
|
1953
2025
|
if (rejectErr && TERMINAL_REJECTIONS.has(rejectErr.code)) {
|
|
1954
|
-
|
|
2026
|
+
// A replay of a delivery the route has seen (a marker
|
|
2027
|
+
// exists) is a duplicate: info. No marker (an orphan
|
|
2028
|
+
// that never reached the route) or an unreadable
|
|
2029
|
+
// marker: warn, as before.
|
|
2030
|
+
const msg = `Result ${job_id} terminally rejected (${rejectErr.code}) — dropping result key, no infinite retry`;
|
|
2031
|
+
if (dedup.state === 'in_flight' || dedup.state === 'settled') {
|
|
2032
|
+
log(`${msg} (duplicate of a delivered result)`);
|
|
2033
|
+
} else {
|
|
2034
|
+
warn(msg);
|
|
2035
|
+
}
|
|
1955
2036
|
await redis.del(key);
|
|
1956
2037
|
} else {
|
|
1957
2038
|
throw rejectErr;
|