@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.
@@ -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) => {
@@ -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