@nanobpm/nano-workforce 0.178.0 → 0.178.1

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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.178.1](https://github.com/nanobpm/nano-workforce/compare/v0.178.0...v0.178.1) (2026-09-03)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **mcp:** stage delivery graphs on a layout-free fast path so cold heavy tools don't time out ([#718](https://github.com/nanobpm/nano-workforce/issues/718)) ([c94ab00](https://github.com/nanobpm/nano-workforce/commit/c94ab00f091dbc5e529138e6818365c5794a1e25)), closes [#715](https://github.com/nanobpm/nano-workforce/issues/715) [nanobpm/nano-ide#488](https://github.com/nanobpm/nano-ide/issues/488) [#716](https://github.com/nanobpm/nano-workforce/issues/716) [#716](https://github.com/nanobpm/nano-workforce/issues/716) [#716](https://github.com/nanobpm/nano-workforce/issues/716)
6
+
1
7
  ## [0.178.0](https://github.com/nanobpm/nano-workforce/compare/v0.177.0...v0.178.0) (2026-09-03)
2
8
 
3
9
  ### Features
@@ -260,23 +260,47 @@ function mustGet<K, V>(map: ReadonlyMap<K, V>, key: K): V {
260
260
  return value;
261
261
  }
262
262
 
263
+ /** The compiler result BEFORE diagram-interchange layout: everything the generated
264
+ * `CompileDeliveryGraphResult` carries EXCEPT the laid-out `bpmn`, plus the `semanticBpmn` (the
265
+ * pre-layout, DI-less BPMN). `compileDeliveryGraphSemantic` produces this cheaply (no
266
+ * `layoutBpmn`); `compileDeliveryGraph` layers the CPU-bound DI layout on top. Because the DI is
267
+ * DERIVED deterministically from `semanticBpmn`, `semanticBpmn` is the canonical content of a graph —
268
+ * the source `deliveryGraphDigest` content-addresses (issue #716). */
269
+ export interface CompiledDeliveryGraphSemantic {
270
+ ok: true;
271
+ /** A human-readable mermaid `flowchart` of the resolved graph. */
272
+ diagram: string;
273
+ /** The compiled one-shot BPMN process definition WITHOUT diagram interchange — the canonical,
274
+ * layout-independent content of the graph. Deterministic: same input graph → byte-identical XML. */
275
+ semanticBpmn: string;
276
+ resolved: CompileDeliveryGraphResult["resolved"];
277
+ humanNodes: CompileDeliveryGraphResult["humanNodes"];
278
+ sideEffects: CompileDeliveryGraphResult["sideEffects"];
279
+ }
280
+
281
+ /** A fully-compiled graph — the generated wire result (`diagram`, laid-out `bpmn`, …) PLUS the
282
+ * pre-layout `semanticBpmn` the content digest is taken over. */
283
+ export type CompiledDeliveryGraph = CompileDeliveryGraphResult & { semanticBpmn: string };
284
+
263
285
  /**
264
- * Validate + compile a delivery graph into a PURE preview (ADR 0005 slice S1). Returns
265
- * `{ ok:true, diagram, bpmn, resolved, humanNodes, sideEffects }` for a well-formed graph, or
266
- * `{ ok:false, errors }` (each error path-qualified) for a malformed one. NEVER deploys, dispatches,
267
- * or mutates anything — safe to call repeatedly. Deterministic: identical input JSON yields
268
- * byte-identical output.
286
+ * Validate + compile a delivery graph into a PURE preview WITHOUT the CPU-bound diagram-interchange
287
+ * layout (issue #716). Returns `{ ok:true, diagram, semanticBpmn, resolved, humanNodes, sideEffects }`
288
+ * for a well-formed graph, or `{ ok:false, errors }` (each error path-qualified) for a malformed one.
289
+ * NEVER deploys, dispatches, or mutates anything — safe to call repeatedly. Deterministic: identical
290
+ * input JSON yields byte-identical output.
269
291
  *
270
- * ASYNC because the final step attaches DIAGRAM INTERCHANGE (`bpmndi:BPMNDiagram`) via the toolkit
271
- * autolayout (`layoutBpmn` `bpmn-auto-layout`), the SAME pass every AUTHORED process gets from
272
- * `npm run layout` (`scripts/layout-bpmn.ts`). This is the one BPMN in the system generated at
273
- * runtime, so without this it was the only one shipping DI-less — unrenderable in the process
274
- * explorer (#440). `layoutBpmn` is itself deterministic given identical semantic input, so
275
- * "same JSON byte-identical XML" still holds with the diagram included.
292
+ * This is the fast path the agent-facing compile/stage doors (`compileDeliveryGraph` /
293
+ * `sequenceIssues` `compileAndStageDeliveryGraph`) take: staging needs only the content digest (taken
294
+ * over `semanticBpmn`), the mermaid `diagram`, and the resolved model NOT the laid-out `bpmn`. Skipping
295
+ * `layoutBpmn` (`bpmn-auto-layout`, superlinear in node/edge count minutes on a 256-node/1024-edge
296
+ * graph) keeps a cold MCP tool call well under the client's per-call timeout instead of tripping a
297
+ * `-32001` that poisons the stateful session (#715). The laid-out `bpmn` is generated lazily, only at the
298
+ * OPERATOR's preview/dispatch time (`previewProposalBpmn` / `dispatchDeliveryGraph`), which is a cockpit
299
+ * action, not an MCP call, and so is not timeout-bound.
276
300
  */
277
- export async function compileDeliveryGraph(
301
+ export async function compileDeliveryGraphSemantic(
278
302
  graph: unknown,
279
- ): Promise<CompileDeliveryGraphResult | CompileDeliveryGraphErrors> {
303
+ ): Promise<CompiledDeliveryGraphSemantic | CompileDeliveryGraphErrors> {
280
304
  const validationErrors: DeliveryGraphError[] = validateDeliveryGraph(graph);
281
305
  if (validationErrors.length > 0) {
282
306
  // Forward every semantic failure verbatim as a wire `{ path, message }` (the stable `code` stays
@@ -509,13 +533,40 @@ export async function compileDeliveryGraph(
509
533
  }
510
534
 
511
535
  const semanticBpmn = renderBpmn(typed, wirings, numberedFlows, startForkGateway, endJoinGateway, boundInputsByElement);
512
- const bpmn = await layoutDeliveryDiagram(semanticBpmn);
513
536
  const diagram = renderMermaid(typed, wirings, resolvedEdges, elementById);
514
537
  const resolved = buildResolved(typed, wirings, resolvedEdges, producersById);
515
538
  const humanNodes = buildHumanNodes(nodes);
516
539
  const sideEffects = buildSideEffects(nodes);
517
540
 
518
- return { ok: true, diagram, bpmn, resolved, humanNodes, sideEffects };
541
+ return { ok: true, diagram, semanticBpmn, resolved, humanNodes, sideEffects };
542
+ }
543
+
544
+ /**
545
+ * Validate + compile a delivery graph into a PURE preview INCLUDING diagram interchange (ADR 0005
546
+ * slice S1). Returns the generated `CompileDeliveryGraphResult` shape (`diagram`, laid-out `bpmn`,
547
+ * `resolved`, `humanNodes`, `sideEffects`) PLUS the pre-layout `semanticBpmn`, or `{ ok:false, errors }`
548
+ * for a malformed graph. NEVER deploys, dispatches, or mutates anything. Deterministic: identical input
549
+ * JSON yields byte-identical output.
550
+ *
551
+ * ASYNC because the final step attaches DIAGRAM INTERCHANGE (`bpmndi:BPMNDiagram`) via the toolkit
552
+ * autolayout (`layoutBpmn` — `bpmn-auto-layout`), the SAME pass every AUTHORED process gets from
553
+ * `npm run layout` (`scripts/layout-bpmn.ts`). This is the one BPMN in the system generated at
554
+ * runtime, so without this it was the only one shipping DI-less — unrenderable in the process
555
+ * explorer (#440). `layoutBpmn` is itself deterministic given identical semantic input, so
556
+ * "same JSON → byte-identical XML" still holds with the diagram included.
557
+ *
558
+ * Callers that only need the content digest / preview (the agent-facing compile+STAGE doors) should
559
+ * use the cheaper {@link compileDeliveryGraphSemantic} instead — layout here is CPU-bound and
560
+ * superlinear (issue #716), so it belongs only on the operator's preview/dispatch/deploy paths that
561
+ * genuinely render or run the BPMN.
562
+ */
563
+ export async function compileDeliveryGraph(
564
+ graph: unknown,
565
+ ): Promise<CompiledDeliveryGraph | CompileDeliveryGraphErrors> {
566
+ const semantic = await compileDeliveryGraphSemantic(graph);
567
+ if (!semantic.ok) return semantic;
568
+ const bpmn = await layoutDeliveryDiagram(semantic.semanticBpmn);
569
+ return { ...semantic, bpmn };
519
570
  }
520
571
 
521
572
  /** Attach diagram interchange (`bpmndi:BPMNDiagram`) to the semantic-only compiled BPMN via the
@@ -63,7 +63,7 @@ export async function dispatchDeliveryGraphRun(
63
63
  return { ok: false, errors: compiled.errors };
64
64
  }
65
65
 
66
- const digest = deliveryGraphDigest(compiled.bpmn);
66
+ const digest = deliveryGraphDigest(compiled.semanticBpmn);
67
67
  const runKey = computeRunKey(options.runKey, digest);
68
68
  const sideEffecting = compiled.sideEffects.length > 0;
69
69
  const explicitTitle = typeof options.title === "string" && options.title.trim() !== "" ? options.title.trim() : "";
@@ -105,8 +105,12 @@ export function isProposalExpired(expiresAtIso: string | null | undefined, at: D
105
105
 
106
106
  /** Extract the operator-facing preview from a successful compile — the diagram, the side effects a
107
107
  * dispatch authorises, and the human stop-points. No BPMN, no digest handle beyond the content
108
- * address; a preview, not a dispatch affordance. */
109
- export function buildProposalPreview(compiled: CompileDeliveryGraphResult): DeliveryProposalPreview {
108
+ * address; a preview, not a dispatch affordance. Accepts either the full compile result or the
109
+ * layout-free {@link compileDeliveryGraphSemantic} result — the preview reads only `diagram`,
110
+ * `sideEffects`, and `humanNodes`, none of which need the laid-out `bpmn` (issue #716). */
111
+ export function buildProposalPreview(
112
+ compiled: Pick<CompileDeliveryGraphResult, "diagram" | "humanNodes" | "sideEffects">,
113
+ ): DeliveryProposalPreview {
110
114
  return {
111
115
  diagram: compiled.diagram,
112
116
  sideEffects: compiled.sideEffects,
@@ -0,0 +1,145 @@
1
+ // Regression coverage for the compile+stage HOT PATH (issue #716, split from #715 gap 4).
2
+ //
3
+ // THE FAILURE MODE
4
+ // ================
5
+ // `compileDeliveryGraph` and `sequenceIssues` (the agent-facing MCP tools) both route through
6
+ // `compileAndStageDeliveryGraph`, which USED to run the full compiler — including the CPU-bound
7
+ // `layoutBpmn` (`bpmn-auto-layout`) diagram-interchange pass. That layout is superlinear in
8
+ // node/edge count: on a large/dense graph (the issue cites up to 256 nodes / 1024 edges) it takes
9
+ // MINUTES, so a cold MCP `tools/call` blew past the client's per-call timeout and returned
10
+ // `-32001 Request timed out` — which (per #715) then poisoned the stateful MCP session.
11
+ //
12
+ // THE FIX
13
+ // =======
14
+ // Staging needs only the content DIGEST, the mermaid `diagram`, and the resolved model — NOT the
15
+ // laid-out `bpmn`. The digest is now taken over the deterministic SEMANTIC BPMN (the DI is derived
16
+ // from it), so `compileAndStageDeliveryGraph` uses the layout-free `compileDeliveryGraphSemantic`
17
+ // and returns in milliseconds. The expensive `layoutBpmn` is deferred to the OPERATOR's
18
+ // preview/dispatch (`previewProposalBpmn` / `dispatchDeliveryGraph`) — cockpit actions, not
19
+ // timeout-bound MCP calls.
20
+ //
21
+ // These tests pin BOTH halves of the fix so it cannot regress:
22
+ // 1. `compileDeliveryGraphSemantic` is layout-free (no `bpmn`, no `bpmndi:` in `semanticBpmn`) and
23
+ // content-addresses IDENTICALLY to the full compile — so staging and dispatch/preview agree.
24
+ // 2. `compileAndStageDeliveryGraph` on a LARGE/DENSE graph completes fast (a bound the old
25
+ // layout-on-the-hot-path could never meet) and stages a live proposal whose `digest` is the
26
+ // SEMANTIC digest (not the laid-out one) — the structural signature of the fast path.
27
+ import { mkdtempSync, rmSync } from "node:fs";
28
+ import { tmpdir } from "node:os";
29
+ import { join, resolve } from "node:path";
30
+ import { after, before, describe, test } from "node:test";
31
+ import assert from "node:assert/strict";
32
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
33
+ import { compileDeliveryGraph, compileDeliveryGraphSemantic } from "./deliveryGraphCompiler.ts";
34
+ import { getStagedProposal } from "./deliveryGraphProposals.ts";
35
+ import { compileAndStageDeliveryGraph } from "./deliveryGraphStage.ts";
36
+ import { deliveryGraphDigest } from "./deliveryRunner.ts";
37
+
38
+ const APP_ROOT = resolve(import.meta.dirname, "..");
39
+
40
+ /** A large, DENSE delivery graph — `n` agent nodes, each edged from its `fan` predecessors. At
41
+ * `n=256, fan=4` this is ~1020 edges: the exact class the issue names, and one `layoutBpmn` takes
42
+ * MINUTES on. The semantic compile of the same graph is ~40ms, so the fast path clears any sane
43
+ * timing bound by three orders of magnitude. */
44
+ function denseGraph(n: number, fan: number): { name: string; nodes: unknown[]; edges: unknown[] } {
45
+ const nodes: unknown[] = [];
46
+ const edges: unknown[] = [];
47
+ for (let i = 0; i < n; i++) {
48
+ nodes.push({ id: `n${i}`, kind: "agent", agent: { jobType: "senior:feature", prompt: `task ${i}` } });
49
+ }
50
+ for (let i = 1; i < n; i++) {
51
+ for (let f = 1; f <= fan && i - f >= 0; f++) edges.push({ from: `n${i - f}`, to: `n${i}` });
52
+ }
53
+ return { name: "dense bench", nodes, edges };
54
+ }
55
+
56
+ describe("compileDeliveryGraphSemantic — the layout-free compile", () => {
57
+ test("produces a DI-less semantic BPMN and NO laid-out bpmn", async () => {
58
+ const g = denseGraph(8, 2);
59
+ const semantic = await compileDeliveryGraphSemantic(g);
60
+ assert(semantic.ok, "a well-formed graph compiles semantically");
61
+ assert(!("bpmn" in semantic), "the layout-free result carries no laid-out `bpmn`");
62
+ assert(
63
+ !semantic.semanticBpmn.includes("bpmndi:"),
64
+ "the semantic BPMN carries no diagram interchange (that is the layout the fast path skips)",
65
+ );
66
+ assert(semantic.semanticBpmn.includes("<bpmn:process"), "it is still a real BPMN process definition");
67
+ });
68
+
69
+ test("content-addresses IDENTICALLY to the full compile (staging and dispatch agree)", async () => {
70
+ const g = denseGraph(6, 2);
71
+ const semantic = await compileDeliveryGraphSemantic(g);
72
+ const full = await compileDeliveryGraph(g);
73
+ assert(semantic.ok && full.ok, "both compiles succeed");
74
+ // The full compile exposes the SAME semanticBpmn (it layers DI on top), and the digest is taken
75
+ // over that — so the fast (stage) path and the full (preview/dispatch) path never drift on how a
76
+ // graph is addressed.
77
+ assert.equal(full.semanticBpmn, semantic.semanticBpmn, "full compile reuses the semantic BPMN verbatim");
78
+ assert.equal(
79
+ deliveryGraphDigest(semantic.semanticBpmn),
80
+ deliveryGraphDigest(full.semanticBpmn),
81
+ "the content digest matches across the fast and full paths",
82
+ );
83
+ // Sanity: the laid-out bytes genuinely differ from the semantic bytes (DI was attached), so a
84
+ // digest taken over the laid-out `bpmn` would be a DIFFERENT value — the thing the fix moves away
85
+ // from.
86
+ assert.notEqual(full.bpmn, full.semanticBpmn, "the laid-out bpmn differs from the semantic bpmn");
87
+ assert.notEqual(
88
+ deliveryGraphDigest(full.bpmn),
89
+ deliveryGraphDigest(full.semanticBpmn),
90
+ "the semantic digest is distinct from the (old) laid-out digest",
91
+ );
92
+ });
93
+ });
94
+
95
+ describe("compileAndStageDeliveryGraph — the agent-facing hot path does not run layout", () => {
96
+ const dirs: string[] = [];
97
+ const apps: TestApp[] = [];
98
+ let app: TestApp;
99
+ before(async () => {
100
+ const d = mkdtempSync(join(tmpdir(), "nwf-stage-hotpath-"));
101
+ dirs.push(d);
102
+ app = await bootTestApp(APP_ROOT, {
103
+ env: { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "", NANO_APP_DB_URL: `file:${join(d, "app.db")}` },
104
+ });
105
+ apps.push(app);
106
+ });
107
+ after(async () => {
108
+ for (const a of apps) await a.stop?.();
109
+ for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
110
+ });
111
+
112
+ test("stages a 256-node / ~1020-edge graph FAST and content-addresses it by its SEMANTIC digest", async () => {
113
+ const g = denseGraph(256, 4);
114
+ assert(g.edges.length > 1000, "the fixture really is dense (the layout-heavy class)");
115
+
116
+ const graphJson = JSON.stringify(g);
117
+ const started = performance.now();
118
+ const staged = await compileAndStageDeliveryGraph(app.db, g, graphJson, "http://example.test");
119
+ const elapsedMs = performance.now() - started;
120
+
121
+ assert(staged.ok, "the dense graph stages successfully");
122
+ assert.equal(staged.status, 200);
123
+ // The whole point of #716: the hot path must NOT run `layoutBpmn` (minutes on this graph). The
124
+ // layout-free semantic compile is ~40ms; a 20s bound is a 500x margin over the passing path and a
125
+ // 6x margin UNDER the failing (layout) path, so it separates them without flaking.
126
+ assert(
127
+ elapsedMs < 20_000,
128
+ `staging a dense graph must not pay the layout tax — took ${elapsedMs.toFixed(0)}ms (a layout would take minutes)`,
129
+ );
130
+
131
+ // The staged proposal is content-addressed by the SEMANTIC digest — the structural signature of
132
+ // the fast path. Before the fix it was the laid-out-bpmn digest (a different value), so this
133
+ // assertion is red on the old behaviour and green on the new one.
134
+ const semantic = await compileDeliveryGraphSemantic(g);
135
+ assert(semantic.ok);
136
+ const semanticDigest = deliveryGraphDigest(semantic.semanticBpmn);
137
+ assert.equal(staged.digest, semanticDigest, "staged proposal is addressed by its semantic digest");
138
+
139
+ // …and it is actually persisted as a live, dispatchable staged proposal (the operator can find it
140
+ // and drive the — deferred — layout at preview/dispatch time).
141
+ const live = await getStagedProposal(app.db, staged.digest);
142
+ assert(live, "the staged proposal is live in the store");
143
+ assert.equal(live.node_count, 256);
144
+ });
145
+ });
@@ -2,7 +2,7 @@
2
2
  // delivery graph (epic nano-workforce#605). Extracted from `operations/compileDeliveryGraph.ts` (S0)
3
3
  // so the intent-shaped generator doors (S4 — `sequenceIssues`) hand their CONSTRUCTED graph to the
4
4
  // EXACT same deterministic compile → validate → stage path the raw `compileDeliveryGraph` door uses:
5
- // one compiler (`compileDeliveryGraph`), one staging path (`stageProposal`), one idempotency/digest
5
+ // one compiler (`compileDeliveryGraphSemantic`), one staging path (`stageProposal`), one idempotency/digest
6
6
  // semantics (content-addressed by `deliveryGraphDigest`). Per AGENTS.md "Derivation over duplication:
7
7
  // no drift surfaces", a generator MUST NOT re-implement a second runner or a second staging path —
8
8
  // it only produces the `DeliveryGraph` and delegates here.
@@ -14,7 +14,7 @@
14
14
  // semantic validation. Nothing is staged on a rejected compile.
15
15
  import type { DataLayer } from "@nanobpm/urban";
16
16
  import type { CompileDeliveryGraphErrors, CompileDeliveryGraphStaged } from "../nano-generated/api-io.d.ts";
17
- import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
17
+ import { compileDeliveryGraphSemantic } from "./deliveryGraphCompiler.ts";
18
18
  import {
19
19
  buildProposalPreview,
20
20
  buildProposalRow,
@@ -64,12 +64,12 @@ export async function compileAndStageDeliveryGraph(
64
64
  graphJson: string,
65
65
  origin: string,
66
66
  ): Promise<StagedResult | StageErrors> {
67
- const result = await compileDeliveryGraph(graph);
67
+ const result = await compileDeliveryGraphSemantic(graph);
68
68
  if (!result.ok) {
69
69
  return { ok: false, status: 400, body: result };
70
70
  }
71
71
 
72
- const digest = deliveryGraphDigest(result.bpmn);
72
+ const digest = deliveryGraphDigest(result.semanticBpmn);
73
73
  const name =
74
74
  typeof result.resolved.name === "string" && result.resolved.name.trim() !== ""
75
75
  ? result.resolved.name.trim()
@@ -127,7 +127,7 @@ export async function parseAndCompileText(
127
127
  },
128
128
  };
129
129
  }
130
- const digest = deliveryGraphDigest(compiled.bpmn);
130
+ const digest = deliveryGraphDigest(compiled.semanticBpmn);
131
131
  const name =
132
132
  typeof compiled.resolved.name === "string" && compiled.resolved.name.trim() !== ""
133
133
  ? compiled.resolved.name.trim()
@@ -6,7 +6,8 @@
6
6
  // runs it); its whole job is deploy + seed + start.
7
7
  //
8
8
  // Definition lifecycle (the ADR open question, resolved here): the deployed process id is
9
- // CONTENT-ADDRESSED — `delivery-graph-<sha256(bpmn)[:12]>`. Identical graphs compile byte-identically
9
+ // CONTENT-ADDRESSED — `delivery-graph-<sha256(semanticBpmn)[:12]>` (the pre-layout semantic model, NOT
10
+ // the laid-out `bpmn` — issue #716). Identical graphs compile byte-identically
10
11
  // (S1 determinism) → identical id → an idempotent redeploy (the engine versions the same id, never a
11
12
  // duplicate definition per run); different graphs get different ids and never collide; and because the
12
13
  // id ENCODES its content, a stale one-shot definition is GC-identifiable by a later sweeper (out of
@@ -22,13 +23,21 @@ import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, read
22
23
  import { repoEnvelopeVars } from "./repoEnvelope.ts";
23
24
  import { isoDuration } from "./reviewWait.ts";
24
25
 
25
- /** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
26
- * content-addressed deploy id (`delivery-graph-<digest>`) AND the dispatch fence's default idempotency
27
- * key + the staged-proposal primary key. The runner (deploy id), `app/deliveryGraphDispatch` (dedupe
28
- * key), and `app/deliveryGraphProposals` (proposal digest) all derive from THIS one function so they
29
- * can never drift on how a graph is addressed. */
30
- export function deliveryGraphDigest(bpmn: string): string {
31
- return createHash("sha256").update(bpmn).digest("hex").slice(0, 12);
26
+ /** The content digest of a compiled graph — `sha256(semanticBpmn)[:12]` — the single source of truth
27
+ * for the content-addressed deploy id (`delivery-graph-<digest>`) AND the dispatch fence's default
28
+ * idempotency key + the staged-proposal primary key. The runner (deploy id),
29
+ * `app/deliveryGraphDispatch` (dedupe key), and `app/deliveryGraphProposals` (proposal digest) all
30
+ * derive from THIS one function so they can never drift on how a graph is addressed.
31
+ *
32
+ * The digest is taken over the graph's SEMANTIC BPMN (the pre-layout `compileDeliveryGraphSemantic`
33
+ * output), NOT the laid-out `bpmn` (issue #716). The diagram interchange is DERIVED deterministically
34
+ * from the semantic model, so the semantic BPMN is the true canonical content of a graph — and, unlike
35
+ * the laid-out BPMN, it is available WITHOUT the CPU-bound `layoutBpmn` pass. This lets the agent-facing
36
+ * compile+stage doors content-address (and stage) a graph on the fast path while the expensive layout is
37
+ * deferred to the operator's preview/dispatch. Every caller MUST pass a `semanticBpmn` so the address
38
+ * stays consistent across staging, preview, dispatch, and deploy. */
39
+ export function deliveryGraphDigest(semanticBpmn: string): string {
40
+ return createHash("sha256").update(semanticBpmn).digest("hex").slice(0, 12);
32
41
  }
33
42
 
34
43
  /** The bounded-timeout / SLA envelope every node inherits (Decision: bounded → escalate). ISO-8601
@@ -120,7 +129,7 @@ export async function prepareDeliveryGraph(
120
129
  const compiled = await compileDeliveryGraph(graph);
121
130
  if (!compiled.ok) return { ok: false, errors: compiled.errors };
122
131
 
123
- const digest = deliveryGraphDigest(compiled.bpmn);
132
+ const digest = deliveryGraphDigest(compiled.semanticBpmn);
124
133
  const processDefinitionId = `${DELIVERY_GRAPH_PROCESS_ID}-${digest}`;
125
134
  const bpmn = rewriteProcessId(compiled.bpmn, processDefinitionId);
126
135
 
@@ -290,6 +290,21 @@ got string`. That retired the nwf-local stringified-body reject mitigation: the
290
290
  `e2e/mcp-surface.e2e.ts` now asserts the door faithfully parses a stringified body (the
291
291
  `assertObjectBodyAccepted` detector's teeth stay pinned synthetically).
292
292
 
293
+ **Heavy compile/stage tools stay under the client timeout (issue #716).** Compiling a large
294
+ delivery graph to laid-out BPMN (`layoutBpmn` / `bpmn-auto-layout`) is CPU-bound and superlinear —
295
+ minutes on a 256-node / 1024-edge graph — so running it inline once tripped a cold
296
+ `sequenceIssues` / `compileDeliveryGraph` call past the client's per-call MCP timeout (`-32001
297
+ Request timed out`, which then poisoned the stateful session, #715). The compile+STAGE hot path
298
+ (`compileAndStageDeliveryGraph`) therefore runs the **layout-free** `compileDeliveryGraphSemantic`:
299
+ staging needs only the content **digest**, the mermaid `diagram`, and the resolved model, so it
300
+ returns in milliseconds. The digest is taken over the deterministic **semantic** BPMN (the diagram
301
+ interchange is derived from it, so it is the canonical content of a graph) — one content address
302
+ shared across staging, `previewProposalBpmn`, `dispatchDeliveryGraph`, and the deploy id, so they
303
+ never drift. The expensive `layoutBpmn` is deferred to the **operator's** preview/dispatch
304
+ (`previewProposalBpmn` recompiles the laid-out BPMN with DI on demand) — a cockpit action, not a
305
+ timeout-bound MCP call. `app/deliveryGraphStage.test.ts` and `e2e/heavy-tool-progress.e2e.ts` pin
306
+ that a large/dense graph stages fast rather than timing out.
307
+
293
308
  ## 5. Fallback
294
309
 
295
310
  Agents without MCP are unchanged — resolve the instance, then
@@ -0,0 +1,86 @@
1
+ // Heavy-tool timeout regression over the REAL runtime-served `/app/mcp` surface (issue #716, split
2
+ // from #715 gap 4).
3
+ //
4
+ // The agent-facing `compileDeliveryGraph` tool used to run the CPU-bound `layoutBpmn` pass inline, so
5
+ // a cold call on a large/dense graph (the issue cites up to 256 nodes / 1024 edges) blew past the
6
+ // client's per-call MCP timeout and returned `-32001 Request timed out` — poisoning the session (#715).
7
+ // The fix stages on a layout-free fast path (the digest is taken over the deterministic semantic BPMN;
8
+ // the expensive layout is deferred to the operator's preview/dispatch).
9
+ //
10
+ // This drives the exact client handshake an agent uses (via the S1 harness) and asserts a large graph
11
+ // gets an IMMEDIATE accepted `status:"ready"` staged response — not a timeout — and that the staged
12
+ // digest is immediately visible over the same surface. It does NOT re-implement the transport (see the
13
+ // harness header's EXTENSION SEAM).
14
+ //
15
+ // Run with `npm run e2e`.
16
+ import assert from "node:assert/strict";
17
+ import { after, before, describe, test } from "node:test";
18
+ import { assertObjectBodyAccepted, bootMcpHarness, type McpHarness } from "./support/mcp-harness.ts";
19
+
20
+ const TOOL = "compileDeliveryGraph";
21
+
22
+ /** A large, DENSE delivery graph — `n` agent nodes, each edged from its `fan` predecessors. At
23
+ * `n=256, fan=4` that is ~1020 edges: the exact class the issue names, and one `layoutBpmn` takes
24
+ * MINUTES on. The layout-free stage path compiles it in tens of milliseconds. */
25
+ function denseGraph(n: number, fan: number): { name: string; nodes: unknown[]; edges: unknown[] } {
26
+ const nodes: unknown[] = [];
27
+ const edges: unknown[] = [];
28
+ for (let i = 0; i < n; i++) {
29
+ nodes.push({ id: `n${i}`, kind: "agent", agent: { jobType: "senior:feature", prompt: `task ${i}` } });
30
+ }
31
+ for (let i = 1; i < n; i++) {
32
+ for (let f = 1; f <= fan && i - f >= 0; f++) edges.push({ from: `n${i - f}`, to: `n${i}` });
33
+ }
34
+ return { name: "dense mcp bench", nodes, edges };
35
+ }
36
+
37
+ interface ListBody {
38
+ count: number;
39
+ proposals: Array<{ digest: string; title: string | null }>;
40
+ }
41
+
42
+ describe("#716 — a heavy compile/stage tool returns an accepted response (not a timeout) over MCP", () => {
43
+ let h: McpHarness;
44
+ before(async () => {
45
+ h = await bootMcpHarness();
46
+ });
47
+ after(async () => {
48
+ await h.stop();
49
+ });
50
+
51
+ test("a cold, large/dense compileDeliveryGraph call stages FAST rather than timing out", async () => {
52
+ const graph = denseGraph(256, 4);
53
+ assert.ok(graph.edges.length > 1000, "the fixture really is dense (the layout-heavy class)");
54
+
55
+ const started = performance.now();
56
+ const res = await h.callTool(TOOL, { body: graph });
57
+ const elapsedMs = performance.now() - started;
58
+
59
+ // The object body arrived as an object (S0 invariant) and the call SUCCEEDED (a real client would
60
+ // have timed out with -32001 on the old layout-inline path).
61
+ assertObjectBodyAccepted(res, TOOL);
62
+ assert.ok(!res.isError, `${TOOL} on a large graph must stage, not error/time out: ${res.text}`);
63
+
64
+ const json = res.json as { status?: string; digest?: string; reviewUrl?: string } | undefined;
65
+ assert.equal(json?.status, "ready", `${TOOL} must return an accepted staged response: ${res.text}`);
66
+ assert.ok(typeof json?.digest === "string" && json.digest.length > 0, "the staged response carries a digest");
67
+
68
+ // The whole point of #716: the tool must not pay the layout tax (minutes on this graph). The
69
+ // layout-free path is tens of ms; a 20s bound is a huge margin over the passing path and well under
70
+ // the failing (layout) path, so it separates them without flaking.
71
+ assert.ok(
72
+ elapsedMs < 20_000,
73
+ `a heavy tool must return promptly — took ${elapsedMs.toFixed(0)}ms (an inline layout would take minutes)`,
74
+ );
75
+
76
+ // The staged proposal is immediately observable over the SAME surface — the operator can find it
77
+ // and drive the (deferred) layout at preview/dispatch time.
78
+ const list = await h.callTool("listStagedProposals", {});
79
+ assert.ok(!list.isError, `listStagedProposals must not error: ${list.text}`);
80
+ const listed = list.json as ListBody | undefined;
81
+ assert.ok(
82
+ listed?.proposals?.some((p) => p.digest === json?.digest),
83
+ `the staged digest ${json?.digest} must be listed: ${list.text}`,
84
+ );
85
+ });
86
+ });
@@ -81,7 +81,7 @@ export default defineOperation("previewProposalBpmn", async ({ body }, app) => {
81
81
  // Determinism guard: the recompiled BPMN must content-address back to the requested digest. A mismatch
82
82
  // means the stored graph drifted from its digest — refuse rather than serve a diagram that doesn't
83
83
  // match the proposal the operator is about to dispatch.
84
- const recompiledDigest = deliveryGraphDigest(compiled.bpmn);
84
+ const recompiledDigest = deliveryGraphDigest(compiled.semanticBpmn);
85
85
  if (recompiledDigest !== digest) {
86
86
  app.log.error("preview-proposal-bpmn: digest drift", { digest, recompiledDigest });
87
87
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.178.0",
3
+ "version": "0.178.1",
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",