@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 +6 -0
- package/app/deliveryGraphCompiler.ts +66 -15
- package/app/deliveryGraphDispatch.ts +1 -1
- package/app/deliveryGraphProposals.ts +6 -2
- package/app/deliveryGraphStage.test.ts +145 -0
- package/app/deliveryGraphStage.ts +4 -4
- package/app/deliveryGraphTextIngress.ts +1 -1
- package/app/deliveryRunner.ts +18 -9
- package/docs/mcp-runbook.md +15 -0
- package/e2e/heavy-tool-progress.e2e.ts +86 -0
- package/operations/previewProposalBpmn.ts +1 -1
- package/package.json +1 -1
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
|
|
265
|
-
* `{ ok:true, diagram,
|
|
266
|
-
* `{ ok:false, errors }` (each error path-qualified) for a malformed one.
|
|
267
|
-
* or mutates anything — safe to call repeatedly. Deterministic: identical
|
|
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
|
-
*
|
|
271
|
-
*
|
|
272
|
-
* `
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
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
|
|
301
|
+
export async function compileDeliveryGraphSemantic(
|
|
278
302
|
graph: unknown,
|
|
279
|
-
): Promise<
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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 (`
|
|
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 {
|
|
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
|
|
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.
|
|
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.
|
|
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()
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
26
|
-
* content-addressed deploy id (`delivery-graph-<digest>`) AND the dispatch fence's default
|
|
27
|
-
* key + the staged-proposal primary key. The runner (deploy id),
|
|
28
|
-
* key), and `app/deliveryGraphProposals` (proposal digest) all
|
|
29
|
-
* can never drift on how a graph is addressed.
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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
|
|
package/docs/mcp-runbook.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|