@nanobpm/nano-workforce 0.200.5 → 0.200.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.
- package/CHANGELOG.md +12 -0
- package/app/deliveryGraphDispatch.test.ts +177 -11
- package/app/deliveryGraphDispatch.ts +72 -3
- package/app/deliveryGraphDispatchLayoutOnce.test.ts +13 -0
- package/app/deliveryGraphRun.test.ts +324 -1
- package/app/deliveryGraphRun.ts +194 -3
- package/app/deliveryRunner.test.ts +22 -0
- package/app/deliveryRunner.ts +15 -3
- package/app/service.ts +52 -3
- package/docs/agent-guide.md +9 -0
- package/operations/dispatchDeliveryGraph.test.ts +23 -1
- package/package.json +2 -2
- package/pages/delivery-graphs/staged.mount.js +17 -6
- package/test/delivery-graphs-staged-slow-dispatch.test.ts +85 -0
|
@@ -8,7 +8,7 @@ import { mkdtempSync, rmSync } from "node:fs";
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join, resolve } from "node:path";
|
|
10
10
|
import { test } from "node:test";
|
|
11
|
-
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
11
|
+
import { assert, assertEquals, assertRejects, assertStringIncludes } from "#test-assert";
|
|
12
12
|
import type { DataLayer } from "@nanobpm/urban";
|
|
13
13
|
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
14
14
|
import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
|
|
@@ -22,7 +22,12 @@ import {
|
|
|
22
22
|
deliveryGraphRunIdentities,
|
|
23
23
|
deliveryGraphRuns,
|
|
24
24
|
humanTaskElementId,
|
|
25
|
+
isStaleLaunchClaim,
|
|
26
|
+
LAUNCH_CLAIM_TTL_MS,
|
|
25
27
|
parseHumanLabels,
|
|
28
|
+
ReconcileConflictError,
|
|
29
|
+
reconcileOriginalInstanceBeforeRelaunch,
|
|
30
|
+
reconcileStaleLaunchClaim,
|
|
26
31
|
} from "./deliveryGraphRun.ts";
|
|
27
32
|
import { pollDeliveryGraphPhase } from "./service.ts";
|
|
28
33
|
|
|
@@ -127,6 +132,324 @@ test("claimRunForLaunch: winning a re-run claim ATOMICALLY invalidates the prior
|
|
|
127
132
|
});
|
|
128
133
|
});
|
|
129
134
|
|
|
135
|
+
// ── #852: a launch claim stranded by a crash mid-launch must not wedge the run forever ──────────────
|
|
136
|
+
// The dispatch claims the row `running` (process_key null) BEFORE deploy; if the process dies mid-launch
|
|
137
|
+
// (merlin: a 54-node graph spent ~105 s in layout, the app went down) `markClaimFailed` never runs, and
|
|
138
|
+
// the phantom row short-circuited every re-dispatch onto a run that does not exist.
|
|
139
|
+
const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
|
|
140
|
+
|
|
141
|
+
test("#852 isStaleLaunchClaim: only a `running` row with NO process key older than the launch TTL is stale", () => {
|
|
142
|
+
const base = claimRow("running");
|
|
143
|
+
assertEquals(isStaleLaunchClaim({ ...base, updated_at: ago(LAUNCH_CLAIM_TTL_MS + 1000) }), true);
|
|
144
|
+
assertEquals(isStaleLaunchClaim({ ...base, updated_at: ago(1000) }), false, "an in-flight launch is not stale");
|
|
145
|
+
assertEquals(isStaleLaunchClaim({ ...base, process_key: "29", updated_at: ago(LAUNCH_CLAIM_TTL_MS * 10) }), false, "a launched run is never stale");
|
|
146
|
+
assertEquals(isStaleLaunchClaim({ ...base, status: "failed", updated_at: ago(LAUNCH_CLAIM_TTL_MS * 10) }), false);
|
|
147
|
+
assert(LAUNCH_CLAIM_TTL_MS >= 10 * 60_000, "the TTL must comfortably exceed a slow large-graph launch");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("#852 claimRunForLaunch: a STALE launch claim is re-claimable; a fresh in-flight claim still blocks the double-launch", async () => {
|
|
151
|
+
await withData(async (data) => {
|
|
152
|
+
const runs = deliveryGraphRuns(data);
|
|
153
|
+
await runs.insert({ ...claimRow("running"), updated_at: ago(1000) });
|
|
154
|
+
assertEquals(await claimRunForLaunch(data, true, claimRow("running")), false, "fresh claim: launch still in flight");
|
|
155
|
+
await runs.update("rk", { updated_at: ago(LAUNCH_CLAIM_TTL_MS + 1000) });
|
|
156
|
+
assertEquals(await claimRunForLaunch(data, true, claimRow("running")), true, "stale claim: the crashed launch is re-claimed");
|
|
157
|
+
assertEquals(await claimRunForLaunch(data, true, claimRow("running")), false, "…by exactly one racer");
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("#852 pollDeliveryGraphPhase: a stale launch claim is reconciled to failed; a fresh one is left alone", async () => {
|
|
162
|
+
await withData(async (data) => {
|
|
163
|
+
const runs = deliveryGraphRuns(data);
|
|
164
|
+
await runs.insert({ ...claimRow("running"), run_key: "stale", updated_at: ago(LAUNCH_CLAIM_TTL_MS + 1000) });
|
|
165
|
+
await runs.insert({ ...claimRow("running"), run_key: "fresh", updated_at: ago(1000) });
|
|
166
|
+
const engine = {
|
|
167
|
+
searchProcessInstances: async () => [],
|
|
168
|
+
searchUserTasks: async () => [],
|
|
169
|
+
};
|
|
170
|
+
await pollDeliveryGraphPhase(data, engine as never);
|
|
171
|
+
assertEquals((await runs.get("stale"))?.status, "failed");
|
|
172
|
+
assertEquals((await runs.get("stale"))?.phase, DELIVERY_PHASE.FAILED);
|
|
173
|
+
assertEquals((await runs.get("fresh"))?.status, "running");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("#852 pollDeliveryGraphPhase: retiring a stale claim cancels a still-running ORIGINAL instance first (at-most-once recovery)", async () => {
|
|
178
|
+
await withData(async (data) => {
|
|
179
|
+
const runs = deliveryGraphRuns(data);
|
|
180
|
+
// A stale claim whose dispatch died AFTER createInstance but BEFORE stamping the key: the row has a
|
|
181
|
+
// NULL process_key AND a NULL process_definition_id (the stamp never landed) — the poller derives the
|
|
182
|
+
// definition id from the row's `digest` (`delivery-graph-d`). A LIVE instance of this run is ACTIVE.
|
|
183
|
+
await runs.insert({
|
|
184
|
+
...claimRow("running"),
|
|
185
|
+
run_key: "rk",
|
|
186
|
+
digest: "d",
|
|
187
|
+
updated_at: ago(LAUNCH_CLAIM_TTL_MS + 1000),
|
|
188
|
+
});
|
|
189
|
+
const cancelled: string[] = [];
|
|
190
|
+
const engine = {
|
|
191
|
+
searchProcessInstances: async (filter?: { processDefinitionId?: string; state?: string }) =>
|
|
192
|
+
filter?.state === "ACTIVE" ? [{ processInstanceKey: "PI-live", state: "ACTIVE" }] : [],
|
|
193
|
+
searchVariables: async (filter?: { processInstanceKey?: string; name?: string }) =>
|
|
194
|
+
filter?.name === "runKey" && filter.processInstanceKey === "PI-live"
|
|
195
|
+
? [{ variableKey: "v", name: "runKey", value: JSON.stringify("rk"), scopeKey: "PI-live", processInstanceKey: "PI-live", isTruncated: false }]
|
|
196
|
+
: [],
|
|
197
|
+
cancelInstance: async (req: { processInstanceKey: string }) => {
|
|
198
|
+
cancelled.push(req.processInstanceKey);
|
|
199
|
+
},
|
|
200
|
+
searchUserTasks: async () => [],
|
|
201
|
+
};
|
|
202
|
+
await pollDeliveryGraphPhase(data, engine as never);
|
|
203
|
+
assertEquals(cancelled, ["PI-live"], "the live original is cancelled before the claim is retired");
|
|
204
|
+
assertEquals((await runs.get("rk"))?.status, "failed");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("#852 pollDeliveryGraphPhase: a reconcile read failure leaves the stale claim for the next pass (no wedge, no blind retire)", async () => {
|
|
209
|
+
await withData(async (data) => {
|
|
210
|
+
const runs = deliveryGraphRuns(data);
|
|
211
|
+
await runs.insert({
|
|
212
|
+
...claimRow("running"),
|
|
213
|
+
run_key: "rk",
|
|
214
|
+
digest: "d",
|
|
215
|
+
updated_at: ago(LAUNCH_CLAIM_TTL_MS + 1000),
|
|
216
|
+
});
|
|
217
|
+
const engine = {
|
|
218
|
+
searchProcessInstances: async () => {
|
|
219
|
+
throw new Error("engine read unavailable");
|
|
220
|
+
},
|
|
221
|
+
searchUserTasks: async () => [],
|
|
222
|
+
};
|
|
223
|
+
await pollDeliveryGraphPhase(data, engine as never);
|
|
224
|
+
// The claim is NOT retired this pass (the reconcile could not prove no live original), so a
|
|
225
|
+
// re-dispatch is not waved through into a possible double-launch; the next pass retries.
|
|
226
|
+
assertEquals((await runs.get("rk"))?.status, "running");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("#853 pollDeliveryGraphPhase: a FRESH in-flight claim (within TTL) is never engine-cancelled — the cancel is fenced behind the staleness CAS", async () => {
|
|
231
|
+
await withData(async (data) => {
|
|
232
|
+
const runs = deliveryGraphRuns(data);
|
|
233
|
+
// A launch that is still in flight: running, no process key yet, but WELL within the launch TTL.
|
|
234
|
+
// `createInstance` may have just succeeded (a legitimate live instance) with the key stamp still
|
|
235
|
+
// pending. The poller must NOT cancel that instance — the row is not stale, so the reconcile lease
|
|
236
|
+
// is never acquired and `finalize` (the cancel) never runs.
|
|
237
|
+
await runs.insert({ ...claimRow("running"), run_key: "fresh", digest: "d", updated_at: ago(1000) });
|
|
238
|
+
const cancelled: string[] = [];
|
|
239
|
+
const engine = {
|
|
240
|
+
searchProcessInstances: async (filter?: { state?: string }) =>
|
|
241
|
+
filter?.state === "ACTIVE" ? [{ processInstanceKey: "PI-legit", state: "ACTIVE" }] : [],
|
|
242
|
+
searchVariables: async (filter?: { processInstanceKey?: string; name?: string }) =>
|
|
243
|
+
filter?.name === "runKey" && filter.processInstanceKey === "PI-legit"
|
|
244
|
+
? [{ variableKey: "v", name: "runKey", value: JSON.stringify("fresh"), scopeKey: "PI-legit", processInstanceKey: "PI-legit", isTruncated: false }]
|
|
245
|
+
: [],
|
|
246
|
+
cancelInstance: async (req: { processInstanceKey: string }) => {
|
|
247
|
+
cancelled.push(req.processInstanceKey);
|
|
248
|
+
},
|
|
249
|
+
searchUserTasks: async () => [],
|
|
250
|
+
};
|
|
251
|
+
await pollDeliveryGraphPhase(data, engine as never);
|
|
252
|
+
assertEquals(cancelled, [], "a fresh in-flight claim's legitimate instance is never cancelled");
|
|
253
|
+
assertEquals((await runs.get("fresh"))?.status, "running", "the fresh claim is left running, not retired");
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("#853 reconcileStaleLaunchClaim: a concurrent dispatch that re-claims the stale row first is NOT cancelled — the finalize cancel runs only for the lease owner", async () => {
|
|
258
|
+
await withData(async (data) => {
|
|
259
|
+
const runs = deliveryGraphRuns(data);
|
|
260
|
+
// The poller read this row as a stale launch claim …
|
|
261
|
+
const observed = { ...claimRow("running"), updated_at: ago(LAUNCH_CLAIM_TTL_MS + 1000) };
|
|
262
|
+
await runs.insert(observed);
|
|
263
|
+
// … but before the poller's CAS, a concurrent dispatch atomically RE-CLAIMS the stale row
|
|
264
|
+
// (refreshing `updated_at`) and starts its replacement instance.
|
|
265
|
+
assertEquals(await claimRunForLaunch(data, true, claimRow("running")), true, "concurrent dispatch re-claims the stale row");
|
|
266
|
+
let cancelled = false;
|
|
267
|
+
// The poller now reconciles off its STALE snapshot, passing the engine cancel as `finalize`. The CAS
|
|
268
|
+
// matches zero rows (updated_at moved), so the lease is not acquired and `finalize` must NOT run —
|
|
269
|
+
// the live replacement is never cancelled.
|
|
270
|
+
const flipped = await reconcileStaleLaunchClaim(data, observed, async () => {
|
|
271
|
+
cancelled = true;
|
|
272
|
+
});
|
|
273
|
+
assertEquals(flipped, false, "the stale snapshot no longer matches → no flip");
|
|
274
|
+
assertEquals(cancelled, false, "the cancel is fenced: it never runs against a claim the poller did not win");
|
|
275
|
+
assertEquals((await runs.get("rk"))?.status, "running", "the renewed claim is left running");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("#853 reconcileStaleLaunchClaim: the lease holder's finalize cancel DOES run, and the row is retired to failed only for the lease owner", async () => {
|
|
280
|
+
await withData(async (data) => {
|
|
281
|
+
const runs = deliveryGraphRuns(data);
|
|
282
|
+
const observed = { ...claimRow("running"), updated_at: ago(LAUNCH_CLAIM_TTL_MS + 1000) };
|
|
283
|
+
await runs.insert(observed);
|
|
284
|
+
let cancelled = false;
|
|
285
|
+
const flipped = await reconcileStaleLaunchClaim(data, observed, async () => {
|
|
286
|
+
cancelled = true;
|
|
287
|
+
});
|
|
288
|
+
assertEquals(flipped, true, "the unchanged stale claim is flipped (lease acquired)");
|
|
289
|
+
assertEquals(cancelled, true, "the lease owner's finalize cancel runs");
|
|
290
|
+
assertEquals((await runs.get("rk"))?.status, "failed", "the row is retired to failed");
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("#852 reconcileStaleLaunchClaim: the retire-to-failed flip is a CAS on the OBSERVED snapshot — a claim a concurrent dispatch re-claimed (refreshing updated_at) between the poller's read and the write is NOT clobbered", async () => {
|
|
295
|
+
await withData(async (data) => {
|
|
296
|
+
const runs = deliveryGraphRuns(data);
|
|
297
|
+
// The poller read this row as a stale launch claim …
|
|
298
|
+
const observed = { ...claimRow("running"), updated_at: ago(LAUNCH_CLAIM_TTL_MS + 1000) };
|
|
299
|
+
await runs.insert(observed);
|
|
300
|
+
// … but before it writes, a concurrent dispatch atomically RE-CLAIMS the stale row, refreshing
|
|
301
|
+
// `updated_at` to a fresh in-flight claim (still running, still no process key yet).
|
|
302
|
+
assertEquals(await claimRunForLaunch(data, true, claimRow("running")), true, "concurrent dispatch re-claims the stale row");
|
|
303
|
+
const reclaimedAt = (await runs.get("rk"))?.updated_at;
|
|
304
|
+
// The poller now reconciles off its STALE snapshot. The CAS must find no matching row and leave the
|
|
305
|
+
// fresh claim untouched — otherwise a third dispatch could re-claim mid-layout and double-launch.
|
|
306
|
+
assertEquals(await reconcileStaleLaunchClaim(data, observed), false, "stale snapshot no longer matches → no flip");
|
|
307
|
+
const row = await runs.get("rk");
|
|
308
|
+
assertEquals(row?.status, "running", "the renewed claim is left running, not clobbered to failed");
|
|
309
|
+
assertEquals(row?.process_key, null);
|
|
310
|
+
assertEquals(row?.updated_at, reclaimedAt, "the renewed claim's updated_at is preserved");
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("#852 reconcileStaleLaunchClaim: a genuinely stale claim whose snapshot still matches IS retired to failed exactly once", async () => {
|
|
315
|
+
await withData(async (data) => {
|
|
316
|
+
const runs = deliveryGraphRuns(data);
|
|
317
|
+
const observed = { ...claimRow("running"), updated_at: ago(LAUNCH_CLAIM_TTL_MS + 1000) };
|
|
318
|
+
await runs.insert(observed);
|
|
319
|
+
assertEquals(await reconcileStaleLaunchClaim(data, observed), true, "the unchanged stale claim is flipped");
|
|
320
|
+
assertEquals((await runs.get("rk"))?.status, "failed");
|
|
321
|
+
assertEquals((await runs.get("rk"))?.phase, DELIVERY_PHASE.FAILED);
|
|
322
|
+
assertEquals(await reconcileStaleLaunchClaim(data, observed), false, "already failed → no second flip");
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("#852 reconcileStaleLaunchClaim: a non-stale claim (launched, or within TTL) is never retired", async () => {
|
|
327
|
+
await withData(async (data) => {
|
|
328
|
+
const runs = deliveryGraphRuns(data);
|
|
329
|
+
const fresh = { ...claimRow("running"), updated_at: ago(1000) };
|
|
330
|
+
await runs.insert(fresh);
|
|
331
|
+
assertEquals(await reconcileStaleLaunchClaim(data, fresh), false, "an in-flight launch is not stale");
|
|
332
|
+
assertEquals((await runs.get("rk"))?.status, "running");
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ── #852 review: reconcile-before-relaunch (the at-most-once fence a timeout-only reclaim lacks) ──
|
|
337
|
+
// A stale launch claim can hide a LIVE original instance (the dispatch died after createInstance but
|
|
338
|
+
// before stamping the process key). `reconcileOriginalInstanceBeforeRelaunch` cancels that original —
|
|
339
|
+
// matched by its seeded `runKey` variable — before a relaunch starts a second instance of the same
|
|
340
|
+
// side-effecting graph (thread deliveryGraphRun.ts:180).
|
|
341
|
+
|
|
342
|
+
/** A minimal engine stub for the reconcile seam: serves the configured ACTIVE instances, their `runKey`
|
|
343
|
+
* variables, and records cancels. Each instance's `runKey` is seeded at the RUN-ROOT scope (scopeKey ===
|
|
344
|
+
* processInstanceKey); an optional `childRunKey` seeds a SAME-NAMED variable at a DIFFERENT (child) scope
|
|
345
|
+
* to exercise the scope-restriction guard. */
|
|
346
|
+
function reconcileEngine(instances: { processInstanceKey: string; runKey?: string; childRunKey?: string; truncated?: boolean }[]) {
|
|
347
|
+
const cancelled: string[] = [];
|
|
348
|
+
const engine = {
|
|
349
|
+
searchProcessInstances: async (filter?: { processDefinitionId?: string; state?: string }) =>
|
|
350
|
+
filter?.state === "ACTIVE" || filter?.state === undefined
|
|
351
|
+
? instances.map((i) => ({ processInstanceKey: i.processInstanceKey, state: "ACTIVE" }))
|
|
352
|
+
: [],
|
|
353
|
+
searchVariables: async (filter?: { processInstanceKey?: string; name?: string }) => {
|
|
354
|
+
const inst = instances.find((i) => i.processInstanceKey === filter?.processInstanceKey);
|
|
355
|
+
if (!inst || filter?.name !== "runKey") return [];
|
|
356
|
+
const out: { variableKey: string; name: string; value: string; scopeKey: string; processInstanceKey: string; isTruncated: boolean }[] = [];
|
|
357
|
+
if (inst.runKey !== undefined) {
|
|
358
|
+
out.push({ variableKey: "v", name: "runKey", value: JSON.stringify(inst.runKey), scopeKey: inst.processInstanceKey, processInstanceKey: inst.processInstanceKey, isTruncated: inst.truncated ?? false });
|
|
359
|
+
}
|
|
360
|
+
if (inst.childRunKey !== undefined) {
|
|
361
|
+
out.push({ variableKey: "vc", name: "runKey", value: JSON.stringify(inst.childRunKey), scopeKey: `${inst.processInstanceKey}-child`, processInstanceKey: inst.processInstanceKey, isTruncated: false });
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
},
|
|
365
|
+
cancelInstance: async (req: { processInstanceKey: string }) => {
|
|
366
|
+
cancelled.push(req.processInstanceKey);
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
return { engine, cancelled };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
test("#852 reconcileOriginalInstanceBeforeRelaunch: a live original carrying THIS run key is cancelled before relaunch", async () => {
|
|
373
|
+
const { engine, cancelled } = reconcileEngine([{ processInstanceKey: "PI-orig", runKey: "rk" }]);
|
|
374
|
+
const cancelledKeys = await reconcileOriginalInstanceBeforeRelaunch(engine as never, { runKey: "rk", processDefinitionId: "delivery-graph-d" });
|
|
375
|
+
assertEquals(cancelledKeys, ["PI-orig"]);
|
|
376
|
+
assertEquals(cancelled, ["PI-orig"], "the live original is cancelled so the relaunch stays at-most-once");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("#852 reconcileOriginalInstanceBeforeRelaunch: an ACTIVE instance of the SAME definition but a DIFFERENT run key is left running", async () => {
|
|
380
|
+
// Two distinct runs of one graph share the content-addressed processDefinitionId; the definition
|
|
381
|
+
// filter alone would cancel a DIFFERENT run's live instance. The runKey variable is the proof.
|
|
382
|
+
const { engine, cancelled } = reconcileEngine([{ processInstanceKey: "PI-other", runKey: "other-run" }]);
|
|
383
|
+
const cancelledKeys = await reconcileOriginalInstanceBeforeRelaunch(engine as never, { runKey: "rk", processDefinitionId: "delivery-graph-d" });
|
|
384
|
+
assertEquals(cancelledKeys, []);
|
|
385
|
+
assertEquals(cancelled, [], "a different run's instance is never cancelled");
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("#853 reconcileOriginalInstanceBeforeRelaunch: an ACTIVE same-definition candidate with NO run-root runKey (pre-upgrade legacy) fails CLOSED — throws rather than waiving a blind relaunch", async () => {
|
|
389
|
+
// A legacy instance launched before this PR seeded the correlation variable has no readable run-root
|
|
390
|
+
// `runKey`. Treating that as "not ours → relaunch" would double-run its side-effecting nodes during
|
|
391
|
+
// the upgrade window (thread deliveryGraphRun.ts:328). We can neither cancel it (unproven) nor rule it
|
|
392
|
+
// out, so we fail closed: throw ReconcileConflictError so the stale claim stays reconcile-pending.
|
|
393
|
+
const { engine, cancelled } = reconcileEngine([{ processInstanceKey: "PI-legacy" }]);
|
|
394
|
+
await assertRejects(
|
|
395
|
+
() => reconcileOriginalInstanceBeforeRelaunch(engine as never, { runKey: "rk", processDefinitionId: "delivery-graph-d" }),
|
|
396
|
+
ReconcileConflictError,
|
|
397
|
+
"PI-legacy",
|
|
398
|
+
);
|
|
399
|
+
assertEquals(cancelled, [], "never cancel an instance we cannot prove belongs to this run");
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("#853 reconcileOriginalInstanceBeforeRelaunch: a TRUNCATED run-root runKey value also fails CLOSED (unreadable ≠ not ours)", async () => {
|
|
403
|
+
// The engine capped the returned root value, so a byte compare would spuriously differ and fail OPEN.
|
|
404
|
+
// Same class as the absent case: unreadable must not be treated as a different run.
|
|
405
|
+
const { engine, cancelled } = reconcileEngine([{ processInstanceKey: "PI-trunc", runKey: "rk", truncated: true }]);
|
|
406
|
+
await assertRejects(
|
|
407
|
+
() => reconcileOriginalInstanceBeforeRelaunch(engine as never, { runKey: "rk", processDefinitionId: "delivery-graph-d" }),
|
|
408
|
+
ReconcileConflictError,
|
|
409
|
+
"PI-trunc",
|
|
410
|
+
);
|
|
411
|
+
assertEquals(cancelled, [], "a truncated root value is unclassifiable, not a cancel");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("#853 reconcileOriginalInstanceBeforeRelaunch: an unclassifiable candidate does NOT block once we positively matched and cancelled our own original", async () => {
|
|
415
|
+
// We found and cancelled our run-root match, so we KNOW which instance is ours; an unclassifiable
|
|
416
|
+
// sibling (a different legacy run of the same graph) must not block our relaunch.
|
|
417
|
+
const { engine, cancelled } = reconcileEngine([
|
|
418
|
+
{ processInstanceKey: "PI-legacy" },
|
|
419
|
+
{ processInstanceKey: "PI-orig", runKey: "rk" },
|
|
420
|
+
]);
|
|
421
|
+
const cancelledKeys = await reconcileOriginalInstanceBeforeRelaunch(engine as never, { runKey: "rk", processDefinitionId: "delivery-graph-d" });
|
|
422
|
+
assertEquals(cancelledKeys, ["PI-orig"]);
|
|
423
|
+
assertEquals(cancelled, ["PI-orig"], "a positive match resolves the reconcile; the unclassifiable sibling is a different run");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("#852 reconcileOriginalInstanceBeforeRelaunch: no ACTIVE instances → nothing to cancel", async () => {
|
|
427
|
+
const { engine, cancelled } = reconcileEngine([]);
|
|
428
|
+
const cancelledKeys = await reconcileOriginalInstanceBeforeRelaunch(engine as never, { runKey: "rk", processDefinitionId: "delivery-graph-d" });
|
|
429
|
+
assertEquals(cancelledKeys, []);
|
|
430
|
+
assertEquals(cancelled, []);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("#853 reconcileOriginalInstanceBeforeRelaunch: a CHILD-scoped runKey equal to ours does NOT mark an unrelated run as ours (scope-restricted match)", async () => {
|
|
434
|
+
// The candidate's ROOT runKey is a DIFFERENT run; it only happens to carry a same-named `runKey`
|
|
435
|
+
// variable in a CHILD scope (a graph-emitted fact, or an agent job-scope field) whose value equals
|
|
436
|
+
// our run key. Matching that child value would cancel an unrelated live run (thread
|
|
437
|
+
// deliveryGraphRun.ts:318). The match is restricted to the run-root scope, so this is left running.
|
|
438
|
+
const { engine, cancelled } = reconcileEngine([{ processInstanceKey: "PI-other", runKey: "other-run", childRunKey: "rk" }]);
|
|
439
|
+
const cancelledKeys = await reconcileOriginalInstanceBeforeRelaunch(engine as never, { runKey: "rk", processDefinitionId: "delivery-graph-d" });
|
|
440
|
+
assertEquals(cancelledKeys, []);
|
|
441
|
+
assertEquals(cancelled, [], "a child-scoped coincidental runKey never triggers a cancel of another run");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("#853 reconcileOriginalInstanceBeforeRelaunch: our run is still cancelled when it ALSO carries a differing child-scoped runKey", async () => {
|
|
445
|
+
// The run-root scope carries OUR run key (a true original) while a child scope carries a different
|
|
446
|
+
// value. The scope-restricted match must still find the root variable and cancel the original.
|
|
447
|
+
const { engine, cancelled } = reconcileEngine([{ processInstanceKey: "PI-orig", runKey: "rk", childRunKey: "something-else" }]);
|
|
448
|
+
const cancelledKeys = await reconcileOriginalInstanceBeforeRelaunch(engine as never, { runKey: "rk", processDefinitionId: "delivery-graph-d" });
|
|
449
|
+
assertEquals(cancelledKeys, ["PI-orig"]);
|
|
450
|
+
assertEquals(cancelled, ["PI-orig"], "a true root-scope match is still cancelled despite a noisy child variable");
|
|
451
|
+
});
|
|
452
|
+
|
|
130
453
|
// ── pollDeliveryGraphPhase: engine-key coercion ───────────────────────────────
|
|
131
454
|
test("pollDeliveryGraphPhase: a numeric engine processInstanceKey still matches the string process_key, so a COMPLETED instance reconciles to done", async () => {
|
|
132
455
|
await withData(async (data) => {
|
package/app/deliveryGraphRun.ts
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
// there is no agent-facing approval gate to park a run at. The old replayable `approvalToken` and the
|
|
24
24
|
// approval-park write were removed with the agent `start` door.
|
|
25
25
|
|
|
26
|
-
import type { DataLayer, ProcessInstanceState } from "@nanobpm/urban";
|
|
26
|
+
import type { DataLayer, EngineClient, ProcessInstanceState } from "@nanobpm/urban";
|
|
27
27
|
import type { CompileDeliveryGraphResult } from "../nano-generated/api-io.d.ts";
|
|
28
28
|
import { isUniqueConstraintFence } from "./dbFence.ts";
|
|
29
29
|
import { redactFreeText } from "./deliveryGraphCompiler.ts";
|
|
@@ -116,6 +116,26 @@ export interface DeliveryGraphRunIdentity {
|
|
|
116
116
|
export const deliveryGraphRunIdentities = (data: DataLayer) =>
|
|
117
117
|
data.table<DeliveryGraphRunIdentity>("delivery_graph_run_identity", "run_key");
|
|
118
118
|
|
|
119
|
+
/** How long a launch claim (`running` with no `process_key` yet) may stay unlaunched before it is
|
|
120
|
+
* presumed dead (issue #852). A dispatch claims the row BEFORE the CPU-bound layout + deploy; a large
|
|
121
|
+
* graph legitimately spends minutes there (54 nodes ≈ 105 s on merlin), but a process that dies
|
|
122
|
+
* mid-launch never runs `markClaimFailed`, stranding a phantom `running` row that short-circuited every
|
|
123
|
+
* re-dispatch onto a run that does not exist. Past this TTL the claim is re-claimable by a dispatch and
|
|
124
|
+
* reconciled to `failed` by the poller (`pollDeliveryGraphPhase`). Generous on purpose: re-claiming a
|
|
125
|
+
* launch that is in fact still in flight would double-launch. */
|
|
126
|
+
export const LAUNCH_CLAIM_TTL_MS = 15 * 60_000;
|
|
127
|
+
|
|
128
|
+
/** True when `run` is a launch claim that never got an instance key within {@link LAUNCH_CLAIM_TTL_MS}
|
|
129
|
+
* (issue #852). A launched run (process key set) or a non-`running` row is never stale. */
|
|
130
|
+
export function isStaleLaunchClaim(
|
|
131
|
+
run: Pick<DeliveryGraphRun, "status" | "process_key" | "updated_at">,
|
|
132
|
+
nowMs: number = Date.now(),
|
|
133
|
+
): boolean {
|
|
134
|
+
if (run.status !== "running" || run.process_key) return false;
|
|
135
|
+
const at = Date.parse(run.updated_at);
|
|
136
|
+
return Number.isFinite(at) && nowMs - at > LAUNCH_CLAIM_TTL_MS;
|
|
137
|
+
}
|
|
138
|
+
|
|
119
139
|
/** Atomically claim a run for LAUNCH — the at-most-once dispatch fence. Returns `true` iff THIS caller
|
|
120
140
|
* won the claim and must proceed to `runDeliveryGraph`; `false` iff a concurrent submit already claimed
|
|
121
141
|
* it (the caller must short-circuit as `alreadyRunning` instead of double-launching). Two fences, one
|
|
@@ -157,7 +177,7 @@ export async function claimRunForLaunch(
|
|
|
157
177
|
}
|
|
158
178
|
const res = await data.open().tx(async (t) => {
|
|
159
179
|
const flip = await t.exec(
|
|
160
|
-
`UPDATE "delivery_graph_runs" SET "status" = ?, "process_key" = ?, "process_definition_id" = ?, "phase" = ?, "phase_node_id" = ?, "updated_at" = ? WHERE "run_key" = ? AND "status" <> 'running'`,
|
|
180
|
+
`UPDATE "delivery_graph_runs" SET "status" = ?, "process_key" = ?, "process_definition_id" = ?, "phase" = ?, "phase_node_id" = ?, "updated_at" = ? WHERE "run_key" = ? AND ("status" <> 'running' OR ("process_key" IS NULL AND "updated_at" < ?))`,
|
|
161
181
|
[
|
|
162
182
|
claim.status,
|
|
163
183
|
claim.process_key,
|
|
@@ -166,6 +186,8 @@ export async function claimRunForLaunch(
|
|
|
166
186
|
claim.phase_node_id,
|
|
167
187
|
claim.updated_at,
|
|
168
188
|
claim.run_key,
|
|
189
|
+
// #852: a stale launch claim (running, never got an instance key) is re-claimable.
|
|
190
|
+
new Date(Date.now() - LAUNCH_CLAIM_TTL_MS).toISOString(),
|
|
169
191
|
],
|
|
170
192
|
);
|
|
171
193
|
if (flip.changed !== 1) return false;
|
|
@@ -185,6 +207,175 @@ export async function claimRunForLaunch(
|
|
|
185
207
|
return res;
|
|
186
208
|
}
|
|
187
209
|
|
|
210
|
+
/** Atomically reconcile a STALE launch claim to `failed` — the poller's mirror of
|
|
211
|
+
* {@link claimRunForLaunch}, and the single canonical writer of that transition (the poller calls ONLY
|
|
212
|
+
* this; it does not run its own unconditional update). A stale claim (`running`, NULL `process_key`,
|
|
213
|
+
* past {@link LAUNCH_CLAIM_TTL_MS}) died mid-launch and leaves a phantom in-flight row, so the poller
|
|
214
|
+
* retires it to `failed`. But the poller observed the row on a PRIOR `find()` read, and between that
|
|
215
|
+
* read and this write a concurrent dispatch may have atomically RE-CLAIMED the same stale row
|
|
216
|
+
* (`claimRunForLaunch`), refreshing `updated_at` (and, once it stamps, `process_key`). An UNCONDITIONAL
|
|
217
|
+
* `update(run_key, {status:'failed'})` would clobber that fresh, live claim — and a third dispatch
|
|
218
|
+
* could then re-claim it mid-layout and double-launch. So the flip is a compare-and-swap GUARDED on the
|
|
219
|
+
* exact snapshot the caller observed (`status='running'`, `process_key IS NULL`, the SAME `updated_at`):
|
|
220
|
+
* a row a concurrent dispatch has touched no longer matches and is left untouched. Returns `true` iff
|
|
221
|
+
* THIS caller flipped the row (issue #852 review — thread service.ts:3065).
|
|
222
|
+
*
|
|
223
|
+
* RECONCILIATION LEASE (`finalize`, issue #853 review — thread service.ts:3077): the poller must cancel
|
|
224
|
+
* a still-running ORIGINAL engine instance BEFORE the row is retired, but that engine cancellation must
|
|
225
|
+
* be FENCED by this same stale-row CAS — otherwise two races open up:
|
|
226
|
+
* • a FRESH `running`/NULL-key claim (still within its TTL) would otherwise enter the cancel branch
|
|
227
|
+
* and cancel a legitimate instance whose `createInstance` just succeeded but whose key stamp has not
|
|
228
|
+
* landed yet; and
|
|
229
|
+
* • a concurrent dispatch could RE-CLAIM the stale row and start its replacement BEFORE the cancel
|
|
230
|
+
* runs, so the poller would cancel the live replacement and only afterwards discover its CAS lost.
|
|
231
|
+
* So the caller passes the engine cancellation as `finalize`, and it is run INSIDE the CAS transaction,
|
|
232
|
+
* ONLY after the lease flip succeeds (`changed === 1`). The flip atomically acquires a reconciliation
|
|
233
|
+
* lease on the observed snapshot: once it lands the row is `failed`, so a concurrent dispatch's
|
|
234
|
+
* `claimRunForLaunch` guard (`status <> 'running'`) can no longer reclaim it — the lease is
|
|
235
|
+
* non-reclaimable for the duration of the cancel. And because `finalize` runs only when THIS caller won
|
|
236
|
+
* the flip, a concurrent dispatch that already reclaimed the row (refreshing `updated_at`) makes the CAS
|
|
237
|
+
* match zero rows, so `finalize` never runs and the live replacement is never cancelled. The retire to
|
|
238
|
+
* `failed` is thus finalized only for the lease owner. If `finalize` throws, the transaction rolls back
|
|
239
|
+
* (the row stays stale-claim `running`) so the pass retries next tick rather than waving a re-dispatch
|
|
240
|
+
* through into a possible double-launch. */
|
|
241
|
+
export async function reconcileStaleLaunchClaim(
|
|
242
|
+
data: DataLayer,
|
|
243
|
+
run: Pick<DeliveryGraphRun, "run_key" | "status" | "process_key" | "updated_at">,
|
|
244
|
+
finalize?: () => Promise<void>,
|
|
245
|
+
nowMs: number = Date.now(),
|
|
246
|
+
): Promise<boolean> {
|
|
247
|
+
// Non-stale rows (launched, terminal, or a claim still within its TTL) are never retired here — the
|
|
248
|
+
// staleness decision is owned HERE, not re-derived by each caller, so the CAS guard below and this
|
|
249
|
+
// predicate can never drift apart. This also fences the engine cancel: a fresh in-flight claim never
|
|
250
|
+
// reaches the flip, so `finalize` (the cancellation) never runs against it.
|
|
251
|
+
if (!isStaleLaunchClaim(run, nowMs)) return false;
|
|
252
|
+
const res = await data.open().tx(async (t) => {
|
|
253
|
+
const flip = await t.exec(
|
|
254
|
+
`UPDATE "delivery_graph_runs" SET "status" = 'failed', "phase" = ?, "phase_node_id" = NULL, "updated_at" = ? WHERE "run_key" = ? AND "status" = 'running' AND "process_key" IS NULL AND "updated_at" = ?`,
|
|
255
|
+
[DELIVERY_PHASE.FAILED, new Date(nowMs).toISOString(), run.run_key, run.updated_at],
|
|
256
|
+
);
|
|
257
|
+
if (flip.changed !== 1) return false;
|
|
258
|
+
// We hold the reconciliation lease (the row is now `failed`, non-reclaimable by a dispatch). Run the
|
|
259
|
+
// fenced engine cancellation BEFORE committing, so a concurrent dispatch can neither be cancelled by
|
|
260
|
+
// us nor reclaim the row out from under the cancel.
|
|
261
|
+
await finalize?.();
|
|
262
|
+
return true;
|
|
263
|
+
});
|
|
264
|
+
return res;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The engine seam {@link reconcileOriginalInstanceBeforeRelaunch} needs — instance search by
|
|
268
|
+
* definition, per-instance variable read, and cancel. */
|
|
269
|
+
type ReconcileEngine = Pick<EngineClient, "searchProcessInstances" | "searchVariables" | "cancelInstance">;
|
|
270
|
+
|
|
271
|
+
/** An ACTIVE instance of this run's content-addressed definition carried no readable RUN-ROOT `runKey`
|
|
272
|
+
* (absent or truncated), so {@link reconcileOriginalInstanceBeforeRelaunch} could neither cancel it as
|
|
273
|
+
* ours nor rule it out as a different run. Thrown (only when no candidate positively matched) to fail
|
|
274
|
+
* CLOSED: the callers leave the stale claim reconcile-pending and retry rather than relaunching a
|
|
275
|
+
* possible duplicate past an unclassifiable (e.g. pre-upgrade legacy) original. */
|
|
276
|
+
export class ReconcileConflictError extends Error {
|
|
277
|
+
readonly runKey: string;
|
|
278
|
+
readonly unclassifiable: string[];
|
|
279
|
+
constructor(runKey: string, unclassifiable: string[]) {
|
|
280
|
+
super(`reconcile-before-relaunch: ${unclassifiable.length} ACTIVE instance(s) of run ${runKey}'s definition carry no readable run-root runKey (${unclassifiable.join(", ")}) — cannot prove they are not a live original; failing closed`);
|
|
281
|
+
this.name = "ReconcileConflictError";
|
|
282
|
+
this.runKey = runKey;
|
|
283
|
+
this.unclassifiable = unclassifiable;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Reconcile a still-running ORIGINAL instance of this run before a stale-claim relaunch starts a
|
|
288
|
+
* second one — the at-most-once recovery the Copilot review on issue #852 asked for (thread
|
|
289
|
+
* deliveryGraphRun.ts:180).
|
|
290
|
+
*
|
|
291
|
+
* A timeout-only reclaim cannot distinguish "the dispatch host died BEFORE `createInstance`" (safe to
|
|
292
|
+
* reclaim) from "died AFTER `createInstance` succeeded but BEFORE the process key was stamped" (a live
|
|
293
|
+
* instance is already executing the graph's side-effecting nodes — reclaiming and relaunching would run
|
|
294
|
+
* them TWICE). Now that the engine seam exposes it (@nanobpm/urban 0.96), close that window: every
|
|
295
|
+
* launched instance carries its `runKey` as a run-root process variable (`runDeliveryGraph`), so a
|
|
296
|
+
* relaunch can FIND a live original of THIS run and cancel it before starting its replacement.
|
|
297
|
+
*
|
|
298
|
+
* Returns the process keys of any live original instances it cancelled (normally ≤1). A candidate is a
|
|
299
|
+
* live duplicate iff it is an ACTIVE instance of this content-addressed `processDefinitionId` whose
|
|
300
|
+
* seeded RUN-ROOT `runKey` variable equals ours (a child-scoped same-named variable is ignored — see
|
|
301
|
+
* the match below). We never CANCEL an instance we cannot prove belongs to this run.
|
|
302
|
+
*
|
|
303
|
+
* Fail CLOSED on an UNCLASSIFIABLE candidate (thread deliveryGraphRun.ts:328): an ACTIVE instance of
|
|
304
|
+
* this exact content-addressed definition whose RUN-ROOT `runKey` is absent or truncated (unreadable)
|
|
305
|
+
* cannot be proven to be a DIFFERENT run — a PRE-UPGRADE (legacy) instance launched before this PR
|
|
306
|
+
* started seeding the correlation variable has exactly this shape, and so does a run whose root value
|
|
307
|
+
* came back truncated. Treating "unreadable" as "not ours" would fail OPEN: during the upgrade window a
|
|
308
|
+
* stale claim whose live original is a legacy instance would be relaunched alongside it, double-running
|
|
309
|
+
* the side-effecting nodes — the very duplicate this reconcile exists to prevent. So when NO candidate
|
|
310
|
+
* positively matches our run key AND at least one is unclassifiable, we throw {@link
|
|
311
|
+
* ReconcileConflictError} instead of returning a clean "nothing to cancel". Both callers (dispatch +
|
|
312
|
+
* poller) treat that throw exactly like a reconcile read failure — the stale claim is left
|
|
313
|
+
* reconcile-pending and retried — so recovery waits until the unclassifiable original terminates rather
|
|
314
|
+
* than launching a duplicate past it. When we DID positively identify and cancel our own original, an
|
|
315
|
+
* unclassifiable sibling is necessarily a DIFFERENT (legacy) run and never blocks our relaunch.
|
|
316
|
+
*
|
|
317
|
+
* Accepted limitation (nanobpm/nano-ide#588, PR #853 review): the candidate search below is a
|
|
318
|
+
* definition+state query, and the `@nanobpm/urban` 0.96 seam returns only a single finite default page
|
|
319
|
+
* for it — it paginates key-based searches, exposes no cursor, and offers no server-side
|
|
320
|
+
* variable-correlation filter. So if MORE simultaneously-ACTIVE instances of one byte-identical
|
|
321
|
+
* content-addressed graph exist than that page size, this run's live original can fall outside the page
|
|
322
|
+
* and be missed, permitting a duplicate launch. That tail needs BOTH a dispatch crash mid-launch (a
|
|
323
|
+
* stale claim) AND >page-size concurrent active runs of the exact same graph. Accepted rather than
|
|
324
|
+
* fixed here because closing it requires extending the engine seam/adapter (cursor pagination or
|
|
325
|
+
* correlation filtering) — tracked upstream at nanobpm/nano-ide#588. */
|
|
326
|
+
export async function reconcileOriginalInstanceBeforeRelaunch(
|
|
327
|
+
engine: ReconcileEngine,
|
|
328
|
+
run: { runKey: string; processDefinitionId: string },
|
|
329
|
+
): Promise<string[]> {
|
|
330
|
+
const candidates = await engine.searchProcessInstances({
|
|
331
|
+
processDefinitionId: run.processDefinitionId,
|
|
332
|
+
state: "ACTIVE",
|
|
333
|
+
});
|
|
334
|
+
const cancelled: string[] = [];
|
|
335
|
+
const unclassifiable: string[] = [];
|
|
336
|
+
for (const candidate of candidates) {
|
|
337
|
+
const processInstanceKey = String(candidate.processInstanceKey);
|
|
338
|
+
// Match the seeded `runKey` variable, not just the definition: two DISTINCT runs of the same graph
|
|
339
|
+
// (different explicit idempotencyKeys) share one content-addressed `processDefinitionId`, so the
|
|
340
|
+
// definition filter alone would cancel a DIFFERENT run's live instance. The variable read is the
|
|
341
|
+
// per-instance proof that this candidate is THIS run.
|
|
342
|
+
const vars = await engine.searchVariables({ processInstanceKey, name: "runKey" });
|
|
343
|
+
// Only the RUN-ROOT scope's `runKey` proves ownership. `searchVariables` returns same-named
|
|
344
|
+
// variables from EVERY scope inside the instance — a delivery graph may legally emit a fact named
|
|
345
|
+
// `runKey`, and an agent may surface a top-level `runKey` field in its job scope — so a CHILD scope
|
|
346
|
+
// whose value coincidentally equals this run key would otherwise mark an unrelated live run as ours
|
|
347
|
+
// and cancel it (thread deliveryGraphRun.ts:318). The run-root variable (seeded by
|
|
348
|
+
// `runDeliveryGraph`) is the one whose `scopeKey` IS the process instance key; restrict the match
|
|
349
|
+
// to it so a child-scoped coincidence can never trigger a cancel.
|
|
350
|
+
const rootVar = vars.find((v) => String(v.scopeKey) === processInstanceKey);
|
|
351
|
+
// Unreadable run-root `runKey`: absent (a pre-upgrade legacy instance, or a non-run-root candidate)
|
|
352
|
+
// or TRUNCATED (the engine capped the returned value, so a byte compare would spuriously differ).
|
|
353
|
+
// Either way we cannot prove this ACTIVE same-definition candidate is a DIFFERENT run, so record it
|
|
354
|
+
// as unclassifiable and fail closed below rather than fail OPEN by treating it as "not ours" (thread
|
|
355
|
+
// deliveryGraphRun.ts:328).
|
|
356
|
+
if (rootVar === undefined || rootVar.isTruncated) {
|
|
357
|
+
unclassifiable.push(processInstanceKey);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
let match: boolean;
|
|
361
|
+
try {
|
|
362
|
+
match = JSON.parse(rootVar.value) === run.runKey;
|
|
363
|
+
} catch {
|
|
364
|
+
match = rootVar.value === run.runKey;
|
|
365
|
+
}
|
|
366
|
+
if (!match) continue;
|
|
367
|
+
await engine.cancelInstance({ processInstanceKey });
|
|
368
|
+
cancelled.push(processInstanceKey);
|
|
369
|
+
}
|
|
370
|
+
// Fail closed ONLY when we did not positively identify our own original: an unclassifiable candidate
|
|
371
|
+
// could BE our (legacy) live original, so refuse the blind relaunch. If we cancelled a positive match
|
|
372
|
+
// we KNOW which instance is ours, so an unclassifiable sibling is a different run and must not block.
|
|
373
|
+
if (cancelled.length === 0 && unclassifiable.length > 0) {
|
|
374
|
+
throw new ReconcileConflictError(run.runKey, unclassifiable);
|
|
375
|
+
}
|
|
376
|
+
return cancelled;
|
|
377
|
+
}
|
|
378
|
+
|
|
188
379
|
/** The idempotency key for a submitted graph: a caller-supplied `idempotencyKey` (trimmed) when
|
|
189
380
|
* present and non-blank, else the graph's content `digest`. So two dispatches of the SAME graph (no
|
|
190
381
|
* explicit key) collapse onto one run, and a caller can force a fresh run with an explicit key. */
|
|
@@ -212,7 +403,7 @@ function firstLine(text: string | undefined | null): string {
|
|
|
212
403
|
* Tasks-inbox "Decision context" (issue #813). Storing the full prompt (not a clamped first line) is
|
|
213
404
|
* what lets the operator read the entire instruction, and lets a URL embedded in the prompt reach the
|
|
214
405
|
* task's clickable link. Falls back to the node id when a human node declares no prompt. */
|
|
215
|
-
export function buildHumanLabels(compiled: CompileDeliveryGraphResult): Record<string, string> {
|
|
406
|
+
export function buildHumanLabels(compiled: Pick<CompileDeliveryGraphResult, "resolved" | "humanNodes">): Record<string, string> {
|
|
216
407
|
const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
|
|
217
408
|
const labels: Record<string, string> = {};
|
|
218
409
|
for (const stop of compiled.humanNodes) {
|
|
@@ -485,6 +485,28 @@ test("runDeliveryGraph coerces a numeric engine processInstanceKey to a string h
|
|
|
485
485
|
assertEquals(typeof r.handle.processInstanceKey, "string");
|
|
486
486
|
});
|
|
487
487
|
|
|
488
|
+
test("runDeliveryGraph seeds the prepared runKey as the createInstance correlation variable (stale-claim recovery handle)", async () => {
|
|
489
|
+
// Regression guard (Copilot review 5408224485, "previously missed"): the stale-claim recovery in
|
|
490
|
+
// `reconcileOriginalInstanceBeforeRelaunch` finds a still-running ORIGINAL instance by correlating on
|
|
491
|
+
// the engine `runKey` variable. The reconciliation tests stub `searchVariables` independently, so
|
|
492
|
+
// deleting this seed would leave every recovery test green while production silently misses the
|
|
493
|
+
// original instance and relaunches a duplicate. Capture the `variables` argument and assert the
|
|
494
|
+
// seeded `runKey` IS the prepared/returned run key.
|
|
495
|
+
let captured: Record<string, unknown> | undefined;
|
|
496
|
+
const engine = {
|
|
497
|
+
deployResources: async () => [],
|
|
498
|
+
createInstance: async (args: { variables: Record<string, unknown> }) => {
|
|
499
|
+
captured = args.variables;
|
|
500
|
+
return { processInstanceKey: "555" };
|
|
501
|
+
},
|
|
502
|
+
};
|
|
503
|
+
const r = await runDeliveryGraph(engine, GRAPH, { repoless: true });
|
|
504
|
+
assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
|
|
505
|
+
assert(captured, "createInstance was called with a variables argument");
|
|
506
|
+
assert(r.handle.runKey, "the prepared run key is non-empty");
|
|
507
|
+
assertEquals(captured.runKey, r.handle.runKey, "the engine correlation variable carries the prepared run key");
|
|
508
|
+
});
|
|
509
|
+
|
|
488
510
|
// Per-node repository isolation (issue #739): the delivery-graph runner seeds the canonical
|
|
489
511
|
// `io.nanobpm.agentTask.repository` isolation envelope PER agent cell as flattened `<zeebe:taskHeaders>`
|
|
490
512
|
// (`io.nanobpm.agentTask.repository.url`, …) injected into the deployable BPMN — NOT as a single
|