@victframework/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-governance.d.ts +285 -0
- package/dist/agent-governance.js +438 -0
- package/dist/agent-governance.js.map +1 -0
- package/dist/agent-registry.d.ts +103 -0
- package/dist/agent-registry.js +758 -0
- package/dist/agent-registry.js.map +1 -0
- package/dist/agent-types.d.ts +433 -0
- package/dist/agent-types.js +480 -0
- package/dist/agent-types.js.map +1 -0
- package/dist/authority.d.ts +83 -0
- package/dist/authority.js +170 -0
- package/dist/authority.js.map +1 -0
- package/dist/boundary-conformance.d.ts +51 -0
- package/dist/boundary-conformance.js +364 -0
- package/dist/boundary-conformance.js.map +1 -0
- package/dist/control-conformance.d.ts +68 -0
- package/dist/control-conformance.js +1131 -0
- package/dist/control-conformance.js.map +1 -0
- package/dist/control-in-memory.d.ts +257 -0
- package/dist/control-in-memory.js +886 -0
- package/dist/control-in-memory.js.map +1 -0
- package/dist/control-types.d.ts +1017 -0
- package/dist/control-types.js +779 -0
- package/dist/control-types.js.map +1 -0
- package/dist/durable-run.d.ts +73 -0
- package/dist/durable-run.js +262 -0
- package/dist/durable-run.js.map +1 -0
- package/dist/effect-policy.d.ts +22 -0
- package/dist/effect-policy.js +45 -0
- package/dist/effect-policy.js.map +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +71 -0
- package/dist/errors.js.map +1 -0
- package/dist/in-memory-stores.d.ts +16 -0
- package/dist/in-memory-stores.js +522 -0
- package/dist/in-memory-stores.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/operator-config.d.ts +141 -0
- package/dist/operator-config.js +289 -0
- package/dist/operator-config.js.map +1 -0
- package/dist/orchestration-activation.d.ts +116 -0
- package/dist/orchestration-activation.js +223 -0
- package/dist/orchestration-activation.js.map +1 -0
- package/dist/orchestration-canary-conformance.d.ts +10 -0
- package/dist/orchestration-canary-conformance.js +726 -0
- package/dist/orchestration-canary-conformance.js.map +1 -0
- package/dist/orchestration-commands.d.ts +47 -0
- package/dist/orchestration-commands.js +538 -0
- package/dist/orchestration-commands.js.map +1 -0
- package/dist/orchestration-conformance.d.ts +59 -0
- package/dist/orchestration-conformance.js +594 -0
- package/dist/orchestration-conformance.js.map +1 -0
- package/dist/orchestration-driver-types.d.ts +163 -0
- package/dist/orchestration-driver-types.js +21 -0
- package/dist/orchestration-driver-types.js.map +1 -0
- package/dist/orchestration-driver.d.ts +48 -0
- package/dist/orchestration-driver.js +1030 -0
- package/dist/orchestration-driver.js.map +1 -0
- package/dist/orchestration-in-memory.d.ts +18 -0
- package/dist/orchestration-in-memory.js +1313 -0
- package/dist/orchestration-in-memory.js.map +1 -0
- package/dist/orchestration-join-conformance.d.ts +20 -0
- package/dist/orchestration-join-conformance.js +542 -0
- package/dist/orchestration-join-conformance.js.map +1 -0
- package/dist/orchestration-plan.d.ts +52 -0
- package/dist/orchestration-plan.js +265 -0
- package/dist/orchestration-plan.js.map +1 -0
- package/dist/orchestration-race-conformance.d.ts +35 -0
- package/dist/orchestration-race-conformance.js +1265 -0
- package/dist/orchestration-race-conformance.js.map +1 -0
- package/dist/orchestration-remediation-conformance.d.ts +54 -0
- package/dist/orchestration-remediation-conformance.js +632 -0
- package/dist/orchestration-remediation-conformance.js.map +1 -0
- package/dist/orchestration-store-types.d.ts +568 -0
- package/dist/orchestration-store-types.js +2 -0
- package/dist/orchestration-store-types.js.map +1 -0
- package/dist/pack-conformance.d.ts +59 -0
- package/dist/pack-conformance.js +222 -0
- package/dist/pack-conformance.js.map +1 -0
- package/dist/pack-install.d.ts +44 -0
- package/dist/pack-install.js +126 -0
- package/dist/pack-install.js.map +1 -0
- package/dist/registry.d.ts +107 -0
- package/dist/registry.js +481 -0
- package/dist/registry.js.map +1 -0
- package/dist/runtime.d.ts +158 -0
- package/dist/runtime.js +1341 -0
- package/dist/runtime.js.map +1 -0
- package/dist/serialization.d.ts +26 -0
- package/dist/serialization.js +162 -0
- package/dist/serialization.js.map +1 -0
- package/dist/store-conformance.d.ts +45 -0
- package/dist/store-conformance.js +876 -0
- package/dist/store-conformance.js.map +1 -0
- package/dist/store-errors.d.ts +49 -0
- package/dist/store-errors.js +48 -0
- package/dist/store-errors.js.map +1 -0
- package/dist/store-types.d.ts +331 -0
- package/dist/store-types.js +14 -0
- package/dist/store-types.js.map +1 -0
- package/dist/store-validation.d.ts +63 -0
- package/dist/store-validation.js +164 -0
- package/dist/store-validation.js.map +1 -0
- package/dist/stream-hub.d.ts +151 -0
- package/dist/stream-hub.js +402 -0
- package/dist/stream-hub.js.map +1 -0
- package/dist/testing.d.ts +25 -0
- package/dist/testing.js +19 -0
- package/dist/testing.js.map +1 -0
- package/dist/types.d.ts +221 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
import { neutralJsonContract } from '@victframework/sdk';
|
|
2
|
+
import { signalWaitGraph, stringContract } from './orchestration-conformance.js';
|
|
3
|
+
/** Deterministic store-level planner for raw claim tests. */
|
|
4
|
+
function rawPlanner(effectClass = 'pure') {
|
|
5
|
+
return {
|
|
6
|
+
invocationIdFor: (token) => `inv_${token.tokenId}`,
|
|
7
|
+
attemptIdFor: (token, attemptNumber) => `att_${token.tokenId}_${attemptNumber}`,
|
|
8
|
+
planFor: (token) => {
|
|
9
|
+
void token;
|
|
10
|
+
return {
|
|
11
|
+
capabilityId: 'raw',
|
|
12
|
+
effectClass,
|
|
13
|
+
deadlineAt: null,
|
|
14
|
+
idempotencyKey: null,
|
|
15
|
+
};
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function createManualOrchestrationClock(start = 1_000_000) {
|
|
20
|
+
let current = start;
|
|
21
|
+
let waiters = [];
|
|
22
|
+
return {
|
|
23
|
+
now: () => current,
|
|
24
|
+
delay: (ms) => new Promise((resolve) => {
|
|
25
|
+
const due = current + Math.max(0, ms);
|
|
26
|
+
if (due <= current) {
|
|
27
|
+
resolve();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
waiters.push({ due, resolve });
|
|
31
|
+
}),
|
|
32
|
+
advance: (ms) => {
|
|
33
|
+
current += Math.max(0, ms);
|
|
34
|
+
const due = waiters.filter((w) => w.due <= current).sort((a, b) => a.due - b.due);
|
|
35
|
+
waiters = waiters.filter((w) => w.due > current);
|
|
36
|
+
for (const waiter of due) {
|
|
37
|
+
waiter.resolve();
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function envelopeOf(orchestration, runId) {
|
|
43
|
+
const run = await orchestration.getOrchestrationRun(runId);
|
|
44
|
+
return {
|
|
45
|
+
runId,
|
|
46
|
+
graphId: run?.graphId ?? '',
|
|
47
|
+
graphVersion: run?.graphVersion ?? '',
|
|
48
|
+
capabilitySetVersion: run?.capabilitySetVersion ?? '',
|
|
49
|
+
activationVersion: run?.activationVersion ?? '',
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const settle = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
|
+
export function runOrchestrationRaceSuite(runner, expect, factory) {
|
|
54
|
+
const t = runner.test;
|
|
55
|
+
t(`[${factory.name}] fencing: two competing claims cannot own one attempt`, async () => {
|
|
56
|
+
const fixture = await factory.create();
|
|
57
|
+
try {
|
|
58
|
+
const { runtime, orchestration } = fixture;
|
|
59
|
+
runtime.registerCapability({
|
|
60
|
+
id: 'hold',
|
|
61
|
+
revision: '1',
|
|
62
|
+
effect: 'pure',
|
|
63
|
+
input: neutralJsonContract,
|
|
64
|
+
output: neutralJsonContract,
|
|
65
|
+
invoke: () => 'x',
|
|
66
|
+
});
|
|
67
|
+
const activated = await runtime.activate({
|
|
68
|
+
id: 'race-claims',
|
|
69
|
+
entry: 'hold',
|
|
70
|
+
nodes: [{ id: 'hold', capability: 'hold' }],
|
|
71
|
+
edges: [],
|
|
72
|
+
});
|
|
73
|
+
expect(activated.ok).toBe(true);
|
|
74
|
+
const runId = `run_race_${Date.now()}`;
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
await orchestration.createOrchestrationRun({
|
|
77
|
+
runId,
|
|
78
|
+
graphId: 'race-claims',
|
|
79
|
+
graphVersion: 'v2_test',
|
|
80
|
+
capabilitySetVersion: 'v1_test',
|
|
81
|
+
activationVersion: activated.activationVersion,
|
|
82
|
+
mode: 'normal',
|
|
83
|
+
retention: 'summary',
|
|
84
|
+
rootTokenId: `tok_${runId}_root`,
|
|
85
|
+
entryNodeId: 'hold',
|
|
86
|
+
checkpoint: 'seed',
|
|
87
|
+
events: [],
|
|
88
|
+
now,
|
|
89
|
+
});
|
|
90
|
+
// Two concurrent claimers: exactly one wins the only ready token.
|
|
91
|
+
const results = await Promise.all([
|
|
92
|
+
orchestration.claimReadyToken({
|
|
93
|
+
runId,
|
|
94
|
+
ownerId: 'owner-a',
|
|
95
|
+
leaseExpiresAt: now + 60_000,
|
|
96
|
+
now,
|
|
97
|
+
planner: rawPlanner(),
|
|
98
|
+
}),
|
|
99
|
+
orchestration.claimReadyToken({
|
|
100
|
+
runId,
|
|
101
|
+
ownerId: 'owner-b',
|
|
102
|
+
leaseExpiresAt: now + 60_000,
|
|
103
|
+
now,
|
|
104
|
+
planner: rawPlanner(),
|
|
105
|
+
}),
|
|
106
|
+
]);
|
|
107
|
+
const claimed = results.filter((result) => result.claimed);
|
|
108
|
+
expect(claimed.length).toBe(1);
|
|
109
|
+
const attemptId = claimed[0].claim.attempt
|
|
110
|
+
.attemptId;
|
|
111
|
+
expect(attemptId).toBeTruthy();
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await fixture.dispose();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
t(`[${factory.name}] fencing: stale owner cannot complete after lease recovery; invocation identity stable`, async () => {
|
|
118
|
+
const fixture = await factory.create();
|
|
119
|
+
try {
|
|
120
|
+
const { runtime, orchestration } = fixture;
|
|
121
|
+
runtime.registerCapability({
|
|
122
|
+
id: 'hold',
|
|
123
|
+
revision: '1',
|
|
124
|
+
effect: 'pure',
|
|
125
|
+
input: neutralJsonContract,
|
|
126
|
+
output: neutralJsonContract,
|
|
127
|
+
invoke: () => 'x',
|
|
128
|
+
});
|
|
129
|
+
const activated = await runtime.activate({
|
|
130
|
+
id: 'race-fence',
|
|
131
|
+
entry: 'hold',
|
|
132
|
+
nodes: [{ id: 'hold', capability: 'hold' }],
|
|
133
|
+
edges: [],
|
|
134
|
+
});
|
|
135
|
+
expect(activated.ok).toBe(true);
|
|
136
|
+
const runId = `run_fence_${Date.now()}`;
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
await orchestration.createOrchestrationRun({
|
|
139
|
+
runId,
|
|
140
|
+
graphId: 'race-fence',
|
|
141
|
+
graphVersion: 'v2_test',
|
|
142
|
+
capabilitySetVersion: 'v1_test',
|
|
143
|
+
activationVersion: activated
|
|
144
|
+
.activationVersion,
|
|
145
|
+
mode: 'normal',
|
|
146
|
+
retention: 'summary',
|
|
147
|
+
rootTokenId: `tok_${runId}_root`,
|
|
148
|
+
entryNodeId: 'hold',
|
|
149
|
+
checkpoint: 'seed',
|
|
150
|
+
events: [],
|
|
151
|
+
now,
|
|
152
|
+
});
|
|
153
|
+
// Owner A claims, then its lease lapses.
|
|
154
|
+
const claimed = await orchestration.claimReadyToken({
|
|
155
|
+
runId,
|
|
156
|
+
ownerId: 'owner-a',
|
|
157
|
+
leaseExpiresAt: now + 1,
|
|
158
|
+
now,
|
|
159
|
+
planner: rawPlanner(),
|
|
160
|
+
});
|
|
161
|
+
expect(claimed.claimed).toBe(true);
|
|
162
|
+
const claim = claimed.claim;
|
|
163
|
+
// Lease recovery reclaims the abandoned claim.
|
|
164
|
+
const recoverable = await orchestration.findRecoverableClaims({ now: now + 5_000 });
|
|
165
|
+
expect(recoverable.length).toBe(1);
|
|
166
|
+
await orchestration.recoverAttempt({
|
|
167
|
+
runId,
|
|
168
|
+
attemptId: claim.attempt.attemptId,
|
|
169
|
+
expectedAttemptFence: claim.attempt.fence,
|
|
170
|
+
now: now + 5_000,
|
|
171
|
+
action: { kind: 'reclaim' },
|
|
172
|
+
events: [],
|
|
173
|
+
});
|
|
174
|
+
// The recovered token is claimed again by a fresh owner; that claim
|
|
175
|
+
// creates attempt 2 of the SAME logical invocation.
|
|
176
|
+
const reclaimed = await orchestration.claimReadyToken({
|
|
177
|
+
runId,
|
|
178
|
+
ownerId: 'owner-b',
|
|
179
|
+
leaseExpiresAt: now + 60_000,
|
|
180
|
+
now: now + 5_000,
|
|
181
|
+
planner: rawPlanner(),
|
|
182
|
+
});
|
|
183
|
+
expect(reclaimed.claimed).toBe(true);
|
|
184
|
+
// The stale owner's genuinely still-running result arrives late and
|
|
185
|
+
// must be REJECTED — never applied.
|
|
186
|
+
let rejected = false;
|
|
187
|
+
try {
|
|
188
|
+
await orchestration.completeAttempt({
|
|
189
|
+
runId,
|
|
190
|
+
attemptId: claim.attempt.attemptId,
|
|
191
|
+
ownerId: 'owner-a',
|
|
192
|
+
expectedAttemptFence: claim.attempt.fence,
|
|
193
|
+
now: now + 5_000,
|
|
194
|
+
outcome: { kind: 'completed', outputSummary: { shape: 'string', length: 3 } },
|
|
195
|
+
continuation: { kind: 'none' },
|
|
196
|
+
events: [],
|
|
197
|
+
run: { status: 'completed' },
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
rejected = true;
|
|
202
|
+
}
|
|
203
|
+
expect(rejected).toBe(true);
|
|
204
|
+
// Attempt numbers and logical invocation identity remain stable.
|
|
205
|
+
const snapshot = await orchestration.getOrchestrationSnapshot(runId);
|
|
206
|
+
const attempts = snapshot?.attempts ?? [];
|
|
207
|
+
expect(attempts.length).toBe(2);
|
|
208
|
+
expect(attempts[0]?.invocationId === attempts[1]?.invocationId).toBe(true);
|
|
209
|
+
expect(attempts.map((attempt) => attempt.attemptNumber).sort()).toEqual([1, 2]);
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await fixture.dispose();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
t(`[${factory.name}] durable intent: attempt and node.started are durable before every invocation`, async () => {
|
|
216
|
+
const fixture = await factory.create();
|
|
217
|
+
try {
|
|
218
|
+
const runtime = fixture.runtime;
|
|
219
|
+
let observed;
|
|
220
|
+
runtime.registerCapability({
|
|
221
|
+
id: 'probe',
|
|
222
|
+
revision: '1',
|
|
223
|
+
effect: 'pure',
|
|
224
|
+
input: neutralJsonContract,
|
|
225
|
+
output: neutralJsonContract,
|
|
226
|
+
invoke: async (input, context) => {
|
|
227
|
+
const snapshot = await fixture.orchestration.getOrchestrationSnapshot(context.runId);
|
|
228
|
+
const attempt = snapshot?.attempts.find((candidate) => candidate.attemptId === context.attemptId);
|
|
229
|
+
const events = await fixture.orchestration.listOrchestrationEvents(context.runId);
|
|
230
|
+
observed = {
|
|
231
|
+
attemptState: attempt?.state ?? 'missing',
|
|
232
|
+
nodeStarted: events.some((event) => event.type === 'node.started' && event.nodeId === 'probe'),
|
|
233
|
+
};
|
|
234
|
+
return `seen:${String(input)}`;
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
const activated = await runtime.activate({
|
|
238
|
+
id: 'race-intent',
|
|
239
|
+
entry: 'probe',
|
|
240
|
+
// A declared timeout forces the durable orchestration engine.
|
|
241
|
+
nodes: [{ id: 'probe', capability: 'probe', timeoutMs: 30_000 }],
|
|
242
|
+
edges: [],
|
|
243
|
+
});
|
|
244
|
+
expect(activated.ok).toBe(true);
|
|
245
|
+
const result = await runtime.run('seed');
|
|
246
|
+
expect(result.status).toBe('completed');
|
|
247
|
+
expect(observed?.attemptState === 'started' || observed?.attemptState === 'claimed').toBe(true);
|
|
248
|
+
expect(observed?.nodeStarted).toBe(true);
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
await fixture.dispose();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
t(`[${factory.name}] signal versus due-timeout race: exactly one winner, no duplicate continuation`, async () => {
|
|
255
|
+
const fixture = await factory.create();
|
|
256
|
+
try {
|
|
257
|
+
const runtime = fixture.runtime;
|
|
258
|
+
let downstreamCalls = 0;
|
|
259
|
+
let timedOutCalls = 0;
|
|
260
|
+
runtime
|
|
261
|
+
.registerCapability({
|
|
262
|
+
id: 'first',
|
|
263
|
+
revision: '1',
|
|
264
|
+
effect: 'pure',
|
|
265
|
+
input: neutralJsonContract,
|
|
266
|
+
output: neutralJsonContract,
|
|
267
|
+
invoke: () => 'one',
|
|
268
|
+
})
|
|
269
|
+
.registerCapability({
|
|
270
|
+
id: 'second',
|
|
271
|
+
revision: '1',
|
|
272
|
+
effect: 'pure',
|
|
273
|
+
input: neutralJsonContract,
|
|
274
|
+
output: neutralJsonContract,
|
|
275
|
+
invoke: (input) => {
|
|
276
|
+
downstreamCalls += 1;
|
|
277
|
+
return `got:${String(input)}`;
|
|
278
|
+
},
|
|
279
|
+
})
|
|
280
|
+
.registerCapability({
|
|
281
|
+
id: 'timedOut',
|
|
282
|
+
revision: '1',
|
|
283
|
+
effect: 'pure',
|
|
284
|
+
input: neutralJsonContract,
|
|
285
|
+
output: neutralJsonContract,
|
|
286
|
+
invoke: () => {
|
|
287
|
+
timedOutCalls += 1;
|
|
288
|
+
return 'timed-out-path';
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
const activated = await runtime.activate({
|
|
292
|
+
id: 'race-signal-timeout',
|
|
293
|
+
entry: 'a',
|
|
294
|
+
nodes: [
|
|
295
|
+
{ id: 'a', capability: 'first' },
|
|
296
|
+
{ id: 'w', kind: 'wait', wait: { kind: 'signal', name: 'go', timeoutMs: 25 } },
|
|
297
|
+
{ id: 't', capability: 'timedOut' },
|
|
298
|
+
{ id: 'b', capability: 'second' },
|
|
299
|
+
],
|
|
300
|
+
edges: [
|
|
301
|
+
{ from: 'a', to: 'w' },
|
|
302
|
+
{ from: 'w', to: 'b' },
|
|
303
|
+
{ from: 'w', to: 't', kind: 'timeout' },
|
|
304
|
+
],
|
|
305
|
+
});
|
|
306
|
+
expect(activated.ok).toBe(true);
|
|
307
|
+
const parked = await runtime.run('seed');
|
|
308
|
+
expect(parked.status).toBe('waiting');
|
|
309
|
+
const waitId = parked.waits?.[0]?.waitId;
|
|
310
|
+
// Race a valid signal against the due-timeout pump at the same
|
|
311
|
+
// instant. Exactly one wins: a losing signal arrives as
|
|
312
|
+
// 'already_resolved', never a duplicate wake or receipt.
|
|
313
|
+
await settle(40); // past the 25ms timeout
|
|
314
|
+
const [signalOutcome, timerOutcome] = await Promise.all([
|
|
315
|
+
runtime.signal({
|
|
316
|
+
runId: parked.runId,
|
|
317
|
+
waitId,
|
|
318
|
+
signalId: 'race-sig-1',
|
|
319
|
+
signalName: 'go',
|
|
320
|
+
payload: 'in-time',
|
|
321
|
+
}),
|
|
322
|
+
runtime.processDueTimers({ runId: parked.runId }),
|
|
323
|
+
]);
|
|
324
|
+
const signalWon = signalOutcome.ok && signalOutcome.status === 'accepted';
|
|
325
|
+
const timerWon = timerOutcome.fired === 1;
|
|
326
|
+
expect(signalWon !== timerWon).toBe(true);
|
|
327
|
+
const final = await runtime.resumeRun(parked.runId);
|
|
328
|
+
expect(final.status).toBe('completed');
|
|
329
|
+
expect(downstreamCalls + timedOutCalls).toBe(1);
|
|
330
|
+
const events = await fixture.orchestration.listOrchestrationEvents(parked.runId);
|
|
331
|
+
const received = events.filter((event) => event.type === 'signal.received').length;
|
|
332
|
+
const fired = events.filter((event) => event.type === 'timer.fired').length;
|
|
333
|
+
expect(received + fired).toBe(1);
|
|
334
|
+
expect(events.filter((event) => event.type === 'run.resumed').length).toBe(1);
|
|
335
|
+
// The losing command creates no receipt when the timer won.
|
|
336
|
+
const receipts = await fixture.orchestration.listSignalReceipts(parked.runId);
|
|
337
|
+
expect(receipts.length).toBe(signalWon ? 1 : 0);
|
|
338
|
+
}
|
|
339
|
+
finally {
|
|
340
|
+
await fixture.dispose();
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
t(`[${factory.name}] timeout wins before a late signal: the late command creates no receipt or wake`, async () => {
|
|
344
|
+
const fixture = await factory.create();
|
|
345
|
+
try {
|
|
346
|
+
const runtime = fixture.runtime;
|
|
347
|
+
let downstreamCalls = 0;
|
|
348
|
+
let timedOutCalls = 0;
|
|
349
|
+
runtime
|
|
350
|
+
.registerCapability({
|
|
351
|
+
id: 'first',
|
|
352
|
+
revision: '1',
|
|
353
|
+
effect: 'pure',
|
|
354
|
+
input: neutralJsonContract,
|
|
355
|
+
output: neutralJsonContract,
|
|
356
|
+
invoke: () => 'one',
|
|
357
|
+
})
|
|
358
|
+
.registerCapability({
|
|
359
|
+
id: 'second',
|
|
360
|
+
revision: '1',
|
|
361
|
+
effect: 'pure',
|
|
362
|
+
input: neutralJsonContract,
|
|
363
|
+
output: neutralJsonContract,
|
|
364
|
+
invoke: () => {
|
|
365
|
+
downstreamCalls += 1;
|
|
366
|
+
return 'success-path';
|
|
367
|
+
},
|
|
368
|
+
})
|
|
369
|
+
.registerCapability({
|
|
370
|
+
id: 'timedOut',
|
|
371
|
+
revision: '1',
|
|
372
|
+
effect: 'pure',
|
|
373
|
+
input: neutralJsonContract,
|
|
374
|
+
output: neutralJsonContract,
|
|
375
|
+
invoke: () => {
|
|
376
|
+
timedOutCalls += 1;
|
|
377
|
+
return 'timed-out-path';
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
const activated = await runtime.activate({
|
|
381
|
+
id: 'race-late-signal',
|
|
382
|
+
entry: 'a',
|
|
383
|
+
nodes: [
|
|
384
|
+
{ id: 'a', capability: 'first' },
|
|
385
|
+
{ id: 'w', kind: 'wait', wait: { kind: 'signal', name: 'go', timeoutMs: 15 } },
|
|
386
|
+
{ id: 't', capability: 'timedOut' },
|
|
387
|
+
{ id: 'b', capability: 'second' },
|
|
388
|
+
],
|
|
389
|
+
edges: [
|
|
390
|
+
{ from: 'a', to: 'w' },
|
|
391
|
+
{ from: 'w', to: 'b' },
|
|
392
|
+
{ from: 'w', to: 't', kind: 'timeout' },
|
|
393
|
+
],
|
|
394
|
+
});
|
|
395
|
+
expect(activated.ok).toBe(true);
|
|
396
|
+
const parked = await runtime.run('seed');
|
|
397
|
+
expect(parked.status).toBe('waiting');
|
|
398
|
+
// The timeout fires first; the signal arrives late.
|
|
399
|
+
await settle(40);
|
|
400
|
+
const pumped = await runtime.processDueTimers({ runId: parked.runId });
|
|
401
|
+
expect(pumped.fired).toBe(1);
|
|
402
|
+
const late = await runtime.signal({
|
|
403
|
+
runId: parked.runId,
|
|
404
|
+
waitId: parked.waits?.[0]?.waitId,
|
|
405
|
+
signalId: 'late-1',
|
|
406
|
+
signalName: 'go',
|
|
407
|
+
payload: 'late',
|
|
408
|
+
});
|
|
409
|
+
expect(late.ok).toBe(true);
|
|
410
|
+
expect(late.ok ? late.status : '').toBe('already_resolved');
|
|
411
|
+
const receipts = await fixture.orchestration.listSignalReceipts(parked.runId);
|
|
412
|
+
expect(receipts.length).toBe(0);
|
|
413
|
+
const final = await runtime.resumeRun(parked.runId);
|
|
414
|
+
expect(final.status).toBe('completed');
|
|
415
|
+
expect(timedOutCalls).toBe(1);
|
|
416
|
+
expect(downstreamCalls).toBe(0);
|
|
417
|
+
}
|
|
418
|
+
finally {
|
|
419
|
+
await fixture.dispose();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
t(`[${factory.name}] keyed-write timeout retries with the same idempotency key and completes once`, async () => {
|
|
423
|
+
// Deterministic by construction: a manual clock drives BOTH the
|
|
424
|
+
// persisted deadline and the retry-timer eligibility, and the first
|
|
425
|
+
// attempt is held by an explicit invocation barrier. Nothing depends
|
|
426
|
+
// on wall-clock sleeps (the previous real-time version could flake
|
|
427
|
+
// under suite load: a slow claim-to-execution start legitimately
|
|
428
|
+
// failed attempt two on its ALREADY-EXPIRED persisted deadline
|
|
429
|
+
// before invocation, scheduling a second retry the test never
|
|
430
|
+
// pumped — the runtime behaved correctly; the test guessed wrong).
|
|
431
|
+
const clock = createManualOrchestrationClock();
|
|
432
|
+
const fixture = await factory.create(clock);
|
|
433
|
+
try {
|
|
434
|
+
const runtime = fixture.runtime;
|
|
435
|
+
const keys = [];
|
|
436
|
+
let attempts = 0;
|
|
437
|
+
let releaseFirstAttempt;
|
|
438
|
+
const firstAttemptGate = new Promise((resolve) => {
|
|
439
|
+
releaseFirstAttempt = resolve;
|
|
440
|
+
});
|
|
441
|
+
let firstAttemptStartedResolve;
|
|
442
|
+
const firstAttemptStarted = new Promise((resolve) => {
|
|
443
|
+
firstAttemptStartedResolve = resolve;
|
|
444
|
+
});
|
|
445
|
+
runtime.registerCapability({
|
|
446
|
+
id: 'keyedSlow',
|
|
447
|
+
revision: '1',
|
|
448
|
+
effect: 'write',
|
|
449
|
+
input: neutralJsonContract,
|
|
450
|
+
output: neutralJsonContract,
|
|
451
|
+
idempotency: 'keyed',
|
|
452
|
+
invoke: async (input, context) => {
|
|
453
|
+
attempts += 1;
|
|
454
|
+
keys.push(context.idempotencyKey);
|
|
455
|
+
if (attempts === 1) {
|
|
456
|
+
// Hold attempt one open past its deadline until the test
|
|
457
|
+
// releases it: its late return value must be fenced.
|
|
458
|
+
firstAttemptStartedResolve?.();
|
|
459
|
+
await firstAttemptGate;
|
|
460
|
+
return `late:${String(input)}`;
|
|
461
|
+
}
|
|
462
|
+
return `applied:${String(input)}`;
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
const activated = await runtime.activate({
|
|
466
|
+
id: 'race-keyed-timeout',
|
|
467
|
+
entry: 'w',
|
|
468
|
+
nodes: [
|
|
469
|
+
{
|
|
470
|
+
id: 'w',
|
|
471
|
+
capability: 'keyedSlow',
|
|
472
|
+
timeoutMs: 20,
|
|
473
|
+
retry: {
|
|
474
|
+
maxAttempts: 3,
|
|
475
|
+
retryOn: ['timeout'],
|
|
476
|
+
backoff: { kind: 'fixed', delayMs: 1 },
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
],
|
|
480
|
+
edges: [],
|
|
481
|
+
});
|
|
482
|
+
expect(activated.ok).toBe(true);
|
|
483
|
+
const startedRun = runtime.run('seed');
|
|
484
|
+
// Barrier: attempt one is genuinely in flight (invoked) before the
|
|
485
|
+
// clock moves — a state-based wait, never a sleep.
|
|
486
|
+
await firstAttemptStarted;
|
|
487
|
+
// Advance the manual clock past the persisted 20ms deadline: the
|
|
488
|
+
// deadline promise fires, the attempt is classified timed_out, and
|
|
489
|
+
// a DURABLE retry timer is scheduled (backoff 1ms).
|
|
490
|
+
clock.advance(25);
|
|
491
|
+
const parked = await startedRun;
|
|
492
|
+
// Attempt one genuinely timed out; the retry is durable.
|
|
493
|
+
expect(parked.status).toBe('running');
|
|
494
|
+
expect(attempts).toBe(1);
|
|
495
|
+
expect(parked.trace.some((event) => event.type === 'node.timed_out')).toBe(true);
|
|
496
|
+
expect(parked.trace.some((event) => event.type === 'node.retry_scheduled')).toBe(true);
|
|
497
|
+
expect(parked.trace.some((event) => event.type === 'timer.scheduled')).toBe(true);
|
|
498
|
+
// Timer eligibility is deterministic: the retry timer became due
|
|
499
|
+
// exactly when the clock passed (completion time + backoff).
|
|
500
|
+
clock.advance(5);
|
|
501
|
+
const pumped = await runtime.processDueTimers({ runId: parked.runId });
|
|
502
|
+
expect(pumped.fired).toBe(1);
|
|
503
|
+
expect(pumped.timers[0]?.kind).toBe('retry');
|
|
504
|
+
// Attempt one's late value arrives only after its attempt was
|
|
505
|
+
// terminally classified — the old attempt cannot overwrite the
|
|
506
|
+
// result (the release order relative to attempt two is irrelevant).
|
|
507
|
+
releaseFirstAttempt?.();
|
|
508
|
+
const final = await runtime.resumeRun(parked.runId);
|
|
509
|
+
expect(final.status).toBe('completed');
|
|
510
|
+
// Attempt two used the SAME idempotency key: one logical
|
|
511
|
+
// invocation, one external mutation, the late value discarded.
|
|
512
|
+
expect(attempts).toBe(2);
|
|
513
|
+
expect(keys[0]).toBeTruthy();
|
|
514
|
+
expect(keys[0] === keys[1]).toBe(true);
|
|
515
|
+
expect(final.output).toBe('applied:seed');
|
|
516
|
+
}
|
|
517
|
+
finally {
|
|
518
|
+
await fixture.dispose();
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
t(`[${factory.name}] irreversible timeout blocks without replay`, async () => {
|
|
522
|
+
// Deterministic: manual clock + invocation barrier (same mechanism and
|
|
523
|
+
// rationale as the keyed-write timeout test above).
|
|
524
|
+
const clock = createManualOrchestrationClock();
|
|
525
|
+
const fixture = await factory.create(clock);
|
|
526
|
+
try {
|
|
527
|
+
const runtime = fixture.runtime;
|
|
528
|
+
let invokeCount = 0;
|
|
529
|
+
let invokedResolve;
|
|
530
|
+
const invoked = new Promise((resolve) => {
|
|
531
|
+
invokedResolve = resolve;
|
|
532
|
+
});
|
|
533
|
+
let releaseIrreversible;
|
|
534
|
+
const irreversibleGate = new Promise((resolve) => {
|
|
535
|
+
releaseIrreversible = resolve;
|
|
536
|
+
});
|
|
537
|
+
runtime.registerCapability({
|
|
538
|
+
id: 'slowIrreversible',
|
|
539
|
+
revision: '1',
|
|
540
|
+
effect: 'irreversible',
|
|
541
|
+
input: neutralJsonContract,
|
|
542
|
+
output: neutralJsonContract,
|
|
543
|
+
invoke: async () => {
|
|
544
|
+
invokeCount += 1;
|
|
545
|
+
invokedResolve?.();
|
|
546
|
+
await irreversibleGate;
|
|
547
|
+
return 'done';
|
|
548
|
+
},
|
|
549
|
+
});
|
|
550
|
+
const activated = await runtime.activate({
|
|
551
|
+
id: 'race-irreversible-timeout',
|
|
552
|
+
entry: 'x',
|
|
553
|
+
nodes: [{ id: 'x', capability: 'slowIrreversible', timeoutMs: 20 }],
|
|
554
|
+
edges: [],
|
|
555
|
+
});
|
|
556
|
+
expect(activated.ok).toBe(true);
|
|
557
|
+
const startedRun = runtime.run('seed', { policy: { allowIrreversible: true } });
|
|
558
|
+
await invoked;
|
|
559
|
+
clock.advance(25);
|
|
560
|
+
const result = await startedRun;
|
|
561
|
+
releaseIrreversible?.();
|
|
562
|
+
expect(result.status).toBe('blocked');
|
|
563
|
+
expect(invokeCount).toBe(1);
|
|
564
|
+
const run = await fixture.orchestration.getOrchestrationRun(result.runId);
|
|
565
|
+
expect(run?.status).toBe('blocked');
|
|
566
|
+
// Operator retry is denied for irreversible work even when authorized.
|
|
567
|
+
const operatorRuntime = await fixture.createOperatorRuntime();
|
|
568
|
+
operatorRuntime.registerCapability({
|
|
569
|
+
id: 'slowIrreversible',
|
|
570
|
+
revision: '1',
|
|
571
|
+
effect: 'irreversible',
|
|
572
|
+
input: neutralJsonContract,
|
|
573
|
+
output: neutralJsonContract,
|
|
574
|
+
invoke: async () => 'done',
|
|
575
|
+
});
|
|
576
|
+
const denied = await operatorRuntime.resolveBlocked({
|
|
577
|
+
runId: result.runId,
|
|
578
|
+
resolutionId: 'retry-attempt',
|
|
579
|
+
action: 'retry',
|
|
580
|
+
reasonCode: 'operator_request',
|
|
581
|
+
});
|
|
582
|
+
expect(denied.ok).toBe(false);
|
|
583
|
+
expect(denied.ok ? '' : denied.code).toBe('VICT_ORCH_OPERATOR_DENIED');
|
|
584
|
+
expect(invokeCount).toBe(1);
|
|
585
|
+
}
|
|
586
|
+
finally {
|
|
587
|
+
await fixture.dispose();
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
t(`[${factory.name}] timer polling is deterministic and repeated polling is idempotent`, async () => {
|
|
591
|
+
const fixture = await factory.create();
|
|
592
|
+
try {
|
|
593
|
+
const runtime = fixture.runtime;
|
|
594
|
+
let downstream = 0;
|
|
595
|
+
runtime
|
|
596
|
+
.registerCapability({
|
|
597
|
+
id: 'first',
|
|
598
|
+
revision: '1',
|
|
599
|
+
effect: 'pure',
|
|
600
|
+
input: neutralJsonContract,
|
|
601
|
+
output: neutralJsonContract,
|
|
602
|
+
invoke: () => 'one',
|
|
603
|
+
})
|
|
604
|
+
.registerCapability({
|
|
605
|
+
id: 'second',
|
|
606
|
+
revision: '1',
|
|
607
|
+
effect: 'pure',
|
|
608
|
+
input: neutralJsonContract,
|
|
609
|
+
output: neutralJsonContract,
|
|
610
|
+
invoke: (input) => {
|
|
611
|
+
downstream += 1;
|
|
612
|
+
return `got:${String(input)}`;
|
|
613
|
+
},
|
|
614
|
+
});
|
|
615
|
+
// Two independent runs with different timer delays.
|
|
616
|
+
const graph = (delayMs) => ({
|
|
617
|
+
id: `race-timer-order-${delayMs}`,
|
|
618
|
+
entry: 'a',
|
|
619
|
+
nodes: [
|
|
620
|
+
{ id: 'a', capability: 'first' },
|
|
621
|
+
{ id: 't', kind: 'wait', wait: { kind: 'timer', delayMs } },
|
|
622
|
+
{ id: 'b', capability: 'second' },
|
|
623
|
+
],
|
|
624
|
+
edges: [
|
|
625
|
+
{ from: 'a', to: 't' },
|
|
626
|
+
{ from: 't', to: 'b' },
|
|
627
|
+
],
|
|
628
|
+
});
|
|
629
|
+
const activated1 = await runtime.activate(graph(5));
|
|
630
|
+
expect(activated1.ok).toBe(true);
|
|
631
|
+
const activated2 = await runtime.activate(graph(40));
|
|
632
|
+
expect(activated2.ok).toBe(true);
|
|
633
|
+
const parked1 = await runtime.run('seed');
|
|
634
|
+
expect(parked1.status).toBe('waiting');
|
|
635
|
+
const parked2 = await runtime.run('seed');
|
|
636
|
+
expect(parked2.status).toBe('waiting');
|
|
637
|
+
await settle(60);
|
|
638
|
+
// First pump fires both due timers; repeated polling is idempotent.
|
|
639
|
+
const fired = await runtime.processDueTimers({});
|
|
640
|
+
expect(fired.fired).toBe(2);
|
|
641
|
+
const repeat = await runtime.processDueTimers({});
|
|
642
|
+
expect(repeat.fired).toBe(0);
|
|
643
|
+
const final1 = await runtime.resumeRun(parked1.runId);
|
|
644
|
+
const final2 = await runtime.resumeRun(parked2.runId);
|
|
645
|
+
expect(final1.status).toBe('completed');
|
|
646
|
+
expect(final2.status).toBe('completed');
|
|
647
|
+
expect(downstream).toBe(2);
|
|
648
|
+
}
|
|
649
|
+
finally {
|
|
650
|
+
await fixture.dispose();
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
t(`[${factory.name}] cancellation after durable claim: the run finalizes cancelled; no downstream work`, async () => {
|
|
654
|
+
const fixture = await factory.create();
|
|
655
|
+
try {
|
|
656
|
+
const { runtime, orchestration } = fixture;
|
|
657
|
+
let downstreamCalls = 0;
|
|
658
|
+
runtime
|
|
659
|
+
.registerCapability({
|
|
660
|
+
id: 'start',
|
|
661
|
+
revision: '1',
|
|
662
|
+
effect: 'pure',
|
|
663
|
+
input: neutralJsonContract,
|
|
664
|
+
output: neutralJsonContract,
|
|
665
|
+
invoke: () => 'seed',
|
|
666
|
+
})
|
|
667
|
+
.registerCapability({
|
|
668
|
+
id: 'next',
|
|
669
|
+
revision: '1',
|
|
670
|
+
effect: 'pure',
|
|
671
|
+
input: neutralJsonContract,
|
|
672
|
+
output: neutralJsonContract,
|
|
673
|
+
invoke: () => {
|
|
674
|
+
downstreamCalls += 1;
|
|
675
|
+
return 'next';
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
const activated = await runtime.activate({
|
|
679
|
+
id: 'race-cancel-claim',
|
|
680
|
+
entry: 'start',
|
|
681
|
+
nodes: [
|
|
682
|
+
{ id: 'start', capability: 'start' },
|
|
683
|
+
{ id: 'next', capability: 'next' },
|
|
684
|
+
],
|
|
685
|
+
edges: [{ from: 'start', to: 'next' }],
|
|
686
|
+
});
|
|
687
|
+
expect(activated.ok).toBe(true);
|
|
688
|
+
const runId = `run_cancel_claim_${Date.now()}`;
|
|
689
|
+
const now = Date.now();
|
|
690
|
+
await orchestration.createOrchestrationRun({
|
|
691
|
+
runId,
|
|
692
|
+
graphId: 'race-cancel-claim',
|
|
693
|
+
graphVersion: 'v2_test',
|
|
694
|
+
capabilitySetVersion: 'v1_test',
|
|
695
|
+
activationVersion: activated
|
|
696
|
+
.activationVersion,
|
|
697
|
+
mode: 'normal',
|
|
698
|
+
retention: 'summary',
|
|
699
|
+
rootTokenId: `tok_${runId}_root`,
|
|
700
|
+
entryNodeId: 'start',
|
|
701
|
+
checkpoint: 'seed',
|
|
702
|
+
events: [],
|
|
703
|
+
now,
|
|
704
|
+
});
|
|
705
|
+
const identity = await envelopeOf(orchestration, runId);
|
|
706
|
+
// Durable claim happens; the (hypothetical) handler is mid-flight.
|
|
707
|
+
const claimed = await orchestration.claimReadyToken({
|
|
708
|
+
runId,
|
|
709
|
+
ownerId: 'owner-a',
|
|
710
|
+
leaseExpiresAt: now + 60_000,
|
|
711
|
+
now,
|
|
712
|
+
planner: rawPlanner(),
|
|
713
|
+
});
|
|
714
|
+
expect(claimed.claimed).toBe(true);
|
|
715
|
+
// Durable cancellation: request + apply (no driver loop is driving
|
|
716
|
+
// this hand-built run, so the store transition is applied directly).
|
|
717
|
+
const requested = await orchestration.requestCancellation({
|
|
718
|
+
runId,
|
|
719
|
+
requestId: 'cancel-1',
|
|
720
|
+
reasonCode: 'operator_request',
|
|
721
|
+
commandHash: 'hash-cancel-1',
|
|
722
|
+
now: now + 5,
|
|
723
|
+
events: [
|
|
724
|
+
{
|
|
725
|
+
type: 'run.cancel_requested',
|
|
726
|
+
requestId: 'cancel-1',
|
|
727
|
+
reasonCode: 'operator_request',
|
|
728
|
+
...identity,
|
|
729
|
+
timestamp: now + 5,
|
|
730
|
+
},
|
|
731
|
+
],
|
|
732
|
+
});
|
|
733
|
+
expect(requested.status === 'accepted' || requested.status === 'duplicate').toBe(true);
|
|
734
|
+
await orchestration.applyCancellation({
|
|
735
|
+
runId,
|
|
736
|
+
now: now + 6,
|
|
737
|
+
requestId: 'cancel-1',
|
|
738
|
+
reasonCode: 'operator_request',
|
|
739
|
+
steps: 1,
|
|
740
|
+
removeCheckpoints: [],
|
|
741
|
+
events: [
|
|
742
|
+
{
|
|
743
|
+
type: 'run.cancelled',
|
|
744
|
+
requestId: 'cancel-1',
|
|
745
|
+
reasonCode: 'operator_request',
|
|
746
|
+
steps: 1,
|
|
747
|
+
...identity,
|
|
748
|
+
timestamp: now + 6,
|
|
749
|
+
},
|
|
750
|
+
],
|
|
751
|
+
});
|
|
752
|
+
// The late completion attempt must never resurrect downstream work.
|
|
753
|
+
const claim = claimed.claim;
|
|
754
|
+
let lateRejected = false;
|
|
755
|
+
try {
|
|
756
|
+
await orchestration.completeAttempt({
|
|
757
|
+
runId,
|
|
758
|
+
attemptId: claim.attempt.attemptId,
|
|
759
|
+
ownerId: 'owner-a',
|
|
760
|
+
expectedAttemptFence: claim.attempt.fence,
|
|
761
|
+
now: now + 10,
|
|
762
|
+
outcome: { kind: 'completed', outputSummary: { shape: 'string', length: 4 } },
|
|
763
|
+
continuation: { kind: 'advance', toNodeId: 'next', payload: 'late' },
|
|
764
|
+
events: [],
|
|
765
|
+
run: { status: 'running' },
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
lateRejected = true;
|
|
770
|
+
}
|
|
771
|
+
void lateRejected;
|
|
772
|
+
const run = await orchestration.getOrchestrationRun(runId);
|
|
773
|
+
expect(run?.status).toBe('cancelled');
|
|
774
|
+
const snapshot = await orchestration.getOrchestrationSnapshot(runId);
|
|
775
|
+
expect((snapshot?.tokens ?? []).some((token) => token.status === 'ready')).toBe(false);
|
|
776
|
+
expect(downstreamCalls).toBe(0);
|
|
777
|
+
}
|
|
778
|
+
finally {
|
|
779
|
+
await fixture.dispose();
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
t(`[${factory.name}] an active capability observes its abort signal; downstream never starts`, async () => {
|
|
783
|
+
const fixture = await factory.create();
|
|
784
|
+
try {
|
|
785
|
+
const runtime = fixture.runtime;
|
|
786
|
+
let abortedObserved = false;
|
|
787
|
+
let downstreamCalls = 0;
|
|
788
|
+
runtime
|
|
789
|
+
.registerCapability({
|
|
790
|
+
id: 'first',
|
|
791
|
+
revision: '1',
|
|
792
|
+
effect: 'pure',
|
|
793
|
+
input: neutralJsonContract,
|
|
794
|
+
output: neutralJsonContract,
|
|
795
|
+
invoke: () => 'one',
|
|
796
|
+
})
|
|
797
|
+
.registerCapability({
|
|
798
|
+
id: 'abortable',
|
|
799
|
+
revision: '1',
|
|
800
|
+
effect: 'pure',
|
|
801
|
+
input: neutralJsonContract,
|
|
802
|
+
output: neutralJsonContract,
|
|
803
|
+
invoke: (input, context) => new Promise((resolve, reject) => {
|
|
804
|
+
const signal = context.abortSignal;
|
|
805
|
+
if (signal?.aborted) {
|
|
806
|
+
abortedObserved = true;
|
|
807
|
+
reject(new Error('aborted-before-start'));
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
signal?.addEventListener('abort', () => {
|
|
811
|
+
abortedObserved = true;
|
|
812
|
+
reject(new Error('aborted'));
|
|
813
|
+
}, { once: true });
|
|
814
|
+
void input;
|
|
815
|
+
setTimeout(() => resolve('completed-anyway'), 10_000);
|
|
816
|
+
}),
|
|
817
|
+
})
|
|
818
|
+
.registerCapability({
|
|
819
|
+
id: 'after',
|
|
820
|
+
revision: '1',
|
|
821
|
+
effect: 'pure',
|
|
822
|
+
input: neutralJsonContract,
|
|
823
|
+
output: neutralJsonContract,
|
|
824
|
+
invoke: () => {
|
|
825
|
+
downstreamCalls += 1;
|
|
826
|
+
return 'after';
|
|
827
|
+
},
|
|
828
|
+
});
|
|
829
|
+
const activated = await runtime.activate({
|
|
830
|
+
id: 'race-cancel-active',
|
|
831
|
+
entry: 'a',
|
|
832
|
+
nodes: [
|
|
833
|
+
{ id: 'a', capability: 'first' },
|
|
834
|
+
{ id: 'h', capability: 'abortable', timeoutMs: 30_000 },
|
|
835
|
+
{ id: 'z', capability: 'after' },
|
|
836
|
+
],
|
|
837
|
+
edges: [
|
|
838
|
+
{ from: 'a', to: 'h' },
|
|
839
|
+
{ from: 'h', to: 'z' },
|
|
840
|
+
],
|
|
841
|
+
});
|
|
842
|
+
expect(activated.ok).toBe(true);
|
|
843
|
+
const driving = runtime.run('seed');
|
|
844
|
+
const settled = await Promise.race([
|
|
845
|
+
driving.then(() => 'done'),
|
|
846
|
+
settle(200).then(() => 'waiting'),
|
|
847
|
+
]);
|
|
848
|
+
expect(settled).toBe('waiting'); // the handler is genuinely in flight
|
|
849
|
+
const parked = (await Promise.race([driving, settle(1).then(() => null)]));
|
|
850
|
+
const runId = parked?.runId ??
|
|
851
|
+
(await (async () => {
|
|
852
|
+
const runs = await fixture.orchestration.listOrchestrationRuns({ status: 'running' });
|
|
853
|
+
return runs[0]?.runId;
|
|
854
|
+
})());
|
|
855
|
+
expect(runId).toBeTruthy();
|
|
856
|
+
// Durable cancellation cooperatively aborts the in-flight context.
|
|
857
|
+
const cancelled = await runtime.cancel({
|
|
858
|
+
runId: runId,
|
|
859
|
+
requestId: 'c-1',
|
|
860
|
+
reasonCode: 'operator_request',
|
|
861
|
+
});
|
|
862
|
+
expect(cancelled.ok).toBe(true);
|
|
863
|
+
const result = await driving;
|
|
864
|
+
expect(abortedObserved).toBe(true);
|
|
865
|
+
expect(result.status).toBe('cancelled');
|
|
866
|
+
expect(downstreamCalls).toBe(0);
|
|
867
|
+
}
|
|
868
|
+
finally {
|
|
869
|
+
await fixture.dispose();
|
|
870
|
+
}
|
|
871
|
+
});
|
|
872
|
+
t(`[${factory.name}] fan-out cancellation: unfinished siblings reach a consistent non-running state`, async () => {
|
|
873
|
+
const fixture = await factory.create();
|
|
874
|
+
try {
|
|
875
|
+
const runtime = fixture.runtime;
|
|
876
|
+
let releaseSlow;
|
|
877
|
+
const slowGate = new Promise((resolve) => {
|
|
878
|
+
releaseSlow = resolve;
|
|
879
|
+
});
|
|
880
|
+
runtime
|
|
881
|
+
.registerCapability({
|
|
882
|
+
id: 'first',
|
|
883
|
+
revision: '1',
|
|
884
|
+
effect: 'pure',
|
|
885
|
+
input: neutralJsonContract,
|
|
886
|
+
output: neutralJsonContract,
|
|
887
|
+
invoke: () => 'one',
|
|
888
|
+
})
|
|
889
|
+
.registerCapability({
|
|
890
|
+
id: 'fast',
|
|
891
|
+
revision: '1',
|
|
892
|
+
effect: 'pure',
|
|
893
|
+
input: neutralJsonContract,
|
|
894
|
+
output: neutralJsonContract,
|
|
895
|
+
invoke: () => 'fast',
|
|
896
|
+
})
|
|
897
|
+
.registerCapability({
|
|
898
|
+
id: 'slow',
|
|
899
|
+
revision: '1',
|
|
900
|
+
effect: 'pure',
|
|
901
|
+
input: neutralJsonContract,
|
|
902
|
+
output: neutralJsonContract,
|
|
903
|
+
invoke: (input, context) => new Promise((resolve) => {
|
|
904
|
+
context.abortSignal?.addEventListener('abort', () => resolve('aborted'), {
|
|
905
|
+
once: true,
|
|
906
|
+
});
|
|
907
|
+
void slowGate.then(() => resolve('slow'));
|
|
908
|
+
void input;
|
|
909
|
+
}),
|
|
910
|
+
})
|
|
911
|
+
.registerCapability({
|
|
912
|
+
id: 'after',
|
|
913
|
+
revision: '1',
|
|
914
|
+
effect: 'pure',
|
|
915
|
+
input: neutralJsonContract,
|
|
916
|
+
output: neutralJsonContract,
|
|
917
|
+
invoke: () => 'after',
|
|
918
|
+
});
|
|
919
|
+
const activated = await runtime.activate({
|
|
920
|
+
id: 'race-cancel-fanout',
|
|
921
|
+
entry: 'a',
|
|
922
|
+
nodes: [
|
|
923
|
+
{ id: 'a', capability: 'first' },
|
|
924
|
+
{ id: 'f', kind: 'fork', join: 'j' },
|
|
925
|
+
{ id: 'b1', capability: 'fast' },
|
|
926
|
+
{ id: 'b2', capability: 'slow' },
|
|
927
|
+
{ id: 'j', kind: 'join', fork: 'f' },
|
|
928
|
+
{ id: 'z', capability: 'after' },
|
|
929
|
+
],
|
|
930
|
+
edges: [
|
|
931
|
+
{ from: 'a', to: 'f' },
|
|
932
|
+
{ from: 'f', to: 'b1', kind: 'branch', key: 'a' },
|
|
933
|
+
{ from: 'f', to: 'b2', kind: 'branch', key: 'b' },
|
|
934
|
+
{ from: 'b1', to: 'j' },
|
|
935
|
+
{ from: 'b2', to: 'j' },
|
|
936
|
+
{ from: 'j', to: 'z' },
|
|
937
|
+
],
|
|
938
|
+
});
|
|
939
|
+
expect(activated.ok).toBe(true);
|
|
940
|
+
const driving = runtime.run('seed', { concurrency: 2 });
|
|
941
|
+
await settle(80); // fast branch completes; slow branch is in flight
|
|
942
|
+
const runs = await fixture.orchestration.listOrchestrationRuns({ status: 'running' });
|
|
943
|
+
const runId = runs[0]?.runId;
|
|
944
|
+
expect(runId).toBeTruthy();
|
|
945
|
+
// Durable cancellation mid-fan-out.
|
|
946
|
+
const cancelled = await runtime.cancel({
|
|
947
|
+
runId: runId,
|
|
948
|
+
requestId: 'cx-1',
|
|
949
|
+
reasonCode: 'shutdown',
|
|
950
|
+
});
|
|
951
|
+
expect(cancelled.ok).toBe(true);
|
|
952
|
+
releaseSlow?.();
|
|
953
|
+
const result = await driving;
|
|
954
|
+
const snapshot = await fixture.orchestration.getOrchestrationSnapshot(runId);
|
|
955
|
+
expect(result.status).toBe('cancelled');
|
|
956
|
+
// No unfinished sibling remains ready, claimed, or waiting.
|
|
957
|
+
const unfinished = (snapshot?.tokens ?? []).filter((token) => ['ready', 'claimed', 'waiting'].includes(token.status));
|
|
958
|
+
expect(unfinished.length).toBe(0);
|
|
959
|
+
// The completed branch stays completed (a durable fact, never reversed).
|
|
960
|
+
const branchCompleted = (snapshot?.tokens ?? []).filter((token) => token.nodeId === 'b1' && token.status === 'completed');
|
|
961
|
+
expect(branchCompleted.length).toBe(1);
|
|
962
|
+
}
|
|
963
|
+
finally {
|
|
964
|
+
await fixture.dispose();
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
t(`[${factory.name}] cancellation idempotency: duplicate IDs dedupe; competing IDs one canonical outcome`, async () => {
|
|
968
|
+
const fixture = await factory.create();
|
|
969
|
+
try {
|
|
970
|
+
const runtime = fixture.runtime;
|
|
971
|
+
let downstreamCalls = 0;
|
|
972
|
+
runtime
|
|
973
|
+
.registerCapability({
|
|
974
|
+
id: 'first',
|
|
975
|
+
revision: '1',
|
|
976
|
+
effect: 'pure',
|
|
977
|
+
input: neutralJsonContract,
|
|
978
|
+
output: neutralJsonContract,
|
|
979
|
+
invoke: () => 'one',
|
|
980
|
+
})
|
|
981
|
+
.registerCapability({
|
|
982
|
+
id: 'second',
|
|
983
|
+
revision: '1',
|
|
984
|
+
effect: 'pure',
|
|
985
|
+
input: neutralJsonContract,
|
|
986
|
+
output: neutralJsonContract,
|
|
987
|
+
invoke: () => {
|
|
988
|
+
downstreamCalls += 1;
|
|
989
|
+
return 'second';
|
|
990
|
+
},
|
|
991
|
+
})
|
|
992
|
+
.registerContract(stringContract('conf-string'));
|
|
993
|
+
const activated = await runtime.activate(signalWaitGraph());
|
|
994
|
+
expect(activated.ok).toBe(true);
|
|
995
|
+
const parked = await runtime.run('seed');
|
|
996
|
+
expect(parked.status).toBe('waiting');
|
|
997
|
+
// Duplicate ID: idempotent, no duplicate terminal event.
|
|
998
|
+
const first = await runtime.cancel({
|
|
999
|
+
runId: parked.runId,
|
|
1000
|
+
requestId: 'same-id',
|
|
1001
|
+
reasonCode: 'operator_request',
|
|
1002
|
+
});
|
|
1003
|
+
expect(first.ok).toBe(true);
|
|
1004
|
+
expect(first.ok ? first.status : '').toBe('accepted');
|
|
1005
|
+
const duplicate = await runtime.cancel({
|
|
1006
|
+
runId: parked.runId,
|
|
1007
|
+
requestId: 'same-id',
|
|
1008
|
+
reasonCode: 'operator_request',
|
|
1009
|
+
});
|
|
1010
|
+
expect(duplicate.ok).toBe(true);
|
|
1011
|
+
expect(duplicate.ok ? duplicate.status : '').toBe('duplicate');
|
|
1012
|
+
// Competing ID: still exactly one canonical terminal outcome.
|
|
1013
|
+
const competing = await runtime.cancel({
|
|
1014
|
+
runId: parked.runId,
|
|
1015
|
+
requestId: 'other-id',
|
|
1016
|
+
reasonCode: 'policy',
|
|
1017
|
+
});
|
|
1018
|
+
expect(competing.ok).toBe(true);
|
|
1019
|
+
const events = await fixture.orchestration.listOrchestrationEvents(parked.runId);
|
|
1020
|
+
expect(events.filter((event) => event.type === 'run.cancelled').length).toBe(1);
|
|
1021
|
+
const final = await runtime.resumeRun(parked.runId);
|
|
1022
|
+
expect(final.status).toBe('cancelled');
|
|
1023
|
+
expect(downstreamCalls).toBe(0);
|
|
1024
|
+
}
|
|
1025
|
+
finally {
|
|
1026
|
+
await fixture.dispose();
|
|
1027
|
+
}
|
|
1028
|
+
});
|
|
1029
|
+
t(`[${factory.name}] unhandled branch failure cancels unfinished siblings and fails the run exactly once`, async () => {
|
|
1030
|
+
const fixture = await factory.create();
|
|
1031
|
+
try {
|
|
1032
|
+
const runtime = fixture.runtime;
|
|
1033
|
+
let downstreamCalls = 0;
|
|
1034
|
+
let releaseSlow;
|
|
1035
|
+
const slowGate = new Promise((resolve) => {
|
|
1036
|
+
releaseSlow = resolve;
|
|
1037
|
+
});
|
|
1038
|
+
runtime
|
|
1039
|
+
.registerCapability({
|
|
1040
|
+
id: 'first',
|
|
1041
|
+
revision: '1',
|
|
1042
|
+
effect: 'pure',
|
|
1043
|
+
input: neutralJsonContract,
|
|
1044
|
+
output: neutralJsonContract,
|
|
1045
|
+
invoke: () => 'one',
|
|
1046
|
+
})
|
|
1047
|
+
.registerCapability({
|
|
1048
|
+
id: 'boom',
|
|
1049
|
+
revision: '1',
|
|
1050
|
+
effect: 'pure',
|
|
1051
|
+
input: neutralJsonContract,
|
|
1052
|
+
output: neutralJsonContract,
|
|
1053
|
+
invoke: () => {
|
|
1054
|
+
throw new Error('SECRET-CAUSE-SHOULD-NOT-LEAK');
|
|
1055
|
+
},
|
|
1056
|
+
})
|
|
1057
|
+
.registerCapability({
|
|
1058
|
+
id: 'slow',
|
|
1059
|
+
revision: '1',
|
|
1060
|
+
effect: 'pure',
|
|
1061
|
+
input: neutralJsonContract,
|
|
1062
|
+
output: neutralJsonContract,
|
|
1063
|
+
invoke: (input, context) => new Promise((resolve) => {
|
|
1064
|
+
context.abortSignal?.addEventListener('abort', () => resolve('aborted'), {
|
|
1065
|
+
once: true,
|
|
1066
|
+
});
|
|
1067
|
+
void slowGate.then(() => resolve('slow'));
|
|
1068
|
+
void input;
|
|
1069
|
+
}),
|
|
1070
|
+
})
|
|
1071
|
+
.registerCapability({
|
|
1072
|
+
id: 'after',
|
|
1073
|
+
revision: '1',
|
|
1074
|
+
effect: 'pure',
|
|
1075
|
+
input: neutralJsonContract,
|
|
1076
|
+
output: neutralJsonContract,
|
|
1077
|
+
invoke: () => {
|
|
1078
|
+
downstreamCalls += 1;
|
|
1079
|
+
return 'after';
|
|
1080
|
+
},
|
|
1081
|
+
});
|
|
1082
|
+
const activated = await runtime.activate({
|
|
1083
|
+
id: 'race-branch-failure',
|
|
1084
|
+
entry: 'a',
|
|
1085
|
+
nodes: [
|
|
1086
|
+
{ id: 'a', capability: 'first' },
|
|
1087
|
+
{ id: 'f', kind: 'fork', join: 'j' },
|
|
1088
|
+
{ id: 'b1', capability: 'boom' },
|
|
1089
|
+
{ id: 'b2', capability: 'slow' },
|
|
1090
|
+
{ id: 'j', kind: 'join', fork: 'f' },
|
|
1091
|
+
{ id: 'z', capability: 'after' },
|
|
1092
|
+
],
|
|
1093
|
+
edges: [
|
|
1094
|
+
{ from: 'a', to: 'f' },
|
|
1095
|
+
{ from: 'f', to: 'b1', kind: 'branch', key: 'a' },
|
|
1096
|
+
{ from: 'f', to: 'b2', kind: 'branch', key: 'b' },
|
|
1097
|
+
{ from: 'b1', to: 'j' },
|
|
1098
|
+
{ from: 'b2', to: 'j' },
|
|
1099
|
+
{ from: 'j', to: 'z' },
|
|
1100
|
+
],
|
|
1101
|
+
});
|
|
1102
|
+
expect(activated.ok).toBe(true);
|
|
1103
|
+
// Release the slow branch after the failure has landed; its late
|
|
1104
|
+
// completion is then fenced by the cancelled sibling token.
|
|
1105
|
+
setTimeout(() => releaseSlow?.(), 150);
|
|
1106
|
+
const result = await runtime.run('seed', { concurrency: 2 });
|
|
1107
|
+
expect(result.status).toBe('failed');
|
|
1108
|
+
expect(downstreamCalls).toBe(0);
|
|
1109
|
+
const events = await fixture.orchestration.listOrchestrationEvents(result.runId);
|
|
1110
|
+
expect(events.filter((event) => event.type === 'run.failed').length).toBe(1);
|
|
1111
|
+
// The thrown message never enters the durable ledger.
|
|
1112
|
+
expect(JSON.stringify(events).includes('SECRET-CAUSE-SHOULD-NOT-LEAK')).toBe(false);
|
|
1113
|
+
const snapshot = await fixture.orchestration.getOrchestrationSnapshot(result.runId);
|
|
1114
|
+
const unfinished = (snapshot?.tokens ?? []).filter((token) => ['ready', 'claimed', 'waiting'].includes(token.status));
|
|
1115
|
+
expect(unfinished.length).toBe(0);
|
|
1116
|
+
}
|
|
1117
|
+
finally {
|
|
1118
|
+
await fixture.dispose();
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
t(`[${factory.name}] blocked resolution: public surface denied by default; authorized, validated, idempotent`, async () => {
|
|
1122
|
+
const fixture = await factory.create();
|
|
1123
|
+
try {
|
|
1124
|
+
const { runtime, orchestration } = fixture;
|
|
1125
|
+
let invokeCount = 0;
|
|
1126
|
+
runtime.registerCapability({
|
|
1127
|
+
id: 'slowWrite',
|
|
1128
|
+
revision: '1',
|
|
1129
|
+
effect: 'write',
|
|
1130
|
+
input: neutralJsonContract,
|
|
1131
|
+
output: neutralJsonContract,
|
|
1132
|
+
invoke: async () => {
|
|
1133
|
+
invokeCount += 1;
|
|
1134
|
+
await settle(120);
|
|
1135
|
+
return 'applied';
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
runtime.registerCapability({
|
|
1139
|
+
id: 'after',
|
|
1140
|
+
revision: '1',
|
|
1141
|
+
effect: 'pure',
|
|
1142
|
+
input: neutralJsonContract,
|
|
1143
|
+
output: neutralJsonContract,
|
|
1144
|
+
invoke: (input) => `after:${JSON.stringify(input)}`,
|
|
1145
|
+
});
|
|
1146
|
+
runtime.registerContract({
|
|
1147
|
+
id: 'conf-blocked-output',
|
|
1148
|
+
revision: '1',
|
|
1149
|
+
expected: 'a record',
|
|
1150
|
+
parse: (input) => typeof input === 'object' && input !== null
|
|
1151
|
+
? { ok: true, value: input, issues: [] }
|
|
1152
|
+
: { ok: false, issues: [{ code: 'TYPE', path: '$', message: 'record' }] },
|
|
1153
|
+
});
|
|
1154
|
+
const activated = await runtime.activate({
|
|
1155
|
+
id: 'race-blocked',
|
|
1156
|
+
entry: 'w',
|
|
1157
|
+
nodes: [
|
|
1158
|
+
{ id: 'w', capability: 'slowWrite', timeoutMs: 20, output: 'conf-blocked-output' },
|
|
1159
|
+
{ id: 'z', capability: 'after' },
|
|
1160
|
+
],
|
|
1161
|
+
edges: [{ from: 'w', to: 'z' }],
|
|
1162
|
+
});
|
|
1163
|
+
expect(activated.ok).toBe(true);
|
|
1164
|
+
const result = await runtime.run('seed');
|
|
1165
|
+
expect(result.status).toBe('blocked');
|
|
1166
|
+
expect(invokeCount).toBe(1);
|
|
1167
|
+
// Denial by default: no resolution happens without authorization.
|
|
1168
|
+
const denied = await runtime.resolveBlocked({
|
|
1169
|
+
runId: result.runId,
|
|
1170
|
+
resolutionId: 'res-1',
|
|
1171
|
+
action: 'confirm_applied',
|
|
1172
|
+
output: { applied: true },
|
|
1173
|
+
reasonCode: 'operator_request',
|
|
1174
|
+
});
|
|
1175
|
+
expect(denied.ok).toBe(false);
|
|
1176
|
+
expect(denied.ok ? '' : denied.code).toBe('VICT_ORCH_OPERATOR_DENIED');
|
|
1177
|
+
expect(invokeCount).toBe(1);
|
|
1178
|
+
// Authorized surface: same stores, explicitly authorized runtime that
|
|
1179
|
+
// re-registers the identical pinned artifacts.
|
|
1180
|
+
const operatorRuntime = await fixture.createOperatorRuntime();
|
|
1181
|
+
operatorRuntime
|
|
1182
|
+
.registerCapability({
|
|
1183
|
+
id: 'slowWrite',
|
|
1184
|
+
revision: '1',
|
|
1185
|
+
effect: 'write',
|
|
1186
|
+
input: neutralJsonContract,
|
|
1187
|
+
output: neutralJsonContract,
|
|
1188
|
+
invoke: async () => {
|
|
1189
|
+
invokeCount += 1;
|
|
1190
|
+
return 'applied';
|
|
1191
|
+
},
|
|
1192
|
+
})
|
|
1193
|
+
.registerCapability({
|
|
1194
|
+
id: 'after',
|
|
1195
|
+
revision: '1',
|
|
1196
|
+
effect: 'pure',
|
|
1197
|
+
input: neutralJsonContract,
|
|
1198
|
+
output: neutralJsonContract,
|
|
1199
|
+
invoke: (input) => `after:${JSON.stringify(input)}`,
|
|
1200
|
+
})
|
|
1201
|
+
.registerContract({
|
|
1202
|
+
id: 'conf-blocked-output',
|
|
1203
|
+
revision: '1',
|
|
1204
|
+
expected: 'a record',
|
|
1205
|
+
parse: (input) => typeof input === 'object' && input !== null
|
|
1206
|
+
? { ok: true, value: input, issues: [] }
|
|
1207
|
+
: { ok: false, issues: [{ code: 'TYPE', path: '$', message: 'record' }] },
|
|
1208
|
+
});
|
|
1209
|
+
const runBefore = await orchestration.getOrchestrationRun(result.runId);
|
|
1210
|
+
const accepted = await operatorRuntime.resolveBlocked({
|
|
1211
|
+
runId: result.runId,
|
|
1212
|
+
resolutionId: 'res-2',
|
|
1213
|
+
action: 'confirm_applied',
|
|
1214
|
+
output: { applied: true },
|
|
1215
|
+
reasonCode: 'operator_request',
|
|
1216
|
+
expectedRunRevision: runBefore?.recordRevision,
|
|
1217
|
+
});
|
|
1218
|
+
expect(accepted.ok).toBe(true);
|
|
1219
|
+
expect(accepted.ok ? accepted.status : '').toBe('accepted');
|
|
1220
|
+
// Duplicate resolution ID is idempotent.
|
|
1221
|
+
const duplicate = await operatorRuntime.resolveBlocked({
|
|
1222
|
+
runId: result.runId,
|
|
1223
|
+
resolutionId: 'res-2',
|
|
1224
|
+
action: 'confirm_applied',
|
|
1225
|
+
output: { applied: true },
|
|
1226
|
+
reasonCode: 'operator_request',
|
|
1227
|
+
expectedRunRevision: runBefore?.recordRevision,
|
|
1228
|
+
});
|
|
1229
|
+
expect(duplicate.ok).toBe(true);
|
|
1230
|
+
expect(duplicate.ok ? duplicate.status : '').toBe('duplicate');
|
|
1231
|
+
// Same ID with different content conflicts.
|
|
1232
|
+
const conflicting = await operatorRuntime.resolveBlocked({
|
|
1233
|
+
runId: result.runId,
|
|
1234
|
+
resolutionId: 'res-2',
|
|
1235
|
+
action: 'fail',
|
|
1236
|
+
failCode: 'VICT_ORCH_OPERATOR_FAILED',
|
|
1237
|
+
reasonCode: 'policy',
|
|
1238
|
+
});
|
|
1239
|
+
expect(conflicting.ok).toBe(false);
|
|
1240
|
+
// Stale expected revision conflicts.
|
|
1241
|
+
const stale = await operatorRuntime.resolveBlocked({
|
|
1242
|
+
runId: result.runId,
|
|
1243
|
+
resolutionId: 'res-3',
|
|
1244
|
+
action: 'fail',
|
|
1245
|
+
failCode: 'VICT_ORCH_OPERATOR_FAILED',
|
|
1246
|
+
reasonCode: 'policy',
|
|
1247
|
+
expectedRunRevision: (runBefore?.recordRevision ?? 0) - 1,
|
|
1248
|
+
});
|
|
1249
|
+
expect(stale.ok).toBe(false);
|
|
1250
|
+
// The authorized resolution resumes the durable work; drive it to
|
|
1251
|
+
// completion under the same pinned activation.
|
|
1252
|
+
const final = await runtime.resumeRun(result.runId);
|
|
1253
|
+
expect(final.status).toBe('completed');
|
|
1254
|
+
expect(final.output).toBe('after:{"applied":true}');
|
|
1255
|
+
const events = await orchestration.listOrchestrationEvents(result.runId);
|
|
1256
|
+
expect(events.filter((event) => event.type === 'operator.intervened').length).toBe(1);
|
|
1257
|
+
const run = await orchestration.getOrchestrationRun(result.runId);
|
|
1258
|
+
expect(run?.status).toBe('completed');
|
|
1259
|
+
}
|
|
1260
|
+
finally {
|
|
1261
|
+
await fixture.dispose();
|
|
1262
|
+
}
|
|
1263
|
+
});
|
|
1264
|
+
}
|
|
1265
|
+
//# sourceMappingURL=orchestration-race-conformance.js.map
|