@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.
@@ -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
- try {
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). Pushed BEFORE the
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
- // Check partial builds
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 — but never
1284
- // for a cancelled book (tombstone/cancel flag precedence:
1285
- // only an explicit new generation clears them).
1286
- if (!(await isBookGenerationCancelled(redis, scene.bookId))) {
1287
- await runtimeScheduler.addSceneToActiveIndex(
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 per-asset states as DIRTY for redispatch
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 markDirtyScene(redis, scene.bookId, scene.chapterId, scene.sceneId);
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: Resume incomplete sessions (optional — requires runBackgroundWindowGeneration)
1759
- if (typeof deps.resumeIncompleteSessions === 'function' && typeof deps.runBackgroundWindowGeneration === 'function') {
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.resumeIncompleteSessions(log, deps.runBackgroundWindowGeneration);
1762
- phases.push('session_resume');
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(`session_resume: ${err.message}`);
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: WORK_TO_DO rebuild (Option E) — startup-only
1828
+ // PHASE C8: asset-state rebuild — startup-only
1818
1829
  // ══════════════════════════════════════════════
1819
- // Авто-возобновление прерванной генерации после потери Redis. Только
1820
- // первый цикл (startup:true) — периодический цикл работу НЕ возрождает
1821
- // (политика Recon #4 §10.3: без user action генерация не стартует).
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(`worklist_rebuild:${c7Count}`);
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 C7: WORK_TO_DO rebuild (Option E) ──────────
2296
- // Cathedral Option E — см. docs/architecture/work-list-rebuild-design.md
2297
- // (Recon #4 design proof). После полной потери Redis пересчитывает WORK_TO_DO
2298
- // из PG + FS и МАТЕРИАЛИЗУЕТ runtime-состояние: per-asset Redis states + SADD
2299
- // в active index. Генерацию НЕ запускает — это делает обычный scheduler tick
2300
- // (единственный владелец 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.
2311
+ //
2312
+ // Command-only generation (2026-10-31, animastor-docs COMMAND_ONLY_GENERATION):
2313
+ // фаза БОЛЬШЕ НЕ СОЗДАЁТ РАБОТУ — не пишет PENDING и не делает SADD в active
2314
+ // index. Прерванная генерация возобновляется только кнопкой (задачи реестра
2315
+ // помечаются interrupted хостом на старте).
2301
2316
  //
2302
- // Доказанные в Recon #4 инварианты:
2303
- // 1. Предикат НЕ читает Redis asset state (C0 over-marks READY/READY/READY
2304
- // для любой сцены с .mp3) — только PG + FS-проба.
2305
- // 2. States пишутся ДО SADD: scheduler решает «что диспатчить» по Redis
2306
- // states (shouldScheduleAssets), пустой ключ = NEW = «диспатчить всё».
2307
- // 3. build_id пинится из manifest.book_id — та же формула, что в attemptDispatch.
2308
- // 4. Книги с cancellation tombstone (Operation #1) пропускаются fail-closed.
2309
- // 5. Фаза последняя среди state-write C-фаз и идёт ПОСЛЕ C6 (layer-config
2310
- // восстановлен — нужен для enabled-слоёв).
2311
- // 6. Ошибки PG — fail-closed: без PG предикат невычислим, фаза прерывается
2312
- // (нельзя воскресить отменённую работу или потерять dirty-маркеры).
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
- const runtimeScheduler = require('./runtime-scheduler');
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: PENDING не пишем (isInFlight-override в shouldScheduleAssets)
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
- if (fileValid && !stale && !dirty && !unitDirty) {
2434
- stateMap[stage] = AssetState.READY;
2435
- } else {
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
- if (!needsWork) continue;
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 recovery (in-flight/queued)`);
2457
+ log(`[WORKLIST] ${bookId}/${chapterId}/${sceneId} ${stage}: ${current} — untouched by rebuild`);
2461
2458
  continue;
2462
2459
  }
2463
- if (target === state.AssetState.PENDING) {
2464
- if (current === state.AssetState.READY || current === state.AssetState.PLACEHOLDER) {
2465
- // C0 over-mark (P1, READY без файла) / placeholder-аудио:
2466
- // прямой READY→PENDING / PLACEHOLDER→PENDING невалиден;
2467
- // READY→DIRTY / PLACEHOLDER→DIRTY валиден → затем DIRTY→PENDING.
2468
- // TOCTOU-guard (audit follow-up): scheduler tick работает
2469
- // конкурентно (runtime-loop стартует ДО startup-цикла),
2470
- // markVersionStaleDirty → dispatch может успеть выставить
2471
- // GENERATING между нашим guard-read и markDirtyScene (который
2472
- // пишет DIRTY через unsafe без re-validation). Re-read прямо
2473
- // перед записью: если состояние изменилось — стадией уже
2474
- // владеет кто-то другой, recovery отступает. setScenePending
2475
- // ниже сам re-reads + validates (вторая линия обороны).
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
- await runtimeScheduler.addSceneToActiveIndex(redis, bookId, chapterId, sceneId);
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) re-added for generation resume`);
2490
+ if (scenesAdded > 0) log(`[WORKLIST] ${scenesAdded} scene(s) asset-state facts rebuilt (no work queued)`);
2508
2491
  return scenesAdded;
2509
2492
  }
2510
2493