@animastor/orchestration 0.1.5 → 0.1.7

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.
@@ -0,0 +1,103 @@
1
+ // ======================================================
2
+ // STAGE 146 — D1 REGRESSION: artifactNaming must be visible to resetScenes
3
+ // ======================================================
4
+ // D1: `const artifactNaming = require('@animastor/generation').artifactNaming;`
5
+ // used to live INSIDE beginStage(). resetScenes() also reads it (step 6 — the
6
+ // stale-PNG/preview pre-delete branch, formerly lines 534/537/555), so on the
7
+ // pre-fix code that branch threw `ReferenceError: artifactNaming is not
8
+ // defined` and /regenerate returned HTTP 500.
9
+ //
10
+ // This test exercises EXACTLY that branch on the fresh package sources:
11
+ // • resetScenes() with a dirty unit and cleanPngUnitIds reaches step 6;
12
+ // • it must NOT throw;
13
+ // • it must delete the old PNG AND the old preview for that unit.
14
+ //
15
+ // The test FAILS on the pre-fix code (the ReferenceError escapes resetScenes —
16
+ // step 6 is not wrapped in try/catch) and PASSES once artifactNaming is hoisted
17
+ // to module scope.
18
+ // ======================================================
19
+
20
+ const { expect } = require('chai');
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+ const { bindHostModules } = require('../src/host/host-bindings');
25
+ const { setHubCancelPort, _resetHubCancelPort } = require('../src/runtime/hub-cancel-port');
26
+ const { createMockRedis } = require('./mocks/redis-mock');
27
+ const artifactNaming = require('@animastor/generation').artifactNaming;
28
+
29
+ const orchestrator = require('../src/orchestration/orchestrator');
30
+
31
+ const BOOK = 'b1';
32
+ const CHAPTER = 'ch1';
33
+ const SCENE = 'sc1';
34
+ const BUILD = 'build1';
35
+ const UID = 'iu_001';
36
+
37
+ describe('Stage 146 — D1: resetScenes reads artifactNaming at module scope', function () {
38
+ this.timeout(10000);
39
+
40
+ let tmpRoot;
41
+ let redis;
42
+
43
+ before(() => {
44
+ tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'stage146-d1-'));
45
+ redis = createMockRedis();
46
+ // resetScenes reaches the PNG-delete branch through these two seams.
47
+ bindHostModules({
48
+ config: () => ({
49
+ OUTPUT_DIR: tmpRoot,
50
+ TIMEOUTS: { FORCE_DISPATCH_TTL_S: 3600 },
51
+ }),
52
+ journal: () => ({
53
+ EventType: { SCENE_RESET: 'SCENE_RESET', SCENE_RESET_COMPLETED: 'SCENE_RESET_COMPLETED' },
54
+ appendSceneEvent: async () => {},
55
+ }),
56
+ });
57
+ setHubCancelPort({ clearHubDispatches: async () => ({ requested: 0, cleared: 0, failed: 0 }) });
58
+ });
59
+
60
+ after(() => {
61
+ _resetHubCancelPort();
62
+ // Restore the fail-fast default from test/test-bindings.js.
63
+ bindHostModules({
64
+ config: () => { throw new Error('[D1-TEST] config unwired'); },
65
+ journal: () => { throw new Error('[D1-TEST] journal unwired'); },
66
+ });
67
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
68
+ });
69
+
70
+ it('removes the stale PNG and preview of a dirty unit without throwing', async () => {
71
+ const buildDir = path.join(tmpRoot, BUILD);
72
+ fs.mkdirSync(buildDir, { recursive: true });
73
+
74
+ const pngPath = path.join(buildDir, artifactNaming.sceneImageName(BOOK, CHAPTER, SCENE, UID));
75
+ const previewPath = path.join(buildDir, artifactNaming.sceneImagePreviewName(BOOK, CHAPTER, SCENE, UID));
76
+ fs.writeFileSync(pngPath, 'png-bytes');
77
+ fs.writeFileSync(previewPath, 'preview-bytes');
78
+ expect(fs.existsSync(pngPath), 'fixture PNG must exist').to.equal(true);
79
+ expect(fs.existsSync(previewPath), 'fixture preview must exist').to.equal(true);
80
+
81
+ const scenes = [{ chapter_id: CHAPTER, scene_id: SCENE, dirty_layers: ['image'] }];
82
+ const layerCfg = { image_enabled: true };
83
+ const bookDiff = { markDirtyScenes: async () => ({ marked: 1 }) };
84
+
85
+ const res = await orchestrator.resetScenes(
86
+ redis, BOOK, BUILD, scenes, layerCfg,
87
+ {
88
+ scope: 'SCENE',
89
+ chapterId: CHAPTER,
90
+ sceneId: SCENE,
91
+ bookDiff,
92
+ cleanPngUnitIds: { [`${CHAPTER}_${SCENE}`]: [UID] },
93
+ }
94
+ );
95
+
96
+ expect(res.reset_scenes).to.equal(1);
97
+ expect(res.marked).to.equal(1);
98
+ // The pre-fix bug threw before this point, so these two assertions are
99
+ // what the regression pins down.
100
+ expect(fs.existsSync(pngPath), 'old PNG must be deleted').to.equal(false);
101
+ expect(fs.existsSync(previewPath), 'old preview must be deleted').to.equal(false);
102
+ });
103
+ });
@@ -0,0 +1,211 @@
1
+ // ======================================================
2
+ // STAGE 147 — A1 REGRESSION: version-gate must see the build_id IS NULL row
3
+ // ======================================================
4
+ // A1 (stage146 §5.4): the key's only scene_assets row had build_id = NULL.
5
+ // completeStage read the asset via sceneAssets.getAsset(..., buildId) —
6
+ // which filters `build_id = $5` — so the row was invisible; markReady, the
7
+ // ONLY writer able to stamp build_id (buildIdFromArtifactPath), runs BEHIND
8
+ // the gate. Result: endless DIRTY + re-dispatch every 5 s scheduler tick
9
+ // (cache_hit → callback → "stale" → DIRTY).
10
+ //
11
+ // The fix (orchestrator.completeStage): when the build-scoped read misses,
12
+ // perform exactly ONE additional read of the SAME key with build_id IS NULL
13
+ // (port op sceneAssets.getAssetNullBuild — never another build's row, never
14
+ // "latest any row"), apply the SAME version rules, then let markReady stamp
15
+ // build_id as before.
16
+ //
17
+ // On the PRE-FIX code tests 1 and 2 FAIL (gate stays DIRTY / fallback op
18
+ // never consulted); they PASS with the fix. Fail-closed on PG errors is
19
+ // preserved for both reads.
20
+ // ======================================================
21
+
22
+ const { expect } = require('chai');
23
+ const { bindHostModules } = require('../src/host/host-bindings');
24
+ const {
25
+ OPS, setPersistencePort, _resetPersistencePort,
26
+ } = require('../src/runtime/persistence-port');
27
+ const { createMockRedis } = require('./mocks/redis-mock');
28
+
29
+ const ORCH = require.resolve('../src/orchestration/orchestrator');
30
+ const CALLBACKS = require.resolve('../src/orchestration/scene-callbacks');
31
+ const DISPATCH = require.resolve('../src/runtime/dispatch-engine');
32
+
33
+ const BOOK = 'book-a1';
34
+ const CHAPTER = 'ch-a1';
35
+ const SCENE = 'sc-a1';
36
+ const BUILD = 'build_a1';
37
+ const ARTIFACT = `/data/output/${BUILD}/scene_a1.png`;
38
+ const SCENE_VERSIONS = [{ content_version: 5, audio_config_version: 5 }];
39
+
40
+ // Port adapter built from the declared OPS (contract completeness is
41
+ // asserted implicitly: setPersistencePort fails fast on any missing op).
42
+ function makeAdapter(overrides = {}) {
43
+ const adapter = {};
44
+ for (const op of OPS) {
45
+ const noop = async () => null;
46
+ const [ns, fn] = op.includes('.') ? op.split('.') : [null, op];
47
+ if (ns === null) adapter[fn] = overrides[op] || noop;
48
+ else {
49
+ adapter[ns] = adapter[ns] || {};
50
+ adapter[ns][fn] = (overrides[op]) || noop;
51
+ }
52
+ }
53
+ return adapter;
54
+ }
55
+
56
+ describe('Stage 147 — A1: version-gate fallback на строку build_id IS NULL (пакет)', function () {
57
+ this.timeout(10000);
58
+
59
+ let redis;
60
+ let calls;
61
+ let savedCache;
62
+
63
+ function stubModule(resolvedPath, exports) {
64
+ savedCache.set(resolvedPath, require.cache[resolvedPath]);
65
+ require.cache[resolvedPath] = { exports, loaded: true };
66
+ }
67
+
68
+ beforeEach(() => {
69
+ redis = createMockRedis();
70
+ calls = { markReady: [], nullBuildReads: [] };
71
+ savedCache = new Map();
72
+ for (const p of [CALLBACKS, DISPATCH]) {
73
+ savedCache.set(p, require.cache[p]);
74
+ delete require.cache[p];
75
+ }
76
+
77
+ // Host seams the gate path touches (state restore after READY).
78
+ bindHostModules({
79
+ config: () => ({ OUTPUT_DIR: '/data/output' }),
80
+ state: () => ({
81
+ AssetState: {
82
+ NEW: 'new', DIRTY: 'dirty', PENDING: 'pending',
83
+ GENERATING: 'generating', READY: 'ready', FAILED: 'failed',
84
+ },
85
+ unsafeRestoreAssetState: async (r, b, c, s, asset, status) => {
86
+ calls.stateWrite = { asset, status };
87
+ },
88
+ }),
89
+ journal: () => ({ EventType: {}, appendSceneEvent: async () => {} }),
90
+ });
91
+
92
+ stubModule(CALLBACKS, {
93
+ STAGE_HANDLERS: {
94
+ image: async () => ({ ok: true, artifact: { buildId: BUILD, path: ARTIFACT } }),
95
+ },
96
+ });
97
+ stubModule(DISPATCH, {
98
+ verifyDispatchIdentity: async () => ({ valid: true, metadata: {} }),
99
+ finalizeDispatch: async (r, b, c, s, stage, opts) => {
100
+ calls.finalize = { stage, opts };
101
+ return { finalized: true };
102
+ },
103
+ });
104
+
105
+ setPersistencePort(makeAdapter({
106
+ 'sceneVersions.getSceneVersions': async () => SCENE_VERSIONS,
107
+ 'sceneAssets.getAsset': async () => (calls.buildRow === undefined ? null : calls.buildRow),
108
+ 'sceneAssets.getAssetNullBuild': async (...args) => {
109
+ calls.nullBuildReads.push(args);
110
+ if (calls.nullRowError) throw calls.nullRowError;
111
+ return calls.nullRow || null;
112
+ },
113
+ 'sceneAssets.markReady': async (...args) => {
114
+ calls.markReady.push(args);
115
+ return { status: 'ready' };
116
+ },
117
+ }));
118
+ });
119
+
120
+ afterEach(() => {
121
+ for (const [p, entry] of savedCache) {
122
+ if (entry) require.cache[p] = entry;
123
+ else delete require.cache[p];
124
+ }
125
+ _resetPersistencePort();
126
+ calls = {};
127
+ // Возврат fail-fast дефолтов из test/test-bindings.js
128
+ bindHostModules({
129
+ journal: () => {
130
+ throw new Error('[ORCH-TEST-BINDINGS] host journal not wired — wire it in the test that asserts journal behavior');
131
+ },
132
+ });
133
+ });
134
+
135
+ async function runGate(stage = 'image') {
136
+ const orchestrator = require(ORCH);
137
+ return orchestrator.completeStage(redis, BOOK, CHAPTER, SCENE, stage, BUILD, 'dispatch-a1');
138
+ }
139
+
140
+ it('1. NULL-строка с подтверждённой версией → READY с одного вызова, markReady вызван', async () => {
141
+ calls.buildRow = null; // getAsset(buildId) промахнулся
142
+ calls.nullRow = { build_id: null, scene_content_version: 5, scene_audio_config_version: 5 };
143
+
144
+ const result = await runGate();
145
+
146
+ expect(result.completed, 'fallback-чтение должно пропустить READY').to.be.true;
147
+ expect(calls.markReady).to.have.length(1);
148
+ expect(calls.markReady[0].slice(0, 5)).to.deep.equal([BOOK, CHAPTER, SCENE, 'image', ARTIFACT]);
149
+ expect(calls.nullBuildReads, 'дополнительное чтение — ровно одно').to.have.length(1);
150
+ expect(calls.nullBuildReads[0]).to.deep.equal([BOOK, CHAPTER, SCENE, 'image']);
151
+ expect(calls.stateWrite).to.deep.equal({ asset: 'image', status: 'ready' });
152
+ expect(calls.finalize.opts.outcome).to.equal('success');
153
+ });
154
+
155
+ it('2. NULL-строка с устаревшей версией (2 < 5) → не READY', async () => {
156
+ calls.buildRow = null;
157
+ calls.nullRow = { build_id: null, scene_content_version: 2, scene_audio_config_version: 2 };
158
+
159
+ const result = await runGate();
160
+
161
+ expect(result.completed).to.be.false;
162
+ expect(result.reason).to.equal('stale');
163
+ expect(calls.markReady).to.have.length(0);
164
+ expect(calls.stateWrite).to.deep.equal({ asset: 'image', status: 'dirty' });
165
+ });
166
+
167
+ it('3. строки этого билда нет, NULL-строки нет (только чужой билд) → не READY', async () => {
168
+ calls.buildRow = null;
169
+ calls.nullRow = null;
170
+
171
+ const result = await runGate();
172
+
173
+ expect(result.completed).to.be.false;
174
+ expect(result.reason).to.equal('stale');
175
+ expect(calls.markReady).to.have.length(0);
176
+ expect(calls.nullBuildReads).to.have.length(1);
177
+ });
178
+
179
+ it('4. строка этого билда найдена → fallback не выполняется', async () => {
180
+ calls.buildRow = { build_id: BUILD, scene_content_version: 5, scene_audio_config_version: 5 };
181
+ calls.nullRow = { build_id: null, scene_content_version: 5, scene_audio_config_version: 5 };
182
+
183
+ const result = await runGate();
184
+
185
+ expect(result.completed).to.be.true;
186
+ expect(calls.nullBuildReads).to.have.length(0);
187
+ expect(calls.markReady).to.have.length(1);
188
+ });
189
+
190
+ it('5. ошибка PG при основном чтении → fail-closed', async () => {
191
+ const adapter = require('../src/runtime/persistence-port').persistence();
192
+ adapter.sceneAssets.getAsset = async () => { throw new Error('pg down'); };
193
+ calls.nullRow = { build_id: null, scene_content_version: 5, scene_audio_config_version: 5 };
194
+
195
+ const result = await runGate();
196
+
197
+ expect(result.completed).to.be.false;
198
+ expect(calls.markReady).to.have.length(0);
199
+ });
200
+
201
+ it('6. ошибка PG при fallback-чтении → fail-closed', async () => {
202
+ calls.buildRow = null;
203
+ calls.nullRowError = new Error('pg down (null-build read)');
204
+
205
+ const result = await runGate();
206
+
207
+ expect(result.completed).to.be.false;
208
+ expect(result.reason).to.equal('stale');
209
+ expect(calls.markReady).to.have.length(0);
210
+ });
211
+ });
@@ -1,124 +0,0 @@
1
- // ======================================================
2
- // DISPATCH OUTCOMES — the shared taxonomy of "nothing was enqueued"
3
- //
4
- // Stage 118 (audit 117 · D3/M4/M5): the classification of an executor
5
- // outcome lives in ONE module so the dispatch engine (runtime tier) and the
6
- // stage executors (orchestration tier) cannot drift apart.
7
- //
8
- // Buckets
9
- // ───────
10
- // • capacity — the GPU capacity is not there right now:
11
- // 'no_worker_available' | 'duplicate_pending' | 'ambiguous_post'
12
- // ⇒ a BOUNDED retry (deferral window) is the right response.
13
- //
14
- // • idempotent — the logical task is ALREADY COMPLETE (the completion marker
15
- // exists): 'already_completed'.
16
- // This is NOT a capacity problem and MUST NOT be classified as
17
- // one (audit 117 · M4): mixing it into the capacity bucket
18
- // turned "this task will never run" into an endless retry
19
- // loop. It gets its own, much longer backoff window and its
20
- // own reporting class.
21
- //
22
- // The host side (@animastor/generation → backend/src/helpers/queue-idempotency)
23
- // mirrors these sets for the transport-level reasons it produces; the two
24
- // lists must stay in sync (see queue-idempotency.cjs · CAPACITY_REASONS).
25
- // ======================================================
26
-
27
- const CAPACITY_OUTCOMES = new Set([
28
- 'no_worker_available', // no live, type-compatible worker
29
- 'duplicate_pending', // the job is already pending in the lane
30
- 'ambiguous_post', // the POST outcome was ambiguous (the hub may have taken the task)
31
- // Stage 129: the intent-fence step could not run (Redis refused the atomic
32
- // path / the client cannot run it). Nothing was enqueued and the send IS
33
- // retryable, so it belongs to the bounded-retry bucket.
34
- 'intent_receipt_unavailable',
35
- ]);
36
-
37
- const IDEMPOTENT_OUTCOMES = new Set([
38
- 'already_completed', // the logical task is already complete (artifact missing)
39
- ]);
40
-
41
- /**
42
- * Fence rejections (Stage 129, audit 128 · F5): the backend refused this
43
- * dispatch because it belongs to a generation the intent record already
44
- * superseded, or because the dispatch/intent pair is inconsistent.
45
- *
46
- * Retrying the SAME generation can never succeed, so they are NOT retryable —
47
- * but they must also not fall into the historic "immediate retry on the next
48
- * tick" bucket, which would turn a stale send into a 5-second dispatch →
49
- * cancel → rollback loop. They get the long (idempotent) deferral window and
50
- * stay visible in the logs/metrics.
51
- */
52
- const FENCE_REJECTED_OUTCOMES = new Set([
53
- 'stale_intent',
54
- 'receipt_conflict',
55
- 'intent_not_active',
56
- 'intent_corrupt',
57
- 'intent_missing',
58
- ]);
59
-
60
- /** Is this outcome a CAPACITY gap (bounded retry with a short window)? */
61
- function isCapacityOutcome(reason) {
62
- return CAPACITY_OUTCOMES.has(String(reason || ''));
63
- }
64
-
65
- /** Is this outcome an idempotent suppression (the task is already done)? */
66
- function isIdempotentOutcome(reason) {
67
- return IDEMPOTENT_OUTCOMES.has(String(reason || ''));
68
- }
69
-
70
- /** Is this outcome a fence rejection (terminal for this generation)? */
71
- function isFenceRejectedOutcome(reason) {
72
- return FENCE_REJECTED_OUTCOMES.has(String(reason || ''));
73
- }
74
-
75
- /** Is this outcome deferrable at all (capacity, idempotent OR fence-rejected)? */
76
- function isDeferrableOutcome(reason) {
77
- return isCapacityOutcome(reason) || isIdempotentOutcome(reason) || isFenceRejectedOutcome(reason);
78
- }
79
-
80
- /**
81
- * Classify the reasons why NOTHING was enqueued for a stage.
82
- *
83
- * Stage 118 (audit 117 · M3): the Stage 116 implementation required EXACTLY
84
- * ONE distinct reason (`notSentReasons.size === 1`). A scene whose units
85
- * failed for two DIFFERENT deferrable reasons (e.g. one unit gated by
86
- * "no live worker" and another suppressed as "duplicate pending") was
87
- * therefore NOT deferred and kept the 5-second dispatch/cancel churn.
88
- *
89
- * The rule now: EVERY reason must be deferrable; the most frequent one is
90
- * reported. A single non-deferrable reason (a real failure) keeps the
91
- * historic semantics (an immediate retry on the next tick) — the
92
- * audit-6929ba5 contract depends on it.
93
- *
94
- * @param {Map<string, number>|Object<string, number>} reasons
95
- * @returns {string|null} the dominant deferrable reason, or null.
96
- */
97
- function classifyNotSentReasons(reasons) {
98
- const entries = reasons instanceof Map
99
- ? [...reasons.entries()]
100
- : Object.entries(reasons || {});
101
- if (entries.length === 0) return null;
102
- if (!entries.every(([reason]) => isDeferrableOutcome(reason))) return null;
103
- let dominant = null;
104
- let dominantCount = -1;
105
- for (const [reason, count] of entries) {
106
- const n = Number(count) || 0;
107
- if (n > dominantCount) {
108
- dominant = reason;
109
- dominantCount = n;
110
- }
111
- }
112
- return dominant;
113
- }
114
-
115
- module.exports = {
116
- CAPACITY_OUTCOMES,
117
- IDEMPOTENT_OUTCOMES,
118
- FENCE_REJECTED_OUTCOMES,
119
- isCapacityOutcome,
120
- isIdempotentOutcome,
121
- isFenceRejectedOutcome,
122
- isDeferrableOutcome,
123
- classifyNotSentReasons,
124
- };