@animastor/orchestration 0.1.3 → 0.1.5

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",
3
+ "version": "0.1.5",
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",
@@ -277,9 +277,17 @@ async function handleImageCompleted(redis, bookId, chapterId, sceneId, buildId)
277
277
  // T1: возвращает { ok, retryable, reason, artifact }
278
278
  log(`IMAGE_CALLBACK: ${bookId}/${chapterId}/${sceneId}`);
279
279
 
280
- const sceneImage = image.resolveCanonicalSceneImage(
281
- '/data/output', buildId, bookId, chapterId, sceneId
280
+ // Stage 143a: legacy canonical scan first (unchanged behavior, also the
281
+ // path every host stub knows), then — flag on and scan empty — the
282
+ // published pointer of this scene build as the completion proof.
283
+ let sceneImage = image.resolveCanonicalSceneImage(
284
+ artifactRoot(), buildId, bookId, chapterId, sceneId
282
285
  );
286
+ if (!sceneImage && typeof image.resolveVersionedSceneProof === 'function') {
287
+ sceneImage = await image.resolveVersionedSceneProof(
288
+ artifactRoot(), buildId, bookId, chapterId, sceneId
289
+ );
290
+ }
283
291
 
284
292
  // Per-asset check
285
293
  const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
@@ -334,7 +342,28 @@ async function handleImageCompleted(redis, bookId, chapterId, sceneId, buildId)
334
342
  const stillPending = [];
335
343
  for (const uid of dirtyIds) {
336
344
  const pngPath = path.join(buildDir, artifactNaming.sceneImageName(bookId, chapterId, sceneId, uid));
337
- if (!fs.existsSync(pngPath)) {
345
+ if (fs.existsSync(pngPath)) continue;
346
+ // ── Stage 143a (phase 2a · N1) ─────────────────────
347
+ // A versioned-only build never writes the canonical file, so
348
+ // the published pointer is the completion proof for this
349
+ // unit. Without it the dirty marker is never cleared and the
350
+ // unit is force-regenerated on EVERY reconcile pass (infinite
351
+ // regeneration loop with the flag on).
352
+ // Flag OFF ⇒ the facade answers null with zero PG calls → the
353
+ // legacy check above decides, exactly as before.
354
+ // A pointer-store ERROR keeps the unit pending (never cleared
355
+ // on an unknown verdict) and is logged.
356
+ let provenByPointer = false;
357
+ try {
358
+ if (image && typeof image.resolveIuImagePath === 'function') {
359
+ const published = await image.resolveIuImagePath(
360
+ artifactRoot(), buildId, bookId, chapterId, sceneId, uid);
361
+ provenByPointer = !!(published && published.status === 'ok');
362
+ }
363
+ } catch (pgErr) {
364
+ warn(`[DIRTY-UNITS] pointer proof unavailable for ${uid}: ${pgErr.message} — keeping it pending`);
365
+ }
366
+ if (!provenByPointer) {
338
367
  stillPending.push(uid);
339
368
  }
340
369
  }
@@ -343,8 +372,31 @@ async function handleImageCompleted(redis, bookId, chapterId, sceneId, buildId)
343
372
  log(`[DIRTY-UNITS-CLEARED] ${bookId}/${chapterId}/${sceneId}: all ${dirtyIds.length} dirty unit(s) completed, cleared`);
344
373
  try {
345
374
  const iuPrefix = artifactNaming.iuImagePrefix(bookId, chapterId, sceneId);
375
+ const scenePrefix = artifactNaming.scenePrefix(bookId, chapterId, sceneId);
346
376
  const allFiles = fs.existsSync(buildDir) ? fs.readdirSync(buildDir) : [];
347
- const iuFileCount = allFiles.filter(f => f.startsWith(iuPrefix) && f.endsWith('.png')).length;
377
+ // ── Stage 143a (phase 2a · N2) ─────────────────
378
+ // Ready units = canonical file names ∪ published pointer
379
+ // ids (versioned files are invisible to this scan). The
380
+ // counter is a DISPLAY source (consumers clamp it), so a
381
+ // pointer-store error here only logs and leaves the
382
+ // legacy count — never a readiness decision.
383
+ const readyIds = new Set();
384
+ for (const f of allFiles) {
385
+ if (f.startsWith(iuPrefix) && f.endsWith('.png')) {
386
+ readyIds.add(f.startsWith(`${scenePrefix}_`)
387
+ ? f.slice(scenePrefix.length + 1, -4)
388
+ : f);
389
+ }
390
+ }
391
+ if (image && typeof image.listPublishedIuIdsForScene === 'function') {
392
+ try {
393
+ const publishedIds = await image.listPublishedIuIdsForScene(buildId, bookId, chapterId, sceneId);
394
+ for (const id of publishedIds) readyIds.add(String(id));
395
+ } catch (pgErr) {
396
+ warn(`[IU-COUNTER-RESET] published ids unavailable for ${bookId}/${chapterId}/${sceneId}: ${pgErr.message} — counting canonical files only`);
397
+ }
398
+ }
399
+ const iuFileCount = readyIds.size;
348
400
  if (iuFileCount > 0) {
349
401
  const progKey = `animastor:iu-progress:${bookId}:${chapterId}:${sceneId}:image`;
350
402
  await redis.set(progKey, String(iuFileCount), 'EX', 14400);
@@ -390,7 +442,7 @@ async function handleVideoCompleted(redis, bookId, chapterId, sceneId, buildId)
390
442
  // T1: возвращает { ok, retryable, reason, artifact }
391
443
  log(`VIDEO_CALLBACK: ${bookId}/${chapterId}/${sceneId}`);
392
444
 
393
- const videoPath = `/data/output/${buildId}/${bookId}_${chapterId}_${sceneId}.mp4`;
445
+ const videoPath = `${artifactRoot()}/${buildId}/${bookId}_${chapterId}_${sceneId}.mp4`;
394
446
  const { valid, duration, metadata } = video.validateVideoFile(videoPath);
395
447
 
396
448
  const assetStates = await state.getAssetStates(redis, bookId, chapterId, sceneId);
@@ -13,10 +13,15 @@ const chunks = lazyHostBinding('media.audio.chunks');
13
13
  // orchestrator never requires gpu-dispatcher or the workflow connector.
14
14
  const provider = require('@animastor/generation').comfyuiProvider;
15
15
  const runtimeScheduler = require('../runtime/runtime-scheduler');
16
+ // Stage 118 (M3): the shared outcome taxonomy — the dispatch engine (runtime
17
+ // tier) and the stage executors (orchestration tier) must classify a
18
+ // "nothing was enqueued" outcome IDENTICALLY.
19
+ const { classifyNotSentReasons } = require('../runtime/dispatch-outcomes');
16
20
  // O-2: persistence arrives ONLY through the PersistencePort (host adapter:
17
21
  // storage/runtime-persistence-adapter) — the scene-assets repository left
18
22
  // this file. SQL lives host-side.
19
23
  const persist = require('../runtime/persistence-port').persist;
24
+ const isPersistencePortWired = require('../runtime/persistence-port').isPersistencePortWired;
20
25
  // O-3: scene content arrives ONLY through the SceneDataPort (host adapter:
21
26
  // storage/scene-data-adapter) — the Book Model facade (../book →
22
27
  // @animastor/vbook-runtime) left this file. Aliased `sceneDataPort` because
@@ -300,8 +305,22 @@ async function executeAudioDispatch(redis, scene, loadedBook, buildId, dispatchI
300
305
  const completion = await completeStage(redis, bookId, chapterId, sceneId, 'audio', buildId, dispatchId);
301
306
  return { dispatched: false, jobs: 0, completed: completion.completed, reason: 'no_segments' };
302
307
  } else {
303
- warn(`AUDIO_DISPATCH: generateSceneAudio did not send jobs for ${bookId}/${chapterId}/${sceneId}`);
304
- return { dispatched: false, jobs: 0, reason: 'no_jobs_generated' };
308
+ // Stage 116 (D3) / Stage 118 (B3, M3): nothing was enqueued — carry
309
+ // the precise reason (no live worker / everything already queued /
310
+ // ambiguous POST) so the dispatch engine can apply a BOUNDED deferral
311
+ // instead of a fatal cancel.
312
+ //
313
+ // Stage 118 (B3): the merged (pure-dialogue) path and the per-segment
314
+ // path must produce the SAME classification — the merged branch used
315
+ // to leave the reason empty, so a pure-dialogue scene kept the
316
+ // 5-second retry loop. `deferReason` is computed by the host (audio
317
+ // generation) for EVERY path; the fallback below keeps the historic
318
+ // 'no_jobs_generated' semantics when the host could not classify it.
319
+ const deferReason = (result && result.reason === 'no_jobs_accepted' && result.deferReason)
320
+ ? result.deferReason
321
+ : 'no_jobs_generated';
322
+ warn(`AUDIO_DISPATCH: generateSceneAudio did not send jobs for ${bookId}/${chapterId}/${sceneId} (${deferReason})`);
323
+ return { dispatched: false, jobs: 0, reason: deferReason, retryable: true };
305
324
  }
306
325
  }
307
326
 
@@ -352,8 +371,11 @@ async function executeImageDispatch(redis, scene, loadedBook, buildId, dispatchI
352
371
 
353
372
  // T3: generateSceneIUImages возвращает количество отправленных jobs
354
373
  let jobsSent = 0;
374
+ // Stage 116 (D3): the result is needed AFTER the try/catch (the deferral
375
+ // reason) — it must be visible outside the block.
376
+ let genResult = null;
355
377
  try {
356
- const genResult = await image.generateSceneIUImages(
378
+ genResult = await image.generateSceneIUImages(
357
379
  redis,
358
380
  sceneData,
359
381
  bookData,
@@ -377,6 +399,16 @@ async function executeImageDispatch(redis, scene, loadedBook, buildId, dispatchI
377
399
  return { dispatched: true, jobs: jobsSent, reason: null };
378
400
  }
379
401
 
402
+ // Stage 116 (D3) / Stage 118 (M3): the executor sent NOTHING. That is
403
+ // not a lost task and not a fatal cancellation — it is a bounded
404
+ // deferral. The precise reason (no live worker / everything already
405
+ // queued / ambiguous POST) is carried through so the deferral can be
406
+ // classified by the shared taxonomy (runtime/dispatch-outcomes).
407
+ if (genResult && genResult.deferReason) {
408
+ warn(`IMAGE_DISPATCH: ${bookId}/${chapterId}/${sceneId} — nothing enqueued (${genResult.deferReason}); deferring`);
409
+ return { dispatched: false, jobs: 0, reason: genResult.deferReason, retryable: true };
410
+ }
411
+
380
412
  log(`IMAGE_DISPATCH: ${bookId}/${chapterId}/${sceneId} — no jobs sent (cache hit or empty units)`);
381
413
  return { dispatched: false, jobs: 0, reason: 'no_jobs_sent' };
382
414
  }
@@ -551,15 +583,41 @@ async function executeVideoDispatch(redis, scene, loadedBook, buildId, dispatchI
551
583
  await videoOrch.setWaitingChunks(redis, bookId, chapterId, sceneId);
552
584
 
553
585
  let sentCount = 0;
586
+ // Stage 116 (D3) / Stage 118 (M3): collect WHY a job was not sent — the
587
+ // dominant reason becomes the deferral reason (bounded backoff) instead
588
+ // of the generic 'send_failed'.
589
+ const notSentReasons = new Map();
554
590
  for (const jobSpec of jobsToSend) {
555
591
  try {
556
- const sendResult = await provider.generate(jobSpec);
592
+ let sendResult = await provider.generate(jobSpec);
593
+ // Stage 122 (N2): the group file was probed absent above (a cached
594
+ // group never reaches this loop), so an `already_completed` verdict
595
+ // is by construction an ORPHANED completion marker. Free it and
596
+ // retry ONCE so the video regenerates instead of looping on a
597
+ // 900 s deferral with the scene stuck in the active index (audit
598
+ // 121 · N2). The release rides the persistence port — the
599
+ // orchestration tier cannot require the backend helper directly.
600
+ // Skipped when the port is unwired (harnesses that don't exercise
601
+ // orphan cleanup); production always wires it in backend.cjs.
602
+ if (isPersistencePortWired()) {
603
+ let released = false;
604
+ try {
605
+ released = await persist('idem.releaseTaskDoneIfOrphan')(redis, sendResult);
606
+ } catch (_) { released = false; }
607
+ if (released) {
608
+ log(`VIDEO_DISPATCH: orphaned completion marker released for ${jobSpec.job_id} — regenerating`);
609
+ sendResult = await provider.generate(jobSpec);
610
+ }
611
+ }
557
612
  if (sendResult.sent) {
558
613
  sentCount++;
559
614
  } else {
560
- warn(`VIDEO_DISPATCH: Hub rejected job ${jobSpec.job_id}: ${sendResult.error || 'unknown'}`);
615
+ const reason = sendResult.reason || 'send_failed';
616
+ notSentReasons.set(reason, (notSentReasons.get(reason) || 0) + 1);
617
+ warn(`VIDEO_DISPATCH: Hub rejected job ${jobSpec.job_id}: ${sendResult.error || reason}`);
561
618
  }
562
619
  } catch (sendErr) {
620
+ notSentReasons.set('send_failed', (notSentReasons.get('send_failed') || 0) + 1);
563
621
  warn(`VIDEO_DISPATCH: provider generate failed for job ${jobSpec.job_id}: ${sendErr.message}`);
564
622
  }
565
623
  }
@@ -569,8 +627,13 @@ async function executeVideoDispatch(redis, scene, loadedBook, buildId, dispatchI
569
627
  return { dispatched: true, jobs: sentCount, reason: null };
570
628
  }
571
629
 
572
- warn(`VIDEO_DISPATCH: ${bookId}/${chapterId}/${sceneId}: all provider.generate calls failed`);
573
- return { dispatched: false, jobs: 0, reason: 'send_failed' };
630
+ // Stage 116 (D3) / Stage 118 (M3): the shared taxonomy decides whether
631
+ // the outcome is a bounded deferral. EVERY reason must be deferrable —
632
+ // a single real failure (send_failed) keeps the historic 'send_failed'
633
+ // semantics (an immediate retry on the next tick).
634
+ const deferReason = classifyNotSentReasons(notSentReasons) || 'send_failed';
635
+ warn(`VIDEO_DISPATCH: ${bookId}/${chapterId}/${sceneId}: nothing enqueued (${deferReason})`);
636
+ return { dispatched: false, jobs: 0, reason: deferReason, retryable: true };
574
637
  }
575
638
 
576
639
  // T4: dispatchStage принимает dispatchId (из dispatch-engine) и передаёт executors
@@ -730,10 +730,194 @@ async function shouldSkipDispatch(redis, bookId, chapterId, sceneId, stage) {
730
730
  * - { dispatched: true, dispatchId, stage, leaseKey }
731
731
  * - { dispatched: false, reason, skip: true/false }
732
732
  */
733
+ // ======================================================
734
+ // STAGE 116 (D3) — BOUNDED DEFERRAL (capacity backoff)
735
+ // STAGE 118 (audit 117 · M2/M4) — taxonomy + force handling
736
+ // ======================================================
737
+ // The scheduler ticks every SCHEDULER_TICK_MS (5 s). A stage whose jobs could
738
+ // not be enqueued (no live compatible worker, or every job already pending in
739
+ // the lane) used to be re-dispatched on EVERY tick: lease created → executor
740
+ // sent nothing → 'no_jobs_sent' → dispatch cancelled → FSM rolled back →
741
+ // next tick repeats. That is an unbounded dispatch/cancel loop with FSM
742
+ // churn, lease churn and unbounded event-journal growth (Stage 115 D3).
743
+ //
744
+ // The deferral is a bounded, per-stage backoff:
745
+ // • checked BEFORE any side effect (no lease, no metadata, no FSM write,
746
+ // no journal event) ⇒ a deferred stage costs one Redis GET per tick;
747
+ // • the stage stays PENDING and stays in the active index ⇒ recoverable;
748
+ // • an explicit cancel clears it (the user asked for the work to run).
749
+ //
750
+ // Stage 118 — three corrections of the Stage 116 implementation:
751
+ //
752
+ // 1. The reason taxonomy now distinguishes CAPACITY from IDEMPOTENT
753
+ // suppression ('already_completed' is NOT a capacity gap — audit 117 · M4).
754
+ // The idempotent bucket gets its own, much longer window: the stage is not
755
+ // "waiting for capacity", it is "already done" — retrying it every minute
756
+ // for a week is not a fix.
757
+ // 2. `force=true` NO LONGER BYPASSES the deferral (audit 117 · M2). The
758
+ // force-dispatch flag lives for FORCE_DISPATCH_TTL_S (120 s) and is set for
759
+ // the WHOLE book by /regenerate, so bypassing the backoff there restored
760
+ // the very dispatch/cancel churn D3 was built to remove (≈24 full attempts
761
+ // per scene per regeneration). The explicit intent (a regeneration, a
762
+ // cancel) clears the deferral at ITS call site — the backoff then applies
763
+ // again after the next capacity failure.
764
+ // 3. The deferral key encodes its components (audit 117 · M1): a book_id may
765
+ // contain ':' (the bundle book_id is client-controlled), so a raw
766
+ // ':'-joined key is ambiguous — `a:b` + chapter `c` collides with book
767
+ // `a` + chapter `b`.
768
+ const DEFERRED_STAGE_KEY_PREFIX = 'animastor:runtime:deferred:';
769
+ const DEFERRED_STAGE_TTL_S = 60; // capacity backoff window (bounded retry)
770
+ const DEFERRED_IDEMPOTENT_TTL_S = 900; // idempotent-suppression window (15 min)
771
+
772
+ /**
773
+ * Executor outcomes that mean "nothing could be enqueued — retry later".
774
+ *
775
+ * The taxonomy lives in ./dispatch-outcomes (shared with the stage
776
+ * executors). The generic 'no_jobs_sent' / 'no_jobs_generated' /
777
+ * 'send_failed' outcomes keep their historic contract (retry on the next
778
+ * tick — the audit-6929ba5 regression tests rely on it): deferring them
779
+ * would change the meaning of an executor result that does not necessarily
780
+ * mean "no capacity".
781
+ */
782
+ const {
783
+ CAPACITY_OUTCOMES,
784
+ IDEMPOTENT_OUTCOMES,
785
+ FENCE_REJECTED_OUTCOMES,
786
+ isCapacityOutcome,
787
+ isIdempotentOutcome,
788
+ isFenceRejectedOutcome,
789
+ isDeferrableOutcome,
790
+ } = require('./dispatch-outcomes');
791
+
792
+ /**
793
+ * Encode one key component so that ':' (and the glob metacharacters) can
794
+ * never be mistaken for the separator. encodeURIComponent escapes ':' as
795
+ * '%3A' and leaves '_' readable.
796
+ */
797
+ function encodeKeyComponent(value) {
798
+ return encodeURIComponent(String(value == null ? '' : value));
799
+ }
800
+
801
+ function deferredStageKey(bookId, chapterId, sceneId, stage) {
802
+ return `${DEFERRED_STAGE_KEY_PREFIX}`
803
+ + `${encodeKeyComponent(bookId)}:${encodeKeyComponent(chapterId)}`
804
+ + `:${encodeKeyComponent(sceneId)}:${encodeKeyComponent(stage)}`;
805
+ }
806
+
807
+ /** Is this stage currently deferred (backoff active)? */
808
+ async function isStageDeferred(redis, bookId, chapterId, sceneId, stage) {
809
+ try {
810
+ const value = await redis.get(deferredStageKey(bookId, chapterId, sceneId, stage));
811
+ return value != null;
812
+ } catch (_) {
813
+ return false;
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Defer a stage for the bounded backoff window.
819
+ * @param {number} [ttlSeconds] — the capacity window by default; the
820
+ * idempotent window for an 'already_completed' suppression.
821
+ */
822
+ async function deferStage(redis, bookId, chapterId, sceneId, stage, ttlSeconds = DEFERRED_STAGE_TTL_S) {
823
+ try {
824
+ await redis.set(deferredStageKey(bookId, chapterId, sceneId, stage), '1', 'EX', ttlSeconds);
825
+ } catch (_) { /* best effort — a missed deferral only costs one extra attempt */ }
826
+ }
827
+
828
+ /** Clear the deferral (the stage dispatched, completed or was cancelled). */
829
+ async function clearStageDeferral(redis, bookId, chapterId, sceneId, stage) {
830
+ try {
831
+ await redis.del(deferredStageKey(bookId, chapterId, sceneId, stage));
832
+ } catch (_) { /* best effort */ }
833
+ }
834
+
835
+ /**
836
+ * Clear the capacity backoff of EVERY stage of a book.
837
+ *
838
+ * Stage 118 (audit 117 · M2/B4): the explicit intents — a cancel, a cache
839
+ * teardown, a deletion — ask for the work to run again, so the backoff must
840
+ * not survive them. The deferral key is owned by this package, so the
841
+ * package clears it (the host only injects the port).
842
+ *
843
+ * @returns {Promise<number>} the number of cleared deferrals.
844
+ */
845
+ async function clearDeferralsForBook(redis, bookId) {
846
+ if (!redis || !bookId) return 0;
847
+ return clearDeferralsByPrefix(redis, `${DEFERRED_STAGE_KEY_PREFIX}${encodeKeyComponent(bookId)}:`);
848
+ }
849
+
850
+ /**
851
+ * Clear the capacity backoff of every stage of one scene (an explicit
852
+ * regeneration of that scene).
853
+ */
854
+ async function clearDeferralsForScene(redis, bookId, chapterId, sceneId) {
855
+ if (!redis || !bookId || !chapterId || !sceneId) return 0;
856
+ return clearDeferralsByPrefix(redis, `${DEFERRED_STAGE_KEY_PREFIX}`
857
+ + `${encodeKeyComponent(bookId)}:${encodeKeyComponent(chapterId)}:${encodeKeyComponent(sceneId)}:`);
858
+ }
859
+
860
+ async function clearDeferralsByPrefix(redis, prefix) {
861
+ let cleared = 0;
862
+ if (!redis || !prefix) return cleared;
863
+ let cursor = '0';
864
+ try {
865
+ do {
866
+ const [next, keys] = await redis.scan(cursor, 'MATCH', `${prefix}*`, 'COUNT', 200);
867
+ cursor = next;
868
+ for (const key of keys || []) {
869
+ if (!key.startsWith(prefix)) continue;
870
+ await redis.del(key);
871
+ cleared++;
872
+ }
873
+ } while (cursor !== '0');
874
+ } catch (_) { /* best effort */ }
875
+ return cleared;
876
+ }
877
+
878
+ /**
879
+ * The backoff window that fits the outcome: a capacity gap retries in
880
+ * minutes, an idempotent suppression (already complete) in quarters of an
881
+ * hour — it is not waiting for capacity, it is simply done.
882
+ */
883
+ function deferralWindowFor(reason) {
884
+ // Stage 129 (audit 128 · F5): a fence rejection is terminal for THIS
885
+ // generation — retrying it immediately would only recreate the
886
+ // dispatch → cancel → rollback churn. It shares the long window with the
887
+ // idempotent suppressions and stays visible through the log/metric.
888
+ return (isIdempotentOutcome(reason) || isFenceRejectedOutcome(reason))
889
+ ? DEFERRED_IDEMPOTENT_TTL_S
890
+ : DEFERRED_STAGE_TTL_S;
891
+ }
892
+
733
893
  async function dispatchStage(redis, bookId, chapterId, sceneId, stage, loadedBook, buildId, options = {}) {
734
894
  const { force = false } = options;
735
895
  const dispatchId = generateDispatchToken();
736
896
 
897
+ // ─────────────────────────────────────────────────────────────────────
898
+ // Stage 116 (D3) / Stage 118 (M2) — BOUNDED DEFERRAL (retry/backoff)
899
+ // ─────────────────────────────────────────────────────────────────────
900
+ // A stage that could not enqueue anything (no live compatible worker, or
901
+ // every job already pending in the lane) must NOT be retried on every
902
+ // scheduler tick: the tick is 5 s, so the un-deferred path produced an
903
+ // endless dispatch → cancel → FSM-rollback cycle (lease churn, FSM churn
904
+ // and UNBOUNDED event-journal growth).
905
+ //
906
+ // The deferral is checked BEFORE any side effect (no lease, no dispatch
907
+ // metadata, no FSM write, no journal event) ⇒ a deferred stage costs one
908
+ // Redis GET per tick. The stage stays PENDING and stays in the active
909
+ // index ⇒ it is recoverable: as soon as the deferral window expires (or a
910
+ // worker appears) the next tick dispatches it again.
911
+ //
912
+ // Stage 118 (M2): the check applies in FORCE mode as well. The
913
+ // force-dispatch flag is per BOOK and lives for 120 s, so bypassing the
914
+ // backoff there reproduced the very churn D3 removes (≈24 full attempts
915
+ // per scene per /regenerate). An EXPLICIT intent (a regeneration, a
916
+ // cancel) clears the deferral at its own call site.
917
+ if (await isStageDeferred(redis, bookId, chapterId, sceneId, stage)) {
918
+ return { dispatched: false, reason: 'deferred_capacity', retryable: true, deferred: true, dispatchId };
919
+ }
920
+
737
921
  log(`DISPATCH_REQUEST: ${bookId}/${chapterId}/${sceneId}:${stage}${force ? ' (force=true)' : ''}`);
738
922
 
739
923
  // Force mode pre-clear: cancel existing dispatch, release quota ТОЛЬКО при наличии lease
@@ -908,6 +1092,18 @@ async function dispatchStage(redis, bookId, chapterId, sceneId, stage, loadedBoo
908
1092
  if (!result.dispatched) {
909
1093
  log(`DISPATCH_SKIPPED: ${bookId}/${chapterId}/${sceneId}:${stage} - ${result.reason || 'already_done'}`);
910
1094
  if (!result.completed) {
1095
+ // Stage 116 (D3): a stage that enqueued NOTHING is not a
1096
+ // lost task and not a fatal cancellation — it is a bounded
1097
+ // deferral. Remember it for a bounded window so the
1098
+ // scheduler's 5 s tick does not spin.
1099
+ //
1100
+ // Stage 118 (M4): the window depends on the bucket — a
1101
+ // capacity gap retries within a minute, an idempotent
1102
+ // suppression ('already_completed') is NOT waiting for
1103
+ // capacity and gets the long window.
1104
+ if (isDeferrableOutcome(result.reason)) {
1105
+ await deferStage(redis, bookId, chapterId, sceneId, stage, deferralWindowFor(result.reason));
1106
+ }
911
1107
  await finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
912
1108
  outcome: 'cancelled',
913
1109
  dispatchId,
@@ -918,13 +1114,29 @@ async function dispatchStage(redis, bookId, chapterId, sceneId, stage, loadedBoo
918
1114
  if (result.rollbackFailed) {
919
1115
  error(`STATE_ROLLBACK_FAILED: ${bookId}/${chapterId}/${sceneId}:${stage}: ${result.rollbackReason || 'unknown'} — stage may be stuck in GENERATING`);
920
1116
  }
1117
+ } else {
1118
+ // The stage completed (cache hit / already done) — it is no
1119
+ // longer waiting for capacity.
1120
+ await clearStageDeferral(redis, bookId, chapterId, sceneId, stage);
921
1121
  }
922
1122
  // Cancelled finalize does not touch the circuit breaker — release
923
1123
  // the test permit here so a half-open circuit can keep recovering.
924
1124
  await releaseTestPermit();
925
- return { dispatched: false, dispatchId, reason: result.reason || 'already_done', result };
1125
+ return {
1126
+ dispatched: false,
1127
+ dispatchId,
1128
+ reason: result.reason || 'already_done',
1129
+ retryable: isDeferrableOutcome(result.reason),
1130
+ // Stage 118 (M9): the caller (scheduler) must be able to
1131
+ // tell "nothing happened, try later" from a real error.
1132
+ deferred: isDeferrableOutcome(result.reason),
1133
+ result,
1134
+ };
926
1135
  }
927
1136
 
1137
+ // The stage really dispatched — capacity deferral is over.
1138
+ await clearStageDeferral(redis, bookId, chapterId, sceneId, stage);
1139
+
928
1140
  // T6: Start lease renewal for this actively dispatched job
929
1141
  // Only started after confirming dispatcher returned dispatched:true
930
1142
  // (not for cache hits, backpressure, or empty executor results)
@@ -1296,6 +1508,9 @@ async function markDispatchFailed(redis, bookId, chapterId, sceneId, stage, erro
1296
1508
  * T6: Останавливает in-memory renewal timer, чтобы избежать утечки.
1297
1509
  */
1298
1510
  async function cancelActiveDispatch(redis, bookId, chapterId, sceneId, stage, reason = 'cancelled') {
1511
+ // Stage 116 (D3): an explicit cancel means the work is wanted again — a
1512
+ // capacity deferral must not survive it.
1513
+ await clearStageDeferral(redis, bookId, chapterId, sceneId, stage);
1299
1514
  const metadata = await getDispatchMetadata(redis, bookId, chapterId, sceneId, stage);
1300
1515
  if (metadata?.dispatch_id) {
1301
1516
  const result = await finalizeDispatch(redis, bookId, chapterId, sceneId, stage, {
@@ -1383,9 +1598,33 @@ async function clearLeasesForScenes(redis, bookId, scenes) {
1383
1598
  }
1384
1599
 
1385
1600
  /**
1386
- * Remove specific cancelled dispatches from GPU Hub.
1601
+ * Remove cancelled dispatches from GPU Hub.
1387
1602
  * Kept here with dispatch cancellation so every caller uses the same auth and
1388
1603
  * error-handling contract.
1604
+ *
1605
+ * Stage 122 (N1): the hub's `DELETE /queue/clear` accepts EITHER a
1606
+ * `dispatch_id` (per-job) OR a `book_id` (whole-book lane sweep). Before this
1607
+ * stage the function only honoured the per-id form; a caller that passed an
1608
+ * EMPTY id list together with `book_id` (the regenerate post-release sweep,
1609
+ * audit 121 · N1) fell straight through the id loop and issued ZERO requests —
1610
+ * a silent NO-OP that left orphan lane entries behind while looking like a
1611
+ * successful sweep.
1612
+ *
1613
+ * The contract now is:
1614
+ * - ids present → one `?dispatch_id=` DELETE per id (unchanged);
1615
+ * - no ids + book_id → exactly ONE `?book_id=` DELETE (the whole-book sweep
1616
+ * the hub already supports and cache/delete paths use);
1617
+ * - neither → NO request at all (never a fake HTTP call).
1618
+ *
1619
+ * A book-scoped sweep is checked against the hub's echoed `scope.book_id`:
1620
+ * if the hub answers `ok` but echoes a DIFFERENT scope (or none at all while
1621
+ * reporting a full wipe) it did not honour our ownership and other books may
1622
+ * have been touched — that is counted as `failed` and warned, never as a
1623
+ * success. Failures never throw (best-effort contract, callers keep claims).
1624
+ *
1625
+ * @returns {Promise<{requested:number, cleared:number, failed:number}>}
1626
+ * `requested` = number of hub requests actually attempted (1 for a
1627
+ * book-scoped sweep, ids.length for per-id, 0 when there is nothing to do).
1389
1628
  */
1390
1629
  async function clearHubDispatches(dispatchIds, options = {}) {
1391
1630
  const ids = [...new Set((dispatchIds || []).filter(Boolean))];
@@ -1396,16 +1635,59 @@ async function clearHubDispatches(dispatchIds, options = {}) {
1396
1635
  const context = options.context || 'DISPATCH';
1397
1636
  const reportWarning = typeof options.warn === 'function' ? options.warn : warn;
1398
1637
  const fetchImpl = options.fetchImpl || globalThis.fetch;
1638
+ const bookId = options.book_id || options.bookId || null;
1399
1639
  let cleared = 0;
1400
1640
  let failed = 0;
1401
1641
 
1642
+ const authHeaders = () => {
1643
+ const headers = {};
1644
+ if (apiKey) headers['x-api-key'] = apiKey;
1645
+ return headers;
1646
+ };
1647
+
1648
+ // ── Book-scoped sweep (no explicit dispatch ids) ──────────────────────
1649
+ // Exactly one request; the hub filters by record.book_id so only this
1650
+ // book's queue/running/result entries are removed.
1651
+ if (ids.length === 0 && bookId) {
1652
+ try {
1653
+ const response = await fetchImpl(
1654
+ `${hubUrl}/queue/clear?book_id=${encodeURIComponent(bookId)}`,
1655
+ { method: 'DELETE', headers: authHeaders() }
1656
+ );
1657
+ if (!response.ok) {
1658
+ failed++;
1659
+ reportWarning(`${context}: GPU hub book cleanup returned ${response.status} for ${bookId}`);
1660
+ } else {
1661
+ // Verify the hub honoured OUR scope. A mismatched echo means it
1662
+ // may have swept a different book (or everything) — that is a
1663
+ // failure, not a success we can report.
1664
+ let enforcedScope = true;
1665
+ try {
1666
+ const body = typeof response.json === 'function' ? await response.json() : null;
1667
+ if (body && body.scope && body.scope.book_id != null && body.scope.book_id !== bookId) {
1668
+ enforcedScope = false;
1669
+ }
1670
+ } catch (_) { /* unparseable body — trust the ok status */ }
1671
+ if (!enforcedScope) {
1672
+ failed++;
1673
+ reportWarning(`${context}: GPU hub book cleanup echoed a foreign scope for ${bookId} — treated as failed`);
1674
+ } else {
1675
+ cleared++;
1676
+ }
1677
+ }
1678
+ } catch (err) {
1679
+ failed++;
1680
+ reportWarning(`${context}: GPU hub book cleanup failed for ${bookId}: ${err.message}`);
1681
+ }
1682
+ return { requested: 1, cleared, failed };
1683
+ }
1684
+
1685
+ // ── Per-dispatch cleanup (unchanged) ─────────────────────────────────
1402
1686
  for (const dispatchId of ids) {
1403
1687
  try {
1404
- const headers = {};
1405
- if (apiKey) headers['x-api-key'] = apiKey;
1406
1688
  const response = await fetchImpl(
1407
1689
  `${hubUrl}/queue/clear?dispatch_id=${encodeURIComponent(dispatchId)}`,
1408
- { method: 'DELETE', headers }
1690
+ { method: 'DELETE', headers: authHeaders() }
1409
1691
  );
1410
1692
  if (!response.ok) {
1411
1693
  failed++;
@@ -1781,6 +2063,29 @@ module.exports = {
1781
2063
  LEASE_TTLS,
1782
2064
  QUOTAS,
1783
2065
 
2066
+ // Stage 116 (D3) / Stage 118 (M2/M4): bounded deferral (retry/backoff) —
2067
+ // the stage is not retried on every 5 s scheduler tick while it cannot
2068
+ // enqueue. Stage 118: the capacity and the idempotent bucket have their
2069
+ // own windows, and the taxonomy is shared with the stage executors.
2070
+ DEFERRED_STAGE_KEY_PREFIX,
2071
+ DEFERRED_STAGE_TTL_S,
2072
+ DEFERRED_IDEMPOTENT_TTL_S,
2073
+ deferredStageKey,
2074
+ isStageDeferred,
2075
+ deferStage,
2076
+ clearStageDeferral,
2077
+ clearDeferralsForBook,
2078
+ clearDeferralsForScene,
2079
+ deferralWindowFor,
2080
+ encodeKeyComponent,
2081
+ isDeferrableOutcome,
2082
+ isCapacityOutcome,
2083
+ isIdempotentOutcome,
2084
+ isFenceRejectedOutcome,
2085
+ CAPACITY_OUTCOMES,
2086
+ IDEMPOTENT_OUTCOMES,
2087
+ FENCE_REJECTED_OUTCOMES,
2088
+
1784
2089
  // PW-2: dispatch identity generation (crypto-hardened)
1785
2090
  generateDispatchToken
1786
2091
  };