@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-10-31) — генерация только по явной команде
|
|
4
|
+
|
|
5
|
+
Правило владельца: GPU-генерация запускается только кнопками «аудио» /
|
|
6
|
+
«картинки» / «всё»; никаких автозапусков следующих стадий и фоновых
|
|
7
|
+
запусков; задачи только на включённые слои. Основание — разбор
|
|
8
|
+
`reports/2026-10-31/auto-image-task-ba-incident.md` (§6, §8). Описание:
|
|
9
|
+
animastor-docs `architecture/COMMAND_ONLY_GENERATION.md`.
|
|
10
|
+
|
|
11
|
+
**Инвариант:** сцена×стадия попадает в active index / dispatch только при
|
|
12
|
+
активной задаче реестра (создана явной командой, стадия = type задачи), при
|
|
13
|
+
включённом слое и без отмены (cancel flag / tombstone).
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `runtime/command-gate.js` — единственная точка вычисления инварианта
|
|
18
|
+
(`getAuthorizedStages`, `isStageAuthorized`), force-набор сцена×стадия,
|
|
19
|
+
`rollbackGeneratingToDirty`, `blockStageNoWorker`. Экспорт
|
|
20
|
+
`runtime.commandGate`.
|
|
21
|
+
- ProgressEventsPort: операция `blockSceneStage` (хост помечает команду
|
|
22
|
+
blocked в реестре и PG).
|
|
23
|
+
|
|
24
|
+
### Changed (поведение)
|
|
25
|
+
|
|
26
|
+
- `active-scenes-index.addActiveScene` — запись в active index только при
|
|
27
|
+
авторизованной стадии; иначе `{ added:false, denied:true }`.
|
|
28
|
+
- `runtime-scheduler.shouldScheduleAssets` — стадии только по гейту; убраны
|
|
29
|
+
fallback на layer-config без задачи и `isInFlight`-override.
|
|
30
|
+
- `runtime-scheduler.tick/attemptDispatch` — force только для сцен×стадий
|
|
31
|
+
команды (ключ `animastor:force-dispatch-stages:<book>`, вместо книжного
|
|
32
|
+
`animastor:force-dispatch:<book>`); `no_worker_available` → стадия blocked
|
|
33
|
+
(GENERATING → DIRTY, задача blocked), без автоповтора.
|
|
34
|
+
- `markVersionStaleDirty` — только помечает stale, в работу не ставит.
|
|
35
|
+
- `orchestrator.resetScenes` — пишет force-набор вместо книжного флага.
|
|
36
|
+
- `scene-orchestrator.dispatchStage` — NEW-сцена переводит в PENDING только
|
|
37
|
+
диспатчимую стадию; при `no_worker_available` откат в DIRTY, не PENDING.
|
|
38
|
+
- `reconciliation-engine`:
|
|
39
|
+
- удалены `PROGRESS_TO_IMAGE` / `PROGRESS_TO_VIDEO`; partial build — только
|
|
40
|
+
диагностика;
|
|
41
|
+
- `REGENERATE_MISSING_ASSET`, `MOVE_TO_PENDING`, `RELEASE_STALE_LEASE`,
|
|
42
|
+
перезапуск аудио в `RECOVER_ORPHAN_ASSETS` — только под активной командой
|
|
43
|
+
стадии, иначе диагностика; осиротевший GENERATING без команды → DIRTY;
|
|
44
|
+
- C5 вызывает `deps.interruptIncompleteSessions` (возобновления нет);
|
|
45
|
+
- C8 `rebuildWorkList` пересобирает только факты asset-state, PENDING и
|
|
46
|
+
active index не пишет.
|
|
47
|
+
- `scene-window.startScene` — без авторизованной стадии сцену не стартует;
|
|
48
|
+
`trySlideWindowOnComplete` — за флагом хоста `windowAutoNextEnabled()`
|
|
49
|
+
(по умолчанию выкл.).
|
|
50
|
+
- `dispatch-engine.repairOrphanGeneratingStates` — опция `target: 'dirty'`.
|
|
51
|
+
|
|
3
52
|
## 0.2.0 (2026-10-27)
|
|
4
53
|
|
|
5
54
|
Version-line split for `stage143a-foundation-phase1`: the branch line moves
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@animastor/orchestration",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Animastor Orchestration — the generation orchestration/runtime contour of the Animastor backend, physically extracted from the host (§32.30): the unified generation lifecycle owner (orchestrator facade, stage executor, scene callbacks/restoration), the dispatch engine and its lease/quota/retry managers, the reconciliation engine and counter reconciliation, the scene window/scheduler/worker-health, the runtime-result emitter/consumer, and the eight O-2..O-10 port contracts (Persistence, SceneData, PlaceholderAudio, ProgressEvents, AudioFsm, VideoFsm, HubCancel, LayerConfig). The package reaches the host (runtime config slices, Redis-domain state layer, media facades, the S-5 seam registry, the artifact root, the PW-2 workspace resolver) ONLY through the composition-root host-bindings seam — no backend/src require, no storage/book/routes/services, no Redis, no PG, no Express inside.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"animastor",
|
package/src/index.js
CHANGED
|
@@ -75,6 +75,7 @@ const EAGER_EXPORTS = {
|
|
|
75
75
|
const RUNTIME_NS = {
|
|
76
76
|
scheduler: './runtime/runtime-scheduler',
|
|
77
77
|
activeScenes: './runtime/active-scenes-index',
|
|
78
|
+
commandGate: './runtime/command-gate',
|
|
78
79
|
reconciliation: './runtime/reconciliation-engine',
|
|
79
80
|
dispatch: './runtime/dispatch-engine',
|
|
80
81
|
leaseManager: './runtime/lease-manager',
|
|
@@ -465,7 +465,7 @@ async function reconcile(redis, bookId, chapterId, sceneId) {
|
|
|
465
465
|
// options.readdToActiveIndex — вернуть сцены планировщику после reset (default true)
|
|
466
466
|
//
|
|
467
467
|
// Что делает:
|
|
468
|
-
// 1. force
|
|
468
|
+
// 1. force-набор: только сцены×стадии этой регенерации
|
|
469
469
|
// 2. событие SCENE_RESET в journal
|
|
470
470
|
// 3. удаление из active index
|
|
471
471
|
// 4. снятие dispatch leases только для сбрасываемых стадий
|
|
@@ -497,11 +497,18 @@ async function resetScenes(redis, bookId, buildId, scenes, layerCfg, options = {
|
|
|
497
497
|
const scopeDisplay = `${scope}${chapterId ? '/' + chapterId : ''}${sceneId ? '/' + sceneId : ''}`;
|
|
498
498
|
log(`[RESET-SCENES] ${bookId}: ${scenes.length} scenes, scope=${scopeDisplay}`);
|
|
499
499
|
|
|
500
|
-
// 1. Force
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
);
|
|
500
|
+
// 1. Force set (T1: TTL из TIMEOUTS). Command-only generation: force
|
|
501
|
+
// covers EXACTLY the scene×stage pairs reset here (dirty_layers ∩ enabled
|
|
502
|
+
// layers of the request) — never the whole book (incident 2026-10-30:
|
|
503
|
+
// one-scene audio regenerate force-dispatched an unrelated image stage).
|
|
504
|
+
const commandGate = require('../runtime/command-gate');
|
|
505
|
+
await commandGate.addForceStages(redis, bookId, scenes.map(ds => ({
|
|
506
|
+
chapter_id: ds.chapter_id,
|
|
507
|
+
scene_id: ds.scene_id,
|
|
508
|
+
stages: (ds.dirty_layers || mediaRegistry.listMediaTypes()).filter(stage =>
|
|
509
|
+
layerCfg?.[`${stage}_enabled`] !== false
|
|
510
|
+
),
|
|
511
|
+
})), runtimeConfig.TIMEOUTS.FORCE_DISPATCH_TTL_S);
|
|
505
512
|
|
|
506
513
|
// 2. Event journal — start
|
|
507
514
|
await journal.appendSceneEvent(redis, bookId, chapterId || bookId, sceneId || 'all',
|
|
@@ -601,10 +608,13 @@ async function resetScenes(redis, bookId, buildId, scenes, layerCfg, options = {
|
|
|
601
608
|
// Selective generation can defer this until its task registry is ready.
|
|
602
609
|
// Гарантированно выполняется даже при ошибке markDirty (шаг 8).
|
|
603
610
|
if (readdToActiveIndex) {
|
|
611
|
+
// Command-gated write: only scenes with an authorized stage enter.
|
|
612
|
+
let readded = 0;
|
|
604
613
|
for (const ds of scenes) {
|
|
605
|
-
await scheduler.addSceneToActiveIndex(redis, bookId, ds.chapter_id, ds.scene_id);
|
|
614
|
+
const r = await scheduler.addSceneToActiveIndex(redis, bookId, ds.chapter_id, ds.scene_id);
|
|
615
|
+
if (r && !r.denied) readded++;
|
|
606
616
|
}
|
|
607
|
-
log(`[RESET-SCENES] ${bookId}: ${scenes.length} scenes re-added to active index`);
|
|
617
|
+
log(`[RESET-SCENES] ${bookId}: ${readded}/${scenes.length} scenes re-added to active index (command-gated)`);
|
|
608
618
|
} else {
|
|
609
619
|
log(`[RESET-SCENES] ${bookId}: activation deferred for ${scenes.length} scenes`);
|
|
610
620
|
}
|
|
@@ -68,19 +68,21 @@ const EXECUTORS = {
|
|
|
68
68
|
video: executeVideoDispatch,
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
-
async function startScene(redis, scene, loadedBook, buildId) {
|
|
71
|
+
async function startScene(redis, scene, loadedBook, buildId, stage = 'audio') {
|
|
72
72
|
const bookId = scene.book_id;
|
|
73
73
|
const chapterId = scene.chapter_id;
|
|
74
74
|
const sceneId = scene.scene_id;
|
|
75
75
|
|
|
76
|
-
log(`START SCENE: ${bookId}/${chapterId}/${sceneId}`);
|
|
76
|
+
log(`START SCENE: ${bookId}/${chapterId}/${sceneId} (stage=${stage})`);
|
|
77
77
|
|
|
78
|
-
await logEvent(redis, scene, 'SCENE_STARTED',
|
|
78
|
+
await logEvent(redis, scene, 'SCENE_STARTED', `${stage}_pending`, {
|
|
79
79
|
buildId, bookId, chapterId, sceneId
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
// T7: через фасад — единый владелец PENDING
|
|
83
|
-
|
|
82
|
+
// T7: через фасад — единый владелец PENDING. Command-only generation:
|
|
83
|
+
// only the stage being dispatched becomes PENDING — a NEW scene asked for
|
|
84
|
+
// `image` must not get an unrequested `audio` PENDING.
|
|
85
|
+
await setScenePending(redis, bookId, chapterId, sceneId, stage, buildId);
|
|
84
86
|
await runtimeScheduler.addSceneToActiveIndex(redis, bookId, chapterId, sceneId);
|
|
85
87
|
log(`ADDED TO ACTIVE: ${bookId}/${chapterId}/${sceneId}`);
|
|
86
88
|
|
|
@@ -652,7 +654,7 @@ async function dispatchStage(redis, scene, loadedBook, buildId, overrideStage, d
|
|
|
652
654
|
);
|
|
653
655
|
if (isNewScene) {
|
|
654
656
|
log(`NEW SCENE: ${bookId}/${chapterId}/${sceneId} (asset-state)`);
|
|
655
|
-
await startScene(redis, scene, loadedBook, buildId);
|
|
657
|
+
await startScene(redis, scene, loadedBook, buildId, overrideStage || 'audio');
|
|
656
658
|
}
|
|
657
659
|
|
|
658
660
|
if (!overrideStage) {
|
|
@@ -673,7 +675,19 @@ async function dispatchStage(redis, scene, loadedBook, buildId, overrideStage, d
|
|
|
673
675
|
result = await executor(redis, scene, loadedBook, buildId, dispatchId);
|
|
674
676
|
|
|
675
677
|
result = result || { dispatched: false, reason: 'no_result', jobs: 0 };
|
|
676
|
-
if (!result.dispatched && !result.completed) {
|
|
678
|
+
if (!result.dispatched && !result.completed && result.reason === 'no_worker_available') {
|
|
679
|
+
// Command-only generation: no live worker of this type → the stage is
|
|
680
|
+
// BLOCKED, not re-queued. GENERATING → DIRTY only (never PENDING): the
|
|
681
|
+
// scheduler marks the command blocked and nothing retries it until a
|
|
682
|
+
// new command arrives (incident 2026-10-30: endless no_worker loop).
|
|
683
|
+
const rollback = await require('../runtime/command-gate').rollbackGeneratingToDirty(
|
|
684
|
+
redis, bookId, chapterId, sceneId, overrideStage, 'no_worker_available');
|
|
685
|
+
if (!rollback.changed && rollback.reason !== 'state_dirty' && rollback.reason !== 'state_ready') {
|
|
686
|
+
warn(`DISPATCH_ROLLBACK_FAILED: ${bookId}/${chapterId}/${sceneId} ${overrideStage}: ${rollback.reason} — blocked stage not released`);
|
|
687
|
+
result.rollbackFailed = true;
|
|
688
|
+
result.rollbackReason = rollback.reason;
|
|
689
|
+
}
|
|
690
|
+
} else if (!result.dispatched && !result.completed) {
|
|
677
691
|
// FSM-валидный rollback (GENERATING → DIRTY → PENDING). Прямой
|
|
678
692
|
// generating→pending запрещён FSM; раньше здесь вызывался setScenePending,
|
|
679
693
|
// rejection молча проглатывался — и сцена навсегда оставалась в GENERATING
|
|
@@ -33,12 +33,28 @@ async function getActiveCount(redis) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Add scene to active index
|
|
36
|
+
* Add scene to active index — COMMAND-GATED (generation only by explicit
|
|
37
|
+
* command, see ./command-gate). The scene enters the index only when at
|
|
38
|
+
* least one of its stages is authorized: an ACTIVE task-registry task of that
|
|
39
|
+
* stage targets the scene, the layer is enabled and the book is not
|
|
40
|
+
* cancelled. Otherwise nothing is written and `{ added:false, denied:true }`
|
|
41
|
+
* is returned with the per-stage denial reasons.
|
|
42
|
+
*
|
|
43
|
+
* This is the ONE write path into the index (scheduler wrapper, regenerate,
|
|
44
|
+
* reconcile, scene-window, host services and the player all reach it), so
|
|
45
|
+
* no background path can queue work without a command.
|
|
37
46
|
*/
|
|
38
47
|
async function addActiveScene(redis, bookId, chapterId, sceneId) {
|
|
39
48
|
const sceneKey = `${bookId}:${chapterId}:${sceneId}`;
|
|
49
|
+
// Lazy: command-gate → scene-window → this module (acyclic at load time).
|
|
50
|
+
const auth = await require('./command-gate').getAuthorizedStages(redis, bookId, chapterId, sceneId);
|
|
51
|
+
if (auth.stages.size === 0) {
|
|
52
|
+
const reasons = require('./command-gate').describeDenied(auth.denied);
|
|
53
|
+
console.log(`[ACTIVE-INDEX] GATE_DENY ${sceneKey}: no command-authorized stage (${reasons}) — not added`);
|
|
54
|
+
return { added: false, denied: true, sceneKey, reasons: auth.denied };
|
|
55
|
+
}
|
|
40
56
|
const added = await redis.sadd(ACTIVE_SCENES_KEY, sceneKey);
|
|
41
|
-
return { added: added > 0, sceneKey };
|
|
57
|
+
return { added: added > 0, sceneKey, stages: [...auth.stages] };
|
|
42
58
|
}
|
|
43
59
|
|
|
44
60
|
/**
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// COMMAND GATE — «генерация только по явной команде»
|
|
3
|
+
// ======================================================
|
|
4
|
+
// Правило владельца (2026-10-31, разбор reports/2026-10-31/
|
|
5
|
+
// auto-image-task-ba-incident.md §6/§8): GPU-генерация запускается ТОЛЬКО
|
|
6
|
+
// кнопками «аудио» / «картинки» / «всё». Никаких автозапусков следующих
|
|
7
|
+
// стадий, никаких фоновых запусков, задачи только на включённые слои.
|
|
8
|
+
//
|
|
9
|
+
// ИНВАРИАНТ. Сцена×стадия попадает в active index / dispatch ТОЛЬКО когда
|
|
10
|
+
// одновременно:
|
|
11
|
+
// 1. есть АКТИВНАЯ задача реестра (generation-progress, создаётся явной
|
|
12
|
+
// командой POST /regenerate), в targets которой есть эта сцена и чей
|
|
13
|
+
// type — эта стадия;
|
|
14
|
+
// 2. слой стадии включён в layer-config книги;
|
|
15
|
+
// 3. у книги нет отмены (cancel flag в Redis / tombstone в PG).
|
|
16
|
+
//
|
|
17
|
+
// Этот модуль — единственное место, где инвариант вычисляется. Им пользуются:
|
|
18
|
+
// • active-scenes-index.addActiveScene — запись в active index;
|
|
19
|
+
// • runtime-scheduler.shouldScheduleAssets — выбор стадий для dispatch;
|
|
20
|
+
// • reconciliation-engine — ремонтные фиксы, которые раньше ставили работу;
|
|
21
|
+
// • scene-window.startScene — оконный старт сцены.
|
|
22
|
+
//
|
|
23
|
+
// Реестр задач читается ТОЛЬКО через ProgressEventsPort (O-5), отмена — через
|
|
24
|
+
// PersistencePort (O-2) и cancel flag scene-window; Redis-ключи хост-сервисов
|
|
25
|
+
// здесь не появляются. Ошибка чтения реестра — fail-closed (стадия НЕ
|
|
26
|
+
// авторизована): без доказанной команды работы нет.
|
|
27
|
+
//
|
|
28
|
+
// Docs: animastor-docs/architecture/COMMAND_ONLY_GENERATION.md
|
|
29
|
+
// ======================================================
|
|
30
|
+
|
|
31
|
+
const mediaRegistry = require('@animastor/generation').mediaRegistry;
|
|
32
|
+
const { lazyHostBinding } = require('../host/host-bindings');
|
|
33
|
+
const state = lazyHostBinding('state');
|
|
34
|
+
const progressEventsOp = require('./progress-events-port').progressEventsOp;
|
|
35
|
+
const persist = require('./persistence-port').persist;
|
|
36
|
+
|
|
37
|
+
const logPrefix = '[COMMAND-GATE]';
|
|
38
|
+
|
|
39
|
+
function log(msg) {
|
|
40
|
+
console.log(`${logPrefix} ${msg}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function warn(msg) {
|
|
44
|
+
console.warn(`${logPrefix} ⚠️ ${msg}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ======================================================
|
|
48
|
+
// LAYER CONFIG
|
|
49
|
+
// ======================================================
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read the book layer config (Redis `animastor:layer-config:<book>`).
|
|
53
|
+
* Missing/broken config → every registered layer enabled (registry default).
|
|
54
|
+
*/
|
|
55
|
+
async function getLayerConfig(redis, bookId) {
|
|
56
|
+
const layerRaw = await redis.get(`animastor:layer-config:${bookId}`);
|
|
57
|
+
if (layerRaw) {
|
|
58
|
+
try { return JSON.parse(layerRaw); } catch (_) { /* fall through to defaults */ }
|
|
59
|
+
}
|
|
60
|
+
const defaults = {};
|
|
61
|
+
for (const t of mediaRegistry.listMediaTypes()) { defaults[`${t}_enabled`] = true; }
|
|
62
|
+
return defaults;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isLayerEnabled(layerCfg, stage) {
|
|
66
|
+
return !layerCfg || layerCfg[`${stage}_enabled`] !== false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ======================================================
|
|
70
|
+
// CANCELLATION
|
|
71
|
+
// ======================================================
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True if the user explicitly cancelled generation for this book (Redis
|
|
75
|
+
* cancel flag and/or PG tombstone). A PG error reads as "not cancelled"
|
|
76
|
+
* (the Redis flag is still honoured) — the same semantics the reconcile
|
|
77
|
+
* resurrection guard always had.
|
|
78
|
+
*/
|
|
79
|
+
async function isBookCancelled(redis, bookId) {
|
|
80
|
+
try {
|
|
81
|
+
// Lazy: scene-window requires active-scenes-index, which requires
|
|
82
|
+
// this module — resolve at call time to keep the module graph acyclic.
|
|
83
|
+
if (await require('./scene-window').isCancelled(redis, bookId)) return true;
|
|
84
|
+
} catch (_) { /* flag unreadable — fall through to tombstone */ }
|
|
85
|
+
try {
|
|
86
|
+
return !!(await persist('cancel.isCancelled')(bookId));
|
|
87
|
+
} catch (_) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ======================================================
|
|
93
|
+
// AUTHORIZATION
|
|
94
|
+
// ======================================================
|
|
95
|
+
|
|
96
|
+
/** Per-pass memo (one scheduler tick / one reconcile pass). */
|
|
97
|
+
function createGateCache() {
|
|
98
|
+
return { layer: new Map(), cancelled: new Map() };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function memo(cache, bucket, bookId, fn) {
|
|
102
|
+
if (!cache) return fn();
|
|
103
|
+
if (cache[bucket].has(bookId)) return cache[bucket].get(bookId);
|
|
104
|
+
const value = await fn();
|
|
105
|
+
cache[bucket].set(bookId, value);
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const EMPTY_TASK_STATE = Object.freeze({ managed: false, activeTypes: new Set() });
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Which stages of one scene are authorized by an explicit command right now.
|
|
113
|
+
*
|
|
114
|
+
* @returns {Promise<{stages:Set<string>, denied:Object<string,string>,
|
|
115
|
+
* cancelled:boolean, layerCfg:object, taskState:{managed:boolean, activeTypes:Set<string>}}>}
|
|
116
|
+
* `denied[stage]` ∈ 'cancelled' | 'no_active_task' | 'layer_disabled'.
|
|
117
|
+
*/
|
|
118
|
+
async function getAuthorizedStages(redis, bookId, chapterId, sceneId, cache = null) {
|
|
119
|
+
const cancelled = await memo(cache, 'cancelled', bookId, () => isBookCancelled(redis, bookId));
|
|
120
|
+
const layerCfg = await memo(cache, 'layer', bookId, () => getLayerConfig(redis, bookId));
|
|
121
|
+
|
|
122
|
+
let taskState = EMPTY_TASK_STATE;
|
|
123
|
+
try {
|
|
124
|
+
const ts = await progressEventsOp('getSceneTaskState')(redis, bookId, chapterId, sceneId);
|
|
125
|
+
if (ts && ts.activeTypes instanceof Set) taskState = ts;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// Fail-closed: an unreadable registry proves no command.
|
|
128
|
+
warn(`task registry unreadable for ${bookId}/${chapterId}/${sceneId}: ${err.message} — no stage authorized`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const stages = new Set();
|
|
132
|
+
const denied = {};
|
|
133
|
+
for (const stage of mediaRegistry.listMediaTypes()) {
|
|
134
|
+
if (cancelled) { denied[stage] = 'cancelled'; continue; }
|
|
135
|
+
if (!taskState.activeTypes.has(stage)) { denied[stage] = 'no_active_task'; continue; }
|
|
136
|
+
if (!isLayerEnabled(layerCfg, stage)) { denied[stage] = 'layer_disabled'; continue; }
|
|
137
|
+
stages.add(stage);
|
|
138
|
+
}
|
|
139
|
+
return { stages, denied, cancelled, layerCfg, taskState };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Is ONE stage of a scene authorized? */
|
|
143
|
+
async function isStageAuthorized(redis, bookId, chapterId, sceneId, stage, cache = null) {
|
|
144
|
+
const auth = await getAuthorizedStages(redis, bookId, chapterId, sceneId, cache);
|
|
145
|
+
return auth.stages.has(stage);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function describeDenied(denied) {
|
|
149
|
+
return Object.entries(denied || {}).map(([s, r]) => `${s}:${r}`).join(',') || 'none';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// ======================================================
|
|
153
|
+
// STATE HYGIENE WITHOUT WORK
|
|
154
|
+
// ======================================================
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* GENERATING → DIRTY (FSM-valid edge). Used where a dead/unsent generation
|
|
158
|
+
* must be released WITHOUT re-queueing it: without a command a stage may not
|
|
159
|
+
* sit in PENDING (PENDING is "queued work" — the incident's mine).
|
|
160
|
+
* Only GENERATING is touched; every other state is left as-is.
|
|
161
|
+
*/
|
|
162
|
+
async function rollbackGeneratingToDirty(redis, bookId, chapterId, sceneId, stage, reason) {
|
|
163
|
+
const states = await state.getAssetStates(redis, bookId, chapterId, sceneId);
|
|
164
|
+
const current = states?.[stage];
|
|
165
|
+
if (current !== state.AssetState.GENERATING) {
|
|
166
|
+
return { changed: false, reason: `state_${current || 'new'}` };
|
|
167
|
+
}
|
|
168
|
+
const check = state.validateAssetTransition(current, state.AssetState.DIRTY);
|
|
169
|
+
if (!check.valid) {
|
|
170
|
+
return { changed: false, reason: `invalid_transition:${current}->dirty` };
|
|
171
|
+
}
|
|
172
|
+
await state.unsafeRestoreAssetState(redis, bookId, chapterId, sceneId, stage, state.AssetState.DIRTY);
|
|
173
|
+
log(`${bookId}/${chapterId}/${sceneId} ${stage}: generating → dirty (reason=${reason}) — not re-queued`);
|
|
174
|
+
return { changed: true, from: current, to: state.AssetState.DIRTY };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ======================================================
|
|
178
|
+
// NO WORKER → BLOCKED (no auto-retry)
|
|
179
|
+
// ======================================================
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A stage could not be enqueued because no live worker of its type exists.
|
|
183
|
+
* The command that asked for it is BLOCKED (task registry + PG) with the
|
|
184
|
+
* reason «нет воркера <type>»; the stage loses its authorization, so the
|
|
185
|
+
* scheduler never retries it. Only a NEW command retries.
|
|
186
|
+
* Storage writes happen host-side behind ProgressEventsPort.blockSceneStage.
|
|
187
|
+
*/
|
|
188
|
+
async function blockStageNoWorker(redis, bookId, chapterId, sceneId, stage) {
|
|
189
|
+
const reason = `no_worker:${stage}`;
|
|
190
|
+
let result = { tasks: [] };
|
|
191
|
+
try {
|
|
192
|
+
result = (await progressEventsOp('blockSceneStage')(redis, bookId, chapterId, sceneId, stage, reason)) || result;
|
|
193
|
+
} catch (err) {
|
|
194
|
+
warn(`blockSceneStage failed for ${bookId}/${chapterId}/${sceneId}:${stage}: ${err.message}`);
|
|
195
|
+
}
|
|
196
|
+
const ids = (result.tasks || []).map(t => t.task_id || t).join(',') || 'none';
|
|
197
|
+
log(`BLOCKED ${bookId}/${chapterId}/${sceneId}:${stage} — нет воркера ${stage}; tasks=[${ids}] — no auto-retry, a new command is required`);
|
|
198
|
+
return { reason, ...result };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ======================================================
|
|
202
|
+
// FORCE — only for the scene×stage pairs of the command
|
|
203
|
+
// ======================================================
|
|
204
|
+
// The former `animastor:force-dispatch:<book>` flag bypassed the duplicate/
|
|
205
|
+
// lease guards for the WHOLE book for 120 s (incident §6 item 7). The force
|
|
206
|
+
// set below holds exactly the scene×stage pairs reset by one regeneration.
|
|
207
|
+
|
|
208
|
+
const FORCE_STAGES_KEY_PREFIX = 'animastor:force-dispatch-stages:';
|
|
209
|
+
|
|
210
|
+
function forceStagesKey(bookId) {
|
|
211
|
+
return `${FORCE_STAGES_KEY_PREFIX}${bookId}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function forceMember(chapterId, sceneId, stage) {
|
|
215
|
+
return JSON.stringify([String(chapterId), String(sceneId), String(stage)]);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function addForceStages(redis, bookId, pairs, ttlSeconds) {
|
|
219
|
+
const members = [];
|
|
220
|
+
for (const p of pairs || []) {
|
|
221
|
+
for (const stage of p.stages || []) members.push(forceMember(p.chapter_id, p.scene_id, stage));
|
|
222
|
+
}
|
|
223
|
+
if (members.length === 0) return 0;
|
|
224
|
+
const key = forceStagesKey(bookId);
|
|
225
|
+
await redis.sadd(key, ...members);
|
|
226
|
+
await redis.expire(key, ttlSeconds);
|
|
227
|
+
return members.length;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @returns {Promise<Set<string>>} force members of the book (see forceMember). */
|
|
231
|
+
async function getForceStages(redis, bookId) {
|
|
232
|
+
try {
|
|
233
|
+
return new Set(await redis.smembers(forceStagesKey(bookId)) || []);
|
|
234
|
+
} catch (_) {
|
|
235
|
+
return new Set();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Stages of one scene present in a book force set. */
|
|
240
|
+
function forcedStagesForScene(forceSet, chapterId, sceneId) {
|
|
241
|
+
const out = new Set();
|
|
242
|
+
if (!forceSet || forceSet.size === 0) return out;
|
|
243
|
+
for (const stage of mediaRegistry.listMediaTypes()) {
|
|
244
|
+
if (forceSet.has(forceMember(chapterId, sceneId, stage))) out.add(stage);
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function clearForceStages(redis, bookId) {
|
|
250
|
+
try { await redis.del(forceStagesKey(bookId)); } catch (_) { /* best effort */ }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = {
|
|
254
|
+
getLayerConfig,
|
|
255
|
+
isLayerEnabled,
|
|
256
|
+
isBookCancelled,
|
|
257
|
+
createGateCache,
|
|
258
|
+
getAuthorizedStages,
|
|
259
|
+
isStageAuthorized,
|
|
260
|
+
describeDenied,
|
|
261
|
+
rollbackGeneratingToDirty,
|
|
262
|
+
blockStageNoWorker,
|
|
263
|
+
FORCE_STAGES_KEY_PREFIX,
|
|
264
|
+
forceStagesKey,
|
|
265
|
+
forceMember,
|
|
266
|
+
addForceStages,
|
|
267
|
+
getForceStages,
|
|
268
|
+
forcedStagesForScene,
|
|
269
|
+
clearForceStages,
|
|
270
|
+
};
|
|
@@ -498,7 +498,8 @@ async function getDispatchEvidence(redis, bookId, chapterId, sceneId, stage) {
|
|
|
498
498
|
*
|
|
499
499
|
* @param {Object} redis
|
|
500
500
|
* @param {string} bookId
|
|
501
|
-
* @param {{reason?: string}} [opts]
|
|
501
|
+
* @param {{reason?: string, target?: 'pending'|'dirty'}} [opts] — target 'dirty'
|
|
502
|
+
* (command-only generation, Stop All) releases to DIRTY without re-queueing.
|
|
502
503
|
* @returns {Promise<{repaired: Array<{chapterId, sceneId, stage, path}>}>}
|
|
503
504
|
*/
|
|
504
505
|
async function repairOrphanGeneratingStates(redis, bookId, opts = {}) {
|
|
@@ -530,6 +531,16 @@ async function repairOrphanGeneratingStates(redis, bookId, opts = {}) {
|
|
|
530
531
|
if (states[stage] !== stateModule.AssetState.GENERATING) continue;
|
|
531
532
|
const evidence = await getDispatchEvidence(redis, bookId, chapterId, sceneId, stage);
|
|
532
533
|
if (evidence.alive) continue;
|
|
534
|
+
if (opts.target === 'dirty') {
|
|
535
|
+
// Command-only generation (Stop All): release the ghost
|
|
536
|
+
// to DIRTY without re-queueing it (no PENDING).
|
|
537
|
+
const released = await require('./command-gate').rollbackGeneratingToDirty(
|
|
538
|
+
redis, bookId, chapterId, sceneId, stage, reason);
|
|
539
|
+
if (released.changed) {
|
|
540
|
+
repaired.push({ chapterId, sceneId, stage, path: [stateModule.AssetState.DIRTY] });
|
|
541
|
+
}
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
533
544
|
const rollback = await rollbackStageToPending(
|
|
534
545
|
redis, bookId, chapterId, sceneId, stage, null, reason
|
|
535
546
|
);
|
|
@@ -39,6 +39,11 @@
|
|
|
39
39
|
// pre-pass; the getAssetStates callback arrives
|
|
40
40
|
// as an argument exactly as pre-O-5)
|
|
41
41
|
//
|
|
42
|
+
// blockSceneStage — command-only generation (2026-10-31): no live
|
|
43
|
+
// worker of a stage's type → block the command
|
|
44
|
+
// that asked for it (registry + PG are host-side;
|
|
45
|
+
// runtime-scheduler via command-gate)
|
|
46
|
+
//
|
|
42
47
|
// Deliberately NOT in the port: createTasks/listTasks/getTask/updateTask/
|
|
43
48
|
// markCompleted/markCancelled/removeTask/clear (routes-side task CRUD —
|
|
44
49
|
// the HTTP contour owns task lifecycle), getActiveTasksByType,
|
|
@@ -53,6 +58,7 @@ const OPS = [
|
|
|
53
58
|
'getSceneTaskState',
|
|
54
59
|
'hasActiveTasks',
|
|
55
60
|
'reconcileCompletedTasks',
|
|
61
|
+
'blockSceneStage',
|
|
56
62
|
];
|
|
57
63
|
|
|
58
64
|
let impl = null;
|