@animastor/orchestration 0.1.2 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animastor/orchestration",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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",
@@ -93,17 +93,28 @@ async function startScene(redis, scene, loadedBook, buildId) {
93
93
  // открывал circuit breaker.
94
94
  //
95
95
  // Контракт: перед отправкой любого GPU job ассет обязан пройти
96
- // NEW → PENDING (здесь) → GENERATING (сразу ниже),
96
+ // NEW|DIRTY → PENDING (здесь) → GENERATING (сразу ниже),
97
97
  // либо уже находиться в состоянии, из которого GENERATING достижим.
98
98
  // Если переход невозможен — dispatch ПРЕРЫВАЕТСЯ ДО отправки job.
99
+ //
100
+ // Stage 50: DIRTY включён в нормализацию. DIRTY → GENERATING запрещён FSM
101
+ // (scene-state.js: DIRTY → [PENDING, PLACEHOLDER]), поэтому до этого фикса
102
+ // dirty-ассет (version-stale pre-pass, C1/C1b recovery, RELEASE_STALE_LEASE,
103
+ // markDirtyScene) не мог перегенерироваться через планировщик вообще: guard
104
+ // отклонял setSceneGenerating(dirty), rejection проглатывался в
105
+ // attemptDispatch — ровно тот `INVALID state=dirty attempted=generating`
106
+ // из journal (Case A, stage 49). Комментарий теста stage-dispatch-lifecycle
107
+ // («scheduler's version-stale pre-pass owns DIRTY→PENDING») описывал
108
+ // нормализацию, которой не существовало. PENDING-хоп здесь — это и есть
109
+ // недостающая половина контракта.
99
110
  async function ensureStageDispatchable(redis, bookId, chapterId, sceneId, stage) {
100
111
  const states = await state.getAssetStates(redis, bookId, chapterId, sceneId);
101
- if (states?.[stage] === state.AssetState.NEW) {
112
+ if (states?.[stage] === state.AssetState.NEW || states?.[stage] === state.AssetState.DIRTY) {
102
113
  const init = await setScenePending(redis, bookId, chapterId, sceneId, stage);
103
114
  if (!init.changed) {
104
115
  return { ok: false, reason: `pending_init_failed:${init.reason}` };
105
116
  }
106
- log(`STAGE_INIT: ${bookId}/${chapterId}/${sceneId} ${stage}: NEW → PENDING`);
117
+ log(`STAGE_INIT: ${bookId}/${chapterId}/${sceneId} ${stage}: ${states[stage]} → PENDING`);
107
118
  }
108
119
  const gen = await setSceneGenerating(redis, bookId, chapterId, sceneId, stage);
109
120
  if (!gen.changed) {
@@ -112,6 +123,34 @@ async function ensureStageDispatchable(redis, bookId, chapterId, sceneId, stage)
112
123
  return { ok: true };
113
124
  }
114
125
 
126
+ // ── Stage 50: terminal audio-orch record vs requested regeneration ──
127
+ // DONE — терминальная фаза (VALID_TRANSITIONS[DONE] = []), но per-asset DIRTY
128
+ // поверх неё — явный запрос на перегенерацию (version-stale pre-pass, C1/C1b
129
+ // recovery, RELEASE_STALE_LEASE, completeStage version-gate stale). Без сброса
130
+ // записи dispatch навсегда упирается в DONE guard (already_done) — состояние
131
+ // DONE+DIRTY зависает навсегда (2 сцены в production, stage 49). Хил:
132
+ // терминальная запись удаляется, дальше ensureStageDispatchable проводит
133
+ // DIRTY → PENDING → GENERATING, а no_state-лега переинициализирует FSM.
134
+ // Не-DONE записи (WAITING_CHUNKS/MERGING/FAILED) НЕ трогаются — их ведут
135
+ // штатные recovery-леги (Фаза 4: чистится только DONE).
136
+ async function clearTerminalAudioOrchForDirty(redis, bookId, chapterId, sceneId) {
137
+ let orchState;
138
+ try {
139
+ orchState = await audioFsmOp('getState')(redis, bookId, chapterId, sceneId);
140
+ } catch (err) {
141
+ warn(`AUDIO_DONE_DIRTY_HEAL: getState failed for ${bookId}/${chapterId}/${sceneId}: ${err.message}`);
142
+ return false;
143
+ }
144
+ if (!orchState || orchState.phase !== audioFsmPhases().DONE) return false;
145
+
146
+ const states = await state.getAssetStates(redis, bookId, chapterId, sceneId);
147
+ if (states?.audio !== state.AssetState.DIRTY) return false;
148
+
149
+ log(`AUDIO_DONE_DIRTY_HEAL: ${bookId}/${chapterId}/${sceneId} — regeneration requested over terminal DONE record — clearing audio-orch state for re-dispatch`);
150
+ await audioFsmOp('deleteState')(redis, bookId, chapterId, sceneId);
151
+ return true;
152
+ }
153
+
115
154
  // T3: Каждый executor возвращает { dispatched: true/false, jobs, reason }
116
155
  async function executeAudioDispatch(redis, scene, loadedBook, buildId, dispatchId) {
117
156
  const bookId = scene.book_id;
@@ -125,6 +164,10 @@ async function executeAudioDispatch(redis, scene, loadedBook, buildId, dispatchI
125
164
  buildId, dispatchType: 'orchestrator'
126
165
  });
127
166
 
167
+ // Stage 50: heal the DONE×DIRTY deadlock BEFORE the lifecycle guard —
168
+ // a DIRTY request over a terminal DONE record must regenerate, not skip.
169
+ await clearTerminalAudioOrchForDirty(redis, bookId, chapterId, sceneId);
170
+
128
171
  // T7: через фасад — единый владелец GENERATING (audio)
129
172
  // Lifecycle guard: abort BEFORE any GPU job if the transition chain fails.
130
173
  const dispatchable = await ensureStageDispatchable(redis, bookId, chapterId, sceneId, 'audio');
@@ -591,6 +634,7 @@ async function dispatchStage(redis, scene, loadedBook, buildId, overrideStage, d
591
634
 
592
635
  module.exports = {
593
636
  ensureStageDispatchable,
637
+ clearTerminalAudioOrchForDirty,
594
638
  dispatchStage,
595
639
  restoreSceneChunkStatus,
596
640
  handleAudioCompleted,
@@ -24,6 +24,9 @@ const runtimeMetrics = require('./runtime-metrics');
24
24
  // domain state over the injected redis parameter).
25
25
  const circuitBreaker = require('./circuit-breaker');
26
26
  const retryBudget = require('./retry-budget-manager');
27
+ // O-2 (Stage 50): the dispatch-time scene-version stamp is a PG-side effect
28
+ // consumed through the PersistencePort (host adapter owns the SQL).
29
+ const persist = require('./persistence-port').persist;
27
30
  const crypto = require('crypto');
28
31
  // Phase 5: Runtime Result Contract — dispatch finalizations are reported to
29
32
  // orchestration through the contracts seam (runtime → contract → injected
@@ -864,7 +867,22 @@ async function dispatchStage(redis, bookId, chapterId, sceneId, stage, loadedBoo
864
867
  dispatchId,
865
868
  leaseKey: lease.leaseKey,
866
869
  started_at: metadata.started_at
867
- }); // Step 6: Get orchestrator to perform dispatch
870
+ });
871
+
872
+ // Step 5.5 (Stage 50): stamp the scene's CURRENT content/audio-config
873
+ // versions onto the asset row — «версия, под которую стартовала генерация».
874
+ // completeStage's version gate then compares exactly that, so a regen
875
+ // started for the current content reaches READY (convergence of the
876
+ // DONE×DIRTY / version-stale flows), while a version bump DURING the
877
+ // generation still fails the gate (T1.11 semantics preserved). Best-effort:
878
+ // dispatch is a Redis-domain operation; a PG outage must not block it —
879
+ // the gate stays fail-closed and the next dispatch re-stamps.
880
+ try {
881
+ await persist('sceneAssets.stampSceneVersionsForDispatch')(bookId, chapterId, sceneId, stage);
882
+ } catch (stampErr) {
883
+ warn(`DISPATCH_VERSION_STAMP_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage}: ${stampErr.message}`);
884
+ }
885
+ // Step 6: Get orchestrator to perform dispatch
868
886
  try {
869
887
  // S-5: the stage executor entry (scene-orchestrator.dispatchStage) is
870
888
  // injected by the composition root — runtime must not import
@@ -59,6 +59,7 @@ const OPS = [
59
59
 
60
60
  'sceneAssets.getAsset',
61
61
  'sceneAssets.markReady',
62
+ 'sceneAssets.stampSceneVersionsForDispatch',
62
63
  'sceneAssets.getDirtyUnitIds',
63
64
  'sceneAssets.setDirtyUnitIds',
64
65
  'sceneAssets.clearDirtyUnitIds',
@@ -345,15 +345,23 @@ async function checkOrphanGeneratingState(redis, bookId, chapterId, sceneId) {
345
345
  /**
346
346
  * Check for partially built scenes.
347
347
  * T8: использует per-asset state вместо линейного.
348
+ *
349
+ * Stage 50: генерация в полёте — не «partial build». Раньше registry мог
350
+ * ещё не содержать image (registerSceneImage пишется по завершении), а
351
+ * ассет уже PENDING/GENERATING с живым lease — отчёт partial_audio_only
352
+ * вёл к PROGRESS_TO_IMAGE → setScenePending(generating→pending rejected)
353
+ * каждый цикл (issue №3 stage 49: ch-1bb5123c/sc-45c789c6).
348
354
  */
349
355
  async function checkPartialBuilds(redis, bookId, chapterId, sceneId) {
350
356
  const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
351
357
  const assets = await persist('registry.getSceneAssetsRedis')(redis, bookId, chapterId, sceneId);
352
358
 
359
+ const isInFlight = s => s === state.AssetState.PENDING || s === state.AssetState.GENERATING;
360
+
353
361
  // Audio READY/PLACEHOLDER but no image asset
354
362
  const audioReady = assetStates && assetStates.audio &&
355
363
  (assetStates.audio === state.AssetState.READY || assetStates.audio === state.AssetState.PLACEHOLDER);
356
- if (audioReady && !assets?.image) {
364
+ if (audioReady && !assets?.image && !isInFlight(assetStates.image)) {
357
365
  return {
358
366
  type: 'partial_audio_only',
359
367
  scene: { bookId, chapterId, sceneId },
@@ -366,7 +374,7 @@ async function checkPartialBuilds(redis, bookId, chapterId, sceneId) {
366
374
  // Image READY but no video asset
367
375
  const imageReady = assetStates && assetStates.image &&
368
376
  (assetStates.image === state.AssetState.READY || assetStates.image === state.AssetState.PLACEHOLDER);
369
- if (imageReady && !assets?.video) {
377
+ if (imageReady && !assets?.video && !isInFlight(assetStates.video)) {
370
378
  return {
371
379
  type: 'partial_image_video_missing',
372
380
  scene: { bookId, chapterId, sceneId },
@@ -646,7 +654,11 @@ async function checkAudioOrchInvariants(redis, bookId, chapterId, sceneId) {
646
654
  phase,
647
655
  audioState,
648
656
  expected: 'READY or PLACEHOLDER',
649
- recommendation: 'run_completeStage'
657
+ // Stage 50: former value 'run_completeStage' was dead AND
658
+ // destructive metadata — completing the stage would write READY
659
+ // and erase exactly the dirty marker that schedules the pending
660
+ // regeneration. Re-dispatch (stage 50 guard-heal) owns the repair.
661
+ recommendation: 'review_manually'
650
662
  });
651
663
  }
652
664
  } else if (phase === PHASES.FAILED) {
@@ -714,7 +726,9 @@ async function checkVideoOrchInvariants(redis, bookId, chapterId, sceneId) {
714
726
  phase,
715
727
  videoState,
716
728
  expected: 'READY or PLACEHOLDER',
717
- recommendation: 'run_completeStage'
729
+ // Stage 50: mirror of the audio fix — 'run_completeStage' was
730
+ // dead + destructive; manual review only, re-dispatch owns it.
731
+ recommendation: 'review_manually'
718
732
  });
719
733
  }
720
734
  } else if (phase === PHASES.FAILED) {
@@ -1103,6 +1117,23 @@ function getFixRecommendations(inconsistentScenes) {
1103
1117
  reason: `Stale dispatch lease detected`,
1104
1118
  safeToExecute: true
1105
1119
  };
1120
+ // Stage 50: explicit (no longer default-fallback) — a terminal
1121
+ // audio/video-orch phase conflicting with the asset state is
1122
+ // NEVER auto-repaired here. In particular run_completeStage would
1123
+ // write READY and erase the dirty marker that schedules the
1124
+ // pending regeneration (stage 49: 2 stuck production scenes).
1125
+ // The DONE×DIRTY deadlock is healed by re-dispatch (stage 50
1126
+ // guard-heal in scene-orchestrator); everything else is manual.
1127
+ case 'audio_orch_invariant_done':
1128
+ case 'video_orch_invariant_done':
1129
+ case 'audio_orch_invariant_failed':
1130
+ case 'video_orch_invariant_failed':
1131
+ return {
1132
+ scene,
1133
+ action: 'REVIEW_MANUALLY',
1134
+ reason: `${issue}: terminal orch phase conflicts with asset state — re-dispatch heals DONE×DIRTY, other combinations need manual review`,
1135
+ safeToExecute: false
1136
+ };
1106
1137
  default:
1107
1138
  return {
1108
1139
  scene,
@@ -2107,6 +2138,19 @@ async function recoverVideoOrchStates(redis, deps) {
2107
2138
  // the sceneVersions.getVersionStalenessRows() port op (SQL lives in the host
2108
2139
  // adapter verbatim). Pre-O-2 a missing deps.postgres skipped the phase; the
2109
2140
  // unwired-port throw hits the same catch → warn + return 0.
2141
+ //
2142
+ // Stage 50: per-asset и READY-only. Раньше сцена помечалась целиком
2143
+ // (markDirtyScene со ВСЕМИ ассетами по умолчанию) при каждом старте:
2144
+ // • unsafeRestoreAssetState пишет DIRTY поверх GENERATING/PENDING — убивал
2145
+ // бы живую генерацию при рестарте;
2146
+ // • READY→DIRTY на не-stale ассетах — лишняя регенерация;
2147
+ // • повторная маркировка уже-DIRTY ассетов — неидемпотентный счётчик.
2148
+ // Теперь stale-типы считаются построчно (audio: content ИЛИ audio_config;
2149
+ // image/video: content), пишется только READY→DIRTY (FSM-легальный переход),
2150
+ // прежний READY-артефакт не удаляется — он остаётся отдаваемым до успешной
2151
+ // замены (файл и registry не трогаются; completeStage version-gate пропустит
2152
+ // READY только после успешной перегенерации). Повторный прогон: ассет уже
2153
+ // DIRTY → 0 маркировок (идемпотентность), счётчик считает только факты записи.
2110
2154
  async function checkVersionStaleness(redis, deps) {
2111
2155
  const { orchestrator } = deps;
2112
2156
  if (!orchestrator) return 0;
@@ -2118,22 +2162,43 @@ async function checkVersionStaleness(redis, deps) {
2118
2162
  for (const row of result.rows) {
2119
2163
  const key = `${row.book_id}|${row.chapter_id}|${row.scene_id}`;
2120
2164
  if (!sceneMap.has(key)) {
2121
- sceneMap.set(key, { bookId: row.book_id, chapterId: row.chapter_id, sceneId: row.scene_id, stale: false });
2165
+ sceneMap.set(key, { bookId: row.book_id, chapterId: row.chapter_id, sceneId: row.scene_id, staleAssets: new Set() });
2122
2166
  }
2123
2167
  const entry = sceneMap.get(key);
2124
- if ((row.scene_content_version != null && row.content_version != null && row.scene_content_version < row.content_version) ||
2125
- (row.scene_audio_config_version != null && row.audio_config_version != null && row.scene_audio_config_version < row.audio_config_version)) {
2126
- entry.stale = true;
2168
+ const contentStale = row.scene_content_version != null && row.content_version != null &&
2169
+ row.scene_content_version < row.content_version;
2170
+ const audioConfigStale = row.scene_audio_config_version != null && row.audio_config_version != null &&
2171
+ row.scene_audio_config_version < row.audio_config_version;
2172
+ if (row.asset_type === 'audio') {
2173
+ if (contentStale || audioConfigStale) entry.staleAssets.add('audio');
2174
+ } else if (row.asset_type === 'image' || row.asset_type === 'video') {
2175
+ if (contentStale) entry.staleAssets.add(row.asset_type);
2127
2176
  }
2128
2177
  }
2129
2178
 
2130
2179
  let outdated = 0;
2131
2180
  for (const entry of sceneMap.values()) {
2132
- if (entry.stale) {
2133
- log(`[VERSION-STALE] ${entry.bookId}/${entry.chapterId}/${entry.sceneId}`);
2134
- await orchestrator.markDirtyScene(redis, entry.bookId, entry.chapterId, entry.sceneId);
2135
- outdated++;
2181
+ if (entry.staleAssets.size === 0) continue;
2182
+
2183
+ let assetStates;
2184
+ try {
2185
+ assetStates = await state.getAssetStates(redis, entry.bookId, entry.chapterId, entry.sceneId);
2186
+ } catch (stateErr) {
2187
+ warn(`Version staleness: asset-state read failed for ${entry.bookId}/${entry.chapterId}/${entry.sceneId}: ${stateErr.message}`);
2188
+ continue;
2136
2189
  }
2190
+
2191
+ const readyStale = [...entry.staleAssets]
2192
+ .filter(t => assetStates?.[t] === state.AssetState.READY);
2193
+ if (readyStale.length === 0) {
2194
+ // Уже DIRTY/GENERATING/FAILED — регенерация инициирована или идёт;
2195
+ // повторная маркировка была бы неидемпотентной и опасной.
2196
+ continue;
2197
+ }
2198
+
2199
+ log(`[VERSION-STALE] ${entry.bookId}/${entry.chapterId}/${entry.sceneId} → ${readyStale.join(', ')} (READY → DIRTY, previous artifact preserved until replacement)`);
2200
+ await orchestrator.markDirtyScene(redis, entry.bookId, entry.chapterId, entry.sceneId, readyStale);
2201
+ outdated++;
2137
2202
  }
2138
2203
  return outdated;
2139
2204
  } catch (err) {
@@ -571,6 +571,8 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
571
571
 
572
572
  let dispatched = 0;
573
573
  let throttled = 0;
574
+ let anyCompleted = false;
575
+ const failures = [];
574
576
 
575
577
  // Dispatch ALL eligible stages in this tick.
576
578
  // Concurrency limits per stage type are handled by dispatch engine.
@@ -589,10 +591,29 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
589
591
  dispatched++;
590
592
  } else if (result.reason === 'backpressure' || result.reason === 'throttled') {
591
593
  throttled++;
594
+ } else if (result.completed) {
595
+ // Stage finished inside the executor (already_done / cache_hit /
596
+ // no_segments) — not a failure, nothing to dispatch.
597
+ anyCompleted = true;
598
+ } else {
599
+ // Stage 50: раньше per-stage отказ (circuit_open, retry budget,
600
+ // lease, generating_transition_failed, …) молча проглатывался и
601
+ // tick писал «undefined for <scene>» — ошибки исчезали из
602
+ // observability. Теперь причина всплывает в tick-сводку.
603
+ failures.push(`${stage}:${result.reason || 'unknown'}`);
592
604
  }
593
605
  }
594
606
 
595
- return { dispatched, throttled, stages: stages.length };
607
+ if (dispatched > 0) {
608
+ return { dispatched, throttled, stages: stages.length };
609
+ }
610
+ if (failures.length > 0) {
611
+ return { dispatched: 0, throttled, stages: stages.length, success: false, reason: failures.join(', ') };
612
+ }
613
+ if (anyCompleted) {
614
+ return { dispatched: 0, throttled, completed: true, reason: 'stage_completed' };
615
+ }
616
+ return { dispatched: 0, throttled, stages: stages.length, success: true, reason: 'no_dispatch' };
596
617
  }
597
618
 
598
619
  // ======================================================
@@ -615,6 +636,7 @@ module.exports = {
615
636
  // Scheduling
616
637
  shouldScheduleAssets,
617
638
  detectVersionStale,
639
+ attemptDispatch,
618
640
  markVersionStaleDirty,
619
641
  getLayerConfig,
620
642
  checkChunksHaveImages,