@animastor/orchestration 0.2.0 → 0.3.1

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.
@@ -21,6 +21,9 @@ const progressEventsOp = require('./progress-events-port').progressEventsOp;
21
21
  // storage/runtime-persistence-adapter) — no PG repository or database
22
22
  // handles in the runtime tier.
23
23
  const persist = require('./persistence-port').persist;
24
+ // Generation only by explicit command: the single authority for "which
25
+ // scene×stage may be scheduled" (task registry ∧ layer enabled ∧ not cancelled).
26
+ const commandGate = require('./command-gate');
24
27
 
25
28
  const logPrefix = '[SCHEDULER]';
26
29
 
@@ -41,18 +44,10 @@ function error(msg) {
41
44
  // ======================================================
42
45
 
43
46
  /**
44
- * Read layer config for a book.
47
+ * Read layer config for a book (owned by ./command-gate, re-exported here).
45
48
  */
46
49
  async function getLayerConfig(redis, bookId) {
47
- const layerKey = `animastor:layer-config:${bookId}`;
48
- const layerRaw = await redis.get(layerKey);
49
- if (layerRaw) {
50
- try { return JSON.parse(layerRaw); } catch (_) {}
51
- }
52
- // S-2: default layer config derived from registry
53
- const defaults = {};
54
- for (const t of mediaRegistry.listMediaTypes()) { defaults[`${t}_enabled`] = true; }
55
- return defaults;
50
+ return commandGate.getLayerConfig(redis, bookId);
56
51
  }
57
52
 
58
53
  /**
@@ -100,7 +95,8 @@ const activeScenes = require('./active-scenes-index');
100
95
  const ACTIVE_SCENES_KEY = activeScenes.ACTIVE_SCENES_KEY;
101
96
 
102
97
  /**
103
- * Add scene to active index. T8: делегирует в active-scenes-index.js.
98
+ * Add scene to active index. T8: делегирует в active-scenes-index.js, which
99
+ * is command-gated: without an authorized stage the scene is NOT added.
104
100
  */
105
101
  async function addSceneToActiveIndex(redis, bookId, chapterId, sceneId) {
106
102
  const result = await activeScenes.addActiveScene(redis, bookId, chapterId, sceneId);
@@ -262,57 +258,50 @@ async function detectVersionStale(redis, bookId, chapterId, sceneId) {
262
258
  * state-writers map). Returns the number of assets reset.
263
259
  */
264
260
  async function markVersionStaleDirty(redis, bookId, chapterId, sceneId) {
261
+ // Command-only generation: this pass only MARKS stale assets (READY →
262
+ // DIRTY + PG stale via markDirtyScene). It never queues work and never
263
+ // touches the active index — whether a DIRTY stage is dispatched is
264
+ // decided solely by the command gate in shouldScheduleAssets. Disabled
265
+ // layers are left untouched.
265
266
  const layerCfg = await getLayerConfig(redis, bookId);
266
- const taskState = await progressEventsOp('getSceneTaskState')(redis, bookId, chapterId, sceneId);
267
267
  const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
268
- const audioEnabled = taskState.managed
269
- ? taskState.activeTypes.has('audio')
270
- : layerCfg.audio_enabled !== false;
271
- const imageEnabled = taskState.managed
272
- ? taskState.activeTypes.has('image')
273
- : layerCfg.image_enabled !== false;
274
- const videoEnabled = taskState.managed
275
- ? taskState.activeTypes.has('video')
276
- : layerCfg.video_enabled !== false;
277
-
278
- log(`[VERSION-DIRTY] ${bookId}/${chapterId}/${sceneId}: PG version mismatch — resetting per-asset states for dispatch`);
268
+
269
+ log(`[VERSION-DIRTY] ${bookId}/${chapterId}/${sceneId}: PG version mismatch — marking READY assets stale (no scheduling)`);
279
270
  // T8: через фасад — единый владелец DIRTY
280
271
  // S-5: asset-state writer via the injected orchestration seam
281
272
  const markDirtyScene = orchestrationSeams.getOrchestrationSeam('markDirtyScene');
282
- const dirtyAssets = [];
283
- if (audioEnabled && assetStates.audio === state.AssetState.READY) dirtyAssets.push('audio');
284
- if (imageEnabled && assetStates.image === state.AssetState.READY) dirtyAssets.push('image');
285
- if (videoEnabled && assetStates.video === state.AssetState.READY) dirtyAssets.push('video');
273
+ const dirtyAssets = mediaRegistry.listMediaTypes().filter(stage =>
274
+ commandGate.isLayerEnabled(layerCfg, stage) &&
275
+ assetStates[stage] === state.AssetState.READY
276
+ );
286
277
  if (dirtyAssets.length > 0) {
287
278
  await markDirtyScene(redis, bookId, chapterId, sceneId, dirtyAssets);
288
279
  }
289
280
  return dirtyAssets.length;
290
281
  }
291
282
 
292
- async function shouldScheduleAssets(redis, bookId, chapterId, sceneId) {
283
+ async function shouldScheduleAssets(redis, bookId, chapterId, sceneId, gateCache = null) {
293
284
  // Д.2: This function is now PURE — it only reads per-asset states and layer
294
285
  // config and computes { stages, allDone }. It performs NO writes. The former
295
286
  // version-stale READY→DIRTY reset is an explicit pre-pass in attemptDispatch
296
287
  // (detectVersionStale + markVersionStaleDirty), so the decision is separated
297
288
  // from the mutation (single arbiter — see docs/STATE_WRITERS_MAP.md P3).
298
289
  const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
299
- const layerCfg = await getLayerConfig(redis, bookId);
300
- const taskState = await progressEventsOp('getSceneTaskState')(redis, bookId, chapterId, sceneId);
301
-
302
- // Selective generation tasks are authoritative for their target scenes.
303
- // Book-wide layer preferences are only a fallback for the initial/legacy
304
- // generation flow, where no explicit task registry exists.
305
- const isInFlight = status =>
306
- status === state.AssetState.PENDING || status === state.AssetState.GENERATING;
307
- const audioEnabled = taskState.managed
308
- ? taskState.activeTypes.has('audio')
309
- : layerCfg.audio_enabled !== false || isInFlight(assetStates.audio);
310
- const imageEnabled = taskState.managed
311
- ? taskState.activeTypes.has('image')
312
- : layerCfg.image_enabled !== false || isInFlight(assetStates.image);
313
- const videoEnabled = taskState.managed
314
- ? taskState.activeTypes.has('video')
315
- : layerCfg.video_enabled !== false || isInFlight(assetStates.video);
290
+
291
+ // Generation only by explicit command (command-gate): a stage is
292
+ // schedulable ONLY when an active task-registry task of that stage targets
293
+ // this scene, its layer is enabled and the book is not cancelled. There is
294
+ // NO fallback to book-wide layer preferences and NO in-flight override
295
+ // (formerly PENDING/GENERATING kept a disabled layer dispatching —
296
+ // incident 2026-10-30 BA). A scene without any authorized stage reports
297
+ // allDone and leaves the active index.
298
+ const auth = await commandGate.getAuthorizedStages(redis, bookId, chapterId, sceneId, gateCache);
299
+ const audioEnabled = auth.stages.has('audio');
300
+ const imageEnabled = auth.stages.has('image');
301
+ const videoEnabled = auth.stages.has('video');
302
+ if (auth.stages.size === 0) {
303
+ log(`GATE: ${bookId}:${chapterId}:${sceneId} has no command-authorized stage (${commandGate.describeDenied(auth.denied)})`);
304
+ }
316
305
 
317
306
  // Check if all enabled assets are in terminal states
318
307
  // (after potential version-stale reset above)
@@ -412,8 +401,11 @@ async function tick(redis, loadedBooks = {}) {
412
401
  }
413
402
  }
414
403
 
415
- // Cache force-dispatch flags per book to avoid redundant Redis calls
404
+ // Force set per book: ONLY the scene×stage pairs reset by an explicit
405
+ // regeneration (command-gate force set) — never the whole book.
416
406
  const forceCache = new Map();
407
+ // One command-gate memo per tick (layer config / cancellation per book).
408
+ const gateCache = commandGate.createGateCache();
417
409
  // Cache loaded books per tick to avoid redundant disk reads
418
410
  const bookCache = new Map();
419
411
 
@@ -426,15 +418,13 @@ async function tick(redis, loadedBooks = {}) {
426
418
 
427
419
  const { bookId, chapterId, sceneId } = parsed;
428
420
 
429
- // Check force-dispatch flag for recently regenerated books (cached per book)
430
- let force = false;
431
- if (forceCache.has(bookId)) {
432
- force = forceCache.get(bookId);
433
- } else {
434
- const forceFlag = await redis.get(`animastor:force-dispatch:${bookId}`);
435
- force = forceFlag === '1';
436
- forceCache.set(bookId, force);
421
+ // Force applies only to the scene×stage pairs of the command.
422
+ let bookForce = forceCache.get(bookId);
423
+ if (!bookForce) {
424
+ bookForce = await commandGate.getForceStages(redis, bookId);
425
+ forceCache.set(bookId, bookForce);
437
426
  }
427
+ const force = commandGate.forcedStagesForScene(bookForce, chapterId, sceneId);
438
428
 
439
429
  // Resolve loaded book from cache or load it
440
430
  let sceneLoadedBook = loadedBooks[bookId];
@@ -455,7 +445,7 @@ async function tick(redis, loadedBooks = {}) {
455
445
  }
456
446
 
457
447
  try {
458
- const result = await attemptDispatch(redis, bookId, chapterId, sceneId, sceneLoadedBook, force);
448
+ const result = await attemptDispatch(redis, bookId, chapterId, sceneId, sceneLoadedBook, force, gateCache);
459
449
  summary.processed++;
460
450
 
461
451
  if (result.completed) {
@@ -541,10 +531,19 @@ async function getMetrics(redis) {
541
531
  /**
542
532
  * Attempt to dispatch scene stages using per-asset states.
543
533
  * Supports independent workers: dispatches ALL eligible stages in one tick.
544
- * When force=true, any existing lease is cleared before dispatch.
534
+ *
535
+ * `force` — the stages of THIS scene whose existing lease is cleared before
536
+ * dispatch: a Set/Array of stage names (the scheduler passes the command's
537
+ * force set for the scene) or a boolean (true = every scheduled stage of this
538
+ * one scene). Force never widens to other scenes or stages.
539
+ *
540
+ * A stage that reports `no_worker_available` is BLOCKED (command-gate
541
+ * blockStageNoWorker): its task is marked blocked with «нет воркера <type>»
542
+ * and it is never retried automatically.
543
+ *
545
544
  * Returns { dispatched: number, completed: boolean, reason: string }
546
545
  */
547
- async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, force = false) {
546
+ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, force = false, gateCache = null) {
548
547
  const sceneKey = `${bookId}:${chapterId}:${sceneId}`;
549
548
 
550
549
  // Resolve build_id from manifest (passed from tick with book cache) or null
@@ -558,7 +557,7 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
558
557
  }
559
558
 
560
559
  // Determine which asset stages need scheduling (from per-asset states)
561
- const { stages, allDone } = await shouldScheduleAssets(redis, bookId, chapterId, sceneId);
560
+ const { stages, allDone } = await shouldScheduleAssets(redis, bookId, chapterId, sceneId, gateCache);
562
561
 
563
562
  if (allDone) {
564
563
  await removeSceneFromActiveIndex(redis, bookId, chapterId, sceneId);
@@ -572,7 +571,11 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
572
571
  return { success: true, skip: true, reason: 'no_dispatchable_stages' };
573
572
  }
574
573
 
575
- log(`ATTEMPT_DISPATCH: ${sceneKey} -> stages=[${stages.join(', ')}]${force ? ' (force=true)' : ''}`);
574
+ const forcedStages = force === true
575
+ ? new Set(stages)
576
+ : new Set(force instanceof Set ? force : (Array.isArray(force) ? force : []));
577
+ const forcedList = stages.filter(s => forcedStages.has(s));
578
+ log(`ATTEMPT_DISPATCH: ${sceneKey} -> stages=[${stages.join(', ')}]${forcedList.length ? ` (force=[${forcedList.join(', ')}])` : ''}`);
576
579
 
577
580
  let dispatched = 0;
578
581
  let throttled = 0;
@@ -593,8 +596,15 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
593
596
  stage,
594
597
  loadedBook,
595
598
  buildId,
596
- { force }
599
+ { force: forcedStages.has(stage) }
597
600
  );
601
+ if (!result.dispatched && result.reason === 'no_worker_available') {
602
+ // No live worker of this type: the command is blocked, the stage
603
+ // loses its authorization — no retry on the next tick.
604
+ await commandGate.blockStageNoWorker(redis, bookId, chapterId, sceneId, stage);
605
+ failures.push(`${stage}:blocked_no_worker`);
606
+ continue;
607
+ }
598
608
  if (result.dispatched) {
599
609
  dispatched++;
600
610
  } else if (result.reason === 'backpressure' || result.reason === 'throttled') {
@@ -477,6 +477,13 @@ async function isWindowComplete(redis, bookId) {
477
477
  * Try to slide window forward if all current window scenes have audio + image complete.
478
478
  */
479
479
  async function trySlideWindowOnComplete(redis, bookId, loadedBook, buildId) {
480
+ // Command-only generation: sliding the window forward after its scenes
481
+ // complete is an automatic start of the next scenes. Behind the
482
+ // WINDOW_AUTO_NEXT_ENABLED flag, default OFF (owner decision pending).
483
+ const autoNext = typeof config.windowAutoNextEnabled === 'function' && config.windowAutoNextEnabled();
484
+ if (!autoNext) {
485
+ return { started: 0, remaining: 0, reason: 'auto_next_disabled' };
486
+ }
480
487
  if (await isCancelled(redis, bookId)) {
481
488
  log(`trySlideWindowOnComplete: generation cancelled for ${bookId}`);
482
489
  return { started: 0, remaining: 0, reason: 'cancelled' };
@@ -572,9 +579,14 @@ async function slideWindow(redis, bookId, loadedBook, buildId) {
572
579
  await scheduler.removeSceneFromActiveIndex(redis, bookId, scene.chapter_id, scene.scene_id);
573
580
 
574
581
  const didStart = await startScene(redis, scene, buildId, bookId);
575
- if (didStart) {
576
- started++;
582
+ if (!didStart) {
583
+ // Command-only generation: a scene no command authorizes is not
584
+ // started, and the window pointer does NOT move past it — the
585
+ // window waits for an explicit command instead of skipping scenes.
586
+ log(`Slide stopped at ${scene.chapter_id}/${scene.scene_id}: not started (no command / cancelled)`);
587
+ break;
577
588
  }
589
+ started++;
578
590
  nextIdx++;
579
591
  }
580
592
 
@@ -695,6 +707,15 @@ async function startScene(redis, s, buildId, bookId) {
695
707
  return true;
696
708
  }
697
709
 
710
+ // Command-only generation: a scene window may only start scenes an
711
+ // explicit command authorized (active task-registry task ∧ layer enabled
712
+ // ∧ not cancelled). Without it nothing is written — no PENDING, no index.
713
+ const auth = await require('./command-gate').getAuthorizedStages(redis, bookId, chapterId, sceneId);
714
+ if (auth.stages.size === 0) {
715
+ log(`startScene skipped: ${bookId}/${chapterId}/${sceneId} has no command-authorized stage (${require('./command-gate').describeDenied(auth.denied)})`);
716
+ return false;
717
+ }
718
+
698
719
  // Check layer config: if audio is disabled, skip to IMAGE_PENDING
699
720
  const layerKey = `animastor:layer-config:${bookId}`;
700
721
  const layerRaw = await redis.get(layerKey);