@animastor/orchestration 0.1.7 → 0.2.0

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 ADDED
@@ -0,0 +1,107 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0 (2026-10-27)
4
+
5
+ Version-line split for `stage143a-foundation-phase1`: the branch line moves
6
+ `0.1.6 → 0.2.0` so it can never collide with, or be shadowed by, the hotfix
7
+ line. **No functional change** — the content is the `0.1.6` code below (D1 +
8
+ A1 already in this branch).
9
+
10
+ **The hotfix line is separate:** `0.1.7` (branch `hotfix/d1-d2-a1`) is cut
11
+ from `0.1.4`, not from this line, and carries only D1 + the A1 version-gate
12
+ fallback (D2 and the host side of A1 live in `backend`). `^0.1.x` therefore
13
+ resolves within the `0.1.x` line, while this branch's backend depends on
14
+ `^0.2.0`.
15
+
16
+ ## 0.1.7 (2026-10-27) — hotfix line (содержимое входит в 0.2.0)
17
+
18
+ Hotfix line (branch `hotfix/d1-d2-a1`, base `78e8f1ae` = `0.1.4`): only the
19
+ D1 and A1-gate fixes are carried in this package. It is a **separate line from
20
+ the `0.1.6` entry of the `stage143a-foundation-phase1` branch** — this package
21
+ is `0.1.4` + the two fixes, not a descendant of `0.1.5`/`0.1.6`.
22
+
23
+ **In this package:** D1, A1-gate. **In `backend` (not here):** D2 and the host
24
+ side of A1 (`scene-assets-repo.getAssetNullBuild` + runtime adapter + port op).
25
+
26
+ ### Fixed
27
+
28
+ - `orchestration/orchestrator.js` (D1, stage 146) — the
29
+ `require('@animastor/generation').artifactNaming` binding lived INSIDE
30
+ `beginStage()`, but `resetScenes()` also reads it in the stale-PNG/preview
31
+ pre-delete branch (step 6). Any regeneration that passed `cleanPngUnitIds`
32
+ therefore threw `ReferenceError: artifactNaming is not defined`, and
33
+ `/regenerate` returned HTTP 500. The binding now sits at module scope, next
34
+ to the other module-level requires; `beginStage()` keeps its own
35
+ `dispatchEngine` lazy require. Behavior is otherwise unchanged.
36
+ - `orchestration/orchestrator.js` (`completeStage` version gate, A1 /
37
+ stage 146 §5.4) — when `sceneAssets.getAsset(..., buildId)` found no row for
38
+ THIS build, the gate went fail-closed (DIRTY) even though the key's only row
39
+ was a legacy `build_id IS NULL` row: `getAsset` filters `build_id = $5`, and
40
+ `markReady` — the only writer able to stamp `build_id`
41
+ (`buildIdFromArtifactPath`) — runs BEHIND the gate. The scene then
42
+ re-dispatched every 5 s tick (`cache_hit` → callback → `stale` → DIRTY)
43
+ forever. The gate now performs exactly ONE additional read of the SAME key
44
+ with `build_id IS NULL` (never another build's row, never "latest any row")
45
+ through the PersistencePort op `sceneAssets.getAssetNullBuild`, applies the
46
+ SAME version rules (`scene_content_version`, plus
47
+ `scene_audio_config_version` for audio), and lets `markReady` stamp
48
+ `build_id` as before. A NULL row whose version is below the scene version
49
+ still fails the gate (DIRTY), so the loop converges after the next real
50
+ dispatch instead of spinning. PG errors stay fail-closed.
51
+
52
+ ### Shipped by backend, not by this package
53
+
54
+ - D2 — `/api/v1/debug/runtime/*` middleware now calls `next()`
55
+ (`backend/src/routes/debug-routes.cjs`).
56
+ - A1 host side — `sceneAssets.getAssetNullBuild` implementation and delegation
57
+ (`backend/src/storage/postgres/repositories/scene-assets-repo.js`,
58
+ `backend/src/storage/runtime-persistence-adapter.js`) plus the op
59
+ declaration in `runtime/persistence-port.js`.
60
+
61
+ ## 0.1.6 (2026-10-26)
62
+
63
+ Stage 146 — D1 fix: `artifactNaming` hoisted to module scope.
64
+ Stage 147 — A1 fix: version-gate sees the legacy `build_id IS NULL` row.
65
+
66
+ ### Fixed
67
+
68
+ - `orchestration/orchestrator.js` — the `require('@animastor/generation')
69
+ .artifactNaming` binding lived INSIDE `beginStage()`, but
70
+ `resetScenes()` also reads it in the stale-PNG/preview pre-delete branch
71
+ (step 6). Any regeneration that passed `cleanPngUnitIds` therefore threw
72
+ `ReferenceError: artifactNaming is not defined`, and `/regenerate`
73
+ returned HTTP 500. The binding now sits at module scope, next to the
74
+ other module-level requires; `beginStage()` keeps its own
75
+ `dispatchEngine` lazy require. Behavior is otherwise unchanged.
76
+ - `orchestration/orchestrator.js` (`completeStage` version gate, A1 /
77
+ stage146 §5.4) — when `sceneAssets.getAsset(..., buildId)` found no row
78
+ for THIS build, the gate went fail-closed (DIRTY) even though the key's
79
+ only row was a legacy `build_id IS NULL` row: `getAsset` filters
80
+ `build_id = $5`, and `markReady` — the only writer able to stamp
81
+ `build_id` (`buildIdFromArtifactPath`) — runs BEHIND the gate. The scene
82
+ then re-dispatched every 5 s tick (`cache_hit` → callback → `stale` →
83
+ DIRTY) forever. The gate now performs exactly ONE additional read of the
84
+ SAME key with `build_id IS NULL` (never another build's row, never
85
+ "latest any row") through the same PersistencePort op
86
+ `sceneAssets.getAssetNullBuild`, applies the SAME version rules
87
+ (`scene_content_version`, plus `scene_audio_config_version` for audio),
88
+ and lets `markReady` stamp `build_id` as before. A NULL row whose
89
+ version is below the scene version still fails the gate (DIRTY) — the
90
+ correct version arrives from `stampSceneVersionsForDispatch` (dispatch
91
+ step 5.5 stamps ALL rows of the key, incl. `build_id IS NULL`, before
92
+ every generation/cache_hit), so the loop converges after the next real
93
+ dispatch instead of spinning. PG errors stay fail-closed.
94
+
95
+ ### Changed (host adapter, A1)
96
+
97
+ - `backend/src/storage/postgres/repositories/scene-assets-repo.js` — new
98
+ `getAssetNullBuild` (same key, `build_id IS NULL`, latest NULL row).
99
+ - `backend/src/storage/runtime-persistence-adapter.js` — delegates the new
100
+ op; `runtime/persistence-port.js` — declares `sceneAssets.getAssetNullBuild`
101
+ in `OPS` (no SQL crosses the port).
102
+
103
+ ## 0.1.5 (2026-10-26)
104
+
105
+ Stage 143a — versioned-pointer readers + atomic publication. See the
106
+ player 0.1.2/0.1.3 entries (same stage) for the flag-gated IU-media
107
+ contour this package complements.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animastor/orchestration",
3
- "version": "0.1.7",
3
+ "version": "0.2.0",
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",
@@ -18,7 +18,8 @@
18
18
  "src/",
19
19
  "test/",
20
20
  "README.md",
21
- "LICENSE"
21
+ "LICENSE",
22
+ "CHANGELOG.md"
22
23
  ],
23
24
  "scripts": {
24
25
  "test": "mocha --exit --require test/test-bindings.js test/*.test.js"
@@ -277,9 +277,17 @@ 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
- const sceneImage = image.resolveCanonicalSceneImage(
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(
281
284
  artifactRoot(), buildId, bookId, chapterId, sceneId
282
285
  );
286
+ if (!sceneImage && typeof image.resolveVersionedSceneProof === 'function') {
287
+ sceneImage = await image.resolveVersionedSceneProof(
288
+ artifactRoot(), buildId, bookId, chapterId, sceneId
289
+ );
290
+ }
283
291
 
284
292
  // Per-asset check
285
293
  const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
@@ -334,7 +342,28 @@ async function handleImageCompleted(redis, bookId, chapterId, sceneId, buildId)
334
342
  const stillPending = [];
335
343
  for (const uid of dirtyIds) {
336
344
  const pngPath = path.join(buildDir, artifactNaming.sceneImageName(bookId, chapterId, sceneId, uid));
337
- if (!fs.existsSync(pngPath)) {
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) {
338
367
  stillPending.push(uid);
339
368
  }
340
369
  }
@@ -343,8 +372,31 @@ async function handleImageCompleted(redis, bookId, chapterId, sceneId, buildId)
343
372
  log(`[DIRTY-UNITS-CLEARED] ${bookId}/${chapterId}/${sceneId}: all ${dirtyIds.length} dirty unit(s) completed, cleared`);
344
373
  try {
345
374
  const iuPrefix = artifactNaming.iuImagePrefix(bookId, chapterId, sceneId);
375
+ const scenePrefix = artifactNaming.scenePrefix(bookId, chapterId, sceneId);
346
376
  const allFiles = fs.existsSync(buildDir) ? fs.readdirSync(buildDir) : [];
347
- const iuFileCount = allFiles.filter(f => f.startsWith(iuPrefix) && f.endsWith('.png')).length;
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;
348
400
  if (iuFileCount > 0) {
349
401
  const progKey = `animastor:iu-progress:${bookId}:${chapterId}:${sceneId}:image`;
350
402
  await redis.set(progKey, String(iuFileCount), 'EX', 14400);
@@ -13,10 +13,15 @@ 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');
16
20
  // O-2: persistence arrives ONLY through the PersistencePort (host adapter:
17
21
  // storage/runtime-persistence-adapter) — the scene-assets repository left
18
22
  // this file. SQL lives host-side.
19
23
  const persist = require('../runtime/persistence-port').persist;
24
+ const isPersistencePortWired = require('../runtime/persistence-port').isPersistencePortWired;
20
25
  // O-3: scene content arrives ONLY through the SceneDataPort (host adapter:
21
26
  // storage/scene-data-adapter) — the Book Model facade (../book →
22
27
  // @animastor/vbook-runtime) left this file. Aliased `sceneDataPort` because
@@ -300,8 +305,22 @@ async function executeAudioDispatch(redis, scene, loadedBook, buildId, dispatchI
300
305
  const completion = await completeStage(redis, bookId, chapterId, sceneId, 'audio', buildId, dispatchId);
301
306
  return { dispatched: false, jobs: 0, completed: completion.completed, reason: 'no_segments' };
302
307
  } else {
303
- warn(`AUDIO_DISPATCH: generateSceneAudio did not send jobs for ${bookId}/${chapterId}/${sceneId}`);
304
- return { dispatched: false, jobs: 0, reason: 'no_jobs_generated' };
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 };
305
324
  }
306
325
  }
307
326
 
@@ -352,8 +371,11 @@ async function executeImageDispatch(redis, scene, loadedBook, buildId, dispatchI
352
371
 
353
372
  // T3: generateSceneIUImages возвращает количество отправленных jobs
354
373
  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;
355
377
  try {
356
- const genResult = await image.generateSceneIUImages(
378
+ genResult = await image.generateSceneIUImages(
357
379
  redis,
358
380
  sceneData,
359
381
  bookData,
@@ -377,6 +399,16 @@ async function executeImageDispatch(redis, scene, loadedBook, buildId, dispatchI
377
399
  return { dispatched: true, jobs: jobsSent, reason: null };
378
400
  }
379
401
 
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
+
380
412
  log(`IMAGE_DISPATCH: ${bookId}/${chapterId}/${sceneId} — no jobs sent (cache hit or empty units)`);
381
413
  return { dispatched: false, jobs: 0, reason: 'no_jobs_sent' };
382
414
  }
@@ -551,15 +583,41 @@ async function executeVideoDispatch(redis, scene, loadedBook, buildId, dispatchI
551
583
  await videoOrch.setWaitingChunks(redis, bookId, chapterId, sceneId);
552
584
 
553
585
  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();
554
590
  for (const jobSpec of jobsToSend) {
555
591
  try {
556
- const sendResult = await provider.generate(jobSpec);
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
+ }
557
612
  if (sendResult.sent) {
558
613
  sentCount++;
559
614
  } else {
560
- warn(`VIDEO_DISPATCH: Hub rejected job ${jobSpec.job_id}: ${sendResult.error || 'unknown'}`);
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}`);
561
618
  }
562
619
  } catch (sendErr) {
620
+ notSentReasons.set('send_failed', (notSentReasons.get('send_failed') || 0) + 1);
563
621
  warn(`VIDEO_DISPATCH: provider generate failed for job ${jobSpec.job_id}: ${sendErr.message}`);
564
622
  }
565
623
  }
@@ -569,8 +627,13 @@ async function executeVideoDispatch(redis, scene, loadedBook, buildId, dispatchI
569
627
  return { dispatched: true, jobs: sentCount, reason: null };
570
628
  }
571
629
 
572
- warn(`VIDEO_DISPATCH: ${bookId}/${chapterId}/${sceneId}: all provider.generate calls failed`);
573
- return { dispatched: false, jobs: 0, reason: 'send_failed' };
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 };
574
637
  }
575
638
 
576
639
  // T4: dispatchStage принимает dispatchId (из dispatch-engine) и передаёт executors