@animastor/orchestration 0.2.0 → 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 +49 -0
- package/package.json +1 -1
- package/src/index.js +1 -0
- package/src/orchestration/orchestrator.js +18 -8
- package/src/orchestration/scene-orchestrator.js +21 -7
- package/src/runtime/active-scenes-index.js +18 -2
- package/src/runtime/command-gate.js +270 -0
- package/src/runtime/dispatch-engine.js +12 -1
- package/src/runtime/progress-events-port.js +6 -0
- package/src/runtime/reconciliation-engine.js +149 -166
- package/src/runtime/runtime-scheduler.js +70 -60
- package/src/runtime/scene-window.js +23 -2
|
@@ -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)
|
|
@@ -412,8 +401,11 @@ async function tick(redis, loadedBooks = {}) {
|
|
|
412
401
|
}
|
|
413
402
|
}
|
|
414
403
|
|
|
415
|
-
//
|
|
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
|
-
//
|
|
430
|
-
let
|
|
431
|
-
if (
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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);
|