@animastor/orchestration 0.3.2 → 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,61 @@
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
+
3
59
  ## 0.3.2 (2026-11-02) — эпоха dispatch в audio-orch, без ложного FAILED
4
60
 
5
61
  Основание: `reports/2026-11-02/gpu-e2e-installer-025-logs.md` §6.1, §8 п.3, п.5 —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animastor/orchestration",
3
- "version": "0.3.2",
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",
@@ -499,7 +499,7 @@ async function resetScenes(redis, bookId, buildId, scenes, layerCfg, options = {
499
499
 
500
500
  if (!scenes || scenes.length === 0) {
501
501
  log('[RESET-SCENES] No scenes to reset');
502
- return { marked: 0, reset_scenes: 0 };
502
+ return { marked: 0, reset_scenes: 0, dispatchIds: [] };
503
503
  }
504
504
 
505
505
  const scopeDisplay = `${scope}${chapterId ? '/' + chapterId : ''}${sceneId ? '/' + sceneId : ''}`;
@@ -635,7 +635,10 @@ async function resetScenes(redis, bookId, buildId, scenes, layerCfg, options = {
635
635
  { scenes_count: scenes.length, marked: marked.marked }
636
636
  ).catch(() => {});
637
637
 
638
- 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 || [])] };
639
642
  }
640
643
 
641
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 истёк
@@ -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(