@animastor/orchestration 0.1.1 → 0.1.3
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@animastor/orchestration",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Animastor Orchestration — the generation orchestration/runtime contour of the Animastor backend, physically extracted from the host (§32.30): the unified generation lifecycle owner (orchestrator facade, stage executor, scene callbacks/restoration), the dispatch engine and its lease/quota/retry managers, the reconciliation engine and counter reconciliation, the scene window/scheduler/worker-health, the runtime-result emitter/consumer, and the eight O-2..O-10 port contracts (Persistence, SceneData, PlaceholderAudio, ProgressEvents, AudioFsm, VideoFsm, HubCancel, LayerConfig). The package reaches the host (runtime config slices, Redis-domain state layer, media facades, the S-5 seam registry, the artifact root, the PW-2 workspace resolver) ONLY through the composition-root host-bindings seam — no backend/src require, no storage/book/routes/services, no Redis, no PG, no Express inside.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"animastor",
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
|
35
|
-
"url": "git+https://github.com/Animastor/animastor.git",
|
|
35
|
+
"url": "git+https://github.com/Animastor/animastor-backend.git",
|
|
36
36
|
"directory": "packages/animastor-orchestration"
|
|
37
37
|
},
|
|
38
|
-
"homepage": "https://github.com/Animastor/animastor/tree/main/packages/animastor-orchestration#readme",
|
|
38
|
+
"homepage": "https://github.com/Animastor/animastor-backend/tree/main/packages/animastor-orchestration#readme",
|
|
39
39
|
"bugs": {
|
|
40
|
-
"url": "https://github.com/Animastor/animastor/issues"
|
|
40
|
+
"url": "https://github.com/Animastor/animastor-backend/issues"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@animastor/contracts": "^0.1.0",
|
|
@@ -93,17 +93,28 @@ async function startScene(redis, scene, loadedBook, buildId) {
|
|
|
93
93
|
// открывал circuit breaker.
|
|
94
94
|
//
|
|
95
95
|
// Контракт: перед отправкой любого GPU job ассет обязан пройти
|
|
96
|
-
// NEW → PENDING (здесь) → GENERATING (сразу ниже),
|
|
96
|
+
// NEW|DIRTY → PENDING (здесь) → GENERATING (сразу ниже),
|
|
97
97
|
// либо уже находиться в состоянии, из которого GENERATING достижим.
|
|
98
98
|
// Если переход невозможен — dispatch ПРЕРЫВАЕТСЯ ДО отправки job.
|
|
99
|
+
//
|
|
100
|
+
// Stage 50: DIRTY включён в нормализацию. DIRTY → GENERATING запрещён FSM
|
|
101
|
+
// (scene-state.js: DIRTY → [PENDING, PLACEHOLDER]), поэтому до этого фикса
|
|
102
|
+
// dirty-ассет (version-stale pre-pass, C1/C1b recovery, RELEASE_STALE_LEASE,
|
|
103
|
+
// markDirtyScene) не мог перегенерироваться через планировщик вообще: guard
|
|
104
|
+
// отклонял setSceneGenerating(dirty), rejection проглатывался в
|
|
105
|
+
// attemptDispatch — ровно тот `INVALID state=dirty attempted=generating`
|
|
106
|
+
// из journal (Case A, stage 49). Комментарий теста stage-dispatch-lifecycle
|
|
107
|
+
// («scheduler's version-stale pre-pass owns DIRTY→PENDING») описывал
|
|
108
|
+
// нормализацию, которой не существовало. PENDING-хоп здесь — это и есть
|
|
109
|
+
// недостающая половина контракта.
|
|
99
110
|
async function ensureStageDispatchable(redis, bookId, chapterId, sceneId, stage) {
|
|
100
111
|
const states = await state.getAssetStates(redis, bookId, chapterId, sceneId);
|
|
101
|
-
if (states?.[stage] === state.AssetState.NEW) {
|
|
112
|
+
if (states?.[stage] === state.AssetState.NEW || states?.[stage] === state.AssetState.DIRTY) {
|
|
102
113
|
const init = await setScenePending(redis, bookId, chapterId, sceneId, stage);
|
|
103
114
|
if (!init.changed) {
|
|
104
115
|
return { ok: false, reason: `pending_init_failed:${init.reason}` };
|
|
105
116
|
}
|
|
106
|
-
log(`STAGE_INIT: ${bookId}/${chapterId}/${sceneId} ${stage}:
|
|
117
|
+
log(`STAGE_INIT: ${bookId}/${chapterId}/${sceneId} ${stage}: ${states[stage]} → PENDING`);
|
|
107
118
|
}
|
|
108
119
|
const gen = await setSceneGenerating(redis, bookId, chapterId, sceneId, stage);
|
|
109
120
|
if (!gen.changed) {
|
|
@@ -112,6 +123,34 @@ async function ensureStageDispatchable(redis, bookId, chapterId, sceneId, stage)
|
|
|
112
123
|
return { ok: true };
|
|
113
124
|
}
|
|
114
125
|
|
|
126
|
+
// ── Stage 50: terminal audio-orch record vs requested regeneration ──
|
|
127
|
+
// DONE — терминальная фаза (VALID_TRANSITIONS[DONE] = []), но per-asset DIRTY
|
|
128
|
+
// поверх неё — явный запрос на перегенерацию (version-stale pre-pass, C1/C1b
|
|
129
|
+
// recovery, RELEASE_STALE_LEASE, completeStage version-gate stale). Без сброса
|
|
130
|
+
// записи dispatch навсегда упирается в DONE guard (already_done) — состояние
|
|
131
|
+
// DONE+DIRTY зависает навсегда (2 сцены в production, stage 49). Хил:
|
|
132
|
+
// терминальная запись удаляется, дальше ensureStageDispatchable проводит
|
|
133
|
+
// DIRTY → PENDING → GENERATING, а no_state-лега переинициализирует FSM.
|
|
134
|
+
// Не-DONE записи (WAITING_CHUNKS/MERGING/FAILED) НЕ трогаются — их ведут
|
|
135
|
+
// штатные recovery-леги (Фаза 4: чистится только DONE).
|
|
136
|
+
async function clearTerminalAudioOrchForDirty(redis, bookId, chapterId, sceneId) {
|
|
137
|
+
let orchState;
|
|
138
|
+
try {
|
|
139
|
+
orchState = await audioFsmOp('getState')(redis, bookId, chapterId, sceneId);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
warn(`AUDIO_DONE_DIRTY_HEAL: getState failed for ${bookId}/${chapterId}/${sceneId}: ${err.message}`);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
if (!orchState || orchState.phase !== audioFsmPhases().DONE) return false;
|
|
145
|
+
|
|
146
|
+
const states = await state.getAssetStates(redis, bookId, chapterId, sceneId);
|
|
147
|
+
if (states?.audio !== state.AssetState.DIRTY) return false;
|
|
148
|
+
|
|
149
|
+
log(`AUDIO_DONE_DIRTY_HEAL: ${bookId}/${chapterId}/${sceneId} — regeneration requested over terminal DONE record — clearing audio-orch state for re-dispatch`);
|
|
150
|
+
await audioFsmOp('deleteState')(redis, bookId, chapterId, sceneId);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
115
154
|
// T3: Каждый executor возвращает { dispatched: true/false, jobs, reason }
|
|
116
155
|
async function executeAudioDispatch(redis, scene, loadedBook, buildId, dispatchId) {
|
|
117
156
|
const bookId = scene.book_id;
|
|
@@ -125,6 +164,10 @@ async function executeAudioDispatch(redis, scene, loadedBook, buildId, dispatchI
|
|
|
125
164
|
buildId, dispatchType: 'orchestrator'
|
|
126
165
|
});
|
|
127
166
|
|
|
167
|
+
// Stage 50: heal the DONE×DIRTY deadlock BEFORE the lifecycle guard —
|
|
168
|
+
// a DIRTY request over a terminal DONE record must regenerate, not skip.
|
|
169
|
+
await clearTerminalAudioOrchForDirty(redis, bookId, chapterId, sceneId);
|
|
170
|
+
|
|
128
171
|
// T7: через фасад — единый владелец GENERATING (audio)
|
|
129
172
|
// Lifecycle guard: abort BEFORE any GPU job if the transition chain fails.
|
|
130
173
|
const dispatchable = await ensureStageDispatchable(redis, bookId, chapterId, sceneId, 'audio');
|
|
@@ -591,6 +634,7 @@ async function dispatchStage(redis, scene, loadedBook, buildId, overrideStage, d
|
|
|
591
634
|
|
|
592
635
|
module.exports = {
|
|
593
636
|
ensureStageDispatchable,
|
|
637
|
+
clearTerminalAudioOrchForDirty,
|
|
594
638
|
dispatchStage,
|
|
595
639
|
restoreSceneChunkStatus,
|
|
596
640
|
handleAudioCompleted,
|
|
@@ -24,6 +24,9 @@ const runtimeMetrics = require('./runtime-metrics');
|
|
|
24
24
|
// domain state over the injected redis parameter).
|
|
25
25
|
const circuitBreaker = require('./circuit-breaker');
|
|
26
26
|
const retryBudget = require('./retry-budget-manager');
|
|
27
|
+
// O-2 (Stage 50): the dispatch-time scene-version stamp is a PG-side effect
|
|
28
|
+
// consumed through the PersistencePort (host adapter owns the SQL).
|
|
29
|
+
const persist = require('./persistence-port').persist;
|
|
27
30
|
const crypto = require('crypto');
|
|
28
31
|
// Phase 5: Runtime Result Contract — dispatch finalizations are reported to
|
|
29
32
|
// orchestration through the contracts seam (runtime → contract → injected
|
|
@@ -864,7 +867,22 @@ async function dispatchStage(redis, bookId, chapterId, sceneId, stage, loadedBoo
|
|
|
864
867
|
dispatchId,
|
|
865
868
|
leaseKey: lease.leaseKey,
|
|
866
869
|
started_at: metadata.started_at
|
|
867
|
-
});
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
// Step 5.5 (Stage 50): stamp the scene's CURRENT content/audio-config
|
|
873
|
+
// versions onto the asset row — «версия, под которую стартовала генерация».
|
|
874
|
+
// completeStage's version gate then compares exactly that, so a regen
|
|
875
|
+
// started for the current content reaches READY (convergence of the
|
|
876
|
+
// DONE×DIRTY / version-stale flows), while a version bump DURING the
|
|
877
|
+
// generation still fails the gate (T1.11 semantics preserved). Best-effort:
|
|
878
|
+
// dispatch is a Redis-domain operation; a PG outage must not block it —
|
|
879
|
+
// the gate stays fail-closed and the next dispatch re-stamps.
|
|
880
|
+
try {
|
|
881
|
+
await persist('sceneAssets.stampSceneVersionsForDispatch')(bookId, chapterId, sceneId, stage);
|
|
882
|
+
} catch (stampErr) {
|
|
883
|
+
warn(`DISPATCH_VERSION_STAMP_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage}: ${stampErr.message}`);
|
|
884
|
+
}
|
|
885
|
+
// Step 6: Get orchestrator to perform dispatch
|
|
868
886
|
try {
|
|
869
887
|
// S-5: the stage executor entry (scene-orchestrator.dispatchStage) is
|
|
870
888
|
// injected by the composition root — runtime must not import
|
|
@@ -345,15 +345,23 @@ async function checkOrphanGeneratingState(redis, bookId, chapterId, sceneId) {
|
|
|
345
345
|
/**
|
|
346
346
|
* Check for partially built scenes.
|
|
347
347
|
* T8: использует per-asset state вместо линейного.
|
|
348
|
+
*
|
|
349
|
+
* Stage 50: генерация в полёте — не «partial build». Раньше registry мог
|
|
350
|
+
* ещё не содержать image (registerSceneImage пишется по завершении), а
|
|
351
|
+
* ассет уже PENDING/GENERATING с живым lease — отчёт partial_audio_only
|
|
352
|
+
* вёл к PROGRESS_TO_IMAGE → setScenePending(generating→pending rejected)
|
|
353
|
+
* каждый цикл (issue №3 stage 49: ch-1bb5123c/sc-45c789c6).
|
|
348
354
|
*/
|
|
349
355
|
async function checkPartialBuilds(redis, bookId, chapterId, sceneId) {
|
|
350
356
|
const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
|
|
351
357
|
const assets = await persist('registry.getSceneAssetsRedis')(redis, bookId, chapterId, sceneId);
|
|
352
358
|
|
|
359
|
+
const isInFlight = s => s === state.AssetState.PENDING || s === state.AssetState.GENERATING;
|
|
360
|
+
|
|
353
361
|
// Audio READY/PLACEHOLDER but no image asset
|
|
354
362
|
const audioReady = assetStates && assetStates.audio &&
|
|
355
363
|
(assetStates.audio === state.AssetState.READY || assetStates.audio === state.AssetState.PLACEHOLDER);
|
|
356
|
-
if (audioReady && !assets?.image) {
|
|
364
|
+
if (audioReady && !assets?.image && !isInFlight(assetStates.image)) {
|
|
357
365
|
return {
|
|
358
366
|
type: 'partial_audio_only',
|
|
359
367
|
scene: { bookId, chapterId, sceneId },
|
|
@@ -366,7 +374,7 @@ async function checkPartialBuilds(redis, bookId, chapterId, sceneId) {
|
|
|
366
374
|
// Image READY but no video asset
|
|
367
375
|
const imageReady = assetStates && assetStates.image &&
|
|
368
376
|
(assetStates.image === state.AssetState.READY || assetStates.image === state.AssetState.PLACEHOLDER);
|
|
369
|
-
if (imageReady && !assets?.video) {
|
|
377
|
+
if (imageReady && !assets?.video && !isInFlight(assetStates.video)) {
|
|
370
378
|
return {
|
|
371
379
|
type: 'partial_image_video_missing',
|
|
372
380
|
scene: { bookId, chapterId, sceneId },
|
|
@@ -607,8 +615,21 @@ async function checkStalledVideoScenes(redis, deps) {
|
|
|
607
615
|
// ======================================================
|
|
608
616
|
// Проверяет соответствие между audio-orch phase и asset state:
|
|
609
617
|
// phase=DONE ⇔ asset.audio = READY
|
|
610
|
-
// phase=FAILED ⇒ asset.audio
|
|
618
|
+
// phase=FAILED ⇒ asset.audio ∈ {FAILED, PENDING, DIRTY}
|
|
611
619
|
// промежуточные ⇒ asset.audio ∈ {PENDING, GENERATING, DIRTY}
|
|
620
|
+
//
|
|
621
|
+
// Stage 47 (FAILED + DIRTY): DIRTY — штатный итог для phase=FAILED.
|
|
622
|
+
// Два писателя приводят к FAILED на стороне оркестратора:
|
|
623
|
+
// • failStage(redispatch=true) → asset = PENDING (FAILED пишется первым и
|
|
624
|
+
// тут же перезаписывается), redispatch=false → asset = FAILED;
|
|
625
|
+
// • startup C1/C1b recovery → setFailed, затем markDirtyScene → asset =
|
|
626
|
+
// DIRTY (+ PG scene_assets.status='stale'). Это сделано нарочно:
|
|
627
|
+
// shouldScheduleAssets() перескакивает READY/FAILED и передиспатчитивает
|
|
628
|
+
// только PENDING/DIRTY, т.е. asset=FAILED застрял бы навсегда, а
|
|
629
|
+
// recommendation 'mark_dirty' (см. ветку ниже) есть ничто иное, как
|
|
630
|
+
// путь к допустимому состоянию. Проверка до stage 47 отвергала именно
|
|
631
|
+
// тот итог, к которому сама же рекомендовала прийти, — ложный
|
|
632
|
+
// audio_orch_invariant_failed на каждой успешной recovery.
|
|
612
633
|
|
|
613
634
|
async function checkAudioOrchInvariants(redis, bookId, chapterId, sceneId) {
|
|
614
635
|
// O-7: through the AudioFsmPort (host adapter: storage/audio-fsm-adapter)
|
|
@@ -633,17 +654,27 @@ async function checkAudioOrchInvariants(redis, bookId, chapterId, sceneId) {
|
|
|
633
654
|
phase,
|
|
634
655
|
audioState,
|
|
635
656
|
expected: 'READY or PLACEHOLDER',
|
|
636
|
-
|
|
657
|
+
// Stage 50: former value 'run_completeStage' was dead AND
|
|
658
|
+
// destructive metadata — completing the stage would write READY
|
|
659
|
+
// and erase exactly the dirty marker that schedules the pending
|
|
660
|
+
// regeneration. Re-dispatch (stage 50 guard-heal) owns the repair.
|
|
661
|
+
recommendation: 'review_manually'
|
|
637
662
|
});
|
|
638
663
|
}
|
|
639
664
|
} else if (phase === PHASES.FAILED) {
|
|
640
|
-
|
|
665
|
+
// Stage 47: DIRTY is a legitimate FAILED-phase asset state (see the
|
|
666
|
+
// header of this section). Everything else — READY above all — stays
|
|
667
|
+
// a real contradiction: the orchestrator failed while the asset claims
|
|
668
|
+
// its result is accepted.
|
|
669
|
+
if (audioState !== state.AssetState.FAILED &&
|
|
670
|
+
audioState !== state.AssetState.PENDING &&
|
|
671
|
+
audioState !== state.AssetState.DIRTY) {
|
|
641
672
|
violations.push({
|
|
642
673
|
type: 'audio_orch_invariant_failed',
|
|
643
674
|
scene: { bookId, chapterId, sceneId },
|
|
644
675
|
phase,
|
|
645
676
|
audioState,
|
|
646
|
-
expected: 'FAILED or
|
|
677
|
+
expected: 'FAILED, PENDING or DIRTY',
|
|
647
678
|
recommendation: 'mark_dirty'
|
|
648
679
|
});
|
|
649
680
|
}
|
|
@@ -668,7 +699,8 @@ async function checkAudioOrchInvariants(redis, bookId, chapterId, sceneId) {
|
|
|
668
699
|
// VIDEO-ORCH INVARIANT CHECKS (зеркало checkAudioOrchInvariants)
|
|
669
700
|
// ======================================================
|
|
670
701
|
// phase=DONE ⇒ asset.video = READY (или PLACEHOLDER)
|
|
671
|
-
// phase=FAILED ⇒ asset.video
|
|
702
|
+
// phase=FAILED ⇒ asset.video ∈ {FAILED, PENDING, DIRTY} (stage 47 —
|
|
703
|
+
// зеркало аудио: C1b recovery заканчивается markDirtyScene)
|
|
672
704
|
// промежуточные ⇒ asset.video = PENDING|GENERATING|DIRTY
|
|
673
705
|
|
|
674
706
|
async function checkVideoOrchInvariants(redis, bookId, chapterId, sceneId) {
|
|
@@ -694,17 +726,23 @@ async function checkVideoOrchInvariants(redis, bookId, chapterId, sceneId) {
|
|
|
694
726
|
phase,
|
|
695
727
|
videoState,
|
|
696
728
|
expected: 'READY or PLACEHOLDER',
|
|
697
|
-
|
|
729
|
+
// Stage 50: mirror of the audio fix — 'run_completeStage' was
|
|
730
|
+
// dead + destructive; manual review only, re-dispatch owns it.
|
|
731
|
+
recommendation: 'review_manually'
|
|
698
732
|
});
|
|
699
733
|
}
|
|
700
734
|
} else if (phase === PHASES.FAILED) {
|
|
701
|
-
|
|
735
|
+
// Stage 47: mirror of the audio branch — DIRTY is the deliberate C1b
|
|
736
|
+
// recovery outcome (markDirtyScene → asset DIRTY for re-dispatch).
|
|
737
|
+
if (videoState !== state.AssetState.FAILED &&
|
|
738
|
+
videoState !== state.AssetState.PENDING &&
|
|
739
|
+
videoState !== state.AssetState.DIRTY) {
|
|
702
740
|
violations.push({
|
|
703
741
|
type: 'video_orch_invariant_failed',
|
|
704
742
|
scene: { bookId, chapterId, sceneId },
|
|
705
743
|
phase,
|
|
706
744
|
videoState,
|
|
707
|
-
expected: 'FAILED or
|
|
745
|
+
expected: 'FAILED, PENDING or DIRTY',
|
|
708
746
|
recommendation: 'mark_dirty'
|
|
709
747
|
});
|
|
710
748
|
}
|
|
@@ -1079,6 +1117,23 @@ function getFixRecommendations(inconsistentScenes) {
|
|
|
1079
1117
|
reason: `Stale dispatch lease detected`,
|
|
1080
1118
|
safeToExecute: true
|
|
1081
1119
|
};
|
|
1120
|
+
// Stage 50: explicit (no longer default-fallback) — a terminal
|
|
1121
|
+
// audio/video-orch phase conflicting with the asset state is
|
|
1122
|
+
// NEVER auto-repaired here. In particular run_completeStage would
|
|
1123
|
+
// write READY and erase the dirty marker that schedules the
|
|
1124
|
+
// pending regeneration (stage 49: 2 stuck production scenes).
|
|
1125
|
+
// The DONE×DIRTY deadlock is healed by re-dispatch (stage 50
|
|
1126
|
+
// guard-heal in scene-orchestrator); everything else is manual.
|
|
1127
|
+
case 'audio_orch_invariant_done':
|
|
1128
|
+
case 'video_orch_invariant_done':
|
|
1129
|
+
case 'audio_orch_invariant_failed':
|
|
1130
|
+
case 'video_orch_invariant_failed':
|
|
1131
|
+
return {
|
|
1132
|
+
scene,
|
|
1133
|
+
action: 'REVIEW_MANUALLY',
|
|
1134
|
+
reason: `${issue}: terminal orch phase conflicts with asset state — re-dispatch heals DONE×DIRTY, other combinations need manual review`,
|
|
1135
|
+
safeToExecute: false
|
|
1136
|
+
};
|
|
1082
1137
|
default:
|
|
1083
1138
|
return {
|
|
1084
1139
|
scene,
|
|
@@ -2083,6 +2138,19 @@ async function recoverVideoOrchStates(redis, deps) {
|
|
|
2083
2138
|
// the sceneVersions.getVersionStalenessRows() port op (SQL lives in the host
|
|
2084
2139
|
// adapter verbatim). Pre-O-2 a missing deps.postgres skipped the phase; the
|
|
2085
2140
|
// unwired-port throw hits the same catch → warn + return 0.
|
|
2141
|
+
//
|
|
2142
|
+
// Stage 50: per-asset и READY-only. Раньше сцена помечалась целиком
|
|
2143
|
+
// (markDirtyScene со ВСЕМИ ассетами по умолчанию) при каждом старте:
|
|
2144
|
+
// • unsafeRestoreAssetState пишет DIRTY поверх GENERATING/PENDING — убивал
|
|
2145
|
+
// бы живую генерацию при рестарте;
|
|
2146
|
+
// • READY→DIRTY на не-stale ассетах — лишняя регенерация;
|
|
2147
|
+
// • повторная маркировка уже-DIRTY ассетов — неидемпотентный счётчик.
|
|
2148
|
+
// Теперь stale-типы считаются построчно (audio: content ИЛИ audio_config;
|
|
2149
|
+
// image/video: content), пишется только READY→DIRTY (FSM-легальный переход),
|
|
2150
|
+
// прежний READY-артефакт не удаляется — он остаётся отдаваемым до успешной
|
|
2151
|
+
// замены (файл и registry не трогаются; completeStage version-gate пропустит
|
|
2152
|
+
// READY только после успешной перегенерации). Повторный прогон: ассет уже
|
|
2153
|
+
// DIRTY → 0 маркировок (идемпотентность), счётчик считает только факты записи.
|
|
2086
2154
|
async function checkVersionStaleness(redis, deps) {
|
|
2087
2155
|
const { orchestrator } = deps;
|
|
2088
2156
|
if (!orchestrator) return 0;
|
|
@@ -2094,22 +2162,43 @@ async function checkVersionStaleness(redis, deps) {
|
|
|
2094
2162
|
for (const row of result.rows) {
|
|
2095
2163
|
const key = `${row.book_id}|${row.chapter_id}|${row.scene_id}`;
|
|
2096
2164
|
if (!sceneMap.has(key)) {
|
|
2097
|
-
sceneMap.set(key, { bookId: row.book_id, chapterId: row.chapter_id, sceneId: row.scene_id,
|
|
2165
|
+
sceneMap.set(key, { bookId: row.book_id, chapterId: row.chapter_id, sceneId: row.scene_id, staleAssets: new Set() });
|
|
2098
2166
|
}
|
|
2099
2167
|
const entry = sceneMap.get(key);
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2168
|
+
const contentStale = row.scene_content_version != null && row.content_version != null &&
|
|
2169
|
+
row.scene_content_version < row.content_version;
|
|
2170
|
+
const audioConfigStale = row.scene_audio_config_version != null && row.audio_config_version != null &&
|
|
2171
|
+
row.scene_audio_config_version < row.audio_config_version;
|
|
2172
|
+
if (row.asset_type === 'audio') {
|
|
2173
|
+
if (contentStale || audioConfigStale) entry.staleAssets.add('audio');
|
|
2174
|
+
} else if (row.asset_type === 'image' || row.asset_type === 'video') {
|
|
2175
|
+
if (contentStale) entry.staleAssets.add(row.asset_type);
|
|
2103
2176
|
}
|
|
2104
2177
|
}
|
|
2105
2178
|
|
|
2106
2179
|
let outdated = 0;
|
|
2107
2180
|
for (const entry of sceneMap.values()) {
|
|
2108
|
-
if (entry.
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2181
|
+
if (entry.staleAssets.size === 0) continue;
|
|
2182
|
+
|
|
2183
|
+
let assetStates;
|
|
2184
|
+
try {
|
|
2185
|
+
assetStates = await state.getAssetStates(redis, entry.bookId, entry.chapterId, entry.sceneId);
|
|
2186
|
+
} catch (stateErr) {
|
|
2187
|
+
warn(`Version staleness: asset-state read failed for ${entry.bookId}/${entry.chapterId}/${entry.sceneId}: ${stateErr.message}`);
|
|
2188
|
+
continue;
|
|
2112
2189
|
}
|
|
2190
|
+
|
|
2191
|
+
const readyStale = [...entry.staleAssets]
|
|
2192
|
+
.filter(t => assetStates?.[t] === state.AssetState.READY);
|
|
2193
|
+
if (readyStale.length === 0) {
|
|
2194
|
+
// Уже DIRTY/GENERATING/FAILED — регенерация инициирована или идёт;
|
|
2195
|
+
// повторная маркировка была бы неидемпотентной и опасной.
|
|
2196
|
+
continue;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
log(`[VERSION-STALE] ${entry.bookId}/${entry.chapterId}/${entry.sceneId} → ${readyStale.join(', ')} (READY → DIRTY, previous artifact preserved until replacement)`);
|
|
2200
|
+
await orchestrator.markDirtyScene(redis, entry.bookId, entry.chapterId, entry.sceneId, readyStale);
|
|
2201
|
+
outdated++;
|
|
2113
2202
|
}
|
|
2114
2203
|
return outdated;
|
|
2115
2204
|
} catch (err) {
|
|
@@ -571,6 +571,8 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
|
|
|
571
571
|
|
|
572
572
|
let dispatched = 0;
|
|
573
573
|
let throttled = 0;
|
|
574
|
+
let anyCompleted = false;
|
|
575
|
+
const failures = [];
|
|
574
576
|
|
|
575
577
|
// Dispatch ALL eligible stages in this tick.
|
|
576
578
|
// Concurrency limits per stage type are handled by dispatch engine.
|
|
@@ -589,10 +591,29 @@ async function attemptDispatch(redis, bookId, chapterId, sceneId, loadedBook, fo
|
|
|
589
591
|
dispatched++;
|
|
590
592
|
} else if (result.reason === 'backpressure' || result.reason === 'throttled') {
|
|
591
593
|
throttled++;
|
|
594
|
+
} else if (result.completed) {
|
|
595
|
+
// Stage finished inside the executor (already_done / cache_hit /
|
|
596
|
+
// no_segments) — not a failure, nothing to dispatch.
|
|
597
|
+
anyCompleted = true;
|
|
598
|
+
} else {
|
|
599
|
+
// Stage 50: раньше per-stage отказ (circuit_open, retry budget,
|
|
600
|
+
// lease, generating_transition_failed, …) молча проглатывался и
|
|
601
|
+
// tick писал «undefined for <scene>» — ошибки исчезали из
|
|
602
|
+
// observability. Теперь причина всплывает в tick-сводку.
|
|
603
|
+
failures.push(`${stage}:${result.reason || 'unknown'}`);
|
|
592
604
|
}
|
|
593
605
|
}
|
|
594
606
|
|
|
595
|
-
|
|
607
|
+
if (dispatched > 0) {
|
|
608
|
+
return { dispatched, throttled, stages: stages.length };
|
|
609
|
+
}
|
|
610
|
+
if (failures.length > 0) {
|
|
611
|
+
return { dispatched: 0, throttled, stages: stages.length, success: false, reason: failures.join(', ') };
|
|
612
|
+
}
|
|
613
|
+
if (anyCompleted) {
|
|
614
|
+
return { dispatched: 0, throttled, completed: true, reason: 'stage_completed' };
|
|
615
|
+
}
|
|
616
|
+
return { dispatched: 0, throttled, stages: stages.length, success: true, reason: 'no_dispatch' };
|
|
596
617
|
}
|
|
597
618
|
|
|
598
619
|
// ======================================================
|
|
@@ -615,6 +636,7 @@ module.exports = {
|
|
|
615
636
|
// Scheduling
|
|
616
637
|
shouldScheduleAssets,
|
|
617
638
|
detectVersionStale,
|
|
639
|
+
attemptDispatch,
|
|
618
640
|
markVersionStaleDirty,
|
|
619
641
|
getLayerConfig,
|
|
620
642
|
checkChunksHaveImages,
|