@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.
@@ -122,6 +122,10 @@ export interface PreparedDeliveryGraph {
122
122
  processDefinitionId: string;
123
123
  bpmn: string;
124
124
  nodeInputs: Record<string, NodeInput>;
125
+ /** The per-run token this prepare resolved (`options.runKey` or a fresh random). Surfaced so
126
+ * `runDeliveryGraph` seeds the SAME value as a run-root process variable — the reconciliation
127
+ * handle a stale-claim relaunch matches a still-running original instance on (issue #852). */
128
+ runKey: string;
125
129
  }
126
130
 
127
131
  export type PrepareDeliveryResult =
@@ -193,7 +197,7 @@ export async function prepareDeliveryGraph(
193
197
  if (element === undefined) continue; // unreachable — resolved covers every node — but keep total.
194
198
  nodeInputs[element] = buildNodeInput(node, { runKey, element, ...timeouts, requiredEmits: requiredEmitsByNodeId.get(node.id) ?? EMPTY_REQUIRED_EMITS });
195
199
  }
196
- return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs } };
200
+ return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs, runKey } };
197
201
  }
198
202
 
199
203
  /** Deploy + start a compiled graph as a running engine-native instance. Idempotent at the DEFINITION
@@ -207,7 +211,7 @@ export async function runDeliveryGraph(
207
211
  ): Promise<RunDeliveryResult> {
208
212
  const prep = await prepareDeliveryGraph(graph, options);
209
213
  if (!prep.ok) return prep;
210
- const { processDefinitionId, bpmn, nodeInputs } = prep.prepared;
214
+ const { processDefinitionId, bpmn, nodeInputs, runKey } = prep.prepared;
211
215
 
212
216
  await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
213
217
  // Per-node repository isolation (#739): the `io.nanobpm.agentTask.repository` envelope is now seeded
@@ -218,10 +222,18 @@ export async function runDeliveryGraph(
218
222
  // repository variable is seeded here; the resolution + loud-failure invariant (unresolved agent cell
219
223
  // on a non-`repoless` run) and the `repoless`/repo-base conflict guard all live in
220
224
  // `injectAgentRepoEnvelopes`, which the prepare step above already ran (throwing before deploy).
225
+ //
226
+ // `runKey` IS seeded as a run-root variable (unlike the repo envelope): it is the durable
227
+ // reconciliation handle a stale-claim relaunch uses to find a still-running ORIGINAL instance of this
228
+ // run before starting a second one (issue #852 review — thread deliveryGraphRun.ts:180). Every
229
+ // instance of this content-addressed definition carries its run key, so
230
+ // `searchProcessInstances({ processDefinitionId, state:"ACTIVE" })` + a `runKey` variable match
231
+ // identifies the live duplicate a timeout-only reclaim cannot distinguish from a dead claim.
221
232
  const { processInstanceKey } = await engine.createInstance({
222
233
  processDefinitionId,
223
234
  variables: {
224
235
  nodeInputs,
236
+ runKey,
225
237
  // Stage 0 transcript correlation (#543): the transcript-endpoint base every agent node's
226
238
  // completing worker appends its jobKey-scoped stream to, to emit `transcriptUrl` (see the agent
227
239
  // node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
@@ -233,7 +245,7 @@ export async function runDeliveryGraph(
233
245
  // downstream consumers expect a string — coerce (codebase-wide `String(...)` pattern, e.g. app/plan.ts).
234
246
  return {
235
247
  ok: true,
236
- handle: { processDefinitionId, bpmn, nodeInputs, processInstanceKey: String(processInstanceKey) },
248
+ handle: { processDefinitionId, bpmn, nodeInputs, runKey, processInstanceKey: String(processInstanceKey) },
237
249
  };
238
250
  }
239
251
 
package/app/service.ts CHANGED
@@ -31,8 +31,9 @@ import { envContract, readEnvOr } from "./contracts.ts";
31
31
  import { makeDefaultReadHead } from "./currentHead.ts";
32
32
  import { isUniqueConstraintFence } from "./dbFence.ts";
33
33
  import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
34
+ import { DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
34
35
  import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
35
- import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels } from "./deliveryGraphRun.ts";
36
+ import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels, reconcileOriginalInstanceBeforeRelaunch, reconcileStaleLaunchClaim } from "./deliveryGraphRun.ts";
36
37
  import { deliveryHumanContextQuestion, deliveryHumanContextUrl, isDeliveryHumanElement } from "./deliveryHuman.ts";
37
38
  import { fleetSupportsDurableResume } from "./durableResume.ts";
38
39
  import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
@@ -3049,10 +3050,58 @@ export async function pollTasklessPlanTermination(
3049
3050
  * Scoped to `running` rows (an `awaiting-approval` run has no instance yet), so it stays O(in-flight). */
3050
3051
  export async function pollDeliveryGraphPhase(
3051
3052
  data: DataLayer,
3052
- engine: Pick<EngineClient, "searchProcessInstances" | "searchUserTasks">,
3053
+ engine: Pick<EngineClient, "searchProcessInstances" | "searchUserTasks" | "searchVariables" | "cancelInstance">,
3053
3054
  ) {
3054
3055
  for (const run of await deliveryGraphRuns(data).find({ status: "running" })) {
3055
- if (!run.process_key) continue;
3056
+ if (!run.process_key) {
3057
+ // #852: a launch claim that never got an instance key within the TTL died mid-launch (the dispatch
3058
+ // process went down before deploy, so its own `markClaimFailed` never ran). Reconcile it to
3059
+ // `failed` so the cockpit stops showing a phantom in-flight run; a re-dispatch re-claims it. The
3060
+ // flip is a CAS on the snapshot we read (via `reconcileStaleLaunchClaim`), so a claim a concurrent
3061
+ // dispatch has already re-claimed between our `find()` and here is left untouched, never clobbered.
3062
+ // Reconcile-before-retire (issue #852 review — thread deliveryGraphRun.ts:180): the crash may have
3063
+ // happened AFTER `createInstance` succeeded but BEFORE the key was stamped, leaving a LIVE instance
3064
+ // executing this graph's side-effecting nodes. Retiring the row to `failed` and letting a
3065
+ // re-dispatch relaunch would then double-run those nodes. Cancel any still-ACTIVE instance of THIS
3066
+ // run (matched by its seeded `runKey` variable) BEFORE retiring the claim, so the recovery stays
3067
+ // at-most-once. Best-effort: a reconcile read failure must not wedge the pass — log and skip the
3068
+ // retire this pass (the row stays stale-claim `running` and is retried next pass). The definition
3069
+ // id is derived from the row's `digest` (`delivery-graph-<digest>`), NOT the `process_definition_id`
3070
+ // column — that column is stamped only AFTER a successful launch, so it is NULL on exactly the
3071
+ // crashed-mid-launch claim this path exists to recover.
3072
+ //
3073
+ // Fence the engine cancellation with the stale-claim CAS lease (issue #853 review — thread
3074
+ // service.ts:3077): the cancel is passed to `reconcileStaleLaunchClaim` as `finalize`, run INSIDE
3075
+ // the CAS transaction ONLY after the lease flip lands. Two races close:
3076
+ // • a FRESH `running`/NULL-key claim (still within its TTL) is non-stale, so
3077
+ // `reconcileStaleLaunchClaim` returns before the flip and the cancel never runs — a legitimate
3078
+ // just-launched instance (createInstance succeeded, key stamp pending) is never cancelled; and
3079
+ // • a concurrent dispatch that RE-CLAIMS the row first (refreshing `updated_at`) makes the CAS
3080
+ // match zero rows, so `finalize` never runs and the live replacement is never cancelled — the
3081
+ // retire is finalized only for the lease owner, and the lease is non-reclaimable for the
3082
+ // cancel's duration (the flipped `failed` row fails the dispatch's `status <> 'running'` guard).
3083
+ // Best-effort: a reconcile read failure must not wedge the pass — `finalize` throws, the
3084
+ // transaction rolls back (the row stays stale-claim `running`), we log and skip the retire this
3085
+ // pass, and the next pass retries. A fail-closed `ReconcileConflictError` (an ACTIVE
3086
+ // same-definition instance with no readable run-root `runKey`, e.g. a pre-upgrade legacy original)
3087
+ // takes this same path: the retire is deferred rather than relaunching a possible duplicate.
3088
+ const cancelOriginal = async () => {
3089
+ if (!run.digest) return;
3090
+ const cancelled = await reconcileOriginalInstanceBeforeRelaunch(engine, {
3091
+ runKey: run.run_key,
3092
+ processDefinitionId: `${DELIVERY_GRAPH_PROCESS_ID}-${run.digest}`,
3093
+ });
3094
+ if (cancelled.length > 0) {
3095
+ console.error(`[poller] delivery graph ${run.run_key}: cancelled a still-running original instance before retiring the stale claim: ${cancelled.join(", ")}`);
3096
+ }
3097
+ };
3098
+ try {
3099
+ await reconcileStaleLaunchClaim(data, run, cancelOriginal);
3100
+ } catch (err) {
3101
+ console.error(`[poller] delivery graph ${run.run_key}: reconcile-before-retire failed, leaving the claim for next pass: ${err}`);
3102
+ }
3103
+ continue;
3104
+ }
3056
3105
  const processKey = run.process_key;
3057
3106
  try {
3058
3107
  const [snapshots, parks] = await Promise.all([
@@ -116,6 +116,15 @@ blank/absent base is rejected with a 400. Starting a
116
116
  plan is idempotent on the plan key; an already-running plan short-circuits. The
117
117
  response (202) echoes the `planKey` and engine `processKey`.
118
118
 
119
+ **Single issue, one PR (feature run, `startFeature`)** — no planning or fan-out. Same body and
120
+ base-branch admission, plus `converge` (enrol the PR in §1) and `autoMerge`:
121
+
122
+ ```bash
123
+ curl -sS -X POST __BASE__/actions/start/feature \
124
+ -H 'content-type: application/json' \
125
+ -d '{ "issue": "owner/repo#123", "baseBranch": "main", "confirmDefaultBase": true, "converge": true, "autoMerge": true }'
126
+ ```
127
+
119
128
  ### Base-branch admission (ADR 0003)
120
129
 
121
130
  `startPlanFanout` admits the base through one fail-fast gate before any task fans out.
@@ -15,7 +15,7 @@ import assert from "node:assert/strict";
15
15
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
16
16
  import { redactFreeText } from "../app/deliveryGraphCompiler.ts";
17
17
  import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
18
- import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
18
+ import { deliveryGraphRuns, LAUNCH_CLAIM_TTL_MS } from "../app/deliveryGraphRun.ts";
19
19
  import { isEquivalentReStage, stableProposalRunKey } from "./dispatchDeliveryGraph.ts";
20
20
 
21
21
  const APP_ROOT = resolve(import.meta.dirname, "..");
@@ -190,6 +190,28 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
190
190
  assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
191
191
  });
192
192
 
193
+ test("#852: a launch claim stranded by a crash mid-launch (running, no process key, past the TTL) does not wedge the proposal — a re-dispatch LAUNCHES it", async () => {
194
+ const app = await boot();
195
+ assert.ok(app.api);
196
+ const api = app.api;
197
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
198
+ const digest = staged.body.digest;
199
+ // The merlin state: the claim row + identity were written, then the process died before deploy.
200
+ const stale = new Date(Date.now() - LAUNCH_CLAIM_TTL_MS - 60_000).toISOString();
201
+ await deliveryGraphRuns(app.db).insert({
202
+ run_key: digest, process_key: null, process_definition_id: null, digest, status: "running", side_effecting: 0,
203
+ node_count: 1, human_node_count: 1, side_effect_count: 0, title: "manual gate", phase: "Running", phase_node_id: null,
204
+ human_labels: null, created_at: stale, updated_at: stale, acknowledged_at: null,
205
+ });
206
+ const res = await api.call<{ ok: boolean; alreadyRunning: boolean; processInstanceKey?: string }>("dispatchDeliveryGraph", {
207
+ body: { digest, repoless: true },
208
+ });
209
+ assert.equal(res.status, 202, JSON.stringify(res.body));
210
+ assert.equal(res.body.alreadyRunning, false, "must not short-circuit onto the phantom run");
211
+ assert.ok(res.body.processInstanceKey, "a real instance was started");
212
+ assert.equal((await deliveryGraphProposals(app.db).get(digest))?.status, "dispatched");
213
+ });
214
+
193
215
  test("re-dispatching an ALREADY-dispatched digest → 400 (the proposal is consumed; the run shows in the in-flight grid)", async () => {
194
216
  const app = await boot();
195
217
  assert.ok(app.api);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.200.5",
3
+ "version": "0.200.7",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -70,7 +70,7 @@
70
70
  },
71
71
  "devDependencies": {
72
72
  "@biomejs/biome": "^2.4.11",
73
- "@nanobpm/urban-testkit": "^1.4.0",
73
+ "@nanobpm/urban-testkit": "^1.6.0",
74
74
  "@nanobpm/workflow": "^0.15.0",
75
75
  "@semantic-release/changelog": "^7.0.0",
76
76
  "@semantic-release/git": "^11.0.0",
@@ -53,6 +53,12 @@ const DEFAULT_REFRESH_MS = 5000;
53
53
  // forever, so the busy() lock never clears and the UI is stranded; on timeout the AbortController
54
54
  // rejects the fetch, surfacing as an error banner and re-enabling the controls via the finally blocks.
55
55
  const REQUEST_TIMEOUT_MS = 30000;
56
+ // The dispatch POST gets its own, much longer budget (#852): the server lays out + deploys the graph
57
+ // before it answers, which for a large graph legitimately takes minutes (54 nodes ≈ 105 s on merlin).
58
+ // Aborting at 30 s showed the operator a failure while the launch was still in flight. Stays bounded so
59
+ // a genuinely hung door still releases the controls. Kept under the server's launch-claim TTL
60
+ // (`LAUNCH_CLAIM_TTL_MS`, 15 min) so the UI never gives up AFTER a crashed launch became re-claimable.
61
+ const DISPATCH_TIMEOUT_MS = 10 * 60_000;
56
62
 
57
63
  // The confirm shown before a dispatch — dispatching authorises every side-effecting node, so the
58
64
  // operator acknowledges that the launch (and its side effects) is content-addressed to this graph.
@@ -273,9 +279,9 @@ export function mountStagedProposals(host, config = {}) {
273
279
 
274
280
  /** Fetch JSON from a door and return { status, body } (never throws on an HTTP error). Rejects
275
281
  * (AbortError) if the request outlives REQUEST_TIMEOUT_MS so a hung door can't wedge the busy lock. */
276
- async function request(url, init) {
282
+ async function request(url, init, timeoutMs = REQUEST_TIMEOUT_MS) {
277
283
  const controller = new AbortController();
278
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
284
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
279
285
  try {
280
286
  const res = await fetch(url, { ...init, headers: headers(url), signal: controller.signal });
281
287
  let body = {};
@@ -291,7 +297,7 @@ export function mountStagedProposals(host, config = {}) {
291
297
  }
292
298
 
293
299
  const get = (url) => request(url, { method: "GET" });
294
- const post = (url, payload) => request(url, { method: "POST", body: JSON.stringify(payload) });
300
+ const post = (url, payload, timeoutMs) => request(url, { method: "POST", body: JSON.stringify(payload) }, timeoutMs);
295
301
 
296
302
  let disposed = false;
297
303
  // True while the last completed load failed — so a subsequent successful load knows to clear its own
@@ -372,9 +378,9 @@ export function mountStagedProposals(host, config = {}) {
372
378
  // the list on the next poll — refresh immediately so the operator sees it leave.
373
379
  async function doDispatch(payload) {
374
380
  busy(true);
375
- setStatus("Dispatching…");
381
+ setStatus("Dispatching… laying out and deploying the graph — a large graph can take a few minutes.");
376
382
  try {
377
- const { status, body } = await post(dispatchUrl, payload);
383
+ const { status, body } = await post(dispatchUrl, payload, DISPATCH_TIMEOUT_MS);
378
384
  if ((status === 202 || status === 200) && body.ok) {
379
385
  setStatus("\u2713 Dispatched — the run is now in flight.", "ok");
380
386
  await refresh();
@@ -382,7 +388,12 @@ export function mountStagedProposals(host, config = {}) {
382
388
  setStatus(body && body.error ? body.error : "Dispatch failed.", "err");
383
389
  }
384
390
  } catch (err) {
385
- setStatus(err && err.message ? err.message : "Dispatch request failed.", "err");
391
+ setStatus(
392
+ err && err.name === "AbortError"
393
+ ? "Dispatch is taking longer than expected — it may still be launching. Check Active Delivery Graphs before dispatching again."
394
+ : err && err.message ? err.message : "Dispatch request failed.",
395
+ "err",
396
+ );
386
397
  } finally {
387
398
  busy(false);
388
399
  }
@@ -0,0 +1,85 @@
1
+ // #852 — a large graph's dispatch legitimately takes minutes (layout + deploy: ~105 s for 54 nodes on
2
+ // merlin). The staged view aborted EVERY request at a 30 s timeout and rendered the AbortError as the
3
+ // dispatch failure — the operator saw a "crash" while the server was still launching. The dispatch POST
4
+ // must outlive a slow-but-healthy launch (bounded by its own, much longer budget) and tell the operator
5
+ // it is still working.
6
+ import { mock, test } from "node:test";
7
+ import { assert, assertEquals } from "#test-assert";
8
+ import { parseHTML } from "linkedom";
9
+ import { mountStagedProposals } from "../pages/delivery-graphs/staged.mount.js";
10
+
11
+ const STAGED_URL = "https://app.test/app/api/delivery-graph/staged";
12
+ const DISPATCH_URL = "https://app.test/app/api/actions/delivery-graph/dispatch";
13
+ const PROPOSAL = {
14
+ digest: "abc123def456",
15
+ title: "Big graph",
16
+ sideEffecting: true,
17
+ nodeCount: 54,
18
+ humanNodeCount: 2,
19
+ sideEffectCount: 34,
20
+ createdAt: "2026-01-01T00:00:00.000Z",
21
+ expiresAt: "2026-01-02T00:00:00.000Z",
22
+ };
23
+
24
+ const flush = async () => {
25
+ for (let i = 0; i < 6; i++) await new Promise((resolve) => setImmediate(resolve));
26
+ };
27
+
28
+ test("#852: a dispatch slower than the generic 30 s request timeout is NOT aborted — it completes and reports success", async () => {
29
+ const { window: domWindow, document } = parseHTML("<!doctype html><html><body><div id='host'></div></body></html>");
30
+ const host = document.getElementById("host");
31
+ assert(host, "host");
32
+ let resolveDispatch: (() => void) | undefined;
33
+ let aborted = false;
34
+ const origFetch = globalThis.fetch;
35
+ globalThis.fetch = async (input, init) => {
36
+ const url = String(input);
37
+ if (url === STAGED_URL) return new Response(JSON.stringify({ proposals: [PROPOSAL] }), { status: 200 });
38
+ if (url === DISPATCH_URL) {
39
+ return await new Promise<Response>((resolve, reject) => {
40
+ init?.signal?.addEventListener("abort", () => {
41
+ aborted = true;
42
+ reject(new DOMException("signal is aborted without reason", "AbortError"));
43
+ });
44
+ resolveDispatch = () => resolve(new Response(JSON.stringify({ ok: true, status: "running" }), { status: 202 }));
45
+ });
46
+ }
47
+ return new Response("{}", { status: 404 });
48
+ };
49
+ const win = { confirm: () => false, prompt: () => null, location: { href: "https://app.test/delivery-graphs/", origin: "https://app.test" }, parent: undefined as unknown };
50
+ win.parent = win;
51
+ const origWindow = Reflect.get(globalThis, "window");
52
+ Reflect.set(globalThis, "window", win);
53
+ mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
54
+ const dispose = mountStagedProposals(host, { stagedUrl: STAGED_URL, dispatchUrl: DISPATCH_URL, refreshMs: 1_000_000_000 });
55
+ const click = (sel: string) => {
56
+ const el = host.querySelector(sel);
57
+ assert(el, `missing ${sel}`);
58
+ el.dispatchEvent(new domWindow.Event("click", { bubbles: true, cancelable: true }));
59
+ };
60
+ try {
61
+ await flush();
62
+ click("[data-dispatch]");
63
+ await flush();
64
+ click("[data-dispatch-confirm]");
65
+ await flush();
66
+ assert(resolveDispatch, "the dispatch POST was sent");
67
+ // Two minutes of a healthy-but-slow launch (layout + deploy) — well past the old 30 s abort.
68
+ for (let s = 0; s < 120; s++) {
69
+ mock.timers.tick(1000);
70
+ await flush();
71
+ }
72
+ assertEquals(aborted, false, "the dispatch request must not be aborted while the server is still launching");
73
+ const working = host.querySelector(".status")?.textContent ?? "";
74
+ assert(/dispatching/i.test(working), `the operator is told it is still working, got: ${working}`);
75
+ resolveDispatch();
76
+ await flush();
77
+ const status = host.querySelector(".status")?.textContent ?? "";
78
+ assert(/dispatched/i.test(status), `expected success, got: ${status}`);
79
+ } finally {
80
+ dispose();
81
+ mock.timers.reset();
82
+ globalThis.fetch = origFetch;
83
+ Reflect.set(globalThis, "window", origWindow);
84
+ }
85
+ });