@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
|
@@ -40,6 +40,10 @@ const runtimeScheduler = require('./runtime-scheduler');
|
|
|
40
40
|
const counterReconciliation = require('./counter-reconciliation');
|
|
41
41
|
const dispatchEngine = require('./dispatch-engine');
|
|
42
42
|
const leaseManager = require('./lease-manager');
|
|
43
|
+
// Generation only by explicit command: every fix that used to (re)queue work
|
|
44
|
+
// is gated per scene×stage by the command gate (active task-registry task ∧
|
|
45
|
+
// layer enabled ∧ not cancelled). Without authorization a fix is diagnostics.
|
|
46
|
+
const commandGate = require('./command-gate');
|
|
43
47
|
// S-5: orchestration-owned behavior (FSM-safe facade writers, rollbacks)
|
|
44
48
|
// reaches runtime ONLY through the composition-root-injected seam registry.
|
|
45
49
|
// No orchestration module is imported from this file (S-5).
|
|
@@ -157,7 +161,7 @@ async function checkOrphanImageState(redis, bookId, chapterId, sceneId) {
|
|
|
157
161
|
// Orphan-check uses the canonical book build identity (audit d9d67a3).
|
|
158
162
|
const buildId = resolveBookBuildId(bookId);
|
|
159
163
|
const imageModule = lazyHostBinding('media.image');
|
|
160
|
-
|
|
164
|
+
let imageInfo = imageModule.resolveCanonicalSceneImage(
|
|
161
165
|
artifactRoot(),
|
|
162
166
|
buildId,
|
|
163
167
|
bookId,
|
|
@@ -165,6 +169,31 @@ async function checkOrphanImageState(redis, bookId, chapterId, sceneId) {
|
|
|
165
169
|
sceneId,
|
|
166
170
|
);
|
|
167
171
|
|
|
172
|
+
// ── Stage 143a (phase 2a · B2) ───────────────────────────────────
|
|
173
|
+
// Legacy scan first (byte-identical legacy behavior). A versioned-only
|
|
174
|
+
// build never writes the canonical file, so with the scan empty we ask
|
|
175
|
+
// the image facade for the published pointer of this scene build — the
|
|
176
|
+
// facade is flag-gated, flag OFF answers null with ZERO PG calls.
|
|
177
|
+
//
|
|
178
|
+
// An UNREADABLE pointer store ABORTS this reconcile pass (logged,
|
|
179
|
+
// `pass_incomplete`) instead of being read as "no publication": that
|
|
180
|
+
// reading would recommend regenerating a scene that is already
|
|
181
|
+
// published. No orphan issue is emitted on the aborted pass, so no
|
|
182
|
+
// regeneration is launched.
|
|
183
|
+
if (!imageInfo) {
|
|
184
|
+
try {
|
|
185
|
+
if (typeof imageModule.resolveVersionedSceneProof === 'function') {
|
|
186
|
+
imageInfo = await imageModule.resolveVersionedSceneProof(
|
|
187
|
+
artifactRoot(), buildId, bookId, chapterId, sceneId);
|
|
188
|
+
}
|
|
189
|
+
} catch (pgErr) {
|
|
190
|
+
error(`versioned image proof unavailable for ${bookId}/${chapterId}/${sceneId}: ${pgErr.message} — pass skipped, no regeneration launched`);
|
|
191
|
+
const err = new Error(`reconcile_pass_incomplete: image proof unavailable for ${bookId}/${chapterId}/${sceneId} (${pgErr.message})`);
|
|
192
|
+
err.code = 'pass_incomplete';
|
|
193
|
+
throw err;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
168
197
|
if (!imageInfo) {
|
|
169
198
|
return {
|
|
170
199
|
type: 'orphan_image_state',
|
|
@@ -251,11 +280,37 @@ async function checkOrphanAssets(redis, bookId, chapterId, sceneId) {
|
|
|
251
280
|
try {
|
|
252
281
|
await fs.access(assets.image.path);
|
|
253
282
|
} catch (err) {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
283
|
+
// ── Stage 143a (phase 2a · B2) ───────────────────────────
|
|
284
|
+
// With the flag ON a published pointer is the authority: a LIVE
|
|
285
|
+
// versioned file for this scene build explains the missing
|
|
286
|
+
// canonical file (versioned-only build) → no issue is emitted.
|
|
287
|
+
// Flag OFF ⇒ the facade answers null with ZERO PG calls and the
|
|
288
|
+
// legacy issue is reported exactly as before.
|
|
289
|
+
// An UNREADABLE pointer store aborts the pass (logged) instead of
|
|
290
|
+
// emitting a regeneration recommendation on a guess.
|
|
291
|
+
let coveredByPointer = false;
|
|
292
|
+
const imageModule = lazyHostBinding('media.image');
|
|
293
|
+
try {
|
|
294
|
+
if (typeof imageModule.resolveVersionedSceneProof === 'function') {
|
|
295
|
+
const buildId = resolveBookBuildId(bookId);
|
|
296
|
+
coveredByPointer = !!(await imageModule.resolveVersionedSceneProof(
|
|
297
|
+
artifactRoot(), buildId, bookId, chapterId, sceneId));
|
|
298
|
+
}
|
|
299
|
+
} catch (pgErr) {
|
|
300
|
+
error(`versioned image proof unavailable for ${bookId}/${chapterId}/${sceneId}: ${pgErr.message} — pass skipped, no recommendation emitted`);
|
|
301
|
+
const e = new Error(`reconcile_pass_incomplete: image proof unavailable for ${bookId}/${chapterId}/${sceneId} (${pgErr.message})`);
|
|
302
|
+
e.code = 'pass_incomplete';
|
|
303
|
+
throw e;
|
|
304
|
+
}
|
|
305
|
+
if (coveredByPointer) {
|
|
306
|
+
log(`missing_image_file suppressed: ${bookId}/${chapterId}/${sceneId} is covered by a published versioned pointer`);
|
|
307
|
+
} else {
|
|
308
|
+
orphanAssets.push({
|
|
309
|
+
type: 'missing_image_file',
|
|
310
|
+
scene: { bookId, chapterId, sceneId },
|
|
311
|
+
path: assets.image.path
|
|
312
|
+
});
|
|
313
|
+
}
|
|
259
314
|
}
|
|
260
315
|
}
|
|
261
316
|
|
|
@@ -284,15 +339,7 @@ async function checkOrphanAssets(redis, bookId, chapterId, sceneId) {
|
|
|
284
339
|
* Reconciliation must never resurrect a cancelled book.
|
|
285
340
|
*/
|
|
286
341
|
async function isBookGenerationCancelled(redis, bookId) {
|
|
287
|
-
|
|
288
|
-
const sceneWindow = require('./scene-window');
|
|
289
|
-
if (await sceneWindow.isCancelled(redis, bookId)) return true;
|
|
290
|
-
} catch (_) { /* flag unreadable — fall through to tombstone */ }
|
|
291
|
-
try {
|
|
292
|
-
return await persist('cancel.isCancelled')(bookId);
|
|
293
|
-
} catch (_) {
|
|
294
|
-
return false;
|
|
295
|
-
}
|
|
342
|
+
return commandGate.isBookCancelled(redis, bookId);
|
|
296
343
|
}
|
|
297
344
|
|
|
298
345
|
/**
|
|
@@ -346,9 +393,15 @@ async function checkOrphanGeneratingState(redis, bookId, chapterId, sceneId) {
|
|
|
346
393
|
// ======================================================
|
|
347
394
|
|
|
348
395
|
/**
|
|
349
|
-
* Check for partially built scenes.
|
|
396
|
+
* Check for partially built scenes — DIAGNOSTICS ONLY.
|
|
350
397
|
* T8: использует per-asset state вместо линейного.
|
|
351
398
|
*
|
|
399
|
+
* Command-only generation (2026-10-31): a partial build is NOT an
|
|
400
|
+
* inconsistency and never produces a fix. The former PROGRESS_TO_IMAGE /
|
|
401
|
+
* PROGRESS_TO_VIDEO auto-progression (incident 2026-10-30 BA: reconcile
|
|
402
|
+
* started image after audio READY while image was disabled) is removed —
|
|
403
|
+
* the next stage starts only by an explicit command.
|
|
404
|
+
*
|
|
352
405
|
* Stage 50: генерация в полёте — не «partial build». Раньше registry мог
|
|
353
406
|
* ещё не содержать image (registerSceneImage пишется по завершении), а
|
|
354
407
|
* ассет уже PENDING/GENERATING с живым lease — отчёт partial_audio_only
|
|
@@ -941,9 +994,7 @@ async function reconcileScene(redis, bookId, chapterId, sceneId) {
|
|
|
941
994
|
}
|
|
942
995
|
}
|
|
943
996
|
|
|
944
|
-
// Check orphan GENERATING states (audit d9d67a3).
|
|
945
|
-
// partial-build checks: those de-duplicate by sceneId, and the orphan
|
|
946
|
-
// repair must win over a PROGRESS_TO_* resurrection attempt.
|
|
997
|
+
// Check orphan GENERATING states (audit d9d67a3).
|
|
947
998
|
const orphanGenerating = await checkOrphanGeneratingState(redis, bookId, chapterId, sceneId);
|
|
948
999
|
if (orphanGenerating) {
|
|
949
1000
|
report.orphanStates.push(...orphanGenerating);
|
|
@@ -956,16 +1007,11 @@ async function reconcileScene(redis, bookId, chapterId, sceneId) {
|
|
|
956
1007
|
}
|
|
957
1008
|
}
|
|
958
1009
|
|
|
959
|
-
//
|
|
1010
|
+
// Partial builds — diagnostics only (command-only generation): kept in
|
|
1011
|
+
// the report, NEVER turned into an inconsistency/fix.
|
|
960
1012
|
const partial = await checkPartialBuilds(redis, bookId, chapterId, sceneId);
|
|
961
1013
|
if (partial) {
|
|
962
1014
|
report.partialBuilds.push(partial);
|
|
963
|
-
if (!report.inconsistentScenes.find(s => s.scene.sceneId === sceneId)) {
|
|
964
|
-
report.inconsistentScenes.push({
|
|
965
|
-
scene: { bookId, chapterId, sceneId },
|
|
966
|
-
issue: partial.type
|
|
967
|
-
});
|
|
968
|
-
}
|
|
969
1015
|
}
|
|
970
1016
|
|
|
971
1017
|
// Check stale locks
|
|
@@ -1058,20 +1104,6 @@ function getFixRecommendations(inconsistentScenes) {
|
|
|
1058
1104
|
reason: 'AUDIO_READY but no audio file',
|
|
1059
1105
|
safeToExecute: true
|
|
1060
1106
|
};
|
|
1061
|
-
case 'partial_audio_only':
|
|
1062
|
-
return {
|
|
1063
|
-
scene,
|
|
1064
|
-
action: 'PROGRESS_TO_IMAGE',
|
|
1065
|
-
reason: 'Audio ready but image not started',
|
|
1066
|
-
safeToExecute: true
|
|
1067
|
-
};
|
|
1068
|
-
case 'partial_image_video_missing':
|
|
1069
|
-
return {
|
|
1070
|
-
scene,
|
|
1071
|
-
action: 'PROGRESS_TO_VIDEO',
|
|
1072
|
-
reason: 'Image ready but video not started',
|
|
1073
|
-
safeToExecute: true
|
|
1074
|
-
};
|
|
1075
1107
|
case 'stale_locks':
|
|
1076
1108
|
return {
|
|
1077
1109
|
scene,
|
|
@@ -1156,12 +1188,15 @@ function getFixRecommendations(inconsistentScenes) {
|
|
|
1156
1188
|
*/
|
|
1157
1189
|
const RESURRECTION_ACTIONS = new Set([
|
|
1158
1190
|
'REGENERATE_MISSING_ASSET',
|
|
1159
|
-
'PROGRESS_TO_IMAGE',
|
|
1160
|
-
'PROGRESS_TO_VIDEO',
|
|
1161
1191
|
'RECOVER_ORPHAN_ASSETS',
|
|
1162
1192
|
'RELEASE_STALE_LEASE',
|
|
1163
1193
|
]);
|
|
1164
1194
|
|
|
1195
|
+
function diagnosticOnly(action, scene, what) {
|
|
1196
|
+
log(`[DIAGNOSTIC] ${action} ${scene.bookId}/${scene.chapterId}/${scene.sceneId}: ${what} — no command authorizes it, nothing queued`);
|
|
1197
|
+
return { success: false, action, scene, diagnostic: true, details: `diagnostic only: ${what} (no command-authorized stage)` };
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1165
1200
|
/**
|
|
1166
1201
|
* Apply a fix recommendation.
|
|
1167
1202
|
* Returns { success: boolean, action, scene, details }
|
|
@@ -1213,6 +1248,23 @@ async function applyFix(redis, fix) {
|
|
|
1213
1248
|
};
|
|
1214
1249
|
}
|
|
1215
1250
|
|
|
1251
|
+
// Command-only generation: without an active command for
|
|
1252
|
+
// this stage the dead generation is released to DIRTY
|
|
1253
|
+
// (ghost removed) but NOT re-queued — no PENDING, no index.
|
|
1254
|
+
if (!(await commandGate.isStageAuthorized(
|
|
1255
|
+
redis, scene.bookId, scene.chapterId, scene.sceneId, fix.stage))) {
|
|
1256
|
+
const released = await commandGate.rollbackGeneratingToDirty(
|
|
1257
|
+
redis, scene.bookId, scene.chapterId, scene.sceneId,
|
|
1258
|
+
fix.stage, 'orphan_generating_no_command');
|
|
1259
|
+
log(`[DIAGNOSTIC] orphan GENERATING ${scene.bookId}/${scene.chapterId}/${scene.sceneId} ${fix.stage}: no command — released to DIRTY, not re-queued`);
|
|
1260
|
+
return {
|
|
1261
|
+
success: released.changed, action, scene, diagnostic: true,
|
|
1262
|
+
details: released.changed
|
|
1263
|
+
? `${fix.stage}: GENERATING → DIRTY (orphan, no command — not re-queued)`
|
|
1264
|
+
: `orphan release skipped: ${released.reason}`
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1216
1268
|
// FSM-safe path GENERATING → DIRTY → PENDING (a direct
|
|
1217
1269
|
// GENERATING → PENDING is forbidden). Errors are NOT
|
|
1218
1270
|
// swallowed (rollbackStageToPending logs + journals +
|
|
@@ -1229,13 +1281,11 @@ async function applyFix(redis, fix) {
|
|
|
1229
1281
|
{ stage: fix.stage, fromState: 'generating', recoveredBy: 'reconciliation-engine' }
|
|
1230
1282
|
).catch(() => {});
|
|
1231
1283
|
|
|
1232
|
-
// Make the repaired stage dispatchable again —
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
redis, scene.bookId, scene.chapterId, scene.sceneId);
|
|
1238
|
-
}
|
|
1284
|
+
// Make the repaired stage dispatchable again — the active
|
|
1285
|
+
// command authorizes it (the index write is command-gated:
|
|
1286
|
+
// cancellation / missing task / disabled layer deny it).
|
|
1287
|
+
await runtimeScheduler.addSceneToActiveIndex(
|
|
1288
|
+
redis, scene.bookId, scene.chapterId, scene.sceneId);
|
|
1239
1289
|
|
|
1240
1290
|
return {
|
|
1241
1291
|
success: true, action, scene,
|
|
@@ -1244,6 +1294,14 @@ async function applyFix(redis, fix) {
|
|
|
1244
1294
|
}
|
|
1245
1295
|
|
|
1246
1296
|
// ── Legacy stuck_state path: whole-scene DIRTY ──
|
|
1297
|
+
// Command-only generation: only under an active command.
|
|
1298
|
+
{
|
|
1299
|
+
const auth = await commandGate.getAuthorizedStages(
|
|
1300
|
+
redis, scene.bookId, scene.chapterId, scene.sceneId);
|
|
1301
|
+
if (auth.stages.size === 0) {
|
|
1302
|
+
return diagnosticOnly(action, scene, 'stuck scene');
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1247
1305
|
// Mark all per-asset states as DIRTY for redispatch
|
|
1248
1306
|
// M5: Route through orchestrator.markDirtyScene instead of direct state.setAssetState
|
|
1249
1307
|
// S-5: asset-state writer via the injected orchestration seam
|
|
@@ -1280,12 +1338,22 @@ async function applyFix(redis, fix) {
|
|
|
1280
1338
|
let removedCount = 0;
|
|
1281
1339
|
const clearedDispatchIds = [];
|
|
1282
1340
|
|
|
1341
|
+
// Command-only generation: the lease release + re-queue runs
|
|
1342
|
+
// only for stages under an active command; otherwise the stale
|
|
1343
|
+
// lease is reported (diagnostics) and expires on its own TTL.
|
|
1344
|
+
const auth = await commandGate.getAuthorizedStages(
|
|
1345
|
+
redis, scene.bookId, scene.chapterId, scene.sceneId);
|
|
1346
|
+
if (auth.stages.size === 0) {
|
|
1347
|
+
return diagnosticOnly(action, scene, 'stale dispatch lease');
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1283
1350
|
// Release all dispatch leases for this scene through the
|
|
1284
1351
|
// canonical cancellation path (lease + owned quota + renewal
|
|
1285
1352
|
// timer + completion marker). A bare `del` would leak the
|
|
1286
1353
|
// quota slot and the in-memory renewal timer, and could race a
|
|
1287
1354
|
// concurrent re-acquire (no token check).
|
|
1288
1355
|
for (const stage of mediaRegistry.listMediaTypes()) {
|
|
1356
|
+
if (!auth.stages.has(stage)) continue;
|
|
1289
1357
|
const result = await dispatchEngine.cancelActiveDispatch(
|
|
1290
1358
|
redis, scene.bookId, scene.chapterId, scene.sceneId, stage,
|
|
1291
1359
|
'stale_lease_recovery'
|
|
@@ -1312,14 +1380,21 @@ async function applyFix(redis, fix) {
|
|
|
1312
1380
|
}
|
|
1313
1381
|
}
|
|
1314
1382
|
|
|
1315
|
-
// Mark
|
|
1383
|
+
// Mark the in-flight states of the AUTHORIZED stages DIRTY for
|
|
1384
|
+
// redispatch (never a finished READY stage, never a stage
|
|
1385
|
+
// without a command).
|
|
1316
1386
|
// M5: Route through orchestrator.markDirtyScene instead of direct state.setAssetState
|
|
1317
|
-
// M5 Шаг 3: syncLinearState уже внутри markDirtyScene
|
|
1318
1387
|
// S-5: asset-state writer via the injected orchestration seam
|
|
1319
1388
|
const markDirtyScene = orchestrationSeams.getOrchestrationSeam('markDirtyScene');
|
|
1320
|
-
await
|
|
1389
|
+
const currentStates = await state.getAssetStates(redis, scene.bookId, scene.chapterId, scene.sceneId);
|
|
1390
|
+
const redo = [...auth.stages].filter(stage =>
|
|
1391
|
+
currentStates[stage] === state.AssetState.GENERATING ||
|
|
1392
|
+
currentStates[stage] === state.AssetState.PENDING);
|
|
1393
|
+
if (redo.length > 0) {
|
|
1394
|
+
await markDirtyScene(redis, scene.bookId, scene.chapterId, scene.sceneId, redo);
|
|
1395
|
+
}
|
|
1321
1396
|
|
|
1322
|
-
// Add back to active index
|
|
1397
|
+
// Add back to active index (command-gated write)
|
|
1323
1398
|
await runtimeScheduler.addSceneToActiveIndex(redis, scene.bookId, scene.chapterId, scene.sceneId);
|
|
1324
1399
|
|
|
1325
1400
|
return { success: removedCount > 0, action, scene, details: `released ${removedCount} stale leases` };
|
|
@@ -1340,6 +1415,13 @@ async function applyFix(redis, fix) {
|
|
|
1340
1415
|
if (!pendingState) {
|
|
1341
1416
|
return { success: false, action, scene, details: `unknown asset type (reason: ${fix.reason})` };
|
|
1342
1417
|
}
|
|
1418
|
+
{
|
|
1419
|
+
const missingType = pendingState.replace(/_pending$/, '');
|
|
1420
|
+
if (!(await commandGate.isStageAuthorized(
|
|
1421
|
+
redis, scene.bookId, scene.chapterId, scene.sceneId, missingType))) {
|
|
1422
|
+
return diagnosticOnly(action, scene, `${missingType} READY but file missing`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1343
1425
|
|
|
1344
1426
|
// M5 Шаг 4: Route through orchestrator.setScenePending (which includes syncLinearState)
|
|
1345
1427
|
// S-5: asset-state writer via the injected orchestration seam
|
|
@@ -1362,21 +1444,6 @@ async function applyFix(redis, fix) {
|
|
|
1362
1444
|
return { success: true, action, scene, details: `marked as ${pendingState}` };
|
|
1363
1445
|
}
|
|
1364
1446
|
|
|
1365
|
-
case 'PROGRESS_TO_IMAGE': {
|
|
1366
|
-
// M5 Шаг 4: Route through orchestrator.setScenePending (includes syncLinearState)
|
|
1367
|
-
// S-5: asset-state writer via the injected orchestration seam
|
|
1368
|
-
await orchestrationSeams.getOrchestrationSeam('setScenePending')(redis, scene.bookId, scene.chapterId, scene.sceneId, 'image');
|
|
1369
|
-
|
|
1370
|
-
await runtimeScheduler.addSceneToActiveIndex(
|
|
1371
|
-
redis,
|
|
1372
|
-
scene.bookId,
|
|
1373
|
-
scene.chapterId,
|
|
1374
|
-
scene.sceneId
|
|
1375
|
-
);
|
|
1376
|
-
|
|
1377
|
-
return { success: true, action, scene, details: 'progressed to IMAGE_PENDING' };
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
1447
|
case 'RECONCILE_COUNTER_DRIFT': {
|
|
1381
1448
|
// Get the current lease count and fix counter
|
|
1382
1449
|
const stage = fix.stage || 'audio';
|
|
@@ -1387,21 +1454,6 @@ async function applyFix(redis, fix) {
|
|
|
1387
1454
|
return { success: true, action, scene, details: `counter corrected to ${leaseCount}` };
|
|
1388
1455
|
}
|
|
1389
1456
|
|
|
1390
|
-
case 'PROGRESS_TO_VIDEO': {
|
|
1391
|
-
// M5 Шаг 4: Route through orchestrator.setScenePending (includes syncLinearState)
|
|
1392
|
-
// S-5: asset-state writer via the injected orchestration seam
|
|
1393
|
-
await orchestrationSeams.getOrchestrationSeam('setScenePending')(redis, scene.bookId, scene.chapterId, scene.sceneId, 'video');
|
|
1394
|
-
|
|
1395
|
-
await runtimeScheduler.addSceneToActiveIndex(
|
|
1396
|
-
redis,
|
|
1397
|
-
scene.bookId,
|
|
1398
|
-
scene.chapterId,
|
|
1399
|
-
scene.sceneId
|
|
1400
|
-
);
|
|
1401
|
-
|
|
1402
|
-
return { success: true, action, scene, details: 'progressed to VIDEO_PENDING' };
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
1457
|
case 'RECOVER_ORPHAN_ASSETS': {
|
|
1406
1458
|
// Clear registry entries for missing assets
|
|
1407
1459
|
const assets = await persist('registry.getSceneAssetsRedis')(redis, scene.bookId, scene.chapterId, scene.sceneId);
|
|
@@ -1426,6 +1478,13 @@ async function applyFix(redis, fix) {
|
|
|
1426
1478
|
}
|
|
1427
1479
|
}
|
|
1428
1480
|
|
|
1481
|
+
// Registry fact repair above is hygiene. Re-queueing audio is
|
|
1482
|
+
// work — only under an active audio command.
|
|
1483
|
+
if (!(await commandGate.isStageAuthorized(
|
|
1484
|
+
redis, scene.bookId, scene.chapterId, scene.sceneId, 'audio'))) {
|
|
1485
|
+
log(`[DIAGNOSTIC] RECOVER_ORPHAN_ASSETS ${scene.bookId}/${scene.chapterId}/${scene.sceneId}: registry marked not-ready; audio not re-queued (no command)`);
|
|
1486
|
+
return { success: true, action, scene, diagnostic: true, details: 'registry marked for recovery (no command — nothing queued)' };
|
|
1487
|
+
}
|
|
1429
1488
|
// M5 Шаг 4: Route through orchestrator.setScenePending (includes syncLinearState)
|
|
1430
1489
|
// S-5: asset-state writer via the injected orchestration seam
|
|
1431
1490
|
await orchestrationSeams.getOrchestrationSeam('setScenePending')(redis, scene.bookId, scene.chapterId, scene.sceneId, 'audio');
|
|
@@ -1704,14 +1763,17 @@ async function reconcileCycle(redis, deps = {}, options = {}) {
|
|
|
1704
1763
|
summary.errors.push(`counter_reconcile: ${err.message}`);
|
|
1705
1764
|
}
|
|
1706
1765
|
|
|
1707
|
-
// C5:
|
|
1708
|
-
|
|
1766
|
+
// C5: Interrupted window sessions (command-only generation): the
|
|
1767
|
+
// host marks sessions that were running at shutdown as
|
|
1768
|
+
// interrupted. Nothing is resumed in the background — the user
|
|
1769
|
+
// resumes with a button.
|
|
1770
|
+
if (typeof deps.interruptIncompleteSessions === 'function') {
|
|
1709
1771
|
try {
|
|
1710
|
-
await deps.
|
|
1711
|
-
phases.push(
|
|
1772
|
+
const c5Count = await deps.interruptIncompleteSessions(log);
|
|
1773
|
+
phases.push(`session_interrupt:${c5Count || 0}`);
|
|
1712
1774
|
} catch (err) {
|
|
1713
1775
|
warn(`Phase C5 failed: ${err.message}`);
|
|
1714
|
-
summary.errors.push(`
|
|
1776
|
+
summary.errors.push(`session_interrupt: ${err.message}`);
|
|
1715
1777
|
}
|
|
1716
1778
|
}
|
|
1717
1779
|
}
|
|
@@ -1763,15 +1825,17 @@ async function reconcileCycle(redis, deps = {}, options = {}) {
|
|
|
1763
1825
|
}
|
|
1764
1826
|
|
|
1765
1827
|
// ══════════════════════════════════════════════
|
|
1766
|
-
// PHASE C8:
|
|
1828
|
+
// PHASE C8: asset-state rebuild — startup-only
|
|
1767
1829
|
// ══════════════════════════════════════════════
|
|
1768
|
-
//
|
|
1769
|
-
//
|
|
1770
|
-
//
|
|
1830
|
+
// Command-only generation (2026-10-31): the former WORK_TO_DO rebuild
|
|
1831
|
+
// re-queued work after a restart (PENDING + active index). It now only
|
|
1832
|
+
// restores per-asset FACTS from PG + FS (READY for proven files,
|
|
1833
|
+
// DIRTY for READY without a valid file / version-stale) — it never
|
|
1834
|
+
// writes PENDING and never touches the active index.
|
|
1771
1835
|
if (startup && !scope) {
|
|
1772
1836
|
try {
|
|
1773
1837
|
const c7Count = await rebuildWorkList(redis);
|
|
1774
|
-
if (c7Count > 0) phases.push(`
|
|
1838
|
+
if (c7Count > 0) phases.push(`asset_state_rebuild:${c7Count}`);
|
|
1775
1839
|
} catch (err) {
|
|
1776
1840
|
warn(`Phase C8 failed: ${err.message}`);
|
|
1777
1841
|
summary.errors.push(`worklist_rebuild: ${err.message}`);
|
|
@@ -2241,24 +2305,26 @@ async function reconcileMissingSceneState(redis, deps) {
|
|
|
2241
2305
|
}
|
|
2242
2306
|
}
|
|
2243
2307
|
|
|
2244
|
-
// ── PHASE
|
|
2245
|
-
// Cathedral Option E — см. docs/architecture/work-list-rebuild-design.md
|
|
2246
|
-
//
|
|
2247
|
-
// из PG + FS и МАТЕРИАЛИЗУЕТ runtime-состояние: per-asset Redis states + SADD
|
|
2248
|
-
// в active index. Генерацию НЕ запускает — это делает обычный scheduler tick
|
|
2249
|
-
// (единственный владелец execution, SCHEDULER_TICK_LOCK).
|
|
2308
|
+
// ── PHASE C8: asset-state rebuild (formerly WORK_TO_DO, Option E) ──
|
|
2309
|
+
// Cathedral Option E — см. docs/architecture/work-list-rebuild-design.md.
|
|
2310
|
+
// После потери Redis пересчитывает per-asset ФАКТЫ из PG + FS.
|
|
2250
2311
|
//
|
|
2251
|
-
//
|
|
2252
|
-
//
|
|
2253
|
-
//
|
|
2254
|
-
//
|
|
2255
|
-
//
|
|
2256
|
-
//
|
|
2257
|
-
//
|
|
2258
|
-
//
|
|
2259
|
-
//
|
|
2260
|
-
//
|
|
2261
|
-
//
|
|
2312
|
+
// Command-only generation (2026-10-31, animastor-docs COMMAND_ONLY_GENERATION):
|
|
2313
|
+
// фаза БОЛЬШЕ НЕ СОЗДАЁТ РАБОТУ — не пишет PENDING и не делает SADD в active
|
|
2314
|
+
// index. Прерванная генерация возобновляется только кнопкой (задачи реестра
|
|
2315
|
+
// помечаются interrupted хостом на старте).
|
|
2316
|
+
//
|
|
2317
|
+
// Что фаза делает:
|
|
2318
|
+
// 1. Предикат НЕ читает Redis asset state (C0 over-marks READY) — только PG + FS.
|
|
2319
|
+
// 2. Файл валиден и нет dirty-маркеров → READY (guarded restore-fact write),
|
|
2320
|
+
// поверх GENERATING/PENDING не пишет.
|
|
2321
|
+
// 3. Нужна работа (нет файла / stale / dirty-маркер): READY → DIRTY, а NEW при
|
|
2322
|
+
// существующем устаревшем файле → DIRTY (stale-метка, FSM-валидно). Без файла
|
|
2323
|
+
// NEW остаётся NEW; PLACEHOLDER, DIRTY, FAILED, PENDING, GENERATING не
|
|
2324
|
+
// трогаются: работа — только по команде.
|
|
2325
|
+
// 4. Книги с cancellation tombstone пропускаются fail-closed.
|
|
2326
|
+
// 5. Выключенные слои не трогаются.
|
|
2327
|
+
// 6. Ошибки PG — fail-closed (фаза прерывается).
|
|
2262
2328
|
async function rebuildWorkList(redis) {
|
|
2263
2329
|
// Все зависимости — lazy require на момент вызова, чтобы фаза была
|
|
2264
2330
|
// самодостаточной (не зависит от того, с какими зависимостями был
|
|
@@ -2284,10 +2350,8 @@ const state = lazyHostBinding('state');
|
|
|
2284
2350
|
// host service left this function.
|
|
2285
2351
|
const placeholderAudioOp = require('./placeholder-audio-port').placeholderAudioOp;
|
|
2286
2352
|
const sceneWindow = require('./scene-window');
|
|
2287
|
-
|
|
2288
|
-
// S-5: asset-state writers via the injected orchestration seams
|
|
2353
|
+
// S-5: asset-state writer via the injected orchestration seam
|
|
2289
2354
|
const markDirtyScene = orchestrationSeams.getOrchestrationSeam('markDirtyScene');
|
|
2290
|
-
const setScenePending = orchestrationSeams.getOrchestrationSeam('setScenePending');
|
|
2291
2355
|
|
|
2292
2356
|
const AssetState = state.AssetState;
|
|
2293
2357
|
const STAGES = mediaRegistry.listMediaTypes();
|
|
@@ -2372,88 +2436,58 @@ const state = lazyHostBinding('state');
|
|
|
2372
2436
|
}
|
|
2373
2437
|
|
|
2374
2438
|
const stateMap = {};
|
|
2375
|
-
let needsWork = false;
|
|
2376
2439
|
for (const stage of STAGES) {
|
|
2377
|
-
if (!enabled[stage]) continue; // disabled:
|
|
2440
|
+
if (!enabled[stage]) continue; // disabled layer: untouched
|
|
2378
2441
|
const fileValid = fsStatus[stage].exists && (stage !== 'audio' || audioReal);
|
|
2379
2442
|
const stale = !!staleAssets[`${key}:${stage}`];
|
|
2380
2443
|
const dirty = !!dirtyStages[`${key}:${stage}`];
|
|
2381
2444
|
const unitDirty = stage === 'image' && unitDirtyScenes.has(key);
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
stateMap[stage] = AssetState.PENDING;
|
|
2386
|
-
needsWork = true;
|
|
2387
|
-
}
|
|
2445
|
+
stateMap[stage] = (fileValid && !stale && !dirty && !unitDirty)
|
|
2446
|
+
? AssetState.READY
|
|
2447
|
+
: AssetState.DIRTY;
|
|
2388
2448
|
}
|
|
2389
2449
|
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
// 4. Материализовать states ДО SADD — через SAFE facade (audit gap 1).
|
|
2393
|
-
// Нельзя downgrade-ить выполняющуюся генерацию:
|
|
2394
|
-
// GENERATING/PENDING/PLACEHOLDER → НЕ трогаем (in-flight/очередь
|
|
2395
|
-
// владеют стадией; recovery не имеет права их откатывать).
|
|
2396
|
-
// PENDING-таргеты → orchestrator.setScenePending (валидация переходов:
|
|
2397
|
-
// GENERATING→PENDING rejected самой таблицей переходов).
|
|
2398
|
-
// READY-таргеты → guarded restore-fact write (S2.4): только из
|
|
2399
|
-
// NEW/DIRTY/FAILED/READY, никогда поверх GENERATING/PENDING.
|
|
2400
|
-
// C0 over-mark (READY без файла) → READY→DIRTY (markDirtyScene) →
|
|
2401
|
-
// →PENDING (setScenePending) — self-heal P1 через валидные переходы.
|
|
2450
|
+
// 4. Материализовать ФАКТЫ через safe-переходы. Работа не ставится.
|
|
2402
2451
|
const currentStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
|
|
2452
|
+
let touched = false;
|
|
2403
2453
|
for (const [stage, target] of Object.entries(stateMap)) {
|
|
2404
2454
|
const current = currentStates[stage] || state.AssetState.NEW;
|
|
2405
|
-
// GENERATING/PENDING — выполняющаяся/очередная генерация:
|
|
2406
|
-
// recovery не имеет права downgrade-ить её (audit gap 1).
|
|
2407
2455
|
if (current === state.AssetState.GENERATING ||
|
|
2408
2456
|
current === state.AssetState.PENDING) {
|
|
2409
|
-
log(`[WORKLIST] ${bookId}/${chapterId}/${sceneId} ${stage}: ${current} — untouched by
|
|
2457
|
+
log(`[WORKLIST] ${bookId}/${chapterId}/${sceneId} ${stage}: ${current} — untouched by rebuild`);
|
|
2410
2458
|
continue;
|
|
2411
2459
|
}
|
|
2412
|
-
if (target === state.AssetState.
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
const fresh = (await state.getAssetStates(redis, bookId, chapterId, sceneId))[stage] || state.AssetState.NEW;
|
|
2426
|
-
if (fresh !== current) {
|
|
2427
|
-
log(`[WORKLIST] ${bookId}/${chapterId}/${sceneId} ${stage}: state changed ${current}→${fresh} during rebuild — untouched (owner present)`);
|
|
2428
|
-
continue;
|
|
2429
|
-
}
|
|
2430
|
-
log(`[WORKLIST] ${bookId}/${chapterId}/${sceneId} ${stage}: ${current} → DIRTY → PENDING (valid transitions)`);
|
|
2431
|
-
await markDirtyScene(redis, bookId, chapterId, sceneId, [stage]);
|
|
2432
|
-
await setScenePending(redis, bookId, chapterId, sceneId, stage);
|
|
2433
|
-
} else {
|
|
2434
|
-
// NEW/DIRTY/FAILED → PENDING — валидные переходы.
|
|
2435
|
-
await setScenePending(redis, bookId, chapterId, sceneId, stage);
|
|
2436
|
-
}
|
|
2437
|
-
} else {
|
|
2438
|
-
// target === READY — restore-fact (файл валиден на диске):
|
|
2439
|
-
// guarded unsafe write, S2.4 (restore path). Пропускаются
|
|
2440
|
-
// GENERATING/PENDING (см. guard выше); из остальных состояний
|
|
2441
|
-
// (NEW/DIRTY/FAILED/PLACEHOLDER/READY) восстановить READY
|
|
2442
|
-
// безопасно — файл доказан FS-пробой.
|
|
2443
|
-
if (current !== state.AssetState.READY) {
|
|
2444
|
-
await state.unsafeRestoreAssetState(redis, bookId, chapterId, sceneId, stage, state.AssetState.READY);
|
|
2460
|
+
if (target === state.AssetState.DIRTY) {
|
|
2461
|
+
// Stale marker only (the work waits for an explicit
|
|
2462
|
+
// command): a READY over-mark, or a NEW stage whose
|
|
2463
|
+
// artifact exists but is outdated/flagged. A stage
|
|
2464
|
+
// with no artifact at all stays NEW; PLACEHOLDER,
|
|
2465
|
+
// DIRTY, FAILED are left as they are.
|
|
2466
|
+
const markable = current === state.AssetState.READY ||
|
|
2467
|
+
(current === state.AssetState.NEW && fsStatus[stage].exists);
|
|
2468
|
+
if (!markable) continue;
|
|
2469
|
+
const fresh = (await state.getAssetStates(redis, bookId, chapterId, sceneId))[stage] || state.AssetState.NEW;
|
|
2470
|
+
if (fresh !== current) {
|
|
2471
|
+
log(`[WORKLIST] ${bookId}/${chapterId}/${sceneId} ${stage}: state changed ${current}→${fresh} during rebuild — untouched (owner present)`);
|
|
2472
|
+
continue;
|
|
2445
2473
|
}
|
|
2474
|
+
log(`[WORKLIST] ${bookId}/${chapterId}/${sceneId} ${stage}: ${current} without a valid/current file → DIRTY (stale; regeneration only by command)`);
|
|
2475
|
+
await markDirtyScene(redis, bookId, chapterId, sceneId, [stage]);
|
|
2476
|
+
touched = true;
|
|
2477
|
+
} else if (current !== state.AssetState.READY) {
|
|
2478
|
+
// target === READY — restore-fact (файл доказан FS-пробой).
|
|
2479
|
+
await state.unsafeRestoreAssetState(redis, bookId, chapterId, sceneId, stage, state.AssetState.READY);
|
|
2480
|
+
touched = true;
|
|
2446
2481
|
}
|
|
2447
2482
|
}
|
|
2448
|
-
|
|
2449
|
-
scenesAdded++;
|
|
2483
|
+
if (touched) scenesAdded++;
|
|
2450
2484
|
}
|
|
2451
2485
|
} catch (err) {
|
|
2452
2486
|
warn(`Rebuild failed for book ${bookId}: ${err.message}`);
|
|
2453
2487
|
}
|
|
2454
2488
|
}
|
|
2455
2489
|
|
|
2456
|
-
if (scenesAdded > 0) log(`[WORKLIST] ${scenesAdded} scene(s)
|
|
2490
|
+
if (scenesAdded > 0) log(`[WORKLIST] ${scenesAdded} scene(s) asset-state facts rebuilt (no work queued)`);
|
|
2457
2491
|
return scenesAdded;
|
|
2458
2492
|
}
|
|
2459
2493
|
|