@animastor/orchestration 0.1.1

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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/package.json +51 -0
  4. package/src/host/host-bindings.js +138 -0
  5. package/src/index.js +99 -0
  6. package/src/orchestration/index.js +40 -0
  7. package/src/orchestration/orchestrator.js +630 -0
  8. package/src/orchestration/runtime-result-consumer.js +54 -0
  9. package/src/orchestration/scene-callbacks.js +542 -0
  10. package/src/orchestration/scene-orchestrator.js +601 -0
  11. package/src/orchestration/scene-restoration.js +129 -0
  12. package/src/orchestration/scene-utils.js +36 -0
  13. package/src/runtime/active-scenes-index.js +164 -0
  14. package/src/runtime/audio-fsm-port.js +187 -0
  15. package/src/runtime/circuit-breaker.js +588 -0
  16. package/src/runtime/counter-reconciliation.js +308 -0
  17. package/src/runtime/dispatch-engine.js +1768 -0
  18. package/src/runtime/failure-taxonomy.js +125 -0
  19. package/src/runtime/hub-cancel-port.js +122 -0
  20. package/src/runtime/layer-config-port.js +136 -0
  21. package/src/runtime/lease-manager.js +607 -0
  22. package/src/runtime/persistence-port.js +162 -0
  23. package/src/runtime/placeholder-audio-port.js +117 -0
  24. package/src/runtime/progress-events-port.js +121 -0
  25. package/src/runtime/reconciliation-engine.js +2405 -0
  26. package/src/runtime/retry-budget-manager.js +229 -0
  27. package/src/runtime/runtime-metrics.js +427 -0
  28. package/src/runtime/runtime-result-emitter.js +123 -0
  29. package/src/runtime/runtime-scheduler.js +629 -0
  30. package/src/runtime/scene-data-port.js +114 -0
  31. package/src/runtime/scene-window.js +827 -0
  32. package/src/runtime/video-fsm-port.js +189 -0
  33. package/src/runtime/worker-health.js +270 -0
  34. package/test/counter-reconciliation.test.js +164 -0
  35. package/test/mocks/redis-mock.js +221 -0
  36. package/test/test-bindings.js +60 -0
@@ -0,0 +1,1768 @@
1
+ // ======================================================
2
+ // Dispatch Engine - v1.0.0
3
+ // ======================================================
4
+ // Single authority for dispatching scene stages.
5
+ // Implements leases for determinism, quotas for backpressure.
6
+ // Supports renewable leases and drift detection.
7
+
8
+ const { lazyHostBinding } = require('../host/host-bindings');
9
+ // §32.30: former host requires resolve lazily through the host-bindings seam.
10
+ const state = lazyHostBinding('state');
11
+ // S-5: the event journal is an append-only observability sink (zero requires,
12
+ // no orchestration policy) — the ONLY runtime→Generation dependency allowed
13
+ // without a seam (s5-runtime-orchestration-cycle.test.js S5-A).
14
+ const journal = lazyHostBinding('journal');
15
+ // S-5: orchestration-owned behavior (stage executor, FSM-safe rollbacks)
16
+ // reaches runtime ONLY through the composition-root-injected seam registry.
17
+ // No orchestration module is imported from this file (S-5).
18
+ const orchestrationSeams = lazyHostBinding('seams');
19
+ const leaseManager = require('./lease-manager');
20
+ const counterReconciliation = require('./counter-reconciliation');
21
+ const runtimeMetrics = require('./runtime-metrics');
22
+ // O-2: the former dead `../storage` require is GONE — dispatch-engine holds
23
+ // no persistence dependency at all (leases/quotas/markers are Redis-keyed
24
+ // domain state over the injected redis parameter).
25
+ const circuitBreaker = require('./circuit-breaker');
26
+ const retryBudget = require('./retry-budget-manager');
27
+ const crypto = require('crypto');
28
+ // Phase 5: Runtime Result Contract — dispatch finalizations are reported to
29
+ // orchestration through the contracts seam (runtime → contract → injected
30
+ // consumer), NOT through an orchestration import.
31
+ // Docs: docs/architecture/PHASE_5_ORCHESTRATION_RUNTIME.md
32
+ const runtimeResultEmitter = require('./runtime-result-emitter');
33
+
34
+ const logPrefix = '[DISPATCH]';
35
+
36
+ function log(msg) {
37
+ console.log(`${logPrefix} ${msg}`);
38
+ }
39
+
40
+ function warn(msg) {
41
+ console.warn(`${logPrefix} ⚠️ ${msg}`);
42
+ }
43
+
44
+ function error(msg) {
45
+ console.error(`${logPrefix} ❌ ${msg}`);
46
+ }
47
+
48
+ // ======================================================
49
+ // CONFIGURATION
50
+ // ======================================================
51
+
52
+ // S-2 COMPLETION: Lease TTLs and quotas resolved through media registry.
53
+ // The registry reads from runtime-config (canonical source) at startup.
54
+ // dispatch-engine no longer maintains its own media-type maps.
55
+ // LEASE_TTLS / QUOTAS stay exported for backward compatibility (tests and
56
+ // metrics read them) — they are thin lazy views over the registry, NOT a
57
+ // second source of truth: property access resolves through the registry on
58
+ // every read, so registry/config changes are reflected immediately.
59
+ const mediaRegistry = require('@animastor/generation').mediaRegistry;
60
+ const runtimeConfig = lazyHostBinding('config');
61
+
62
+ // S-2: resolve all registered stages dynamically
63
+ function _allStages() { return mediaRegistry.listMediaTypes(); }
64
+ function _leaseTtl(stage) { return mediaRegistry.resolveLeaseTtl(stage, 1800); }
65
+ function _maxActive(stage) { return mediaRegistry.resolveMaxActive(stage, 1); }
66
+
67
+ /** Lazy registry view: LEASE_TTLS.audio → resolveLeaseTtl('audio'). */
68
+ const LEASE_TTLS = new Proxy({}, {
69
+ get(_, stage) { return mediaRegistry.resolveLeaseTtl(stage); },
70
+ ownKeys() { return [...mediaRegistry.listMediaTypes()]; },
71
+ getOwnPropertyDescriptor(_, stage) {
72
+ const v = mediaRegistry.resolveLeaseTtl(stage);
73
+ return v === undefined ? undefined : { value: v, enumerable: true, configurable: true };
74
+ },
75
+ });
76
+
77
+ /** Lazy registry view: QUOTAS.maxActiveAudio → resolveMaxActive('audio'). */
78
+ const QUOTAS = new Proxy({}, {
79
+ get(_, key) {
80
+ if (typeof key !== 'string' || !key.startsWith('maxActive')) return undefined;
81
+ return mediaRegistry.resolveMaxActive(key.slice('maxActive'.length).toLowerCase());
82
+ },
83
+ ownKeys() {
84
+ return mediaRegistry.listMediaTypes().map(t => `maxActive${t.charAt(0).toUpperCase() + t.slice(1)}`);
85
+ },
86
+ getOwnPropertyDescriptor(_, key) {
87
+ const v = this.get(key);
88
+ return v === undefined ? undefined : { value: v, enumerable: true, configurable: true };
89
+ },
90
+ });
91
+
92
+ // ======================================================
93
+ // KEY PATTERNS
94
+ // ======================================================
95
+
96
+ const DISPATCH_LEASE_PREFIX = 'animastor:dispatch-lease';
97
+ const DISPATCH_META_PREFIX = 'animastor:dispatch-meta';
98
+ // Д.1: idempotency marker for completion — guards releaseQuota against a
99
+ // repeated markDispatchCompleted (e.g. a callback retry that slipped past the
100
+ // HTTP-layer dedup). Cleared at dispatch start so each new dispatch completes once.
101
+ const DISPATCH_COMPLETED_PREFIX = 'animastor:dispatch-completed';
102
+ const ACTIVE_STAGE_PREFIX = 'animastor:runtime:active';
103
+
104
+ // IU in-flight markers (image stage): iu-processor создаёт маркер
105
+ // animastor:iu-in-flight:* ДО gpu.send и регистрирует его в per-dispatch
106
+ // индексе. Значение маркера = dispatch_id владельца. Как только GPU job
107
+ // реально отправлен, маркер снимается с индекса (с этого момента job живёт
108
+ // сам по себе, маркер очищает completion callback). Всё, что осталось в
109
+ // индексе на момент финализации dispatch — маркеры НЕотправленных jobs:
110
+ // их безопасно удалять compare-and-delete по dispatch_id (forensic audit
111
+ // 6929ba5: stale маркеры сорванного dispatch блокировали следующий dispatch
112
+ // → no_jobs_sent → ghost GENERATING).
113
+ const IU_IN_FLIGHT_INDEX_PREFIX = 'animastor:iu-in-flight-index';
114
+ // §32.30: module-level config read deferred — the config module arrives
115
+ // through the host-bindings seam and is resolved on first use.
116
+ let IU_IN_FLIGHT_MARKER_TTL_S_ = null;
117
+ function IU_IN_FLIGHT_MARKER_TTL_S() {
118
+ if (IU_IN_FLIGHT_MARKER_TTL_S_ === null) IU_IN_FLIGHT_MARKER_TTL_S_ = runtimeConfig.TIMEOUTS.IU_IN_FLIGHT_TTL_S;
119
+ return IU_IN_FLIGHT_MARKER_TTL_S_;
120
+ }
121
+
122
+ const SCHEDULER_TICK_LOCK = 'animastor:runtime:scheduler-lock';
123
+ const SCHEDULER_TICK_LOCK_TTL = 30; // 30 seconds
124
+
125
+ // ======================================================
126
+ // LEASE MANAGEMENT
127
+ // ======================================================
128
+
129
+ /**
130
+ * Get lease key for scene:stage.
131
+ */
132
+ function getLeaseKey(bookId, chapterId, sceneId, stage) {
133
+ return `${DISPATCH_LEASE_PREFIX}:${bookId}:${chapterId}:${sceneId}:${stage}`;
134
+ }
135
+
136
+ /**
137
+ * Get dispatch metadata key.
138
+ */
139
+ function getDispatchMetaKey(bookId, chapterId, sceneId, stage) {
140
+ return `${DISPATCH_META_PREFIX}:${bookId}:${chapterId}:${sceneId}:${stage}`;
141
+ }
142
+
143
+ /**
144
+ * Get dispatch-completed idempotency key (Д.1).
145
+ */
146
+ function getDispatchCompletedKey(bookId, chapterId, sceneId, stage, dispatchId = null) {
147
+ const base = `${DISPATCH_COMPLETED_PREFIX}:${bookId}:${chapterId}:${sceneId}:${stage}`;
148
+ return dispatchId ? `${base}:${dispatchId}` : base;
149
+ }
150
+
151
+ /**
152
+ * Get active stage counter key.
153
+ */
154
+ function getActiveCounterKey(stage) {
155
+ return `${ACTIVE_STAGE_PREFIX}-${stage}`;
156
+ }
157
+
158
+ /**
159
+ * Generate unique dispatch token.
160
+ * PW-2: cryptographic randomness (128-bit) instead of Math.random — the
161
+ * dispatch_id is an ownership/claim token on the hub hot path; predictable
162
+ * ids would let a caller guess or collide dispatch identities.
163
+ */
164
+ function generateDispatchToken() {
165
+ return `dispatch-${Date.now()}-${crypto.randomBytes(16).toString('hex')}`;
166
+ }
167
+
168
+ /**
169
+ * Acquire stage lease for scene.
170
+ * When force=true, any existing lease is deleted and a new one is created.
171
+ * Returns { acquired: boolean, token: string | null, leaseKey: string }
172
+ */
173
+ async function acquireStageLease(redis, bookId, chapterId, sceneId, stage, force = false) {
174
+ const leaseKey = getLeaseKey(bookId, chapterId, sceneId, stage);
175
+ const token = generateDispatchToken();
176
+ const ttl = _leaseTtl(stage);
177
+
178
+ if (force) {
179
+ // Force mode: delete any existing lease, then set new one
180
+ const existing = await redis.get(leaseKey);
181
+ if (existing) {
182
+ log(`LEASE_FORCE_REPLACE: ${bookId}/${chapterId}/${sceneId}:${stage} — replacing existing lease`);
183
+ await redis.del(leaseKey);
184
+ }
185
+ await redis.set(leaseKey, token, 'EX', ttl);
186
+ log(`LEASE_FORCE_ACQUIRED: ${bookId}/${chapterId}/${sceneId}:${stage} (ttl=${ttl}s, token=${token.slice(0, 16)}...)`);
187
+ return { acquired: true, token, leaseKey, forced: true };
188
+ }
189
+
190
+ const acquired = await redis.set(leaseKey, token, 'NX', 'EX', ttl);
191
+
192
+ if (acquired) {
193
+ log(`LEASE_ACQUIRED: ${bookId}/${chapterId}/${sceneId}:${stage} (ttl=${ttl}s, token=${token.slice(0, 16)}...)`);
194
+ return { acquired: true, token, leaseKey };
195
+ }
196
+
197
+ // Check if lease is still valid (not stale)
198
+ const currentToken = await redis.get(leaseKey);
199
+ if (currentToken) {
200
+ warn(`DISPATCH_SKIPPED_DUPLICATE: ${bookId}/${chapterId}/${sceneId}:${stage} (lease still active)`);
201
+ return { acquired: false, token: null, leaseKey, reason: 'lease_active', currentToken };
202
+ }
203
+
204
+ return { acquired: false, token: null, leaseKey, reason: 'lease_missing' };
205
+ }
206
+
207
+ /**
208
+ * Release stage lease.
209
+ * Only owner can release.
210
+ */
211
+ async function releaseStageLease(redis, leaseKey, token) {
212
+ const current = await redis.get(leaseKey);
213
+
214
+ if (!current) {
215
+ log(`LEASE_RELEASED: already expired (key=${leaseKey})`);
216
+ return { released: true, reason: 'already_expired' };
217
+ }
218
+
219
+ if (current !== token) {
220
+ warn(`LEASE_RELEASE_FAILED: token mismatch (key=${leaseKey}, current=${current.slice(0, 16)}..., expected=${token.slice(0, 16)}...)`);
221
+ return { released: false, reason: 'token_mismatch' };
222
+ }
223
+
224
+ await redis.del(leaseKey);
225
+ log(`LEASE_RELEASED: ${leaseKey}`);
226
+ return { released: true, reason: 'success' };
227
+ }
228
+
229
+ /**
230
+ * Check if lease is still valid.
231
+ */
232
+ async function isLeaseValid(redis, leaseKey, token) {
233
+ const current = await redis.get(leaseKey);
234
+ return current === token;
235
+ }
236
+
237
+ /**
238
+ * Get lease data.
239
+ */
240
+ async function getLeaseData(redis, bookId, chapterId, sceneId, stage) {
241
+ const leaseKey = getLeaseKey(bookId, chapterId, sceneId, stage);
242
+ const token = await redis.get(leaseKey);
243
+ return { leaseKey, token };
244
+ }
245
+
246
+ // ======================================================
247
+ // DISPATCH METADATA
248
+ // ======================================================
249
+
250
+ /**
251
+ * Create dispatch metadata.
252
+ */
253
+ function createDispatchMetadata(dispatchId, stage, worker = 'scheduler', ownership = {}) {
254
+ return {
255
+ dispatch_id: dispatchId,
256
+ stage,
257
+ started_at: Date.now(),
258
+ worker,
259
+ retry_attempt: 0,
260
+ status: 'dispatched',
261
+ lease_key: ownership.leaseKey || null,
262
+ lease_token: ownership.leaseToken || null,
263
+ quota_owned: ownership.quotaOwned === true
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Set dispatch metadata.
269
+ */
270
+ async function setDispatchMetadata(redis, bookId, chapterId, sceneId, stage, metadata) {
271
+ const key = getDispatchMetaKey(bookId, chapterId, sceneId, stage);
272
+ await redis.set(key, JSON.stringify(metadata), 'EX', _leaseTtl(stage));
273
+ }
274
+
275
+ /**
276
+ * Get dispatch metadata.
277
+ */
278
+ async function getDispatchMetadata(redis, bookId, chapterId, sceneId, stage) {
279
+ const key = getDispatchMetaKey(bookId, chapterId, sceneId, stage);
280
+ const raw = await redis.get(key);
281
+ return raw ? JSON.parse(raw) : null;
282
+ }
283
+
284
+ async function verifyDispatchIdentity(redis, bookId, chapterId, sceneId, stage, dispatchId) {
285
+ if (!dispatchId || typeof dispatchId !== 'string') {
286
+ return { valid: false, reason: 'missing_dispatch_id', metadata: null };
287
+ }
288
+
289
+ const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
290
+ if (!metadata) {
291
+ return { valid: false, reason: 'no_active_dispatch', metadata: null };
292
+ }
293
+ if (!metadata.dispatch_id) {
294
+ return { valid: false, reason: 'metadata_missing_dispatch_id', metadata };
295
+ }
296
+ if (metadata.dispatch_id !== dispatchId) {
297
+ return {
298
+ valid: false,
299
+ reason: 'stale_dispatch',
300
+ metadata,
301
+ currentDispatchId: metadata.dispatch_id
302
+ };
303
+ }
304
+ return { valid: true, reason: 'current_dispatch', metadata };
305
+ }
306
+
307
+ /**
308
+ * Delete dispatch metadata (on completion/failure).
309
+ */
310
+ async function deleteDispatchMetadata(redis, bookId, chapterId, sceneId, stage) {
311
+ const key = getDispatchMetaKey(bookId, chapterId, sceneId, stage);
312
+ await redis.del(key);
313
+ }
314
+
315
+ // ======================================================
316
+ // IU IN-FLIGHT MARKER OWNERSHIP (image stage)
317
+ // ======================================================
318
+
319
+ /**
320
+ * Per-dispatch index key of iu-in-flight markers created by this dispatch.
321
+ */
322
+ function getInFlightIndexKey(dispatchId) {
323
+ return `${IU_IN_FLIGHT_INDEX_PREFIX}:${dispatchId}`;
324
+ }
325
+
326
+ /**
327
+ * Register an iu-in-flight marker under the owning dispatch (iu-processor
328
+ * calls this right after creating the marker, BEFORE gpu.send).
329
+ */
330
+ async function registerInFlightMarker(redis, dispatchId, markerKey) {
331
+ if (!dispatchId || typeof dispatchId !== 'string') return;
332
+ const indexKey = getInFlightIndexKey(dispatchId);
333
+ await redis.sadd(indexKey, markerKey);
334
+ await redis.expire(indexKey, IU_IN_FLIGHT_MARKER_TTL_S());
335
+ }
336
+
337
+ /**
338
+ * Remove a marker from the owning dispatch index WITHOUT deleting the marker
339
+ * itself. Called after gpu.send:
340
+ * - sent:true → the GPU job is really running; the marker now belongs to
341
+ * the job lifecycle (completion callback clears it) and must
342
+ * NOT be touched by dispatch cancellation;
343
+ * - sent:false → the caller deletes the marker itself (job never existed).
344
+ */
345
+ async function unregisterInFlightMarker(redis, dispatchId, markerKey) {
346
+ if (!dispatchId || typeof dispatchId !== 'string') return;
347
+ await redis.srem(getInFlightIndexKey(dispatchId), markerKey);
348
+ }
349
+
350
+ /**
351
+ * Atomic compare-and-delete for an owned marker (single Lua step).
352
+ * GET+compare+DEL отдельными командами оставляли TOCTOU-окно: claim
353
+ * finalizeDispatch освобождает lease/metadata ДО очистки маркеров (шаг 2.5),
354
+ * поэтому конкурентный новый dispatch мог успеть пере-создать маркер между
355
+ * GET и DEL — и запоздалый cleanup старого dispatch'а удалил бы чужой маркер.
356
+ * Returns:
357
+ * { deleted: true } — marker removed (we owned it)
358
+ * { deleted: false, reason: 'missing' } — key already gone
359
+ * { deleted: false, reason: 'owner_changed' } — re-owned by another dispatch
360
+ */
361
+ const COMPARE_AND_DELETE_MARKER_SCRIPT = `
362
+ local key = KEYS[1]
363
+ local expected = ARGV[1]
364
+
365
+ local current = redis.call('GET', key)
366
+
367
+ if not current then
368
+ return 0
369
+ end
370
+
371
+ if current ~= ARGV[1] then
372
+ return -1
373
+ end
374
+
375
+ redis.call('DEL', KEYS[1])
376
+
377
+ return 1
378
+ `;
379
+
380
+ async function compareAndDeleteMarker(redis, markerKey, expectedOwner) {
381
+ const result = Number(await redis.eval(COMPARE_AND_DELETE_MARKER_SCRIPT, 1, markerKey, expectedOwner));
382
+ if (result === 1) return { deleted: true };
383
+ if (result === 0) return { deleted: false, reason: 'missing' };
384
+ return { deleted: false, reason: 'owner_changed' };
385
+ }
386
+
387
+ /**
388
+ * Release all iu-in-flight markers still owned by a dispatch (i.e. markers
389
+ * whose GPU job was NEVER actually sent). Atomic compare-and-delete by
390
+ * dispatch_id: a marker re-created by a newer dispatch is never touched.
391
+ * Returns { removed, kept, errors } — errors MUST NOT be swallowed by callers.
392
+ */
393
+ async function releaseDispatchInFlightMarkers(redis, dispatchId) {
394
+ if (!dispatchId || typeof dispatchId !== 'string') {
395
+ return { removed: 0, kept: 0, errors: [] };
396
+ }
397
+ const indexKey = getInFlightIndexKey(dispatchId);
398
+ const errors = [];
399
+ let members = [];
400
+ try {
401
+ members = await redis.smembers(indexKey);
402
+ } catch (err) {
403
+ errors.push(`index_read:${err.message}`);
404
+ }
405
+
406
+ let removed = 0;
407
+ let kept = 0;
408
+ for (const markerKey of members || []) {
409
+ try {
410
+ const cas = await compareAndDeleteMarker(redis, markerKey, dispatchId);
411
+ if (cas.deleted) {
412
+ removed++;
413
+ } else if (cas.reason === 'owner_changed') {
414
+ // Re-owned by a newer dispatch — a job may really be running.
415
+ kept++;
416
+ }
417
+ // 'missing' — already gone (TTL/explicit cleanup), nothing to do
418
+ } catch (err) {
419
+ errors.push(`${markerKey}:${err.message}`);
420
+ }
421
+ }
422
+
423
+ try {
424
+ await redis.del(indexKey);
425
+ } catch (err) {
426
+ errors.push(`index_del:${err.message}`);
427
+ }
428
+ return { removed, kept, errors };
429
+ }
430
+
431
+ // ======================================================
432
+ // ORPHAN GENERATING DETECTION & REPAIR (audit d9d67a3)
433
+ // ======================================================
434
+ // GENERATING without ANY evidence of a live generation is an orphan: the
435
+ // dispatch died (crash/restart/TTL expiry) and no rollback ever ran, so the
436
+ // scene is stuck in GENERATING forever (UI ghost "0/9", Stop All powerless).
437
+ //
438
+ // Evidence of a LIVE generation (any one is enough):
439
+ // - dispatch lease (held + renewed from dispatch to finalization)
440
+ // - dispatch metadata (same TTL as the lease, owns the dispatch_id)
441
+ // - iu-in-flight marker (image only: a GPU job may still be running)
442
+ //
443
+ // A stage is only ever set GENERATING AFTER lease+metadata exist (dispatch
444
+ // order: acquireLease → setDispatchMetadata → executor markGenerating), so
445
+ // the absence of ALL evidence proves the generation is dead. Age alone is
446
+ // NEVER a reason to repair — live long jobs keep their lease renewed.
447
+
448
+ const IU_IN_FLIGHT_KEY_PREFIX = 'animastor:iu-in-flight';
449
+
450
+ /**
451
+ * Check whether a stage's generation is still alive.
452
+ * @returns {Promise<{alive: boolean, reason: string}>}
453
+ */
454
+ async function getDispatchEvidence(redis, bookId, chapterId, sceneId, stage) {
455
+ const leaseToken = await redis.get(getLeaseKey(bookId, chapterId, sceneId, stage));
456
+ if (leaseToken) {
457
+ return { alive: true, reason: 'lease_present' };
458
+ }
459
+
460
+ const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
461
+ if (metadata) {
462
+ return { alive: true, reason: 'dispatch_meta_present' };
463
+ }
464
+
465
+ if (stage === 'image') {
466
+ // Marker key: animastor:iu-in-flight:{bookId}_{chapterId}_{sceneId}_{unitId}
467
+ const pattern = `${IU_IN_FLIGHT_KEY_PREFIX}:${bookId}_${chapterId}_${sceneId}_*`;
468
+ let cursor = '0';
469
+ do {
470
+ const [next, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 200);
471
+ cursor = next;
472
+ if (keys && keys.length > 0) {
473
+ return { alive: true, reason: 'iu_in_flight_present' };
474
+ }
475
+ } while (cursor !== '0');
476
+ }
477
+
478
+ return { alive: false, reason: 'no_evidence' };
479
+ }
480
+
481
+ /**
482
+ * Repair orphan GENERATING states for a whole book.
483
+ *
484
+ * Every per-asset state stuck in GENERATING with NO live-generation evidence
485
+ * (lease/dispatch-meta/in-flight marker) is rolled back through the FSM-safe
486
+ * path GENERATING → DIRTY → PENDING (orchestrator.rollbackStageToPending —
487
+ * a direct GENERATING → PENDING is forbidden by the FSM).
488
+ *
489
+ * Used by Stop All (cancel-generation) after clearAllLeasesForBook: that
490
+ * call only cancels lease-backed dispatches; a GENERATING state whose
491
+ * dispatch/lease already died would otherwise survive the cancel forever.
492
+ *
493
+ * Never touches a live GENERATING (any evidence → skip). Does NOT dispatch
494
+ * anything and does not require workers.
495
+ *
496
+ * @param {Object} redis
497
+ * @param {string} bookId
498
+ * @param {{reason?: string}} [opts]
499
+ * @returns {Promise<{repaired: Array<{chapterId, sceneId, stage, path}>}>}
500
+ */
501
+ async function repairOrphanGeneratingStates(redis, bookId, opts = {}) {
502
+ const stateModule = lazyHostBinding('state');
503
+ // S-5: FSM-safe rollback via the injected orchestration seam
504
+ const rollbackStageToPending = orchestrationSeams.getOrchestrationSeam('rollbackStageToPending');
505
+ const reason = opts.reason || 'orphan_generating_repair';
506
+ const repaired = [];
507
+
508
+ const pattern = `${stateModule.ASSET_STATE_KEY_PREFIX}:${bookId}:*`;
509
+ const seen = new Set();
510
+ let cursor = '0';
511
+ do {
512
+ const [next, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 200);
513
+ cursor = next;
514
+ for (const key of keys || []) {
515
+ // animastor:asset-state:{bookId}:{chapterId}:{sceneId}
516
+ // (bookId/chapterId/sceneId never contain ':')
517
+ const parts = key.split(':');
518
+ if (parts.length < 5) continue;
519
+ const chapterId = parts[3];
520
+ const sceneId = parts[4];
521
+ const sceneKey = `${chapterId}:${sceneId}`;
522
+ if (seen.has(sceneKey)) continue;
523
+ seen.add(sceneKey);
524
+
525
+ const states = await stateModule.getAssetStates(redis, bookId, chapterId, sceneId);
526
+ for (const stage of _allStages()) {
527
+ if (states[stage] !== stateModule.AssetState.GENERATING) continue;
528
+ const evidence = await getDispatchEvidence(redis, bookId, chapterId, sceneId, stage);
529
+ if (evidence.alive) continue;
530
+ const rollback = await rollbackStageToPending(
531
+ redis, bookId, chapterId, sceneId, stage, null, reason
532
+ );
533
+ if (rollback.changed) {
534
+ repaired.push({ chapterId, sceneId, stage, path: rollback.path });
535
+ }
536
+ }
537
+ }
538
+ } while (cursor !== '0');
539
+
540
+ return { repaired };
541
+ }
542
+
543
+ // ======================================================
544
+ // ACTIVE COUNTERS (for backpressure)
545
+ // ======================================================
546
+
547
+ /**
548
+ * Increment active stage counter.
549
+ */
550
+ async function incrementActiveCounter(redis, stage) {
551
+ const key = getActiveCounterKey(stage);
552
+ return await redis.incr(key);
553
+ }
554
+
555
+ /**
556
+ * Decrement active stage counter.
557
+ */
558
+ async function decrementActiveCounter(redis, stage) {
559
+ const key = getActiveCounterKey(stage);
560
+ const current = await redis.get(key);
561
+ if (current) {
562
+ const val = parseInt(current, 10);
563
+ if (val > 0) {
564
+ return await redis.decr(key);
565
+ }
566
+ }
567
+ return 0;
568
+ }
569
+
570
+ /**
571
+ * Get active counter value.
572
+ */
573
+ async function getActiveCounter(redis, stage) {
574
+ const key = getActiveCounterKey(stage);
575
+ const val = await redis.get(key);
576
+ return parseInt(val || '0', 10);
577
+ }
578
+
579
+ // ======================================================
580
+ // BACKPRESSURE QUOTAS
581
+ // ======================================================
582
+
583
+ /**
584
+ * Check if quota is available for a stage.
585
+ */
586
+ async function checkQuota(redis, stage) {
587
+ const current = await getActiveCounter(redis, stage);
588
+ const max = _maxActive(stage);
589
+ return { exceeded: current >= max, current, max };
590
+ }
591
+
592
+ // Lua script for atomic quota acquire: check limit and increment in one Redis call.
593
+ // Returns 0 if quota exceeded, or new counter value if acquired.
594
+ const ATOMIC_ACQUIRE_SCRIPT = `
595
+ local key = KEYS[1]
596
+ local max = tonumber(ARGV[1])
597
+ local current = redis.call('GET', key)
598
+ if current and tonumber(current) >= max then
599
+ return 0
600
+ end
601
+ return redis.call('INCR', key)
602
+ `;
603
+
604
+ /**
605
+ * Acquire quota slot — atomically.
606
+ * Uses Lua EVAL to check limit and increment in a single Redis call.
607
+ * Eliminates race condition between checkQuota (GET) and incrementActiveCounter (INCR).
608
+ */
609
+ async function acquireQuota(redis, stage) {
610
+ const key = getActiveCounterKey(stage);
611
+ const max = _maxActive(stage);
612
+
613
+ const result = await redis.eval(ATOMIC_ACQUIRE_SCRIPT, 1, key, max);
614
+
615
+ // ioredis may return EVAL result as string '0' — use loose check
616
+ if (!result || result === 0) {
617
+ const current = await getActiveCounter(redis, stage);
618
+ return { acquired: false, reason: 'quota_exceeded', current, max };
619
+ }
620
+
621
+ return { acquired: true, current: result, max };
622
+ }
623
+
624
+ /**
625
+ * Release quota slot.
626
+ */
627
+ async function releaseQuota(redis, stage) {
628
+ await decrementActiveCounter(redis, stage);
629
+ const current = await getActiveCounter(redis, stage);
630
+ return { released: true, current };
631
+ }
632
+
633
+ // ======================================================
634
+ // SCHEDULER TICK LOCK (single-flight)
635
+ // ======================================================
636
+
637
+ /**
638
+ * Acquire scheduler tick lock.
639
+ */
640
+ async function acquireSchedulerTickLock(redis) {
641
+ const token = generateDispatchToken();
642
+ const acquired = await redis.set(SCHEDULER_TICK_LOCK, token, 'NX', 'EX', SCHEDULER_TICK_LOCK_TTL);
643
+ return { acquired, token, lockKey: SCHEDULER_TICK_LOCK };
644
+ }
645
+
646
+ /**
647
+ * Release scheduler tick lock.
648
+ */
649
+ async function releaseSchedulerTickLock(redis, token) {
650
+ const current = await redis.get(SCHEDULER_TICK_LOCK);
651
+ if (!current) return { released: true, reason: 'already_expired' };
652
+ if (current !== token) return { released: false, reason: 'token_mismatch' };
653
+ await redis.del(SCHEDULER_TICK_LOCK);
654
+ return { released: true, reason: 'success' };
655
+ }
656
+
657
+ /**
658
+ * Check if scheduler tick is running.
659
+ */
660
+ async function isSchedulerTickRunning(redis) {
661
+ return await redis.exists(SCHEDULER_TICK_LOCK);
662
+ }
663
+
664
+ // ======================================================
665
+ // STAGE DISPATCH LOGIC
666
+ // ======================================================
667
+
668
+ /**
669
+ * Log dispatch event to journal.
670
+ */
671
+ async function logDispatchEvent(redis, bookId, chapterId, sceneId, eventType, stage, details = {}) {
672
+ await journal.appendSceneEvent(
673
+ redis,
674
+ bookId,
675
+ chapterId,
676
+ sceneId,
677
+ `DISPATCH_${eventType}`,
678
+ stage,
679
+ { ...details, dispatchedBy: 'dispatch-engine' }
680
+ );
681
+ }
682
+
683
+ /**
684
+ * Check if dispatch should be skipped (duplicate detection).
685
+ *
686
+ * Liveness uses the same canonical signal as reconciliation-engine: the
687
+ * lease's REMAINING TTL (kept pinned by renewal), never metadata.started_at.
688
+ * A live renewed lease — however old — is `lease_active` (skip, no duplicate);
689
+ * only a lease whose TTL decayed below target - grace (renewals stopped) is
690
+ * `stale_lease` and may be recovered. (audit c8b79f6: the started_at age
691
+ * heuristic here mirrored the reconciliation bug that re-dispatched live
692
+ * video jobs every ~28 min.)
693
+ */
694
+ async function shouldSkipDispatch(redis, bookId, chapterId, sceneId, stage) {
695
+ const { leaseKey, token } = await getLeaseData(redis, bookId, chapterId, sceneId, stage);
696
+
697
+ if (token) {
698
+ // Lease exists — decide liveness by remaining TTL, not started_at age.
699
+ const leaseTtlS = await redis.ttl(leaseKey);
700
+
701
+ if (leaseManager.isLeaseStale(leaseTtlS, stage)) {
702
+ // Renewals stopped and the TTL decayed past the grace window — the
703
+ // owner is gone. Surface the token so the caller can release it.
704
+ warn(`STALE_LEASE: ${bookId}/${chapterId}/${sceneId}:${stage} (ttl=${leaseTtlS}s, target=${leaseManager.getRenewalTargetTtlS(stage)}s)`);
705
+ return { skip: true, reason: 'stale_lease', leaseKey, currentToken: token };
706
+ }
707
+
708
+ return { skip: true, reason: 'lease_active', leaseKey, currentToken: token };
709
+ }
710
+
711
+ // No lease - proceed with dispatch
712
+ return { skip: false, reason: 'no_lease' };
713
+ }
714
+
715
+ /**
716
+ * Evaluate policy for dispatch decision — UNUSED (removed in Phase 6).
717
+ * Legacy governance modules (policyEngine, workloadClassifier, costEstimator)
718
+ * were loaded via safeRequire but never wired into the production dispatch path.
719
+ */
720
+
721
+ /**
722
+ * Dispatch a scene stage.
723
+ * This is the ONLY way to start a scene stage.
724
+ * When options.force=true, any existing lease is cleared before dispatch.
725
+ *
726
+ * Returns:
727
+ * - { dispatched: true, dispatchId, stage, leaseKey }
728
+ * - { dispatched: false, reason, skip: true/false }
729
+ */
730
+ async function dispatchStage(redis, bookId, chapterId, sceneId, stage, loadedBook, buildId, options = {}) {
731
+ const { force = false } = options;
732
+ const dispatchId = generateDispatchToken();
733
+
734
+ log(`DISPATCH_REQUEST: ${bookId}/${chapterId}/${sceneId}:${stage}${force ? ' (force=true)' : ''}`);
735
+
736
+ // Force mode pre-clear: cancel existing dispatch, release quota ТОЛЬКО при наличии lease
737
+ if (force) {
738
+ const { leaseKey, token } = await getLeaseData(redis, bookId, chapterId, sceneId, stage);
739
+ if (token) {
740
+ log(`FORCE_CLEAR_LEASE: ${bookId}/${chapterId}/${sceneId}:${stage} — cancelling existing dispatch`);
741
+ // T5: Используем cancelActiveDispatch для корректного освобождения
742
+ await cancelActiveDispatch(redis, bookId, chapterId, sceneId, stage, 'force_reset');
743
+ } else {
744
+ log(`FORCE_CLEAR: ${bookId}/${chapterId}/${sceneId}:${stage} — no active lease, only clearing metadata`);
745
+ await deleteDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
746
+ // T5: Не освобождаем quota — не было lease → не было quota для этой сцены
747
+ }
748
+ }
749
+
750
+ // Phase 9 Step 0: Check circuit breaker (with auto-recovery: an OPEN
751
+ // circuit that has cooled down transitions to HALF_OPEN and lets this
752
+ // dispatch through as a test request — see checkDispatchWithRecovery).
753
+ const circuitStatus = await circuitBreaker.checkDispatchWithRecovery(redis, stage);
754
+ if (circuitStatus.recovered) {
755
+ log(`CIRCUIT_RECOVERED_TEST: ${bookId}/${chapterId}/${sceneId}:${stage} → half-open test dispatch`);
756
+ }
757
+ // Half-open test-permit tracking: if this dispatch was admitted as a
758
+ // half-open test request, its permit MUST be released on every abort path
759
+ // below that does not reach finalizeDispatch('success'|'failure') — those
760
+ // two outcomes release it themselves via recordSuccess/recordFailure.
761
+ // Otherwise the permit leaks and the stage stays blocked on
762
+ // 'half_open_limit_reached' forever (2026-08-26 video incident follow-up).
763
+ const isCircuitTestRequest = Boolean(circuitStatus.allowed && circuitStatus.isTestRequest);
764
+ const releaseTestPermit = () => isCircuitTestRequest
765
+ ? circuitBreaker.releaseHalfOpenPermit(redis, stage)
766
+ : Promise.resolve({ released: false });
767
+ if (!circuitStatus.allowed) {
768
+ log(`CIRCUIT_BLOCKED: ${bookId}/${chapterId}/${sceneId}:${stage} (circuit: ${circuitStatus.circuitState})`);
769
+ await journal.appendSceneEvent(
770
+ redis,
771
+ bookId,
772
+ chapterId,
773
+ sceneId,
774
+ 'DISPATCH_BLOCKED_CIRCUIT',
775
+ stage,
776
+ { circuit: circuitStatus.circuitState, reason: circuitStatus.reason }
777
+ );
778
+ return { dispatched: false, reason: 'circuit_open', circuitState: circuitStatus.circuitState, dispatchId };
779
+ }
780
+
781
+ // Step 1: Check for duplicate/lease (skipped in force mode — lease was already cleared)
782
+ if (!force) {
783
+ const shouldSkip = await shouldSkipDispatch(redis, bookId, chapterId, sceneId, stage);
784
+ if (shouldSkip.skip) {
785
+ if (shouldSkip.reason === 'stale_lease') {
786
+ // Recover the stale lease through the canonical cancellation
787
+ // path (lease + owned quota + renewal timer + completion
788
+ // marker) instead of a bare lease delete — a bare delete would
789
+ // leak the quota slot and the in-memory renewal timer.
790
+ await cancelActiveDispatch(redis, bookId, chapterId, sceneId, stage, 'stale_lease_recovery');
791
+ // Fall through to create new lease
792
+ } else {
793
+ await logDispatchEvent(redis, bookId, chapterId, sceneId, 'SKIPPED_DUPLICATE', stage, {
794
+ reason: shouldSkip.reason,
795
+ dispatchId
796
+ }); await releaseTestPermit();
797
+ return { dispatched: false, reason: 'duplicate', skip: true, dispatchId };
798
+ }
799
+ }
800
+ } else {
801
+ log(`FORCE_DISPATCH: ${bookId}/${chapterId}/${sceneId}:${stage} — duplicate check skipped`);
802
+ }
803
+
804
+ // Step 2: Acquire quota
805
+ const quota = await acquireQuota(redis, stage);
806
+ if (!quota.acquired) {
807
+ log(`BACKPRESSURE_DELAY: ${bookId}/${chapterId}/${sceneId}:${stage} (active=${quota.current}/${quota.max})`);
808
+ // B5: НЕ вызывать releaseQuota — квота не была захвачена (Lua скрипт вернул 0, INCR не было).
809
+ // releaseQuota здесь декрементировал бы счётчик ниже нуля → дрифт активных счётчиков.
810
+ await logDispatchEvent(redis, bookId, chapterId, sceneId, 'BACKPRESSURE_DELAY', stage, {
811
+ current: quota.current,
812
+ max: quota.max
813
+ });
814
+ await releaseTestPermit();
815
+ return { dispatched: false, reason: 'backpressure', quota: quota, dispatchId };
816
+ }
817
+
818
+ // Phase 9: Check retry budget
819
+ const failureType = 'transient';
820
+ const retryBudgetCheck = await retryBudget.checkRetryBudget(
821
+ redis,
822
+ bookId,
823
+ chapterId,
824
+ sceneId,
825
+ stage,
826
+ failureType,
827
+ 'scheduler'
828
+ );
829
+ if (!retryBudgetCheck.allowed) {
830
+ log(`RETRY_BUDGET_EXCEEDED: ${bookId}/${chapterId}/${sceneId}:${stage} (${retryBudgetCheck.reason})`);
831
+ await releaseQuota(redis, stage);
832
+ await releaseTestPermit();
833
+ await journal.appendSceneEvent(
834
+ redis,
835
+ bookId,
836
+ chapterId,
837
+ sceneId,
838
+ 'RETRY_BUDGET_EXCEEDED',
839
+ stage,
840
+ { reason: retryBudgetCheck.reason, budgets: retryBudgetCheck.budgets }
841
+ );
842
+ return { dispatched: false, reason: 'retry_budget_exceeded', budgets: retryBudgetCheck.budgets, dispatchId };
843
+ }
844
+
845
+ // Step 3: Acquire lease (force mode bypasses existing lease)
846
+ const lease = await acquireStageLease(redis, bookId, chapterId, sceneId, stage, force);
847
+ if (!lease.acquired) {
848
+ // Release quota if lease acquisition failed
849
+ await releaseQuota(redis, stage);
850
+ await releaseTestPermit();
851
+ return { dispatched: false, reason: lease.reason, dispatchId };
852
+ }
853
+
854
+ // Step 4: Set dispatch metadata
855
+ const metadata = createDispatchMetadata(dispatchId, stage, 'scheduler', {
856
+ leaseKey: lease.leaseKey,
857
+ leaseToken: lease.token,
858
+ quotaOwned: true
859
+ });
860
+ await setDispatchMetadata(redis, bookId, chapterId, sceneId, stage, metadata);
861
+
862
+ // Step 5: Log dispatch event
863
+ await logDispatchEvent(redis, bookId, chapterId, sceneId, 'STARTED', stage, {
864
+ dispatchId,
865
+ leaseKey: lease.leaseKey,
866
+ started_at: metadata.started_at
867
+ }); // Step 6: Get orchestrator to perform dispatch
868
+ try {
869
+ // S-5: the stage executor entry (scene-orchestrator.dispatchStage) is
870
+ // injected by the composition root — runtime must not import
871
+ // orchestration implementation directly.
872
+ const executeStageDispatch = orchestrationSeams.getOrchestrationSeam('dispatchStage');
873
+
874
+ // T4: передаём dispatchId — executor включит его в job specs для GPU Hub
875
+ // dispatchId устанавливается в metadata ДО вызова executor, поэтому
876
+ // callback при проверке identity увидит актуальный dispatchId.
877
+ metadata.dispatch_id = dispatchId;
878
+ await setDispatchMetadata(redis, bookId, chapterId, sceneId, stage, metadata);
879
+
880
+ const result = await executeStageDispatch(
881
+ redis,
882
+ { book_id: bookId, chapter_id: chapterId, scene_id: sceneId },
883
+ loadedBook,
884
+ buildId,
885
+ stage,
886
+ dispatchId // T4: передаём dispatchId в executor
887
+ );
888
+
889
+ // If orchestrator didn't dispatch (already done/cached), release quota and lease
890
+ if (!result.dispatched) {
891
+ log(`DISPATCH_SKIPPED: ${bookId}/${chapterId}/${sceneId}:${stage} - ${result.reason || 'already_done'}`);
892
+ if (!result.completed) {
893
+ await finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
894
+ outcome: 'cancelled',
895
+ dispatchId,
896
+ reason: result.reason || 'executor_sent_no_jobs'
897
+ });
898
+ // Rollback состояния выполняет executor (scene-orchestrator);
899
+ // если он не удался — не глотаем, явный error-лог.
900
+ if (result.rollbackFailed) {
901
+ error(`STATE_ROLLBACK_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage}: ${result.rollbackReason || 'unknown'} — stage may be stuck in GENERATING`);
902
+ }
903
+ }
904
+ // Cancelled finalize does not touch the circuit breaker — release
905
+ // the test permit here so a half-open circuit can keep recovering.
906
+ await releaseTestPermit();
907
+ return { dispatched: false, dispatchId, reason: result.reason || 'already_done', result };
908
+ }
909
+
910
+ // T6: Start lease renewal for this actively dispatched job
911
+ // Only started after confirming dispatcher returned dispatched:true
912
+ // (not for cache hits, backpressure, or empty executor results)
913
+ startDispatchRenewal(redis, bookId, chapterId, sceneId, stage, lease.leaseKey, lease.token);
914
+
915
+ // Set 3s debounce — task actually sent to GPU hub
916
+ await redis.set(`animastor:runtime:last-active:${stage}`, '1', 'EX', 3);
917
+
918
+ return { dispatched: true, dispatchId, stage, leaseKey: lease.leaseKey, result };
919
+ } catch (err) {
920
+ error(`DISPATCH_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage}: ${err.message}`);
921
+ await releaseTestPermit().catch(() => {});
922
+ await logDispatchEvent(redis, bookId, chapterId, sceneId, 'FAILED', stage, {
923
+ dispatchId,
924
+ error: err.message
925
+ });
926
+ // FSM-валидный rollback состояния: executor мог перевести стадию в
927
+ // GENERATING до throw. Без rollback сцена осталась бы в GENERATING
928
+ // навсегда (ghost, forensic audit 6929ba5). Ошибка rollback не
929
+ // глотается — явный error-лог (rollbackStageToPending дополнительно
930
+ // пишет journal + метрику).
931
+ try {
932
+ const rollback = await orchestrationSeams.getOrchestrationSeam('rollbackStageToPending')(
933
+ redis, bookId, chapterId, sceneId, stage, buildId,
934
+ `dispatch_error:${err.message}`
935
+ );
936
+ if (!rollback.changed && !rollback.alreadyPending && rollback.reason !== 'already_ready') {
937
+ error(`STATE_ROLLBACK_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage}: ${rollback.reason} — stage may be stuck in GENERATING`);
938
+ }
939
+ } catch (rollbackErr) {
940
+ error(`STATE_ROLLBACK_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage}: ${rollbackErr.message} — stage may be stuck in GENERATING`);
941
+ }
942
+ await finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
943
+ outcome: 'cancelled',
944
+ dispatchId,
945
+ reason: `dispatch_error:${err.message}`
946
+ }).catch(finalizeErr => {
947
+ warn(`Dispatch rollback finalization failed: ${finalizeErr.message}`);
948
+ });
949
+ return { dispatched: false, reason: 'dispatch_error', error: err.message, dispatchId };
950
+ }
951
+ }
952
+
953
+ // ======================================================
954
+ // T2: Единая finalization — finalizeDispatch
955
+ // ======================================================
956
+ // Единственная точка финализации dispatch. Принимает outcome:
957
+ // 'success' → recordSuccess, DISPATCH_COMPLETED, без retry budget
958
+ // 'failure' → recordFailure, DISPATCH_FAILED, consumeRetryBudget ровно 1×
959
+ // 'cancelled' → без circuit breaker, без retry budget, DISPATCH_CANCELLED
960
+ //
961
+ // Идемпотентность: один dispatch финализируется ровно один раз
962
+ // (проверка completion marker). Повторный вызов для того же dispatch
963
+ // возвращает { finalized: false, reason: 'already_finalized' }.
964
+ //
965
+ // Порядок:
966
+ // 1. Одним Lua-шагом проверить metadata + lease owner, claim marker,
967
+ // удалить metadata/lease и освободить owned quota
968
+ // 2. Stop renewal
969
+ // 3. Circuit breaker согласно outcome
970
+ // 4. Retry budget только для failure
971
+ // 5. Записать journal event
972
+ // ======================================================
973
+
974
+ /**
975
+ * Finalize a dispatch with a specific outcome.
976
+ *
977
+ * @param {Object} redis
978
+ * @param {string} bookId
979
+ * @param {string} chapterId
980
+ * @param {string} sceneId
981
+ * @param {string} stage - 'audio'|'image'|'video'
982
+ * @param {Object} options
983
+ * @param {'success'|'failure'|'cancelled'} options.outcome
984
+ * @param {string} [options.dispatchId]
985
+ * @param {string} [options.reason]
986
+ * @param {string} [options.failureType] - for failure-taxonomy
987
+ * @param {string} [options.workerId]
988
+ * @returns {Promise<{finalized: boolean, reason: string}>}
989
+ */
990
+ const CLAIM_FINALIZATION_SCRIPT = `
991
+ local metadata = redis.call('GET', KEYS[1])
992
+ if not metadata then
993
+ if redis.call('EXISTS', KEYS[2]) == 1 then
994
+ return 2
995
+ end
996
+ return 0
997
+ end
998
+ if metadata ~= ARGV[1] then
999
+ return -1
1000
+ end
1001
+
1002
+ local lease = redis.call('GET', KEYS[3])
1003
+ local expected_lease = ARGV[4]
1004
+ if lease and (expected_lease == '' or lease ~= expected_lease) then
1005
+ return -2
1006
+ end
1007
+
1008
+ local claimed = redis.call(
1009
+ 'SET',
1010
+ KEYS[2],
1011
+ ARGV[2],
1012
+ 'NX',
1013
+ 'EX',
1014
+ tonumber(ARGV[3])
1015
+ )
1016
+ if not claimed then
1017
+ return 2
1018
+ end
1019
+
1020
+ redis.call('DEL', KEYS[1])
1021
+
1022
+ if lease and expected_lease ~= '' then
1023
+ redis.call('DEL', KEYS[3])
1024
+ end
1025
+
1026
+ if ARGV[5] == '1' then
1027
+ local quota = tonumber(redis.call('GET', KEYS[4]) or '0')
1028
+ if quota > 0 then
1029
+ redis.call('DECR', KEYS[4])
1030
+ end
1031
+ end
1032
+
1033
+ return 1
1034
+ `;
1035
+
1036
+ async function claimFinalization(
1037
+ redis,
1038
+ metadataKey,
1039
+ completedKey,
1040
+ leaseKey,
1041
+ quotaKey,
1042
+ expectedMetadataRaw,
1043
+ markerValue,
1044
+ markerTtl,
1045
+ leaseToken,
1046
+ quotaOwned
1047
+ ) {
1048
+ return redis.eval(
1049
+ CLAIM_FINALIZATION_SCRIPT,
1050
+ 4,
1051
+ metadataKey,
1052
+ completedKey,
1053
+ leaseKey,
1054
+ quotaKey,
1055
+ expectedMetadataRaw,
1056
+ markerValue,
1057
+ markerTtl,
1058
+ leaseToken || '',
1059
+ quotaOwned ? '1' : '0'
1060
+ );
1061
+ }
1062
+
1063
+ async function finalizeDispatch(redis, bookId, chapterId, sceneId, stage, options = {}) {
1064
+ const { outcome = 'success', dispatchId, reason, failureType, workerId } = options;
1065
+
1066
+ if (!['success', 'failure', 'cancelled'].includes(outcome)) {
1067
+ throw new Error(`finalizeDispatch: invalid outcome '${outcome}' — must be success|failure|cancelled`);
1068
+ }
1069
+
1070
+ if (!dispatchId || typeof dispatchId !== 'string') {
1071
+ return { finalized: false, reason: 'missing_dispatch_id' };
1072
+ }
1073
+
1074
+ const completedKey = getDispatchCompletedKey(bookId, chapterId, sceneId, stage, dispatchId);
1075
+ if (await redis.get(completedKey)) {
1076
+ return { finalized: false, reason: 'already_finalized' };
1077
+ }
1078
+
1079
+ const metadataKey = getDispatchMetaKey(bookId, chapterId, sceneId, stage);
1080
+ const metadataRaw = await redis.get(metadataKey);
1081
+ if (!metadataRaw) {
1082
+ return { finalized: false, reason: 'no_active_dispatch' };
1083
+ }
1084
+
1085
+ let metadata;
1086
+ try {
1087
+ metadata = JSON.parse(metadataRaw);
1088
+ } catch (parseErr) {
1089
+ return { finalized: false, reason: 'invalid_dispatch_metadata' };
1090
+ }
1091
+
1092
+ if (metadata.dispatch_id !== dispatchId) {
1093
+ return {
1094
+ finalized: false,
1095
+ reason: 'stale_dispatch',
1096
+ currentDispatchId: metadata.dispatch_id || null
1097
+ };
1098
+ }
1099
+
1100
+ const leaseKey = metadata.lease_key || getLeaseKey(bookId, chapterId, sceneId, stage);
1101
+ const leaseToken = metadata.lease_token || null;
1102
+ if (metadata.quota_owned === true && !leaseToken) {
1103
+ return { finalized: false, reason: 'ownership_metadata_incomplete' };
1104
+ }
1105
+ const currentLeaseToken = await redis.get(leaseKey);
1106
+ if (currentLeaseToken && (!leaseToken || currentLeaseToken !== leaseToken)) {
1107
+ return { finalized: false, reason: 'lease_token_mismatch' };
1108
+ }
1109
+
1110
+ // ── 1. Atomically claim finalization and release Redis-owned resources ──
1111
+ const markerValue = JSON.stringify({
1112
+ outcome,
1113
+ dispatch_id: dispatchId,
1114
+ finalized_at: Date.now()
1115
+ });
1116
+ const claimResult = Number(await claimFinalization(
1117
+ redis,
1118
+ metadataKey,
1119
+ completedKey,
1120
+ leaseKey,
1121
+ getActiveCounterKey(stage),
1122
+ metadataRaw,
1123
+ markerValue,
1124
+ _leaseTtl(stage),
1125
+ leaseToken,
1126
+ metadata.quota_owned === true
1127
+ ));
1128
+ if (claimResult === 2) {
1129
+ return { finalized: false, reason: 'already_finalized' };
1130
+ }
1131
+ if (claimResult === 0) {
1132
+ return { finalized: false, reason: 'no_active_dispatch' };
1133
+ }
1134
+ if (claimResult === -1) {
1135
+ return { finalized: false, reason: 'dispatch_metadata_changed' };
1136
+ }
1137
+ if (claimResult === -2) {
1138
+ return { finalized: false, reason: 'lease_token_mismatch' };
1139
+ }
1140
+ if (claimResult !== 1) {
1141
+ throw new Error(`finalizeDispatch: unexpected claim result '${claimResult}'`);
1142
+ }
1143
+
1144
+ log(`FINALIZE_${outcome.toUpperCase()}: ${bookId}/${chapterId}/${sceneId}:${stage}${reason ? ' (' + reason + ')' : ''}`);
1145
+
1146
+ // ── 2. Stop renewal ──
1147
+ stopDispatchRenewal(bookId, chapterId, sceneId, stage);
1148
+
1149
+ const cleanupErrors = [];
1150
+
1151
+ // ── 2.5. Release iu-in-flight markers owned by this dispatch ──
1152
+ // Только для cancelled/failure и только маркеры НЕотправленных jobs
1153
+ // (отправленный job снимается с индекса сразу после gpu.send, его маркер
1154
+ // трогать нельзя — job реально выполняется). Для success очистку делает
1155
+ // completion callback по всему scene-prefix. Ошибки не глотаем — в
1156
+ // cleanupErrors и в лог.
1157
+ if (outcome === 'cancelled' || outcome === 'failure') {
1158
+ try {
1159
+ const markerCleanup = await releaseDispatchInFlightMarkers(redis, dispatchId);
1160
+ if (markerCleanup.removed > 0 || markerCleanup.kept > 0) {
1161
+ log(`IU_IN_FLIGHT_CLEANUP: ${bookId}/${chapterId}/${sceneId}:${stage} dispatch=${dispatchId.slice(0, 24)}... removed=${markerCleanup.removed} kept_running=${markerCleanup.kept}`);
1162
+ }
1163
+ if (markerCleanup.errors.length > 0) {
1164
+ cleanupErrors.push(...markerCleanup.errors.map(e => `iu_in_flight:${e}`));
1165
+ warn(`IU_IN_FLIGHT_CLEANUP_ERRORS: ${bookId}/${chapterId}/${sceneId}:${stage} dispatch=${dispatchId.slice(0, 24)}...: ${markerCleanup.errors.join('; ')}`);
1166
+ }
1167
+ } catch (cleanupErr) {
1168
+ cleanupErrors.push(`iu_in_flight:${cleanupErr.message}`);
1169
+ warn(`IU_IN_FLIGHT_CLEANUP_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage} dispatch=${dispatchId.slice(0, 24)}...: ${cleanupErr.message}`);
1170
+ }
1171
+ }
1172
+
1173
+ // ── 3-4. Circuit breaker + retry budget согласно outcome ──
1174
+ if (outcome === 'success') {
1175
+ try {
1176
+ const circuitResult = await circuitBreaker.recordSuccess(redis, stage);
1177
+ if (circuitResult.healed) {
1178
+ log(`CIRCUIT_HALF_OPEN_TEST_SUCCEEDED: ${stage} — circuit closed`);
1179
+ }
1180
+ } catch (circuitErr) {
1181
+ cleanupErrors.push(`circuit:${circuitErr.message}`);
1182
+ warn(`recordSuccess failed: ${circuitErr.message}`);
1183
+ }
1184
+ // Success не расходует retry budget
1185
+ } else if (outcome === 'failure') {
1186
+ try {
1187
+ await circuitBreaker.recordFailure(redis, stage);
1188
+ } catch (circuitErr) {
1189
+ cleanupErrors.push(`circuit:${circuitErr.message}`);
1190
+ warn(`recordFailure failed: ${circuitErr.message}`);
1191
+ }
1192
+
1193
+ // T2.8: Расходуем retry budget ровно один раз на принятый failure
1194
+ try {
1195
+ await retryBudget.consumeRetryBudget(redis, bookId, chapterId, sceneId, stage, failureType || 'transient', workerId);
1196
+ log(`RETRY_BUDGET_CONSUMED: ${bookId}/${chapterId}/${sceneId}:${stage} (type=${failureType || 'transient'})`);
1197
+ } catch (budgetErr) {
1198
+ cleanupErrors.push(`retry_budget:${budgetErr.message}`);
1199
+ warn(`consumeRetryBudget failed: ${budgetErr.message}`);
1200
+ }
1201
+ }
1202
+ // 'cancelled': не трогаем circuit breaker, не расходуем retry budget
1203
+
1204
+ // ── 5. Journal event ──
1205
+ const eventSuffix = outcome === 'success' ? 'COMPLETED' : outcome === 'failure' ? 'FAILED' : 'CANCELLED';
1206
+ try {
1207
+ await logDispatchEvent(redis, bookId, chapterId, sceneId, eventSuffix, stage, {
1208
+ outcome,
1209
+ reason: reason || null,
1210
+ failureType: failureType || null,
1211
+ workerId: workerId || null,
1212
+ dispatchId,
1213
+ cleanupErrors
1214
+ });
1215
+ } catch (journalErr) {
1216
+ cleanupErrors.push(`journal:${journalErr.message}`);
1217
+ warn(`finalization journal write failed: ${journalErr.message}`);
1218
+ }
1219
+
1220
+ // ── 6. Phase 5: report the finalization through the Runtime Result seam ──
1221
+ // The emitter maps the dispatch-internal outcome (success|failure|cancelled)
1222
+ // onto the contract status (completed|failed|cancelled); the outcome
1223
+ // vocabulary stays inside runtime. Emission is best-effort and never throws.
1224
+ try {
1225
+ await runtimeResultEmitter.emitRuntimeResult({
1226
+ bookId,
1227
+ chapterId,
1228
+ sceneId,
1229
+ stage,
1230
+ dispatchId,
1231
+ outcome,
1232
+ error: reason || null,
1233
+ metadata: {
1234
+ outcome,
1235
+ finalizedAt: Date.now(),
1236
+ cleanupErrors,
1237
+ },
1238
+ });
1239
+ } catch (emitErr) {
1240
+ warn(`runtime result emit failed: ${emitErr.message}`);
1241
+ }
1242
+
1243
+ return { finalized: true, reason: outcome, cleanupErrors };
1244
+ }
1245
+
1246
+ /**
1247
+ * T2: markDispatchCompleted оставлен как обратно-совместимая обёртка
1248
+ * для callers, которые пока не переведены на finalizeDispatch.
1249
+ * Все новые вызовы должны использовать finalizeDispatch.
1250
+ */
1251
+ async function markDispatchCompleted(redis, bookId, chapterId, sceneId, stage) {
1252
+ const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
1253
+ return finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
1254
+ outcome: 'success',
1255
+ dispatchId: metadata?.dispatch_id
1256
+ });
1257
+ }
1258
+
1259
+ /**
1260
+ * T2: markDispatchFailed — обёртка над finalizeDispatch('failure').
1261
+ */
1262
+ async function markDispatchFailed(redis, bookId, chapterId, sceneId, stage, error) {
1263
+ const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
1264
+ return finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
1265
+ outcome: 'failure',
1266
+ reason: error,
1267
+ dispatchId: metadata?.dispatch_id
1268
+ });
1269
+ }
1270
+
1271
+ // ======================================================
1272
+ // T5: Cancellation + quota-safe lease clearing
1273
+ // ======================================================
1274
+
1275
+ /**
1276
+ * Cancel an active dispatch: stop renewal, release lease+quota, save final record, log event.
1277
+ * Используется force mode и clearLeasesForScenes.
1278
+ * T6: Останавливает in-memory renewal timer, чтобы избежать утечки.
1279
+ */
1280
+ async function cancelActiveDispatch(redis, bookId, chapterId, sceneId, stage, reason = 'cancelled') {
1281
+ const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
1282
+ if (metadata?.dispatch_id) {
1283
+ const result = await finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
1284
+ outcome: 'cancelled',
1285
+ dispatchId: metadata.dispatch_id,
1286
+ reason
1287
+ });
1288
+ return {
1289
+ cancelled: result.finalized,
1290
+ hadLease: !!metadata.lease_token,
1291
+ quotaReleased: result.finalized && metadata.quota_owned === true,
1292
+ dispatchId: metadata.dispatch_id,
1293
+ reason: result.reason
1294
+ };
1295
+ }
1296
+
1297
+ const { leaseKey, token } = await getLeaseData(redis, bookId, chapterId, sceneId, stage);
1298
+ stopDispatchRenewal(bookId, chapterId, sceneId, stage);
1299
+ if (token) {
1300
+ await releaseStageLease(redis, leaseKey, token);
1301
+ warn(`CANCEL_ORPHAN_LEASE: ${bookId}/${chapterId}/${sceneId}:${stage} — quota ownership unknown; counter reconciliation required`);
1302
+ return {
1303
+ cancelled: true,
1304
+ hadLease: true,
1305
+ quotaReleased: false,
1306
+ dispatchId: null,
1307
+ reason: 'orphan_lease'
1308
+ };
1309
+ }
1310
+ return {
1311
+ cancelled: false,
1312
+ hadLease: false,
1313
+ quotaReleased: false,
1314
+ dispatchId: null,
1315
+ reason: 'no_active_dispatch'
1316
+ };
1317
+ }
1318
+
1319
+ /**
1320
+ * Clear leases for specific scenes — T5: корректно освобождает quota
1321
+ * для каждого существующего dispatch. Не удаляет quota без ownership.
1322
+ *
1323
+ * @param {Array<{chapter_id:string,scene_id:string,stages?:string[]}>} scenes - scenes to clear
1324
+ * Each scene may restrict cancellation to specific stages. When omitted, all
1325
+ * stages are cleared for backward compatibility.
1326
+ */
1327
+ async function clearLeasesForScenes(redis, bookId, scenes) {
1328
+ if (!scenes || scenes.length === 0) {
1329
+ return { cancelled: 0, quotaReleased: 0, dispatchIds: [] };
1330
+ }
1331
+ const allStages = _allStages();
1332
+ let cancelled = 0;
1333
+ let quotaReleased = 0;
1334
+ const dispatchIds = [];
1335
+
1336
+ for (const s of scenes) {
1337
+ const stages = Array.isArray(s.stages)
1338
+ ? s.stages.filter(stage => allStages.includes(stage))
1339
+ : allStages;
1340
+ for (const stage of stages) {
1341
+ const result = await cancelActiveDispatch(
1342
+ redis,
1343
+ bookId,
1344
+ s.chapter_id,
1345
+ s.scene_id,
1346
+ stage,
1347
+ 'scene_reset'
1348
+ );
1349
+ if (result.cancelled) {
1350
+ cancelled++;
1351
+ }
1352
+ if (result.quotaReleased) {
1353
+ quotaReleased++;
1354
+ }
1355
+ if (result.dispatchId) {
1356
+ dispatchIds.push(result.dispatchId);
1357
+ }
1358
+ }
1359
+ }
1360
+
1361
+ if (cancelled > 0) {
1362
+ log(`CLEAR_SCENE_LEASES: ${bookId} — ${cancelled} stages cancelled, ${quotaReleased} quota slots released for ${scenes.length} scene(s)`);
1363
+ }
1364
+ return { cancelled, quotaReleased, dispatchIds };
1365
+ }
1366
+
1367
+ /**
1368
+ * Remove specific cancelled dispatches from GPU Hub.
1369
+ * Kept here with dispatch cancellation so every caller uses the same auth and
1370
+ * error-handling contract.
1371
+ */
1372
+ async function clearHubDispatches(dispatchIds, options = {}) {
1373
+ const ids = [...new Set((dispatchIds || []).filter(Boolean))];
1374
+ const hubUrl = options.hubUrl || runtimeConfig.HUB_URL;
1375
+ const apiKey = options.apiKey !== undefined
1376
+ ? options.apiKey
1377
+ : runtimeConfig.GPU_HUB_API_KEY;
1378
+ const context = options.context || 'DISPATCH';
1379
+ const reportWarning = typeof options.warn === 'function' ? options.warn : warn;
1380
+ const fetchImpl = options.fetchImpl || globalThis.fetch;
1381
+ let cleared = 0;
1382
+ let failed = 0;
1383
+
1384
+ for (const dispatchId of ids) {
1385
+ try {
1386
+ const headers = {};
1387
+ if (apiKey) headers['x-api-key'] = apiKey;
1388
+ const response = await fetchImpl(
1389
+ `${hubUrl}/queue/clear?dispatch_id=${encodeURIComponent(dispatchId)}`,
1390
+ { method: 'DELETE', headers }
1391
+ );
1392
+ if (!response.ok) {
1393
+ failed++;
1394
+ reportWarning(`${context}: GPU hub cleanup returned ${response.status} for ${dispatchId}`);
1395
+ continue;
1396
+ }
1397
+ cleared++;
1398
+ } catch (err) {
1399
+ failed++;
1400
+ reportWarning(`${context}: GPU hub cleanup failed for ${dispatchId}: ${err.message}`);
1401
+ }
1402
+ }
1403
+
1404
+ return { requested: ids.length, cleared, failed };
1405
+ }
1406
+
1407
+ /**
1408
+ * Clear all dispatch leases and metadata for a specific stage within a book.
1409
+ * T5: quota-safe — uses cancelActiveDispatch for each lease.
1410
+ * Non-cancelled stages keep their leases and quotas intact.
1411
+ */
1412
+ async function clearLeasesForBookByStage(redis, bookId, stage) {
1413
+ let deleted = 0;
1414
+ let quotaReleased = 0;
1415
+ const dispatchIds = [];
1416
+ let cursor = 0;
1417
+
1418
+ const leasePattern = `${DISPATCH_LEASE_PREFIX}:${bookId}:*:${stage}`;
1419
+ do {
1420
+ const result = await redis.scan(cursor, 'MATCH', leasePattern, 'COUNT', 200);
1421
+ cursor = parseInt(result[0], 10);
1422
+ const keys = result[1];
1423
+
1424
+ for (const key of keys) {
1425
+ const parts = key.split(':');
1426
+ if (parts.length >= 6) {
1427
+ const chapterId = parts[3];
1428
+ const sceneId = parts[4];
1429
+ stopDispatchRenewal(bookId, chapterId, sceneId, stage);
1430
+
1431
+ const token = await redis.get(key);
1432
+ if (token) {
1433
+ const result = await cancelActiveDispatch(
1434
+ redis,
1435
+ bookId,
1436
+ chapterId,
1437
+ sceneId,
1438
+ stage,
1439
+ 'stage_reset'
1440
+ );
1441
+ if (result.cancelled && result.reason !== 'orphan_lease') {
1442
+ quotaReleased++;
1443
+ }
1444
+ if (result.dispatchId) dispatchIds.push(result.dispatchId);
1445
+ }
1446
+ }
1447
+ await redis.del(key).catch(() => {});
1448
+ deleted++;
1449
+ }
1450
+ } while (cursor !== 0);
1451
+
1452
+ // Clean up orphan metadata for this stage
1453
+ cursor = 0;
1454
+ const metaPattern = `${DISPATCH_META_PREFIX}:${bookId}:*:${stage}`;
1455
+ do {
1456
+ const result = await redis.scan(cursor, 'MATCH', metaPattern, 'COUNT', 200);
1457
+ cursor = parseInt(result[0], 10);
1458
+ const keys = result[1];
1459
+ if (keys.length > 0) {
1460
+ await redis.del(...keys);
1461
+ deleted += keys.length;
1462
+ }
1463
+ } while (cursor !== 0);
1464
+
1465
+ if (deleted > 0 || quotaReleased > 0) {
1466
+ log(`CLEAR_STAGE_LEASES: ${bookId}/${stage} — ${deleted} keys deleted, ${quotaReleased} quota slots released`);
1467
+ }
1468
+ return { deleted, quotaReleased, dispatchIds };
1469
+ }
1470
+
1471
+ /**
1472
+ * Clear all dispatch leases and metadata for a book — T5: quota-safe.
1473
+ */
1474
+ async function clearAllLeasesForBook(redis, bookId) {
1475
+ let deleted = 0;
1476
+ let quotaReleased = 0;
1477
+ const dispatchIds = [];
1478
+ let cursor = 0;
1479
+
1480
+ // Scan all dispatch leases for this book
1481
+ const leasePattern = `${DISPATCH_LEASE_PREFIX}:${bookId}:*`;
1482
+ do {
1483
+ const result = await redis.scan(cursor, 'MATCH', leasePattern, 'COUNT', 200);
1484
+ cursor = parseInt(result[0], 10);
1485
+ const keys = result[1];
1486
+
1487
+ for (const key of keys) {
1488
+ // Parse: animastor:dispatch-lease:bookId:chapterId:sceneId:stage
1489
+ const parts = key.split(':');
1490
+ if (parts.length >= 6) {
1491
+ const chapterId = parts[3];
1492
+ const sceneId = parts[4];
1493
+ const stage = parts[5];
1494
+
1495
+ // T6: Останавливаем renewal timer перед удалением lease
1496
+ stopDispatchRenewal(bookId, chapterId, sceneId, stage);
1497
+
1498
+ const token = await redis.get(key);
1499
+ if (token) {
1500
+ const result = await cancelActiveDispatch(
1501
+ redis,
1502
+ bookId,
1503
+ chapterId,
1504
+ sceneId,
1505
+ stage,
1506
+ 'book_reset'
1507
+ );
1508
+ if (result.cancelled && result.reason !== 'orphan_lease') {
1509
+ quotaReleased++;
1510
+ }
1511
+ if (result.dispatchId) dispatchIds.push(result.dispatchId);
1512
+ }
1513
+ }
1514
+ await redis.del(key).catch(() => {});
1515
+ deleted++;
1516
+ }
1517
+ } while (cursor !== 0);
1518
+
1519
+ // Delete orphan metadata. Per-dispatch completion markers are retained until TTL
1520
+ // so late callbacks remain recognizable as finalized/stale.
1521
+ for (const prefix of [DISPATCH_META_PREFIX]) {
1522
+ cursor = 0;
1523
+ const pattern = `${prefix}:${bookId}:*`;
1524
+ do {
1525
+ const result = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 200);
1526
+ cursor = parseInt(result[0], 10);
1527
+ const keys = result[1];
1528
+ if (keys.length > 0) {
1529
+ await redis.del(...keys);
1530
+ deleted += keys.length;
1531
+ }
1532
+ } while (cursor !== 0);
1533
+ }
1534
+
1535
+ if (deleted > 0 || quotaReleased > 0) {
1536
+ log(`CLEAR_ALL_LEASES: ${bookId} — ${deleted} keys deleted, ${quotaReleased} quota slots released`);
1537
+ }
1538
+ return { deleted, quotaReleased, dispatchIds };
1539
+ }
1540
+
1541
+ // ======================================================
1542
+ // METRICS
1543
+ // ======================================================
1544
+
1545
+ /**
1546
+ * Get current dispatch metrics.
1547
+ */
1548
+ async function getMetrics(redis) {
1549
+ const stages = _allStages();
1550
+ const activeCounts = await Promise.all(stages.map(s => getActiveCounter(redis, s)));
1551
+ const activeMap = Object.fromEntries(stages.map((s, i) => [s, activeCounts[i]]));
1552
+
1553
+ return {
1554
+ quotas: Object.fromEntries(_allStages().map(s => [s, { max: _maxActive(s) }])),
1555
+ active: activeMap,
1556
+ schedulerTickRunning: await isSchedulerTickRunning(redis)
1557
+ };
1558
+ }
1559
+
1560
+ /**
1561
+ * Get all active dispatches (leases with metadata).
1562
+ */
1563
+ async function getActiveDispatches(redis, limit = 100) {
1564
+ const activeDispatches = [];
1565
+
1566
+ // Scan for all dispatch leases
1567
+ const pattern = `${DISPATCH_LEASE_PREFIX}:*`;
1568
+ let cursor = 0;
1569
+ let count = 0;
1570
+
1571
+ do {
1572
+ const result = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
1573
+ cursor = parseInt(result[0], 10);
1574
+ const keys = result[1];
1575
+
1576
+ for (const key of keys) {
1577
+ if (count >= limit) break;
1578
+
1579
+ // Parse: animastor:dispatch-lease:bookId:chapterId:sceneId:stage
1580
+ const parts = key.split(':');
1581
+ if (parts.length >= 6) {
1582
+ const bookId = parts[2];
1583
+ const chapterId = parts[3];
1584
+ const sceneId = parts[4];
1585
+ const stage = parts[5];
1586
+
1587
+ const token = await redis.get(key);
1588
+ const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
1589
+
1590
+ activeDispatches.push({
1591
+ key,
1592
+ scene: { bookId, chapterId, sceneId },
1593
+ stage,
1594
+ token: token || null,
1595
+ metadata
1596
+ });
1597
+
1598
+ count++;
1599
+ }
1600
+ }
1601
+ } while (cursor !== 0 && count < limit);
1602
+
1603
+ return activeDispatches;
1604
+ }
1605
+
1606
+ /**
1607
+ * Get all active leases (for debug).
1608
+ */
1609
+ async function getActiveLeases(redis) {
1610
+ const pattern = `${DISPATCH_LEASE_PREFIX}:*`;
1611
+ let cursor = 0;
1612
+ const leases = [];
1613
+
1614
+ do {
1615
+ const result = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
1616
+ cursor = parseInt(result[0], 10);
1617
+ const keys = result[1];
1618
+
1619
+ for (const key of keys) {
1620
+ const token = await redis.get(key);
1621
+ const parts = key.split(':');
1622
+ const leaseData = {
1623
+ key,
1624
+ scene: parts.length >= 6 ? {
1625
+ bookId: parts[2],
1626
+ chapterId: parts[3],
1627
+ sceneId: parts[4],
1628
+ stage: parts[5]
1629
+ } : null,
1630
+ token: token || null
1631
+ };
1632
+ leases.push(leaseData);
1633
+ }
1634
+ } while (cursor !== 0);
1635
+
1636
+ return leases;
1637
+ }
1638
+
1639
+ /**
1640
+ * Get current quota status.
1641
+ */
1642
+ async function getQuotaStatus(redis) {
1643
+ const stages = _allStages();
1644
+ const counts = await Promise.all(stages.map(s => getActiveCounter(redis, s)));
1645
+ const countMap = Object.fromEntries(stages.map((s, i) => [s, counts[i]]));
1646
+
1647
+ const result = {};
1648
+ for (const s of stages) {
1649
+ const max = _maxActive(s);
1650
+ result[s] = { current: countMap[s], max, available: max - countMap[s] };
1651
+ }
1652
+ return result;
1653
+ }
1654
+
1655
+ // ======================================================
1656
+ // LEASE RENEWAL TIMER MANAGEMENT
1657
+ // ======================================================
1658
+
1659
+ /**
1660
+ * Start lease renewal timer for a dispatch.
1661
+ * Called after successful dispatch.
1662
+ */
1663
+ function startDispatchRenewal(redis, bookId, chapterId, sceneId, stage, leaseKey, token) {
1664
+ return leaseManager.startLeaseRenewal(redis, bookId, chapterId, sceneId, stage, leaseKey, token);
1665
+ }
1666
+
1667
+ /**
1668
+ * Stop lease renewal timer for a dispatch.
1669
+ * Called on completion/failure/recovery.
1670
+ */
1671
+ function stopDispatchRenewal(bookId, chapterId, sceneId, stage) {
1672
+ return leaseManager.stopLeaseRenewal(bookId, chapterId, sceneId, stage);
1673
+ }
1674
+
1675
+ /**
1676
+ * Get metrics for runtime.
1677
+ */
1678
+ async function getRuntimeMetrics(redis) {
1679
+ return await runtimeMetrics.getMetricsReport(redis);
1680
+ }
1681
+
1682
+ // ======================================================
1683
+ // EXPORTS
1684
+ // ======================================================
1685
+
1686
+ module.exports = {
1687
+ // Keys and patterns
1688
+ getLeaseKey,
1689
+ getDispatchMetaKey,
1690
+ getDispatchCompletedKey,
1691
+ getActiveCounterKey,
1692
+ SCHEDULER_TICK_LOCK,
1693
+ SCHEDULER_TICK_LOCK_TTL,
1694
+
1695
+ // Lease management
1696
+ acquireStageLease,
1697
+ releaseStageLease,
1698
+ isLeaseValid,
1699
+ getLeaseData,
1700
+
1701
+ // Dispatch metadata
1702
+ createDispatchMetadata,
1703
+ setDispatchMetadata,
1704
+ getDispatchMetadata,
1705
+ verifyDispatchIdentity,
1706
+ deleteDispatchMetadata,
1707
+
1708
+ // IU in-flight marker ownership (image stage)
1709
+ getInFlightIndexKey,
1710
+ registerInFlightMarker,
1711
+ unregisterInFlightMarker,
1712
+ releaseDispatchInFlightMarkers,
1713
+ compareAndDeleteMarker,
1714
+ IU_IN_FLIGHT_INDEX_PREFIX,
1715
+ get IU_IN_FLIGHT_MARKER_TTL_S() { return IU_IN_FLIGHT_MARKER_TTL_S(); },
1716
+
1717
+ // Orphan GENERATING detection & repair (audit d9d67a3)
1718
+ getDispatchEvidence,
1719
+ repairOrphanGeneratingStates,
1720
+
1721
+ // Active counters (backpressure)
1722
+ incrementActiveCounter,
1723
+ decrementActiveCounter,
1724
+ getActiveCounter,
1725
+ checkQuota,
1726
+ acquireQuota,
1727
+ releaseQuota,
1728
+
1729
+ // Scheduler tick lock
1730
+ acquireSchedulerTickLock,
1731
+ releaseSchedulerTickLock,
1732
+ isSchedulerTickRunning,
1733
+
1734
+ // Dispatch control
1735
+ dispatchStage,
1736
+ shouldSkipDispatch,
1737
+ finalizeDispatch,
1738
+ cancelActiveDispatch,
1739
+ markDispatchCompleted,
1740
+ markDispatchFailed,
1741
+
1742
+ // Renewal management
1743
+ startDispatchRenewal,
1744
+ stopDispatchRenewal,
1745
+
1746
+ // Recovery
1747
+ clearAllLeasesForBook,
1748
+ clearLeasesForBookByStage,
1749
+ clearLeasesForScenes,
1750
+ clearHubDispatches,
1751
+
1752
+ // Runtime metrics
1753
+ getRuntimeMetrics,
1754
+
1755
+ // Metrics and debug
1756
+ getMetrics,
1757
+ getActiveDispatches,
1758
+ getActiveLeases,
1759
+ getQuotaStatus,
1760
+
1761
+ // Constants — S-2: lazy views over the media registry (single source of
1762
+ // truth); exported for backward compatibility with tests/metrics.
1763
+ LEASE_TTLS,
1764
+ QUOTAS,
1765
+
1766
+ // PW-2: dispatch identity generation (crypto-hardened)
1767
+ generateDispatchToken
1768
+ };