@animastor/orchestration 0.1.7 → 0.3.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.
@@ -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)
@@ -388,6 +377,7 @@ async function tick(redis, loadedBooks = {}) {
388
377
  throttled: 0,
389
378
  skipped: 0,
390
379
  completed: 0,
380
+ deferred: 0,
391
381
  errors: []
392
382
  };
393
383
 
@@ -411,8 +401,11 @@ async function tick(redis, loadedBooks = {}) {
411
401
  }
412
402
  }
413
403
 
414
- // 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.
415
406
  const forceCache = new Map();
407
+ // One command-gate memo per tick (layer config / cancellation per book).
408
+ const gateCache = commandGate.createGateCache();
416
409
  // Cache loaded books per tick to avoid redundant disk reads
417
410
  const bookCache = new Map();
418
411
 
@@ -425,15 +418,13 @@ async function tick(redis, loadedBooks = {}) {
425
418
 
426
419
  const { bookId, chapterId, sceneId } = parsed;
427
420
 
428
- // Check force-dispatch flag for recently regenerated books (cached per book)
429
- let force = false;
430
- if (forceCache.has(bookId)) {
431
- force = forceCache.get(bookId);
432
- } else {
433
- const forceFlag = await redis.get(`animastor:force-dispatch:${bookId}`);
434
- force = forceFlag === '1';
435
- 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);
436
426
  }
427
+ const force = commandGate.forcedStagesForScene(bookForce, chapterId, sceneId);
437
428
 
438
429
  // Resolve loaded book from cache or load it
439
430
  let sceneLoadedBook = loadedBooks[bookId];
@@ -454,7 +445,7 @@ async function tick(redis, loadedBooks = {}) {
454
445
  }
455
446
 
456
447
  try {
457
- const result = await attemptDispatch(redis, bookId, chapterId, sceneId, sceneLoadedBook, force);
448
+ const result = await attemptDispatch(redis, bookId, chapterId, sceneId, sceneLoadedBook, force, gateCache);
458
449
  summary.processed++;
459
450
 
460
451
  if (result.completed) {
@@ -466,6 +457,10 @@ async function tick(redis, loadedBooks = {}) {
466
457
  summary.skipped++;
467
458
  } else if (result.throttled > 0) {
468
459
  summary.throttled += result.throttled;
460
+ } else if (result.deferred > 0) {
461
+ // Stage 118 (M9): a bounded deferral is a normal, quiet
462
+ // state — it must not pollute the tick error list.
463
+ summary.deferred += result.deferred;
469
464
  } else if (result.reason === 'backpressure' || result.reason === 'throttled') {
470
465
  summary.throttled++;
471
466
  } else if (result.success) {
@@ -480,7 +475,7 @@ async function tick(redis, loadedBooks = {}) {
480
475
  }
481
476
 
482
477
  log(`=== TICK COMPLETE ===`);
483
- log(`Active: ${summary.totalActive}, Dispatched: ${summary.dispatched}, Skipped: ${summary.skipped}, Throttled: ${summary.throttled}, Completed: ${summary.completed}`);
478
+ log(`Active: ${summary.totalActive}, Dispatched: ${summary.dispatched}, Skipped: ${summary.skipped}, Throttled: ${summary.throttled}, Completed: ${summary.completed}, Deferred: ${summary.deferred}`);
484
479
 
485
480
  if (summary.errors.length > 0) {
486
481
  log(`Errors: ${summary.errors.length}`);
@@ -536,10 +531,19 @@ async function getMetrics(redis) {
536
531
  /**
537
532
  * Attempt to dispatch scene stages using per-asset states.
538
533
  * Supports independent workers: dispatches ALL eligible stages in one tick.
539
- * 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
+ *
540
544
  * Returns { dispatched: number, completed: boolean, reason: string }
541
545
  */
542
- async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, force = false) {
546
+ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, force = false, gateCache = null) {
543
547
  const sceneKey = `${bookId}:${chapterId}:${sceneId}`;
544
548
 
545
549
  // Resolve build_id from manifest (passed from tick with book cache) or null
@@ -553,7 +557,7 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
553
557
  }
554
558
 
555
559
  // Determine which asset stages need scheduling (from per-asset states)
556
- const { stages, allDone } = await shouldScheduleAssets(redis, bookId, chapterId, sceneId);
560
+ const { stages, allDone } = await shouldScheduleAssets(redis, bookId, chapterId, sceneId, gateCache);
557
561
 
558
562
  if (allDone) {
559
563
  await removeSceneFromActiveIndex(redis, bookId, chapterId, sceneId);
@@ -567,11 +571,18 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
567
571
  return { success: true, skip: true, reason: 'no_dispatchable_stages' };
568
572
  }
569
573
 
570
- 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(', ')}])` : ''}`);
571
579
 
572
580
  let dispatched = 0;
573
581
  let throttled = 0;
574
582
  let anyCompleted = false;
583
+ // Stage 118 (M9): bounded deferrals are counted separately — they are
584
+ // neither a dispatch nor an error.
585
+ let deferred = 0;
575
586
  const failures = [];
576
587
 
577
588
  // Dispatch ALL eligible stages in this tick.
@@ -585,8 +596,15 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
585
596
  stage,
586
597
  loadedBook,
587
598
  buildId,
588
- { force }
599
+ { force: forcedStages.has(stage) }
589
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
+ }
590
608
  if (result.dispatched) {
591
609
  dispatched++;
592
610
  } else if (result.reason === 'backpressure' || result.reason === 'throttled') {
@@ -595,6 +613,12 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
595
613
  // Stage finished inside the executor (already_done / cache_hit /
596
614
  // no_segments) — not a failure, nothing to dispatch.
597
615
  anyCompleted = true;
616
+ } else if (result.deferred) {
617
+ // Stage 118 (M9): a bounded deferral is NOT an error — nothing
618
+ // happened, the stage is waiting for capacity (or is already
619
+ // idempotently complete). Reporting it as a failure filled the
620
+ // tick summary with "errors" 12 times per minute per scene.
621
+ deferred++;
598
622
  } else {
599
623
  // Stage 50: раньше per-stage отказ (circuit_open, retry budget,
600
624
  // lease, generating_transition_failed, …) молча проглатывался и
@@ -607,6 +631,9 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
607
631
  if (dispatched > 0) {
608
632
  return { dispatched, throttled, stages: stages.length };
609
633
  }
634
+ if (deferred > 0 && failures.length === 0) {
635
+ return { dispatched: 0, throttled, deferred, stages: stages.length, success: true, reason: 'deferred' };
636
+ }
610
637
  if (failures.length > 0) {
611
638
  return { dispatched: 0, throttled, stages: stages.length, success: false, reason: failures.join(', ') };
612
639
  }
@@ -158,6 +158,34 @@ async function getSceneFilesStatus(buildDir, bookId, chapterId, sceneId) {
158
158
  result.image.exists = files.some(f => f.startsWith(imagePrefix) && f.endsWith('.png'));
159
159
  } catch (_) {}
160
160
 
161
+ // ── Stage 143a (phase 2a · B1) ───────────────────────────────────
162
+ // A versioned-only build keeps its IU images under {build}/.versions/**,
163
+ // which this scan can never see. Only when the LEGACY scan came up empty
164
+ // do we ask the image facade for a published pointer; the facade is
165
+ // flag-gated, so flag OFF answers null with ZERO PostgreSQL calls and
166
+ // this function behaves exactly as before (condition 2).
167
+ //
168
+ // A pointer-store ERROR is logged and treated as "no proof" — image
169
+ // stays NOT existing. It is never read as "the file is there" (that
170
+ // would write a false READY) and never throws: this is an advisory
171
+ // checker whose five callers (checkSceneContentCache,
172
+ // restoreChunkStatusForScene, reconcileWindowStatuses, scene-restoration,
173
+ // rebuildWorkList) must keep running on a PG outage.
174
+ if (!result.image.exists) {
175
+ try {
176
+ const imageModule = lazyHostBinding('media.image');
177
+ if (imageModule && typeof imageModule.resolveVersionedSceneProof === 'function') {
178
+ const buildId = path.basename(path.resolve(buildDir));
179
+ const proof = await imageModule.resolveVersionedSceneProof(
180
+ OUTPUT_DIR, buildId, bookId, chapterId, sceneId);
181
+ if (proof) result.image.exists = true;
182
+ }
183
+ } catch (err) {
184
+ warn(`image pointer proof unavailable for ${bookId}/${chapterId}/${sceneId} (${err.message}) — image treated as absent`);
185
+ result.image.exists = false;
186
+ }
187
+ }
188
+
161
189
  // Check video file
162
190
  const videoPath = path.join(buildDir, artifactNaming.sceneVideoName(bookId, chapterId, sceneId));
163
191
  result.video.exists = fs.existsSync(videoPath);
@@ -449,6 +477,13 @@ async function isWindowComplete(redis, bookId) {
449
477
  * Try to slide window forward if all current window scenes have audio + image complete.
450
478
  */
451
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
+ }
452
487
  if (await isCancelled(redis, bookId)) {
453
488
  log(`trySlideWindowOnComplete: generation cancelled for ${bookId}`);
454
489
  return { started: 0, remaining: 0, reason: 'cancelled' };
@@ -544,9 +579,14 @@ async function slideWindow(redis, bookId, loadedBook, buildId) {
544
579
  await scheduler.removeSceneFromActiveIndex(redis, bookId, scene.chapter_id, scene.scene_id);
545
580
 
546
581
  const didStart = await startScene(redis, scene, buildId, bookId);
547
- if (didStart) {
548
- 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;
549
588
  }
589
+ started++;
550
590
  nextIdx++;
551
591
  }
552
592
 
@@ -667,6 +707,15 @@ async function startScene(redis, s, buildId, bookId) {
667
707
  return true;
668
708
  }
669
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
+
670
719
  // Check layer config: if audio is disabled, skip to IMAGE_PENDING
671
720
  const layerKey = `animastor:layer-config:${bookId}`;
672
721
  const layerRaw = await redis.get(layerKey);
@@ -0,0 +1,265 @@
1
+ // ======================================================
2
+ // Stage 116 (D3) — CAPACITY DEFERRAL (bounded retry/backoff)
3
+ // ======================================================
4
+ // Regression net for the Stage 115 defect D3: a stage that could not enqueue
5
+ // anything (no live compatible worker, or every job already pending in the
6
+ // lane) was re-dispatched on EVERY scheduler tick (SCHEDULER_TICK_MS = 5 s).
7
+ // Each attempt created a lease + dispatch metadata, the executor sent
8
+ // nothing, the dispatch was cancelled and the FSM was rolled back — an
9
+ // unbounded dispatch/cancel loop with lease churn, FSM churn and unbounded
10
+ // event-journal growth.
11
+ //
12
+ // The fix: a bounded per-stage deferral. While the deferral window is open
13
+ // the dispatch engine returns WITHOUT any side effect (no lease, no metadata,
14
+ // no FSM write, no journal event), the stage stays PENDING and recoverable,
15
+ // and the deferral is cleared by a successful dispatch or an explicit cancel.
16
+ //
17
+ // Isolated: in-memory Redis mock + stubbed host bindings. No production
18
+ // Redis/PG, no hub, no network.
19
+
20
+ const { expect } = require('chai');
21
+ const { createMockRedis } = require('./mocks/redis-mock');
22
+ const { bindHostModules, clearHostBindings } = require('../src/host/host-bindings');
23
+
24
+ const BOOK = 'test-stage116-book';
25
+ const CHAPTER = 'ch-stage116';
26
+ const SCENE = 'sc-stage116';
27
+ const BUILD = 'build_test-stage116-book';
28
+
29
+ describe('Stage 116 (D3): capacity deferral (bounded retry/backoff)', () => {
30
+ let redis;
31
+ let dispatchEngine;
32
+ let journalEvents;
33
+ let executorCalls;
34
+ let executorResult;
35
+ let rollbacks;
36
+
37
+ beforeEach(async () => {
38
+ redis = createMockRedis();
39
+ journalEvents = [];
40
+ executorCalls = 0;
41
+ rollbacks = [];
42
+ executorResult = { dispatched: false, jobs: 0, reason: 'no_worker_available', retryable: true };
43
+
44
+ clearHostBindings();
45
+ bindHostModules({
46
+ journal: () => ({
47
+ appendSceneEvent: async (r, b, c, s, type, state, details) => {
48
+ journalEvents.push({ type, state, details });
49
+ return true;
50
+ },
51
+ }),
52
+ seams: () => ({
53
+ getOrchestrationSeam: (name) => {
54
+ if (name === 'dispatchStage') {
55
+ return async () => {
56
+ executorCalls++;
57
+ return executorResult;
58
+ };
59
+ }
60
+ if (name === 'rollbackStageToPending') {
61
+ return async (r, b, c, s, stage, buildId, reason) => {
62
+ rollbacks.push({ stage, reason });
63
+ return { changed: true };
64
+ };
65
+ }
66
+ throw new Error(`[stage116-test] seam not wired: ${name}`);
67
+ },
68
+ }),
69
+ });
70
+
71
+ dispatchEngine = require('../src/runtime/dispatch-engine');
72
+ });
73
+
74
+ afterEach(() => {
75
+ clearHostBindings();
76
+ });
77
+
78
+ const dispatch = () => dispatchEngine.dispatchStage(
79
+ redis, BOOK, CHAPTER, SCENE, 'image', { book_id: BOOK }, BUILD
80
+ );
81
+
82
+ it('a stage that enqueued nothing is deferred and reported as retryable', async () => {
83
+ const result = await dispatch();
84
+ expect(result.dispatched).to.equal(false);
85
+ expect(result.reason).to.equal('no_worker_available');
86
+ expect(result.retryable).to.equal(true);
87
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
88
+ });
89
+
90
+ it('a deferred stage is NOT re-dispatched on the next tick (no side effects)', async () => {
91
+ await dispatch(); // attempt 1 — really attempted
92
+ const callsAfterFirst = executorCalls;
93
+ const journalAfterFirst = journalEvents.length;
94
+
95
+ // the next scheduler tick (5 s later) must be a no-op
96
+ const second = await dispatch();
97
+ expect(second.dispatched).to.equal(false);
98
+ expect(second.reason).to.equal('deferred_capacity');
99
+ expect(second.retryable).to.equal(true);
100
+ expect(executorCalls).to.equal(callsAfterFirst); // executor NOT called
101
+ expect(journalEvents.length).to.equal(journalAfterFirst); // no journal event
102
+ });
103
+
104
+ it('100 scheduler ticks inside the deferral window ⇒ ONE attempt (bounded churn)', async () => {
105
+ await dispatch();
106
+ for (let i = 0; i < 99; i++) await dispatch();
107
+ expect(executorCalls).to.equal(1);
108
+ // the journal must not grow with the tick count (bounded, not unbounded)
109
+ const journalAfterLoop = journalEvents.length;
110
+ expect(journalAfterLoop).to.be.lessThan(10);
111
+ });
112
+
113
+ it('the deferral window expires ⇒ the stage is dispatched again', async () => {
114
+ await dispatch();
115
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
116
+ // simulate the TTL elapsing (the mock keeps keys until told otherwise)
117
+ await redis.del(dispatchEngine.deferredStageKey(BOOK, CHAPTER, SCENE, 'image'));
118
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(false);
119
+
120
+ executorResult = { dispatched: true, jobs: 2, reason: null };
121
+ const result = await dispatch();
122
+ expect(result.dispatched).to.equal(true);
123
+ expect(executorCalls).to.equal(2);
124
+ });
125
+
126
+ it('a successful dispatch clears the deferral', async () => {
127
+ await dispatch(); // deferred
128
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
129
+ await redis.del(dispatchEngine.deferredStageKey(BOOK, CHAPTER, SCENE, 'image'));
130
+ executorResult = { dispatched: true, jobs: 1, reason: null };
131
+ await dispatch();
132
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(false);
133
+ });
134
+
135
+ it('an explicit cancel clears the deferral (the user asked for the work)', async () => {
136
+ await dispatch();
137
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
138
+ await dispatchEngine.cancelActiveDispatch(redis, BOOK, CHAPTER, SCENE, 'image', 'user_cancel');
139
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(false);
140
+ });
141
+
142
+ it('a deferred attempt does NOT consume the retry budget (it is not a failure)', async () => {
143
+ const budgetKey = `animastor:retry-budget:${BOOK}:${CHAPTER}:${SCENE}:image`;
144
+ await redis.set(budgetKey, '3');
145
+ await dispatch();
146
+ const after = await redis.get(budgetKey);
147
+ expect(after).to.equal('3'); // unchanged — no budget consumed
148
+ });
149
+
150
+ it('a deferred attempt is not reported as a failure (retryable, not fatal)', async () => {
151
+ const result = await dispatch();
152
+ expect(result.dispatched).to.equal(false);
153
+ expect(result.reason).to.equal('no_worker_available');
154
+ expect(result.retryable).to.equal(true);
155
+ // the dispatch engine must not have marked the stage as failed
156
+ expect(result.result && result.result.retryable).to.equal(true);
157
+ });
158
+
159
+ it('the deferral is per stage — another stage of the same scene is not blocked', async () => {
160
+ await dispatch(); // image stage deferred
161
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
162
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'video')).to.equal(false);
163
+ expect(await dispatchEngine.isStageDeferred(redis, 'other-book', CHAPTER, SCENE, 'image')).to.equal(false);
164
+ });
165
+
166
+ it('deferrable outcomes are classified explicitly (capacity only)', () => {
167
+ expect(dispatchEngine.isDeferrableOutcome('no_worker_available')).to.equal(true);
168
+ expect(dispatchEngine.isDeferrableOutcome('duplicate_pending')).to.equal(true);
169
+ expect(dispatchEngine.isDeferrableOutcome('ambiguous_post')).to.equal(true);
170
+ // Stage 118 (M4): 'already_completed' is NOT a capacity gap — the
171
+ // logical task is already complete. It stays deferrable (the stage
172
+ // must not spin) but in its OWN bucket with its own window.
173
+ expect(dispatchEngine.isCapacityOutcome('already_completed')).to.equal(false);
174
+ expect(dispatchEngine.isIdempotentOutcome('already_completed')).to.equal(true);
175
+ expect(dispatchEngine.isDeferrableOutcome('already_completed')).to.equal(true);
176
+ // the generic "sent nothing" outcomes keep their historic contract
177
+ // (immediate retry) — the audit-6929ba5 regressions depend on it
178
+ expect(dispatchEngine.isDeferrableOutcome('no_jobs_sent')).to.equal(false);
179
+ expect(dispatchEngine.isDeferrableOutcome('no_jobs_generated')).to.equal(false);
180
+ expect(dispatchEngine.isDeferrableOutcome('send_failed')).to.equal(false);
181
+ expect(dispatchEngine.isDeferrableOutcome('already_ready')).to.equal(false);
182
+ expect(dispatchEngine.isDeferrableOutcome('circuit_open')).to.equal(false);
183
+ });
184
+
185
+ it('the deferral window is bounded (not a permanent block)', () => {
186
+ expect(dispatchEngine.DEFERRED_STAGE_TTL_S).to.be.a('number');
187
+ expect(dispatchEngine.DEFERRED_STAGE_TTL_S).to.be.greaterThan(0);
188
+ expect(dispatchEngine.DEFERRED_STAGE_TTL_S).to.be.lessThanOrEqual(600);
189
+ // the key lives under the runtime namespace (backend-owned family)
190
+ expect(dispatchEngine.DEFERRED_STAGE_KEY_PREFIX).to.equal('animastor:runtime:deferred:');
191
+ });
192
+
193
+ // ── Stage 118 (audit 117) ─────────────────────────────────────────────
194
+ describe('Stage 118: audit 117 corrections', () => {
195
+ it('M2: force mode does NOT bypass the deferral', async () => {
196
+ await dispatch(); // deferred (capacity)
197
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
198
+ const callsBefore = executorCalls;
199
+ // The force-dispatch flag lives for 120 s and is set for the WHOLE
200
+ // book by /regenerate — bypassing the backoff there reproduced the
201
+ // very churn D3 removes (≈24 full attempts per scene).
202
+ const forced = await dispatchEngine.dispatchStage(
203
+ redis, BOOK, CHAPTER, SCENE, 'image', { book_id: BOOK }, BUILD, { force: true }
204
+ );
205
+ expect(forced.dispatched).to.equal(false);
206
+ expect(forced.reason).to.equal('deferred_capacity');
207
+ expect(forced.deferred).to.equal(true);
208
+ expect(executorCalls).to.equal(callsBefore); // no side effect
209
+ });
210
+
211
+ it('M4: the idempotent suppression gets its OWN (longer) window', async () => {
212
+ executorResult = { dispatched: false, jobs: 0, reason: 'already_completed', retryable: true };
213
+ await dispatch();
214
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
215
+ expect(dispatchEngine.deferralWindowFor('already_completed'))
216
+ .to.equal(dispatchEngine.DEFERRED_IDEMPOTENT_TTL_S);
217
+ expect(dispatchEngine.deferralWindowFor('no_worker_available'))
218
+ .to.equal(dispatchEngine.DEFERRED_STAGE_TTL_S);
219
+ expect(dispatchEngine.DEFERRED_IDEMPOTENT_TTL_S)
220
+ .to.be.greaterThan(dispatchEngine.DEFERRED_STAGE_TTL_S);
221
+ });
222
+
223
+ it('M1: a book_id containing ":" cannot reach into another book key space', async () => {
224
+ const bookA = 'a';
225
+ const bookB = 'a:b';
226
+ const chapter = 'ch';
227
+ const scene = 'sc';
228
+ // the deferral key of book 'a' / chapter 'b' must not collide
229
+ // with the key of book 'a:b'
230
+ executorResult = { dispatched: false, jobs: 0, reason: 'no_worker_available', retryable: true };
231
+ await dispatchEngine.dispatchStage(redis, bookA, chapter, scene, 'image', { book_id: bookA }, BUILD);
232
+ expect(await dispatchEngine.isStageDeferred(redis, bookA, chapter, scene, 'image')).to.equal(true);
233
+ expect(await dispatchEngine.isStageDeferred(redis, bookB, chapter, scene, 'image')).to.equal(false);
234
+ expect(dispatchEngine.deferredStageKey(bookA, chapter, scene, 'image'))
235
+ .to.not.equal(dispatchEngine.deferredStageKey(bookB, chapter, scene, 'image'));
236
+ });
237
+
238
+ it('M2: an explicit intent clears the backoff of a book / a scene', async () => {
239
+ await dispatch();
240
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
241
+ const cleared = await dispatchEngine.clearDeferralsForBook(redis, BOOK);
242
+ expect(cleared).to.be.greaterThan(0);
243
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(false);
244
+
245
+ await dispatch();
246
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(true);
247
+ await dispatchEngine.clearDeferralsForScene(redis, BOOK, CHAPTER, SCENE);
248
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(false);
249
+ // a sibling book is untouched
250
+ await dispatchEngine.dispatchStage(redis, 'other-book', CHAPTER, SCENE, 'image', { book_id: 'other-book' }, BUILD);
251
+ await dispatchEngine.clearDeferralsForBook(redis, 'other-book');
252
+ expect(await dispatchEngine.isStageDeferred(redis, BOOK, CHAPTER, SCENE, 'image')).to.equal(false);
253
+ });
254
+
255
+ it('M3: several deferrable reasons are classified (the dominant one is reported)', () => {
256
+ const { classifyNotSentReasons } = require('../src/runtime/dispatch-outcomes');
257
+ expect(classifyNotSentReasons(new Map([['no_worker_available', 3], ['duplicate_pending', 1]])))
258
+ .to.equal('no_worker_available');
259
+ // a real failure keeps the historic semantics
260
+ expect(classifyNotSentReasons(new Map([['no_worker_available', 3], ['send_failed', 1]])))
261
+ .to.equal(null);
262
+ expect(classifyNotSentReasons(new Map())).to.equal(null);
263
+ });
264
+ });
265
+ });