@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
|
@@ -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).
|
|
@@ -335,15 +339,7 @@ async function checkOrphanAssets(redis, bookId, chapterId, sceneId) {
|
|
|
335
339
|
* Reconciliation must never resurrect a cancelled book.
|
|
336
340
|
*/
|
|
337
341
|
async function isBookGenerationCancelled(redis, bookId) {
|
|
338
|
-
|
|
339
|
-
const sceneWindow = require('./scene-window');
|
|
340
|
-
if (await sceneWindow.isCancelled(redis, bookId)) return true;
|
|
341
|
-
} catch (_) { /* flag unreadable — fall through to tombstone */ }
|
|
342
|
-
try {
|
|
343
|
-
return await persist('cancel.isCancelled')(bookId);
|
|
344
|
-
} catch (_) {
|
|
345
|
-
return false;
|
|
346
|
-
}
|
|
342
|
+
return commandGate.isBookCancelled(redis, bookId);
|
|
347
343
|
}
|
|
348
344
|
|
|
349
345
|
/**
|
|
@@ -397,9 +393,15 @@ async function checkOrphanGeneratingState(redis, bookId, chapterId, sceneId) {
|
|
|
397
393
|
// ======================================================
|
|
398
394
|
|
|
399
395
|
/**
|
|
400
|
-
* Check for partially built scenes.
|
|
396
|
+
* Check for partially built scenes — DIAGNOSTICS ONLY.
|
|
401
397
|
* T8: использует per-asset state вместо линейного.
|
|
402
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
|
+
*
|
|
403
405
|
* Stage 50: генерация в полёте — не «partial build». Раньше registry мог
|
|
404
406
|
* ещё не содержать image (registerSceneImage пишется по завершении), а
|
|
405
407
|
* ассет уже PENDING/GENERATING с живым lease — отчёт partial_audio_only
|
|
@@ -992,9 +994,7 @@ async function reconcileScene(redis, bookId, chapterId, sceneId) {
|
|
|
992
994
|
}
|
|
993
995
|
}
|
|
994
996
|
|
|
995
|
-
// Check orphan GENERATING states (audit d9d67a3).
|
|
996
|
-
// partial-build checks: those de-duplicate by sceneId, and the orphan
|
|
997
|
-
// repair must win over a PROGRESS_TO_* resurrection attempt.
|
|
997
|
+
// Check orphan GENERATING states (audit d9d67a3).
|
|
998
998
|
const orphanGenerating = await checkOrphanGeneratingState(redis, bookId, chapterId, sceneId);
|
|
999
999
|
if (orphanGenerating) {
|
|
1000
1000
|
report.orphanStates.push(...orphanGenerating);
|
|
@@ -1007,16 +1007,11 @@ async function reconcileScene(redis, bookId, chapterId, sceneId) {
|
|
|
1007
1007
|
}
|
|
1008
1008
|
}
|
|
1009
1009
|
|
|
1010
|
-
//
|
|
1010
|
+
// Partial builds — diagnostics only (command-only generation): kept in
|
|
1011
|
+
// the report, NEVER turned into an inconsistency/fix.
|
|
1011
1012
|
const partial = await checkPartialBuilds(redis, bookId, chapterId, sceneId);
|
|
1012
1013
|
if (partial) {
|
|
1013
1014
|
report.partialBuilds.push(partial);
|
|
1014
|
-
if (!report.inconsistentScenes.find(s => s.scene.sceneId === sceneId)) {
|
|
1015
|
-
report.inconsistentScenes.push({
|
|
1016
|
-
scene: { bookId, chapterId, sceneId },
|
|
1017
|
-
issue: partial.type
|
|
1018
|
-
});
|
|
1019
|
-
}
|
|
1020
1015
|
}
|
|
1021
1016
|
|
|
1022
1017
|
// Check stale locks
|
|
@@ -1109,20 +1104,6 @@ function getFixRecommendations(inconsistentScenes) {
|
|
|
1109
1104
|
reason: 'AUDIO_READY but no audio file',
|
|
1110
1105
|
safeToExecute: true
|
|
1111
1106
|
};
|
|
1112
|
-
case 'partial_audio_only':
|
|
1113
|
-
return {
|
|
1114
|
-
scene,
|
|
1115
|
-
action: 'PROGRESS_TO_IMAGE',
|
|
1116
|
-
reason: 'Audio ready but image not started',
|
|
1117
|
-
safeToExecute: true
|
|
1118
|
-
};
|
|
1119
|
-
case 'partial_image_video_missing':
|
|
1120
|
-
return {
|
|
1121
|
-
scene,
|
|
1122
|
-
action: 'PROGRESS_TO_VIDEO',
|
|
1123
|
-
reason: 'Image ready but video not started',
|
|
1124
|
-
safeToExecute: true
|
|
1125
|
-
};
|
|
1126
1107
|
case 'stale_locks':
|
|
1127
1108
|
return {
|
|
1128
1109
|
scene,
|
|
@@ -1207,12 +1188,15 @@ function getFixRecommendations(inconsistentScenes) {
|
|
|
1207
1188
|
*/
|
|
1208
1189
|
const RESURRECTION_ACTIONS = new Set([
|
|
1209
1190
|
'REGENERATE_MISSING_ASSET',
|
|
1210
|
-
'PROGRESS_TO_IMAGE',
|
|
1211
|
-
'PROGRESS_TO_VIDEO',
|
|
1212
1191
|
'RECOVER_ORPHAN_ASSETS',
|
|
1213
1192
|
'RELEASE_STALE_LEASE',
|
|
1214
1193
|
]);
|
|
1215
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
|
+
|
|
1216
1200
|
/**
|
|
1217
1201
|
* Apply a fix recommendation.
|
|
1218
1202
|
* Returns { success: boolean, action, scene, details }
|
|
@@ -1264,6 +1248,23 @@ async function applyFix(redis, fix) {
|
|
|
1264
1248
|
};
|
|
1265
1249
|
}
|
|
1266
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
|
+
|
|
1267
1268
|
// FSM-safe path GENERATING → DIRTY → PENDING (a direct
|
|
1268
1269
|
// GENERATING → PENDING is forbidden). Errors are NOT
|
|
1269
1270
|
// swallowed (rollbackStageToPending logs + journals +
|
|
@@ -1280,13 +1281,11 @@ async function applyFix(redis, fix) {
|
|
|
1280
1281
|
{ stage: fix.stage, fromState: 'generating', recoveredBy: 'reconciliation-engine' }
|
|
1281
1282
|
).catch(() => {});
|
|
1282
1283
|
|
|
1283
|
-
// Make the repaired stage dispatchable again —
|
|
1284
|
-
//
|
|
1285
|
-
//
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
redis, scene.bookId, scene.chapterId, scene.sceneId);
|
|
1289
|
-
}
|
|
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);
|
|
1290
1289
|
|
|
1291
1290
|
return {
|
|
1292
1291
|
success: true, action, scene,
|
|
@@ -1295,6 +1294,14 @@ async function applyFix(redis, fix) {
|
|
|
1295
1294
|
}
|
|
1296
1295
|
|
|
1297
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
|
+
}
|
|
1298
1305
|
// Mark all per-asset states as DIRTY for redispatch
|
|
1299
1306
|
// M5: Route through orchestrator.markDirtyScene instead of direct state.setAssetState
|
|
1300
1307
|
// S-5: asset-state writer via the injected orchestration seam
|
|
@@ -1331,12 +1338,22 @@ async function applyFix(redis, fix) {
|
|
|
1331
1338
|
let removedCount = 0;
|
|
1332
1339
|
const clearedDispatchIds = [];
|
|
1333
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
|
+
|
|
1334
1350
|
// Release all dispatch leases for this scene through the
|
|
1335
1351
|
// canonical cancellation path (lease + owned quota + renewal
|
|
1336
1352
|
// timer + completion marker). A bare `del` would leak the
|
|
1337
1353
|
// quota slot and the in-memory renewal timer, and could race a
|
|
1338
1354
|
// concurrent re-acquire (no token check).
|
|
1339
1355
|
for (const stage of mediaRegistry.listMediaTypes()) {
|
|
1356
|
+
if (!auth.stages.has(stage)) continue;
|
|
1340
1357
|
const result = await dispatchEngine.cancelActiveDispatch(
|
|
1341
1358
|
redis, scene.bookId, scene.chapterId, scene.sceneId, stage,
|
|
1342
1359
|
'stale_lease_recovery'
|
|
@@ -1363,14 +1380,21 @@ async function applyFix(redis, fix) {
|
|
|
1363
1380
|
}
|
|
1364
1381
|
}
|
|
1365
1382
|
|
|
1366
|
-
// 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).
|
|
1367
1386
|
// M5: Route through orchestrator.markDirtyScene instead of direct state.setAssetState
|
|
1368
|
-
// M5 Шаг 3: syncLinearState уже внутри markDirtyScene
|
|
1369
1387
|
// S-5: asset-state writer via the injected orchestration seam
|
|
1370
1388
|
const markDirtyScene = orchestrationSeams.getOrchestrationSeam('markDirtyScene');
|
|
1371
|
-
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
|
+
}
|
|
1372
1396
|
|
|
1373
|
-
// Add back to active index
|
|
1397
|
+
// Add back to active index (command-gated write)
|
|
1374
1398
|
await runtimeScheduler.addSceneToActiveIndex(redis, scene.bookId, scene.chapterId, scene.sceneId);
|
|
1375
1399
|
|
|
1376
1400
|
return { success: removedCount > 0, action, scene, details: `released ${removedCount} stale leases` };
|
|
@@ -1391,6 +1415,13 @@ async function applyFix(redis, fix) {
|
|
|
1391
1415
|
if (!pendingState) {
|
|
1392
1416
|
return { success: false, action, scene, details: `unknown asset type (reason: ${fix.reason})` };
|
|
1393
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
|
+
}
|
|
1394
1425
|
|
|
1395
1426
|
// M5 Шаг 4: Route through orchestrator.setScenePending (which includes syncLinearState)
|
|
1396
1427
|
// S-5: asset-state writer via the injected orchestration seam
|
|
@@ -1413,21 +1444,6 @@ async function applyFix(redis, fix) {
|
|
|
1413
1444
|
return { success: true, action, scene, details: `marked as ${pendingState}` };
|
|
1414
1445
|
}
|
|
1415
1446
|
|
|
1416
|
-
case 'PROGRESS_TO_IMAGE': {
|
|
1417
|
-
// M5 Шаг 4: Route through orchestrator.setScenePending (includes syncLinearState)
|
|
1418
|
-
// S-5: asset-state writer via the injected orchestration seam
|
|
1419
|
-
await orchestrationSeams.getOrchestrationSeam('setScenePending')(redis, scene.bookId, scene.chapterId, scene.sceneId, 'image');
|
|
1420
|
-
|
|
1421
|
-
await runtimeScheduler.addSceneToActiveIndex(
|
|
1422
|
-
redis,
|
|
1423
|
-
scene.bookId,
|
|
1424
|
-
scene.chapterId,
|
|
1425
|
-
scene.sceneId
|
|
1426
|
-
);
|
|
1427
|
-
|
|
1428
|
-
return { success: true, action, scene, details: 'progressed to IMAGE_PENDING' };
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
1447
|
case 'RECONCILE_COUNTER_DRIFT': {
|
|
1432
1448
|
// Get the current lease count and fix counter
|
|
1433
1449
|
const stage = fix.stage || 'audio';
|
|
@@ -1438,21 +1454,6 @@ async function applyFix(redis, fix) {
|
|
|
1438
1454
|
return { success: true, action, scene, details: `counter corrected to ${leaseCount}` };
|
|
1439
1455
|
}
|
|
1440
1456
|
|
|
1441
|
-
case 'PROGRESS_TO_VIDEO': {
|
|
1442
|
-
// M5 Шаг 4: Route through orchestrator.setScenePending (includes syncLinearState)
|
|
1443
|
-
// S-5: asset-state writer via the injected orchestration seam
|
|
1444
|
-
await orchestrationSeams.getOrchestrationSeam('setScenePending')(redis, scene.bookId, scene.chapterId, scene.sceneId, 'video');
|
|
1445
|
-
|
|
1446
|
-
await runtimeScheduler.addSceneToActiveIndex(
|
|
1447
|
-
redis,
|
|
1448
|
-
scene.bookId,
|
|
1449
|
-
scene.chapterId,
|
|
1450
|
-
scene.sceneId
|
|
1451
|
-
);
|
|
1452
|
-
|
|
1453
|
-
return { success: true, action, scene, details: 'progressed to VIDEO_PENDING' };
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
1457
|
case 'RECOVER_ORPHAN_ASSETS': {
|
|
1457
1458
|
// Clear registry entries for missing assets
|
|
1458
1459
|
const assets = await persist('registry.getSceneAssetsRedis')(redis, scene.bookId, scene.chapterId, scene.sceneId);
|
|
@@ -1477,6 +1478,13 @@ async function applyFix(redis, fix) {
|
|
|
1477
1478
|
}
|
|
1478
1479
|
}
|
|
1479
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
|
+
}
|
|
1480
1488
|
// M5 Шаг 4: Route through orchestrator.setScenePending (includes syncLinearState)
|
|
1481
1489
|
// S-5: asset-state writer via the injected orchestration seam
|
|
1482
1490
|
await orchestrationSeams.getOrchestrationSeam('setScenePending')(redis, scene.bookId, scene.chapterId, scene.sceneId, 'audio');
|
|
@@ -1755,14 +1763,17 @@ async function reconcileCycle(redis, deps = {}, options = {}) {
|
|
|
1755
1763
|
summary.errors.push(`counter_reconcile: ${err.message}`);
|
|
1756
1764
|
}
|
|
1757
1765
|
|
|
1758
|
-
// C5:
|
|
1759
|
-
|
|
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') {
|
|
1760
1771
|
try {
|
|
1761
|
-
await deps.
|
|
1762
|
-
phases.push(
|
|
1772
|
+
const c5Count = await deps.interruptIncompleteSessions(log);
|
|
1773
|
+
phases.push(`session_interrupt:${c5Count || 0}`);
|
|
1763
1774
|
} catch (err) {
|
|
1764
1775
|
warn(`Phase C5 failed: ${err.message}`);
|
|
1765
|
-
summary.errors.push(`
|
|
1776
|
+
summary.errors.push(`session_interrupt: ${err.message}`);
|
|
1766
1777
|
}
|
|
1767
1778
|
}
|
|
1768
1779
|
}
|
|
@@ -1814,15 +1825,17 @@ async function reconcileCycle(redis, deps = {}, options = {}) {
|
|
|
1814
1825
|
}
|
|
1815
1826
|
|
|
1816
1827
|
// ══════════════════════════════════════════════
|
|
1817
|
-
// PHASE C8:
|
|
1828
|
+
// PHASE C8: asset-state rebuild — startup-only
|
|
1818
1829
|
// ══════════════════════════════════════════════
|
|
1819
|
-
//
|
|
1820
|
-
//
|
|
1821
|
-
//
|
|
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.
|
|
1822
1835
|
if (startup && !scope) {
|
|
1823
1836
|
try {
|
|
1824
1837
|
const c7Count = await rebuildWorkList(redis);
|
|
1825
|
-
if (c7Count > 0) phases.push(`
|
|
1838
|
+
if (c7Count > 0) phases.push(`asset_state_rebuild:${c7Count}`);
|
|
1826
1839
|
} catch (err) {
|
|
1827
1840
|
warn(`Phase C8 failed: ${err.message}`);
|
|
1828
1841
|
summary.errors.push(`worklist_rebuild: ${err.message}`);
|
|
@@ -2292,24 +2305,26 @@ async function reconcileMissingSceneState(redis, deps) {
|
|
|
2292
2305
|
}
|
|
2293
2306
|
}
|
|
2294
2307
|
|
|
2295
|
-
// ── PHASE
|
|
2296
|
-
// Cathedral Option E — см. docs/architecture/work-list-rebuild-design.md
|
|
2297
|
-
//
|
|
2298
|
-
//
|
|
2299
|
-
//
|
|
2300
|
-
//
|
|
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.
|
|
2311
|
+
//
|
|
2312
|
+
// Command-only generation (2026-10-31, animastor-docs COMMAND_ONLY_GENERATION):
|
|
2313
|
+
// фаза БОЛЬШЕ НЕ СОЗДАЁТ РАБОТУ — не пишет PENDING и не делает SADD в active
|
|
2314
|
+
// index. Прерванная генерация возобновляется только кнопкой (задачи реестра
|
|
2315
|
+
// помечаются interrupted хостом на старте).
|
|
2301
2316
|
//
|
|
2302
|
-
//
|
|
2303
|
-
// 1. Предикат НЕ читает Redis asset state (C0 over-marks READY
|
|
2304
|
-
//
|
|
2305
|
-
//
|
|
2306
|
-
//
|
|
2307
|
-
//
|
|
2308
|
-
//
|
|
2309
|
-
//
|
|
2310
|
-
//
|
|
2311
|
-
//
|
|
2312
|
-
//
|
|
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 (фаза прерывается).
|
|
2313
2328
|
async function rebuildWorkList(redis) {
|
|
2314
2329
|
// Все зависимости — lazy require на момент вызова, чтобы фаза была
|
|
2315
2330
|
// самодостаточной (не зависит от того, с какими зависимостями был
|
|
@@ -2335,10 +2350,8 @@ const state = lazyHostBinding('state');
|
|
|
2335
2350
|
// host service left this function.
|
|
2336
2351
|
const placeholderAudioOp = require('./placeholder-audio-port').placeholderAudioOp;
|
|
2337
2352
|
const sceneWindow = require('./scene-window');
|
|
2338
|
-
|
|
2339
|
-
// S-5: asset-state writers via the injected orchestration seams
|
|
2353
|
+
// S-5: asset-state writer via the injected orchestration seam
|
|
2340
2354
|
const markDirtyScene = orchestrationSeams.getOrchestrationSeam('markDirtyScene');
|
|
2341
|
-
const setScenePending = orchestrationSeams.getOrchestrationSeam('setScenePending');
|
|
2342
2355
|
|
|
2343
2356
|
const AssetState = state.AssetState;
|
|
2344
2357
|
const STAGES = mediaRegistry.listMediaTypes();
|
|
@@ -2423,88 +2436,58 @@ const state = lazyHostBinding('state');
|
|
|
2423
2436
|
}
|
|
2424
2437
|
|
|
2425
2438
|
const stateMap = {};
|
|
2426
|
-
let needsWork = false;
|
|
2427
2439
|
for (const stage of STAGES) {
|
|
2428
|
-
if (!enabled[stage]) continue; // disabled:
|
|
2440
|
+
if (!enabled[stage]) continue; // disabled layer: untouched
|
|
2429
2441
|
const fileValid = fsStatus[stage].exists && (stage !== 'audio' || audioReal);
|
|
2430
2442
|
const stale = !!staleAssets[`${key}:${stage}`];
|
|
2431
2443
|
const dirty = !!dirtyStages[`${key}:${stage}`];
|
|
2432
2444
|
const unitDirty = stage === 'image' && unitDirtyScenes.has(key);
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
stateMap[stage] = AssetState.PENDING;
|
|
2437
|
-
needsWork = true;
|
|
2438
|
-
}
|
|
2445
|
+
stateMap[stage] = (fileValid && !stale && !dirty && !unitDirty)
|
|
2446
|
+
? AssetState.READY
|
|
2447
|
+
: AssetState.DIRTY;
|
|
2439
2448
|
}
|
|
2440
2449
|
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
// 4. Материализовать states ДО SADD — через SAFE facade (audit gap 1).
|
|
2444
|
-
// Нельзя downgrade-ить выполняющуюся генерацию:
|
|
2445
|
-
// GENERATING/PENDING/PLACEHOLDER → НЕ трогаем (in-flight/очередь
|
|
2446
|
-
// владеют стадией; recovery не имеет права их откатывать).
|
|
2447
|
-
// PENDING-таргеты → orchestrator.setScenePending (валидация переходов:
|
|
2448
|
-
// GENERATING→PENDING rejected самой таблицей переходов).
|
|
2449
|
-
// READY-таргеты → guarded restore-fact write (S2.4): только из
|
|
2450
|
-
// NEW/DIRTY/FAILED/READY, никогда поверх GENERATING/PENDING.
|
|
2451
|
-
// C0 over-mark (READY без файла) → READY→DIRTY (markDirtyScene) →
|
|
2452
|
-
// →PENDING (setScenePending) — self-heal P1 через валидные переходы.
|
|
2450
|
+
// 4. Материализовать ФАКТЫ через safe-переходы. Работа не ставится.
|
|
2453
2451
|
const currentStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
|
|
2452
|
+
let touched = false;
|
|
2454
2453
|
for (const [stage, target] of Object.entries(stateMap)) {
|
|
2455
2454
|
const current = currentStates[stage] || state.AssetState.NEW;
|
|
2456
|
-
// GENERATING/PENDING — выполняющаяся/очередная генерация:
|
|
2457
|
-
// recovery не имеет права downgrade-ить её (audit gap 1).
|
|
2458
2455
|
if (current === state.AssetState.GENERATING ||
|
|
2459
2456
|
current === state.AssetState.PENDING) {
|
|
2460
|
-
log(`[WORKLIST] ${bookId}/${chapterId}/${sceneId} ${stage}: ${current} — untouched by
|
|
2457
|
+
log(`[WORKLIST] ${bookId}/${chapterId}/${sceneId} ${stage}: ${current} — untouched by rebuild`);
|
|
2461
2458
|
continue;
|
|
2462
2459
|
}
|
|
2463
|
-
if (target === state.AssetState.
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
const fresh = (await state.getAssetStates(redis, bookId, chapterId, sceneId))[stage] || state.AssetState.NEW;
|
|
2477
|
-
if (fresh !== current) {
|
|
2478
|
-
log(`[WORKLIST] ${bookId}/${chapterId}/${sceneId} ${stage}: state changed ${current}→${fresh} during rebuild — untouched (owner present)`);
|
|
2479
|
-
continue;
|
|
2480
|
-
}
|
|
2481
|
-
log(`[WORKLIST] ${bookId}/${chapterId}/${sceneId} ${stage}: ${current} → DIRTY → PENDING (valid transitions)`);
|
|
2482
|
-
await markDirtyScene(redis, bookId, chapterId, sceneId, [stage]);
|
|
2483
|
-
await setScenePending(redis, bookId, chapterId, sceneId, stage);
|
|
2484
|
-
} else {
|
|
2485
|
-
// NEW/DIRTY/FAILED → PENDING — валидные переходы.
|
|
2486
|
-
await setScenePending(redis, bookId, chapterId, sceneId, stage);
|
|
2487
|
-
}
|
|
2488
|
-
} else {
|
|
2489
|
-
// target === READY — restore-fact (файл валиден на диске):
|
|
2490
|
-
// guarded unsafe write, S2.4 (restore path). Пропускаются
|
|
2491
|
-
// GENERATING/PENDING (см. guard выше); из остальных состояний
|
|
2492
|
-
// (NEW/DIRTY/FAILED/PLACEHOLDER/READY) восстановить READY
|
|
2493
|
-
// безопасно — файл доказан FS-пробой.
|
|
2494
|
-
if (current !== state.AssetState.READY) {
|
|
2495
|
-
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;
|
|
2496
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;
|
|
2497
2481
|
}
|
|
2498
2482
|
}
|
|
2499
|
-
|
|
2500
|
-
scenesAdded++;
|
|
2483
|
+
if (touched) scenesAdded++;
|
|
2501
2484
|
}
|
|
2502
2485
|
} catch (err) {
|
|
2503
2486
|
warn(`Rebuild failed for book ${bookId}: ${err.message}`);
|
|
2504
2487
|
}
|
|
2505
2488
|
}
|
|
2506
2489
|
|
|
2507
|
-
if (scenesAdded > 0) log(`[WORKLIST] ${scenesAdded} scene(s)
|
|
2490
|
+
if (scenesAdded > 0) log(`[WORKLIST] ${scenesAdded} scene(s) asset-state facts rebuilt (no work queued)`);
|
|
2508
2491
|
return scenesAdded;
|
|
2509
2492
|
}
|
|
2510
2493
|
|