@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.0",
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
- await audioFsmOp('setWaitingChunks')(redis, bookId, chapterId, sceneId);
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
- warn(`Result ${job_id} terminally rejected (${rejectErr.code}) — dropping result key, no infinite retry`);
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;