@animastor/orchestration 0.1.5 → 0.1.7

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.5",
3
+ "version": "0.1.7",
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",
@@ -20,6 +20,10 @@
20
20
 
21
21
  // S-2: media-type knowledge (stage lists, fail-event map) resolved via registry
22
22
  const mediaRegistry = require('@animastor/generation').mediaRegistry;
23
+ // S-4: filename grammar composed from the canonical owner (bytes unchanged).
24
+ // D1 (stage146): must live at module scope — resetScenes() uses it outside the
25
+ // beginStage() body, so a per-function declaration threw ReferenceError there.
26
+ const artifactNaming = require('@animastor/generation').artifactNaming;
23
27
  // S-5: pure FSM-writer ownership (markDirtyScene/setScene*) — re-exported from
24
28
  // the state layer; bodies moved verbatim (see state/scene-state-ops.js header).
25
29
  const { lazyHostBinding } = require('../host/host-bindings');
@@ -56,8 +60,6 @@ async function planScene(redis, bookId, chapterId, sceneId) {
56
60
  // для исполнения генерации).
57
61
  async function beginStage(redis, scene, loadedBook, buildId, stage) {
58
62
  const dispatchEngine = require('../runtime/dispatch-engine');
59
- // S-4: filename grammar composed from the canonical owner (bytes unchanged)
60
- const artifactNaming = require('@animastor/generation').artifactNaming;
61
63
  const bookId = scene.book_id;
62
64
  const chapterId = scene.chapter_id;
63
65
  const sceneId = scene.scene_id;
@@ -157,20 +159,37 @@ async function completeStage(redis, bookId, chapterId, sceneId, stage, buildId,
157
159
 
158
160
  if (sceneRows.length === 1) {
159
161
  const sv = sceneRows[0];
160
- const asset = await persist('sceneAssets.getAsset')(bookId, chapterId, sceneId, stage, buildId);
161
-
162
- if (asset) {
162
+ const versionsConfirm = (row) => {
163
163
  const contentVersionConfirmed =
164
- asset.scene_content_version != null &&
164
+ row.scene_content_version != null &&
165
165
  sv.content_version != null &&
166
- asset.scene_content_version >= sv.content_version;
166
+ row.scene_content_version >= sv.content_version;
167
167
  const audioVersionConfirmed =
168
168
  stage !== 'audio' || (
169
- asset.scene_audio_config_version != null &&
169
+ row.scene_audio_config_version != null &&
170
170
  sv.audio_config_version != null &&
171
- asset.scene_audio_config_version >= sv.audio_config_version
171
+ row.scene_audio_config_version >= sv.audio_config_version
172
172
  );
173
- shouldWriteReady = contentVersionConfirmed && audioVersionConfirmed;
173
+ return contentVersionConfirmed && audioVersionConfirmed;
174
+ };
175
+
176
+ let asset = await persist('sceneAssets.getAsset')(bookId, chapterId, sceneId, stage, buildId);
177
+
178
+ if (!asset) {
179
+ // A1 (stage147): единственная строка ключа может быть
180
+ // легаси-строкой с build_id = NULL (Stage 73/76 семейство):
181
+ // getAsset(buildId) её не видит, а markReady — единственный
182
+ // писатель build_id — вызывается ПОСЛЕ gate (замкнутый цикл
183
+ // DIRTY каждые 5 с, stage146 §5.4). РОВНО одно дополнительное
184
+ // чтение строки того же ключа с build_id IS NULL — не строки
185
+ // другого билда и не «последней любой». Версии проверяются
186
+ // теми же правилами; после пропуска markReady проставит
187
+ // build_id по существующей логике (buildIdFromArtifactPath).
188
+ asset = await persist('sceneAssets.getAssetNullBuild')(bookId, chapterId, sceneId, stage);
189
+ }
190
+
191
+ if (asset) {
192
+ shouldWriteReady = versionsConfirm(asset);
174
193
  }
175
194
  } else if (sceneRows.length === 0) {
176
195
  // Legacy-книга без version-схемы — READY разрешён (сцена
@@ -277,17 +277,9 @@ async function handleImageCompleted(redis, bookId, chapterId, sceneId, buildId)
277
277
  // T1: возвращает { ok, retryable, reason, artifact }
278
278
  log(`IMAGE_CALLBACK: ${bookId}/${chapterId}/${sceneId}`);
279
279
 
280
- // Stage 143a: legacy canonical scan first (unchanged behavior, also the
281
- // path every host stub knows), then — flag on and scan empty — the
282
- // published pointer of this scene build as the completion proof.
283
- let sceneImage = image.resolveCanonicalSceneImage(
280
+ const sceneImage = image.resolveCanonicalSceneImage(
284
281
  artifactRoot(), buildId, bookId, chapterId, sceneId
285
282
  );
286
- if (!sceneImage && typeof image.resolveVersionedSceneProof === 'function') {
287
- sceneImage = await image.resolveVersionedSceneProof(
288
- artifactRoot(), buildId, bookId, chapterId, sceneId
289
- );
290
- }
291
283
 
292
284
  // Per-asset check
293
285
  const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
@@ -342,28 +334,7 @@ async function handleImageCompleted(redis, bookId, chapterId, sceneId, buildId)
342
334
  const stillPending = [];
343
335
  for (const uid of dirtyIds) {
344
336
  const pngPath = path.join(buildDir, artifactNaming.sceneImageName(bookId, chapterId, sceneId, uid));
345
- if (fs.existsSync(pngPath)) continue;
346
- // ── Stage 143a (phase 2a · N1) ─────────────────────
347
- // A versioned-only build never writes the canonical file, so
348
- // the published pointer is the completion proof for this
349
- // unit. Without it the dirty marker is never cleared and the
350
- // unit is force-regenerated on EVERY reconcile pass (infinite
351
- // regeneration loop with the flag on).
352
- // Flag OFF ⇒ the facade answers null with zero PG calls → the
353
- // legacy check above decides, exactly as before.
354
- // A pointer-store ERROR keeps the unit pending (never cleared
355
- // on an unknown verdict) and is logged.
356
- let provenByPointer = false;
357
- try {
358
- if (image && typeof image.resolveIuImagePath === 'function') {
359
- const published = await image.resolveIuImagePath(
360
- artifactRoot(), buildId, bookId, chapterId, sceneId, uid);
361
- provenByPointer = !!(published && published.status === 'ok');
362
- }
363
- } catch (pgErr) {
364
- warn(`[DIRTY-UNITS] pointer proof unavailable for ${uid}: ${pgErr.message} — keeping it pending`);
365
- }
366
- if (!provenByPointer) {
337
+ if (!fs.existsSync(pngPath)) {
367
338
  stillPending.push(uid);
368
339
  }
369
340
  }
@@ -372,31 +343,8 @@ async function handleImageCompleted(redis, bookId, chapterId, sceneId, buildId)
372
343
  log(`[DIRTY-UNITS-CLEARED] ${bookId}/${chapterId}/${sceneId}: all ${dirtyIds.length} dirty unit(s) completed, cleared`);
373
344
  try {
374
345
  const iuPrefix = artifactNaming.iuImagePrefix(bookId, chapterId, sceneId);
375
- const scenePrefix = artifactNaming.scenePrefix(bookId, chapterId, sceneId);
376
346
  const allFiles = fs.existsSync(buildDir) ? fs.readdirSync(buildDir) : [];
377
- // ── Stage 143a (phase 2a · N2) ─────────────────
378
- // Ready units = canonical file names ∪ published pointer
379
- // ids (versioned files are invisible to this scan). The
380
- // counter is a DISPLAY source (consumers clamp it), so a
381
- // pointer-store error here only logs and leaves the
382
- // legacy count — never a readiness decision.
383
- const readyIds = new Set();
384
- for (const f of allFiles) {
385
- if (f.startsWith(iuPrefix) && f.endsWith('.png')) {
386
- readyIds.add(f.startsWith(`${scenePrefix}_`)
387
- ? f.slice(scenePrefix.length + 1, -4)
388
- : f);
389
- }
390
- }
391
- if (image && typeof image.listPublishedIuIdsForScene === 'function') {
392
- try {
393
- const publishedIds = await image.listPublishedIuIdsForScene(buildId, bookId, chapterId, sceneId);
394
- for (const id of publishedIds) readyIds.add(String(id));
395
- } catch (pgErr) {
396
- warn(`[IU-COUNTER-RESET] published ids unavailable for ${bookId}/${chapterId}/${sceneId}: ${pgErr.message} — counting canonical files only`);
397
- }
398
- }
399
- const iuFileCount = readyIds.size;
347
+ const iuFileCount = allFiles.filter(f => f.startsWith(iuPrefix) && f.endsWith('.png')).length;
400
348
  if (iuFileCount > 0) {
401
349
  const progKey = `animastor:iu-progress:${bookId}:${chapterId}:${sceneId}:image`;
402
350
  await redis.set(progKey, String(iuFileCount), 'EX', 14400);
@@ -13,15 +13,10 @@ const chunks = lazyHostBinding('media.audio.chunks');
13
13
  // orchestrator never requires gpu-dispatcher or the workflow connector.
14
14
  const provider = require('@animastor/generation').comfyuiProvider;
15
15
  const runtimeScheduler = require('../runtime/runtime-scheduler');
16
- // Stage 118 (M3): the shared outcome taxonomy — the dispatch engine (runtime
17
- // tier) and the stage executors (orchestration tier) must classify a
18
- // "nothing was enqueued" outcome IDENTICALLY.
19
- const { classifyNotSentReasons } = require('../runtime/dispatch-outcomes');
20
16
  // O-2: persistence arrives ONLY through the PersistencePort (host adapter:
21
17
  // storage/runtime-persistence-adapter) — the scene-assets repository left
22
18
  // this file. SQL lives host-side.
23
19
  const persist = require('../runtime/persistence-port').persist;
24
- const isPersistencePortWired = require('../runtime/persistence-port').isPersistencePortWired;
25
20
  // O-3: scene content arrives ONLY through the SceneDataPort (host adapter:
26
21
  // storage/scene-data-adapter) — the Book Model facade (../book →
27
22
  // @animastor/vbook-runtime) left this file. Aliased `sceneDataPort` because
@@ -305,22 +300,8 @@ async function executeAudioDispatch(redis, scene, loadedBook, buildId, dispatchI
305
300
  const completion = await completeStage(redis, bookId, chapterId, sceneId, 'audio', buildId, dispatchId);
306
301
  return { dispatched: false, jobs: 0, completed: completion.completed, reason: 'no_segments' };
307
302
  } else {
308
- // Stage 116 (D3) / Stage 118 (B3, M3): nothing was enqueued — carry
309
- // the precise reason (no live worker / everything already queued /
310
- // ambiguous POST) so the dispatch engine can apply a BOUNDED deferral
311
- // instead of a fatal cancel.
312
- //
313
- // Stage 118 (B3): the merged (pure-dialogue) path and the per-segment
314
- // path must produce the SAME classification — the merged branch used
315
- // to leave the reason empty, so a pure-dialogue scene kept the
316
- // 5-second retry loop. `deferReason` is computed by the host (audio
317
- // generation) for EVERY path; the fallback below keeps the historic
318
- // 'no_jobs_generated' semantics when the host could not classify it.
319
- const deferReason = (result && result.reason === 'no_jobs_accepted' && result.deferReason)
320
- ? result.deferReason
321
- : 'no_jobs_generated';
322
- warn(`AUDIO_DISPATCH: generateSceneAudio did not send jobs for ${bookId}/${chapterId}/${sceneId} (${deferReason})`);
323
- return { dispatched: false, jobs: 0, reason: deferReason, retryable: true };
303
+ warn(`AUDIO_DISPATCH: generateSceneAudio did not send jobs for ${bookId}/${chapterId}/${sceneId}`);
304
+ return { dispatched: false, jobs: 0, reason: 'no_jobs_generated' };
324
305
  }
325
306
  }
326
307
 
@@ -371,11 +352,8 @@ async function executeImageDispatch(redis, scene, loadedBook, buildId, dispatchI
371
352
 
372
353
  // T3: generateSceneIUImages возвращает количество отправленных jobs
373
354
  let jobsSent = 0;
374
- // Stage 116 (D3): the result is needed AFTER the try/catch (the deferral
375
- // reason) — it must be visible outside the block.
376
- let genResult = null;
377
355
  try {
378
- genResult = await image.generateSceneIUImages(
356
+ const genResult = await image.generateSceneIUImages(
379
357
  redis,
380
358
  sceneData,
381
359
  bookData,
@@ -399,16 +377,6 @@ async function executeImageDispatch(redis, scene, loadedBook, buildId, dispatchI
399
377
  return { dispatched: true, jobs: jobsSent, reason: null };
400
378
  }
401
379
 
402
- // Stage 116 (D3) / Stage 118 (M3): the executor sent NOTHING. That is
403
- // not a lost task and not a fatal cancellation — it is a bounded
404
- // deferral. The precise reason (no live worker / everything already
405
- // queued / ambiguous POST) is carried through so the deferral can be
406
- // classified by the shared taxonomy (runtime/dispatch-outcomes).
407
- if (genResult && genResult.deferReason) {
408
- warn(`IMAGE_DISPATCH: ${bookId}/${chapterId}/${sceneId} — nothing enqueued (${genResult.deferReason}); deferring`);
409
- return { dispatched: false, jobs: 0, reason: genResult.deferReason, retryable: true };
410
- }
411
-
412
380
  log(`IMAGE_DISPATCH: ${bookId}/${chapterId}/${sceneId} — no jobs sent (cache hit or empty units)`);
413
381
  return { dispatched: false, jobs: 0, reason: 'no_jobs_sent' };
414
382
  }
@@ -583,41 +551,15 @@ async function executeVideoDispatch(redis, scene, loadedBook, buildId, dispatchI
583
551
  await videoOrch.setWaitingChunks(redis, bookId, chapterId, sceneId);
584
552
 
585
553
  let sentCount = 0;
586
- // Stage 116 (D3) / Stage 118 (M3): collect WHY a job was not sent — the
587
- // dominant reason becomes the deferral reason (bounded backoff) instead
588
- // of the generic 'send_failed'.
589
- const notSentReasons = new Map();
590
554
  for (const jobSpec of jobsToSend) {
591
555
  try {
592
- let sendResult = await provider.generate(jobSpec);
593
- // Stage 122 (N2): the group file was probed absent above (a cached
594
- // group never reaches this loop), so an `already_completed` verdict
595
- // is by construction an ORPHANED completion marker. Free it and
596
- // retry ONCE so the video regenerates instead of looping on a
597
- // 900 s deferral with the scene stuck in the active index (audit
598
- // 121 · N2). The release rides the persistence port — the
599
- // orchestration tier cannot require the backend helper directly.
600
- // Skipped when the port is unwired (harnesses that don't exercise
601
- // orphan cleanup); production always wires it in backend.cjs.
602
- if (isPersistencePortWired()) {
603
- let released = false;
604
- try {
605
- released = await persist('idem.releaseTaskDoneIfOrphan')(redis, sendResult);
606
- } catch (_) { released = false; }
607
- if (released) {
608
- log(`VIDEO_DISPATCH: orphaned completion marker released for ${jobSpec.job_id} — regenerating`);
609
- sendResult = await provider.generate(jobSpec);
610
- }
611
- }
556
+ const sendResult = await provider.generate(jobSpec);
612
557
  if (sendResult.sent) {
613
558
  sentCount++;
614
559
  } else {
615
- const reason = sendResult.reason || 'send_failed';
616
- notSentReasons.set(reason, (notSentReasons.get(reason) || 0) + 1);
617
- warn(`VIDEO_DISPATCH: Hub rejected job ${jobSpec.job_id}: ${sendResult.error || reason}`);
560
+ warn(`VIDEO_DISPATCH: Hub rejected job ${jobSpec.job_id}: ${sendResult.error || 'unknown'}`);
618
561
  }
619
562
  } catch (sendErr) {
620
- notSentReasons.set('send_failed', (notSentReasons.get('send_failed') || 0) + 1);
621
563
  warn(`VIDEO_DISPATCH: provider generate failed for job ${jobSpec.job_id}: ${sendErr.message}`);
622
564
  }
623
565
  }
@@ -627,13 +569,8 @@ async function executeVideoDispatch(redis, scene, loadedBook, buildId, dispatchI
627
569
  return { dispatched: true, jobs: sentCount, reason: null };
628
570
  }
629
571
 
630
- // Stage 116 (D3) / Stage 118 (M3): the shared taxonomy decides whether
631
- // the outcome is a bounded deferral. EVERY reason must be deferrable —
632
- // a single real failure (send_failed) keeps the historic 'send_failed'
633
- // semantics (an immediate retry on the next tick).
634
- const deferReason = classifyNotSentReasons(notSentReasons) || 'send_failed';
635
- warn(`VIDEO_DISPATCH: ${bookId}/${chapterId}/${sceneId}: nothing enqueued (${deferReason})`);
636
- return { dispatched: false, jobs: 0, reason: deferReason, retryable: true };
572
+ warn(`VIDEO_DISPATCH: ${bookId}/${chapterId}/${sceneId}: all provider.generate calls failed`);
573
+ return { dispatched: false, jobs: 0, reason: 'send_failed' };
637
574
  }
638
575
 
639
576
  // T4: dispatchStage принимает dispatchId (из dispatch-engine) и передаёт executors