@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.
@@ -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
- const imageInfo = imageModule.resolveCanonicalSceneImage(
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
- orphanAssets.push({
255
- type: 'missing_image_file',
256
- scene: { bookId, chapterId, sceneId },
257
- path: assets.image.path
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
- try {
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). Pushed BEFORE the
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
- // Check partial builds
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 — but never
1233
- // for a cancelled book (tombstone/cancel flag precedence:
1234
- // only an explicit new generation clears them).
1235
- if (!(await isBookGenerationCancelled(redis, scene.bookId))) {
1236
- await runtimeScheduler.addSceneToActiveIndex(
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 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).
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 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
+ }
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: Resume incomplete sessions (optional — requires runBackgroundWindowGeneration)
1708
- 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') {
1709
1771
  try {
1710
- await deps.resumeIncompleteSessions(log, deps.runBackgroundWindowGeneration);
1711
- phases.push('session_resume');
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(`session_resume: ${err.message}`);
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: WORK_TO_DO rebuild (Option E) — startup-only
1828
+ // PHASE C8: asset-state rebuild — startup-only
1767
1829
  // ══════════════════════════════════════════════
1768
- // Авто-возобновление прерванной генерации после потери Redis. Только
1769
- // первый цикл (startup:true) — периодический цикл работу НЕ возрождает
1770
- // (политика 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.
1771
1835
  if (startup && !scope) {
1772
1836
  try {
1773
1837
  const c7Count = await rebuildWorkList(redis);
1774
- if (c7Count > 0) phases.push(`worklist_rebuild:${c7Count}`);
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 C7: WORK_TO_DO rebuild (Option E) ──────────
2245
- // Cathedral Option E — см. docs/architecture/work-list-rebuild-design.md
2246
- // (Recon #4 design proof). После полной потери Redis пересчитывает WORK_TO_DO
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
- // Доказанные в Recon #4 инварианты:
2252
- // 1. Предикат НЕ читает Redis asset state (C0 over-marks READY/READY/READY
2253
- // для любой сцены с .mp3) — только PG + FS-проба.
2254
- // 2. States пишутся ДО SADD: scheduler решает «что диспатчить» по Redis
2255
- // states (shouldScheduleAssets), пустой ключ = NEW = «диспатчить всё».
2256
- // 3. build_id пинится из manifest.book_id — та же формула, что в attemptDispatch.
2257
- // 4. Книги с cancellation tombstone (Operation #1) пропускаются fail-closed.
2258
- // 5. Фаза последняя среди state-write C-фаз и идёт ПОСЛЕ C6 (layer-config
2259
- // восстановлен — нужен для enabled-слоёв).
2260
- // 6. Ошибки PG — fail-closed: без PG предикат невычислим, фаза прерывается
2261
- // (нельзя воскресить отменённую работу или потерять dirty-маркеры).
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
- const runtimeScheduler = require('./runtime-scheduler');
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: PENDING не пишем (isInFlight-override в shouldScheduleAssets)
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
- if (fileValid && !stale && !dirty && !unitDirty) {
2383
- stateMap[stage] = AssetState.READY;
2384
- } else {
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
- if (!needsWork) continue;
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 recovery (in-flight/queued)`);
2457
+ log(`[WORKLIST] ${bookId}/${chapterId}/${sceneId} ${stage}: ${current} — untouched by rebuild`);
2410
2458
  continue;
2411
2459
  }
2412
- if (target === state.AssetState.PENDING) {
2413
- if (current === state.AssetState.READY || current === state.AssetState.PLACEHOLDER) {
2414
- // C0 over-mark (P1, READY без файла) / placeholder-аудио:
2415
- // прямой READY→PENDING / PLACEHOLDER→PENDING невалиден;
2416
- // READY→DIRTY / PLACEHOLDER→DIRTY валиден → затем DIRTY→PENDING.
2417
- // TOCTOU-guard (audit follow-up): scheduler tick работает
2418
- // конкурентно (runtime-loop стартует ДО startup-цикла),
2419
- // markVersionStaleDirty → dispatch может успеть выставить
2420
- // GENERATING между нашим guard-read и markDirtyScene (который
2421
- // пишет DIRTY через unsafe без re-validation). Re-read прямо
2422
- // перед записью: если состояние изменилось — стадией уже
2423
- // владеет кто-то другой, recovery отступает. setScenePending
2424
- // ниже сам re-reads + validates (вторая линия обороны).
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
- await runtimeScheduler.addSceneToActiveIndex(redis, bookId, chapterId, sceneId);
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) re-added for generation resume`);
2490
+ if (scenesAdded > 0) log(`[WORKLIST] ${scenesAdded} scene(s) asset-state facts rebuilt (no work queued)`);
2457
2491
  return scenesAdded;
2458
2492
  }
2459
2493