@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.
- package/CHANGELOG.md +156 -0
- package/package.json +3 -2
- package/src/index.js +1 -0
- package/src/orchestration/orchestrator.js +18 -8
- package/src/orchestration/scene-callbacks.js +55 -3
- package/src/orchestration/scene-orchestrator.js +91 -14
- package/src/runtime/active-scenes-index.js +18 -2
- package/src/runtime/command-gate.js +270 -0
- package/src/runtime/dispatch-engine.js +322 -6
- package/src/runtime/dispatch-outcomes.js +124 -0
- package/src/runtime/hub-cancel-port.js +6 -0
- package/src/runtime/persistence-port.js +6 -0
- package/src/runtime/progress-events-port.js +6 -0
- package/src/runtime/reconciliation-engine.js +206 -172
- package/src/runtime/runtime-scheduler.js +88 -61
- package/src/runtime/scene-window.js +51 -2
- package/test/capacity-deferral.test.js +265 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
:
|
|
313
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
429
|
-
let
|
|
430
|
-
if (
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|