@animastor/orchestration 0.3.1 → 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,30 @@
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
+
3
28
  ## 0.3.1 (2026-11-01) — результат GPU обрабатывается один раз
4
29
 
5
30
  Основание: `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.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,
@@ -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
 
@@ -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}`);