@nanobpm/nano-workforce 0.199.0 → 0.200.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,15 @@
1
+ ## [0.200.1](https://github.com/nanobpm/nano-workforce/compare/v0.200.0...v0.200.1) (2026-10-03)
2
+
3
+ ### Performance Improvements
4
+
5
+ * **delivery-graph:** run BPMN autolayout off the main thread (worker_threads) so a large graph cannot freeze the app ([#855](https://github.com/nanobpm/nano-workforce/issues/855)) ([d726bca](https://github.com/nanobpm/nano-workforce/commit/d726bcafd1b8e89484c7231a705f287943633a8a)), closes [#852](https://github.com/nanobpm/nano-workforce/issues/852) [#854](https://github.com/nanobpm/nano-workforce/issues/854)
6
+
7
+ ## [0.200.0](https://github.com/nanobpm/nano-workforce/compare/v0.199.0...v0.200.0) (2026-10-02)
8
+
9
+ ### Features
10
+
11
+ * **convergence:** local adversarial-review agent pass before Copilot ([#845](https://github.com/nanobpm/nano-workforce/issues/845)) ([3aec0da](https://github.com/nanobpm/nano-workforce/commit/3aec0da99224dd29f43566a9c75a5cf34dbc843a)), closes [#842](https://github.com/nanobpm/nano-workforce/issues/842) [#844](https://github.com/nanobpm/nano-workforce/issues/844) [#844](https://github.com/nanobpm/nano-workforce/issues/844) [#844](https://github.com/nanobpm/nano-workforce/issues/844) [#822](https://github.com/nanobpm/nano-workforce/issues/822)
12
+
1
13
  ## [0.199.0](https://github.com/nanobpm/nano-workforce/compare/v0.198.1...v0.199.0) (2026-10-02)
2
14
 
3
15
  ### Features
package/SPEC.md CHANGED
@@ -110,7 +110,13 @@ known at submit time, carried as a process variable and stored on the DB row.
110
110
  │ │ → [Escalate: unaddressed comments] (blocked) │
111
111
  │ │ → [Wait: wait-answer userTask] ────────────────────────────┤
112
112
  │ │ │
113
- │ ├── addressed → [Record round] → [Check progress] (did the PR head advance?) │
113
+ │ ├── addressed → <adversarial pass due? (#844)> │
114
+ │ │ (or waiting) ├── pass due (addressed, or first push; budget unspent) │
115
+ │ │ │ → [Adversarial review] (service task, taskType: senior:adversarial-review) │
116
+ │ │ │ → <findings?> ── findings → re-dispatch [Review round] (round unchanged) │
117
+ │ │ │ └── clean → [Record round] │
118
+ │ │ └── disabled / budget spent → [Record round] │
119
+ │ │ [Record round] → [Check progress] (did the PR head advance?) │
114
120
  │ │ ├── progressed → <guard: round ≥ maxRounds → escalate "not converged"> │
115
121
  │ │ │ → <event-based gateway: review ready or timeout?> │
116
122
  │ │ │ ├── readiness-ready (msg catch, key = prKey) → round++ ─┐ │
@@ -139,6 +145,21 @@ step, then retry the same round with the human's `answer`. They differ only by e
139
145
  which the UI uses to label the card. Neither ends the run — a human always gets
140
146
  a chance to unblock and resume.
141
147
 
148
+ **Local adversarial-review stage (issue #844).** Before an `addressed` or first-push
149
+ `waiting` round parks on `waiting_review` (the only status the poller solicits a Copilot
150
+ review for), the loop runs a bounded local adversarial pass over the round's diff. The
151
+ `<adversarial pass due?>` gate (`gw-adv-due`) fires when the per-round budget is unspent
152
+ and the round is `addressed` **or** the first push (`advMax != null and advPass < advMax
153
+ and (status != "waiting" or round = 1)`); otherwise (disabled, budget spent) it skips
154
+ straight to `[Record round]`. The `senior:adversarial-review` service task (prompt
155
+ delivered via the `prompts/adversarial-review.md` linked resource) critiques the diff and
156
+ returns `adversarialFindings`. Non-blank findings **re-dispatch `[Review round]` within the
157
+ same round** (round unchanged) with `adversarialFindings` seeded, so the agent fixes them
158
+ before this app's poller solicits the Copilot review (the only `waiting_review` trigger); a
159
+ clean result records the round and proceeds to `[Check progress]`. The pass budget is
160
+ `MAX_ADVERSARIAL_PASSES` (default 1,
161
+ `NANO_PR_MAX_ADVERSARIAL_PASSES=0` disables the stage).
162
+
142
163
  **Durable adjudication auto-resume (issue #806).** A human's answer to a `wait-answer`
143
164
  is remembered durably, keyed by `(PR, canonical question fingerprint)` — the
144
165
  `record-answer` step persists it (`pr_adjudications`), and before the poller surfaces a
@@ -350,6 +371,7 @@ Notes:
350
371
  | `prNumber` | int | |
351
372
  | `round` | int | 1-based round counter |
352
373
  | `answer` | string? | present only when resuming from an escalation |
374
+ | `adversarialFindings` | string? | present only when the local adversarial-review stage (§4) produced findings on this round's push — the agent is re-dispatched within the same round to address them |
353
375
 
354
376
  The base instructions are **not** a job variable: they are delivered as a
355
377
  **linked resource** on the `senior:pr-review` task —
package/app/contracts.ts CHANGED
@@ -104,6 +104,22 @@ export const ENV_CONTRACTS = {
104
104
  "Clone timeout in milliseconds emitted as `repository.cloneTimeoutMs` in the agent-task envelope so the c8ctl harness raises its 120s default for large-repo provisioning (branch-scoped blobless clones of big monorepos still approach/exceed 120s; issue #694). Inherited by both the review-round and merge paths via the one `repoEnvelopeVars` builder.",
105
105
  default: "600000",
106
106
  },
107
+ NANO_DELIVERY_LAYOUT_TIMEOUT_MS: {
108
+ category: "env",
109
+ name: "NANO_DELIVERY_LAYOUT_TIMEOUT_MS",
110
+ owner: "app/layoutOffThread.ts",
111
+ semantics:
112
+ "Bound (ms) on the delivery-graph BPMN autolayout (`layoutBpmn` → `bpmn-auto-layout`), which runs OFF the main event loop in a `node:worker_threads` worker (`layoutBpmnOffThread`, issue #854). On expiry the worker is terminated and the compile/launch fails CLEANLY rather than hanging — the inline layout previously froze the whole app (no HTTP, no poll passes) for the entire superlinear run (#852). Shared by both the dispatch (`dispatchDeliveryGraphRun` → `compileDeliveryGraph`) and preview (`previewProposalBpmn`) paths, which both funnel through the one `layoutDeliveryDiagram` entry point.",
113
+ default: "300000",
114
+ },
115
+ NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY: {
116
+ category: "env",
117
+ name: "NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY",
118
+ owner: "app/layoutOffThread.ts",
119
+ semantics:
120
+ "Maximum number of delivery-graph BPMN autolayout workers (`layoutBpmnOffThread`, issue #854) allowed to run CONCURRENTLY. Each layout is a CPU-bound `node:worker_threads` worker running the superlinear `bpmn-auto-layout` pass; overlapping preview/dispatch requests would otherwise spawn an UNBOUNDED number of them and saturate CPU/memory, starving the very event loop this off-thread move exists to protect. Excess layouts queue (FIFO) for a slot via the shared `Semaphore`. Capacity is fixed at first use, so a retune needs a restart (unlike the per-call timeout).",
121
+ default: "2",
122
+ },
107
123
  NANO_PR_MAX_ROUNDS: {
108
124
  category: "env",
109
125
  name: "NANO_PR_MAX_ROUNDS",
@@ -140,6 +156,14 @@ export const ENV_CONTRACTS = {
140
156
  "Maximum bounded review-round re-dispatches the convergence loop makes to auto-ack unacked suppressed advisories before escalating to a human (#796); 0 escalates on the first ack-only block.",
141
157
  default: "2",
142
158
  },
159
+ NANO_PR_MAX_ADVERSARIAL_PASSES: {
160
+ category: "env",
161
+ name: "NANO_PR_MAX_ADVERSARIAL_PASSES",
162
+ owner: "app/service.ts",
163
+ semantics:
164
+ "Maximum local adversarial-review agent passes per convergence round before the round parks for a Copilot review (#844); non-blank findings re-dispatch review-round. 0 disables the stage.",
165
+ default: "1",
166
+ },
143
167
  NANO_PR_MAX_MERGE_STALL_ROUNDS: {
144
168
  category: "env",
145
169
  name: "NANO_PR_MAX_MERGE_STALL_ROUNDS",
@@ -0,0 +1,294 @@
1
+ // Behavioural coverage for the local adversarial-review pass on the convergence loop's ADDRESSED
2
+ // path (issue #844, sub-issue of #842).
3
+ //
4
+ // Copilot had become our discovery loop: each finding costs a full GitHub re-review round, and many
5
+ // are the obvious "next" finding a second reader would catch locally. So an `addressed` round now
6
+ // runs a separate `senior:adversarial-review` agent BEFORE the round is persisted / parked on
7
+ // `waiting_review` (the only status the poller solicits a Copilot review for). Non-blank findings
8
+ // loop straight back into `review-round` (carrying `adversarialFindings`) — not through
9
+ // `capture-head`, so the round-entry head and the no-progress check still span the whole round.
10
+ // The pass count is BOUNDED per round by `advPass`/`advMax` and reset when a new review lands.
11
+ //
12
+ // These deploy the committed convergence-loop into the real WASM engine and drive tokens through it,
13
+ // asserting what the loop DOES (element run counts, job inputs, the parked wait), not how it is drawn.
14
+ import { after, test } from "node:test";
15
+ import { assert } from "#test-assert";
16
+ import { readFileSync } from "node:fs";
17
+ import { assertThatInstance, byProcessId, createWasmEngineClient, type WasmEngineClient } from "@nanobpm/urban-testkit";
18
+
19
+ const MODEL = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
20
+
21
+ type Output = Record<string, unknown>;
22
+ type Job = { variables: Record<string, unknown> };
23
+ type Responder = Output | Output[] | ((job: Job) => Output);
24
+
25
+ const ALL_JOB_TYPES = [
26
+ "senior:pr-review",
27
+ "senior:adversarial-review",
28
+ "pr.capture-head",
29
+ "pr.persist-round",
30
+ "pr.progress-check",
31
+ "pr.converge-gate",
32
+ "senior:scope-classify",
33
+ "pr.finalize",
34
+ "pr.persist-escalation",
35
+ "pr.answer-escalation",
36
+ ] as const;
37
+
38
+ const FINDINGS = "Open: src/a.ts:10 fail-open on unknown input (high)";
39
+ const CLEAN = { adversarialFindings: "", adversarialSummary: "no findings" };
40
+
41
+ const DEFAULT_RESPONSES: Record<string, Responder> = {
42
+ "senior:pr-review": { status: "addressed", summary: "fixed" },
43
+ "senior:adversarial-review": CLEAN,
44
+ "pr.capture-head": { roundEntryHead: "sha-entry" },
45
+ "pr.persist-round": {},
46
+ "pr.progress-check": { progressed: true },
47
+ "pr.converge-gate": { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false },
48
+ "senior:scope-classify": { scopeBlocked: false, scopeBlockReason: "" },
49
+ "pr.finalize": {},
50
+ "pr.persist-escalation": { escalated: true },
51
+ "pr.answer-escalation": {},
52
+ };
53
+
54
+ const DEFAULT_VARS: Record<string, unknown> = {
55
+ prKey: "o/r#1",
56
+ repo: "o/r",
57
+ prNumber: 1,
58
+ prUrl: "https://example.test/pr/1",
59
+ round: 1,
60
+ maxRounds: 20,
61
+ reviewWaitTimeout: "PT30M",
62
+ ackRetryRound: 0,
63
+ ackRetryMax: 2,
64
+ advPass: 0,
65
+ advMax: 1,
66
+ abandonBrief: null,
67
+ status: null,
68
+ question: null,
69
+ summary: null,
70
+ answer: null,
71
+ scopePending: false,
72
+ scopeAnswer: null,
73
+ convergeBlocked: null,
74
+ convergeBlockReason: null,
75
+ convergeAckOnly: null,
76
+ reviewStale: null,
77
+ roundEntryHead: null,
78
+ huskRetries: 0,
79
+ progressed: true,
80
+ escalated: null,
81
+ };
82
+
83
+ const engines: WasmEngineClient[] = [];
84
+ after(async () => {
85
+ await Promise.all(engines.map((e) => e.close()));
86
+ });
87
+
88
+ /** Boot the loop; records every job's input variables per job type for assertions. */
89
+ async function boot(opts: { responses?: Record<string, Responder>; vars?: Record<string, unknown> } = {}) {
90
+ const engine = await createWasmEngineClient();
91
+ engines.push(engine);
92
+ await engine.deployResources([{ name: "convergence-loop.bpmn", content: MODEL, contentType: "text/xml" }]);
93
+ const responses = { ...DEFAULT_RESPONSES, ...(opts.responses ?? {}) };
94
+ const seen: Record<string, Record<string, unknown>[]> = {};
95
+ for (const jobType of ALL_JOB_TYPES) {
96
+ const responder = responses[jobType];
97
+ const queue = Array.isArray(responder) ? [...responder] : null;
98
+ seen[jobType] = [];
99
+ await engine.registerWorker(jobType, (job) => {
100
+ seen[jobType].push({ ...(job as Job).variables });
101
+ if (queue) return queue.length > 1 ? queue.shift()! : (queue[0] ?? {});
102
+ if (typeof responder === "function") return responder(job as Job);
103
+ return responder ?? {};
104
+ });
105
+ }
106
+ await engine.createInstance({
107
+ processDefinitionId: "convergence-loop",
108
+ awaitCompletion: false,
109
+ variables: { ...DEFAULT_VARS, ...(opts.vars ?? {}) },
110
+ });
111
+ return { engine, seen };
112
+ }
113
+
114
+ function completions(engine: WasmEngineClient, elementId: string): number {
115
+ const snap = engine.snapshot() as { elementStats?: { elementId: string; completed: number }[] };
116
+ return (snap.elementStats ?? []).find((s) => s.elementId === elementId)?.completed ?? 0;
117
+ }
118
+ function instanceVars(engine: WasmEngineClient): Record<string, unknown> {
119
+ const snap = engine.snapshot() as { instances?: { variables?: Record<string, unknown> }[] };
120
+ return snap.instances?.[0]?.variables ?? {};
121
+ }
122
+ function runs(engine: WasmEngineClient): string {
123
+ return ["review-round", "adversarial-review", "persist-round", "check-progress"]
124
+ .map((id) => `${id}=${completions(engine, id)}`)
125
+ .join(" ");
126
+ }
127
+
128
+ test("addressed + adversarial findings loop back into review-round with the findings, then park for Copilot", async () => {
129
+ const { engine, seen } = await boot({
130
+ responses: { "senior:adversarial-review": [{ adversarialFindings: FINDINGS, adversarialSummary: "1 finding" }, CLEAN] },
131
+ vars: { advMax: 2 },
132
+ });
133
+ assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
134
+ assert(completions(engine, "review-round") === 2, `review-round should run twice: ${runs(engine)}`);
135
+ assert(completions(engine, "adversarial-review") === 2, `adversarial-review should run twice: ${runs(engine)}`);
136
+ // The round is persisted and progress-checked ONCE, only after the adversarial pass came back clean.
137
+ assert(completions(engine, "persist-round") === 1, `persist-round once: ${runs(engine)}`);
138
+ assert(completions(engine, "check-progress") === 1, `check-progress once: ${runs(engine)}`);
139
+ // The loop goes straight back to review-round, not through capture-head (round-entry head kept).
140
+ assert(completions(engine, "capture-head") === 1, "the findings loop must not re-capture the round-entry head");
141
+ const reviewJobs = seen["senior:pr-review"];
142
+ assert(!reviewJobs[0].adversarialFindings, "the first review-round has no adversarial findings");
143
+ assert(reviewJobs[1].adversarialFindings === FINDINGS, "the re-dispatched review-round receives the findings");
144
+ // Parked on the Copilot review wait (the poller solicits only after progress-check parks it).
145
+ assert(completions(engine, "wait-review") === 0, "must be parked waiting for the review");
146
+ });
147
+
148
+ test("a clean adversarial pass proceeds straight to persist-round", async () => {
149
+ const { engine } = await boot();
150
+ assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
151
+ assert(completions(engine, "review-round") === 1, runs(engine));
152
+ assert(completions(engine, "adversarial-review") === 1, runs(engine));
153
+ assert(completions(engine, "persist-round") === 1, runs(engine));
154
+ });
155
+
156
+ // Regression for Copilot review 5386944381 (round 5): the round-4 verdict reset on
157
+ // `adversarial-review` was UNCONDITIONAL, so it also fired on the clean/default arm
158
+ // (`f_advClean` -> `persist-round`). A first-round `waiting` verdict was therefore blanked before
159
+ // `pr.progress-check` ran; the real worker classifies a blank status as `addressed`, and because the
160
+ // head did not change it routed the initial pass to no-progress escalation instead of parking on
161
+ // `waiting_review` for Copilot. The reset must be findings-ONLY: a clean pass must PRESERVE the
162
+ // review-round verdict (status/summary/question) all the way to persist-round.
163
+ test("a clean adversarial pass preserves a first-round waiting verdict for progress-check", async () => {
164
+ const { engine, seen } = await boot({
165
+ responses: {
166
+ "senior:pr-review": { status: "waiting", summary: "no review yet" },
167
+ "senior:adversarial-review": CLEAN,
168
+ },
169
+ });
170
+ assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
171
+ assert(completions(engine, "adversarial-review") === 1, runs(engine));
172
+ assert(completions(engine, "persist-round") === 1, runs(engine));
173
+ // The clean pass must NOT blank the verdict: persist-round / progress-check see `waiting`, so the
174
+ // worker parks the PR on `waiting_review` (the only status the poller solicits a review for).
175
+ const recorded = seen["pr.persist-round"][0];
176
+ assert(
177
+ recorded.status === "waiting",
178
+ `a clean pass must preserve the waiting verdict; status reaching persist-round: ${JSON.stringify(recorded.status)}`,
179
+ );
180
+ const progress = seen["pr.progress-check"][0];
181
+ assert(
182
+ progress.status === "waiting",
183
+ `progress-check must see the waiting verdict, not a blanked one: ${JSON.stringify(progress.status)}`,
184
+ );
185
+ });
186
+
187
+ test("the adversarial pass is bounded by advMax per round", async () => {
188
+ const { engine } = await boot({
189
+ responses: { "senior:adversarial-review": { adversarialFindings: FINDINGS, adversarialSummary: "always" } },
190
+ vars: { advMax: 2 },
191
+ });
192
+ assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
193
+ assert(completions(engine, "adversarial-review") === 2, `cap of 2 passes: ${runs(engine)}`);
194
+ // 1 initial + 2 findings loops = 3 review-rounds, then the round proceeds despite findings.
195
+ assert(completions(engine, "review-round") === 3, runs(engine));
196
+ assert(completions(engine, "persist-round") === 1, runs(engine));
197
+ });
198
+
199
+ test("advMax=0 disables the adversarial pass", async () => {
200
+ const { engine } = await boot({ vars: { advMax: 0 } });
201
+ assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
202
+ assert(completions(engine, "adversarial-review") === 0, runs(engine));
203
+ assert(completions(engine, "persist-round") === 1, runs(engine));
204
+ });
205
+
206
+ test("an instance seeded without advPass/advMax (pre-#844 shape) skips the pass, no incident", async () => {
207
+ const { engine } = await boot({ vars: { advPass: null, advMax: null } });
208
+ assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
209
+ assert(completions(engine, "adversarial-review") === 0, runs(engine));
210
+ assert(completions(engine, "persist-round") === 1, runs(engine));
211
+ });
212
+
213
+ // The FIRST push (#844): a PR enters the loop with no Copilot review yet, so round 1 returns
214
+ // `waiting`. That never-reviewed head is exactly what Copilot would review first, so the adversarial
215
+ // pass runs on it too — before progress-check parks the PR on `waiting_review` (which is what makes
216
+ // the poller solicit the initial Copilot review). This covers every PR entering convergence
217
+ // (feature runs, plan fan-out slices, human-submitted PRs) from the one canonical stage.
218
+ test("the first push (round 1, no review yet) gets an adversarial pass before Copilot is solicited", async () => {
219
+ const { engine, seen } = await boot({
220
+ responses: {
221
+ "senior:pr-review": [{ status: "waiting", summary: "no review yet" }, { status: "addressed", summary: "fixed finding" }],
222
+ "senior:adversarial-review": [{ adversarialFindings: FINDINGS, adversarialSummary: "1 finding" }],
223
+ },
224
+ });
225
+ assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
226
+ assert(completions(engine, "adversarial-review") === 1, `first push must be adversarially reviewed: ${runs(engine)}`);
227
+ assert(completions(engine, "review-round") === 2, `findings must re-dispatch review-round: ${runs(engine)}`);
228
+ assert(seen["senior:pr-review"][1].adversarialFindings === FINDINGS, "the re-dispatch carries the findings");
229
+ assert(completions(engine, "persist-round") === 1, runs(engine));
230
+ assert(completions(engine, "capture-head") === 1, "no head re-capture within the round");
231
+ });
232
+
233
+ test("a later waiting round (round > 1, review still pending) does not re-run the adversarial pass", async () => {
234
+ const { engine } = await boot({
235
+ vars: { round: 2 },
236
+ responses: { "senior:pr-review": { status: "waiting", summary: "review pending" } },
237
+ });
238
+ assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
239
+ assert(completions(engine, "adversarial-review") === 0, runs(engine));
240
+ assert(completions(engine, "persist-round") === 1, runs(engine));
241
+ });
242
+
243
+ // Issue #822 husk failure mode ON THE FINDINGS BACK-EDGE: the `f_advFindings` edge re-dispatches
244
+ // `review-round` straight from `gw-adv-result`, bypassing `capture-head` — so it must not inherit the
245
+ // PRIOR review-round's verdict. Here round 1 returns `waiting` (no review yet), the adversarial pass
246
+ // finds a finding, and the re-dispatched `review-round` HUSKS (completes with NO result vars). If the
247
+ // back-edge does not reset the round's output vars, the stale `status="waiting"` survives into
248
+ // gw-status / persist-round even though the findings were never handled. The back-edge must clear
249
+ // `status`/`summary`/`question` (a findings-only reset) while PRESERVING `roundEntryHead`, `advPass`
250
+ // and `adversarialFindings` (so the budget and the findings survive the re-dispatch).
251
+ test("a husked re-dispatch on the findings back-edge does not reuse the prior round's verdict", async () => {
252
+ const { engine, seen } = await boot({
253
+ responses: {
254
+ // Round 1 review-round: waiting (no review yet). Re-dispatched review-round: HUSK ({}).
255
+ "senior:pr-review": [{ status: "waiting", summary: "no review yet" }, {}],
256
+ "senior:adversarial-review": [{ adversarialFindings: FINDINGS, adversarialSummary: "1 finding" }],
257
+ },
258
+ vars: { advMax: 1 },
259
+ });
260
+ assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
261
+ assert(completions(engine, "review-round") === 2, `findings re-dispatch review-round: ${runs(engine)}`);
262
+ assert(completions(engine, "adversarial-review") === 1, runs(engine));
263
+ assert(completions(engine, "persist-round") === 1, runs(engine));
264
+ // The findings survived the re-dispatch (the husked review-round received them)...
265
+ assert(seen["senior:pr-review"][1].adversarialFindings === FINDINGS, "the re-dispatch carries the findings");
266
+ // ...and the pass budget was consumed (advPass incremented, NOT reset to 0 by a capture-head re-entry).
267
+ assert(instanceVars(engine).advPass === 1, `advPass preserved across the back-edge: ${instanceVars(engine).advPass}`);
268
+ // ...but the stale `waiting` verdict did NOT reach persist-round: the back-edge reset it to blank,
269
+ // so the round is recorded on the safe default rather than the inherited `waiting`.
270
+ const recorded = seen["pr.persist-round"][0];
271
+ assert(
272
+ recorded.status == null || recorded.status === "",
273
+ `a husked re-dispatch must not record the stale verdict; status reaching persist-round: ${JSON.stringify(recorded.status)}`,
274
+ );
275
+ });
276
+
277
+ test("a converged round does not run the adversarial pass", async () => {
278
+ const { engine } = await boot({ responses: { "senior:pr-review": { status: "converged", summary: "done" } } });
279
+ assertThatInstance(engine, byProcessId("convergence-loop")).hasCompleted().hasNoIncident();
280
+ assert(completions(engine, "adversarial-review") === 0, runs(engine));
281
+ });
282
+
283
+ test("the pass budget resets when the next review lands (new round)", async () => {
284
+ const { engine } = await boot({
285
+ responses: { "senior:adversarial-review": { adversarialFindings: FINDINGS, adversarialSummary: "always" } },
286
+ vars: { advMax: 1 },
287
+ });
288
+ assert(completions(engine, "adversarial-review") === 1, runs(engine));
289
+ assert(instanceVars(engine).advPass === 1, `advPass after round 1: ${instanceVars(engine).advPass}`);
290
+ await engine.publishMessage({ name: "readiness-ready", correlationKey: "o/r#1", variables: { ready: true } });
291
+ assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
292
+ // Round 2 gets its own full budget of 1 pass.
293
+ assert(completions(engine, "adversarial-review") === 2, `round 2 must get a fresh pass: ${runs(engine)}`);
294
+ });
@@ -23,7 +23,6 @@
23
23
  // parallel gateways for genuine fan-out (>1 downstream) and fan-in (>1 upstream). This slice targets
24
24
  // the WIRING/SHAPE — the concrete node bodies land in S4.
25
25
 
26
- import { layoutBpmn } from "@nanobpm/urban";
27
26
  import type {
28
27
  CompileDeliveryGraphErrors,
29
28
  CompileDeliveryGraphResult,
@@ -50,6 +49,7 @@ import {
50
49
  validateDeliveryGraph,
51
50
  } from "./deliveryGraph.ts";
52
51
  import { DELIVERY_HUMAN_ELEMENT, GENERIC_HUMAN_FORM } from "./deliveryHuman.ts";
52
+ import { layoutBpmnOffThread } from "./layoutOffThread.ts";
53
53
  import { DEFAULT_BACKOFF, DEFAULT_EVERY_MS, DEFAULT_ON_TIMEOUT, DEFAULT_TIMEOUT_MS, isProbeKind, normalizePoll, redactEmbeddedCredential, redactString } from "./readiness.ts";
54
54
  import { AGENT_TASK_NS } from "./repoEnvelope.ts";
55
55
  import { isoDuration } from "./reviewWait.ts";
@@ -719,9 +719,15 @@ export async function compileDeliveryGraph(
719
719
  * `bpmn-auto-layout` is a real runtime dependency of `@nanobpm/urban` (which re-exports `layoutBpmn`),
720
720
  * but the toolkit no-ops layout (semantic model unchanged, no DI) when it is somehow absent. That
721
721
  * silent no-op is exactly the DI-less bug this fixes, so we FAIL LOUD if the pass produced no diagram.
722
- * Deterministic given identical input, preserving the compiler's "same JSON → byte-identical XML". */
722
+ * Deterministic given identical input, preserving the compiler's "same JSON → byte-identical XML".
723
+ *
724
+ * The autolayout itself runs OFF the main event loop in a `node:worker_threads` worker, bounded by a
725
+ * timeout (`layoutBpmnOffThread` → `app/layoutOffThread.ts`, issue #854): `layoutBpmn` is superlinear
726
+ * and, run inline here, blocked the whole app (no HTTP, no poll passes) for the entire layout — a hard
727
+ * lock-up on a large graph (#852). This is the ONE place the layout runs, so hoisting it off-thread
728
+ * covers BOTH the dispatch and preview paths that funnel through `compileDeliveryGraph`. */
723
729
  async function layoutDeliveryDiagram(semanticBpmn: string): Promise<string> {
724
- const laidOut = await layoutBpmn(semanticBpmn);
730
+ const laidOut = await layoutBpmnOffThread(semanticBpmn);
725
731
  const start = laidOut.indexOf("<bpmndi:BPMNDiagram");
726
732
  const endTag = "</bpmndi:BPMNDiagram>";
727
733
  const end = laidOut.lastIndexOf(endTag);
@@ -0,0 +1,208 @@
1
+ // Guard coverage for the off-main-thread BPMN autolayout (issue #854). `layoutBpmn`
2
+ // (`bpmn-auto-layout`) is superlinear and used to run SYNCHRONOUSLY on the app's event loop inside
3
+ // `compileDeliveryGraph` → `layoutDeliveryDiagram`, freezing the whole app (no HTTP, no poll passes)
4
+ // for the entire layout — a 3287ms single gap locally on the 54-node merlin graph, minutes under load
5
+ // (#852). It now runs in a `node:worker_threads` worker bounded by a timeout. These tests assert:
6
+ // • the event loop KEEPS TICKING (bounded max gap) while a large graph lays out off-thread — the
7
+ // core liveness property this fixes,
8
+ // • the timeout TERMINATES a runaway layout and fails CLEANLY (rejects) rather than hanging,
9
+ // • the canonical `compileDeliveryGraph` path still produces a diagram through the off-thread bridge.
10
+ import { test } from "node:test";
11
+ import { assert, assertRejects, assertStringIncludes } from "#test-assert";
12
+ import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
13
+ import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
14
+ import { layoutBpmnOffThread, layoutMaxConcurrency, layoutTimeoutMs, Semaphore } from "./layoutOffThread.ts";
15
+
16
+ /** Build a wide-and-deep delivery graph with `count` agent nodes wired into a chain with periodic
17
+ * fan-out, so the autolayout has enough nodes/edges to spend real CPU time laying it out. */
18
+ function bigGraph(count: number): DeliveryGraph {
19
+ const nodes: DeliveryGraph["nodes"] = [];
20
+ const edges: DeliveryGraph["edges"] = [];
21
+ for (let i = 0; i < count; i++) {
22
+ nodes.push({ id: `n${i}`, kind: "agent", agent: { jobType: "senior:feature", prompt: `slice ${i}` } });
23
+ if (i > 0) edges.push({ from: `n${i - 1}`, to: `n${i}` });
24
+ // Periodic fan-out to widen the graph (more edges → more layout work).
25
+ if (i >= 3 && i % 3 === 0) edges.push({ from: `n${i - 3}`, to: `n${i}` });
26
+ }
27
+ return { name: "big-layout-graph", nodes, edges } as DeliveryGraph;
28
+ }
29
+
30
+ test("#854 the main event loop keeps servicing queued work while a large graph lays out off-thread", async () => {
31
+ const graph = bigGraph(60);
32
+
33
+ // ORDER-based liveness proof (not a wall-clock threshold): race a trivial main-thread `setImmediate`
34
+ // checkpoint against the layout. Off-thread, the loop reaches the next tick and fires the immediate
35
+ // in microseconds while the worker chews on the superlinear layout for far longer, so the checkpoint
36
+ // ALWAYS wins. Inline (the regression), `compileDeliveryGraph`'s synchronous `layoutBpmn` call would
37
+ // block the loop for the whole layout and its promise would then resolve via a microtask BEFORE any
38
+ // queued `setImmediate` macrotask could run, so the layout would win — failing this test. Because the
39
+ // worker's real CPU work dwarfs a single queued-tick dispatch by orders of magnitude, the winner is
40
+ // deterministic under any scheduler load (AGENTS.md deterministic-test rule) — no elapsed-time bound.
41
+ let compiled: Awaited<ReturnType<typeof compileDeliveryGraph>> | undefined;
42
+ const checkpoint = new Promise<"checkpoint">((resolve) => {
43
+ setImmediate(() => resolve("checkpoint"));
44
+ });
45
+ const layoutDone = compileDeliveryGraph(graph).then((c): "layout" => {
46
+ compiled = c;
47
+ return "layout";
48
+ });
49
+
50
+ const winner = await Promise.race([checkpoint, layoutDone]);
51
+ assert(
52
+ winner === "checkpoint",
53
+ "a queued main-thread setImmediate must win the race against the off-thread layout; if layout ran " +
54
+ "inline it would block the loop and its promise would resolve before the immediate could fire",
55
+ );
56
+
57
+ await layoutDone; // let the worker finish and tear down so the test leaks no worker/handle
58
+ assert(compiled !== undefined && compiled.ok, `expected ok:true, got ${JSON.stringify(compiled)}`);
59
+ assert(compiled.bpmn.includes("<bpmndi:BPMNDiagram"), "off-thread layout should still attach diagram interchange");
60
+ });
61
+
62
+ test("#854 a layout that exceeds its timeout fails cleanly (rejects) instead of hanging", async () => {
63
+ // A 1ms bound is unsatisfiable for any real graph, so the worker is terminated and the call rejects.
64
+ const semantic = "<?xml version=\"1.0\"?><bpmn:definitions xmlns:bpmn=\"http://www.omg.org/spec/BPMN/20100524/MODEL\" id=\"d\"><bpmn:process id=\"p\" isExecutable=\"true\"><bpmn:startEvent id=\"s\"/></bpmn:process></bpmn:definitions>";
65
+ const err = await assertRejects(() => layoutBpmnOffThread(semantic, 1));
66
+ assertStringIncludes(err.message, "854");
67
+ });
68
+
69
+ test("#854 layoutTimeoutMs honours the env knob and ignores garbage", () => {
70
+ assert(layoutTimeoutMs({}) === 300000, "unset → registered default");
71
+ assert(layoutTimeoutMs({ NANO_DELIVERY_LAYOUT_TIMEOUT_MS: "90000" }) === 90000, "valid override honoured");
72
+ assert(layoutTimeoutMs({ NANO_DELIVERY_LAYOUT_TIMEOUT_MS: "nope" }) === 300000, "garbage → default");
73
+ assert(layoutTimeoutMs({ NANO_DELIVERY_LAYOUT_TIMEOUT_MS: "-5" }) === 300000, "non-positive → default");
74
+ // Node's setTimeout ceiling is a signed 32-bit ms value (2_147_483_647); a larger delay wraps to
75
+ // 1ms and would abort every layout immediately, so an overflowing override must degrade to the
76
+ // registered default rather than be honoured (issue #854 review).
77
+ assert(layoutTimeoutMs({ NANO_DELIVERY_LAYOUT_TIMEOUT_MS: "2147483647" }) === 2147483647, "ceiling value honoured");
78
+ assert(layoutTimeoutMs({ NANO_DELIVERY_LAYOUT_TIMEOUT_MS: "2147483648" }) === 300000, "above Node timer ceiling → default");
79
+ assert(layoutTimeoutMs({ NANO_DELIVERY_LAYOUT_TIMEOUT_MS: "999999999999" }) === 300000, "far above ceiling → default");
80
+ });
81
+
82
+ test("#854 layoutMaxConcurrency honours the env knob and degrades a bad bound to the default", () => {
83
+ assert(layoutMaxConcurrency({}) === 2, "unset → registered default");
84
+ assert(layoutMaxConcurrency({ NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY: "4" }) === 4, "valid override honoured");
85
+ assert(layoutMaxConcurrency({ NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY: "nope" }) === 2, "garbage → default");
86
+ assert(layoutMaxConcurrency({ NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY: "0" }) === 2, "zero (unbounded-by-stall) → default");
87
+ assert(layoutMaxConcurrency({ NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY: "-3" }) === 2, "negative → default");
88
+ // A fractional bound is not a valid holder count; it must degrade rather than round silently.
89
+ assert(layoutMaxConcurrency({ NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY: "1.5" }) === 2, "non-integer → default");
90
+ // An absurdly large bound is effectively "unbounded", which defeats the guard — degrade to default.
91
+ assert(layoutMaxConcurrency({ NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY: "99999" }) === 2, "above ceiling → default");
92
+ assert(layoutMaxConcurrency({ NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY: "1024" }) === 1024, "ceiling value honoured");
93
+ });
94
+
95
+ test("#854 Semaphore rejects a non-positive / non-integer bound (the guard must never be 0/∞)", () => {
96
+ for (const bad of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
97
+ let threw = false;
98
+ try {
99
+ new Semaphore(bad);
100
+ } catch {
101
+ threw = true;
102
+ }
103
+ assert(threw, `Semaphore(${bad}) should throw — a bad bound would silently defeat the concurrency guard`);
104
+ }
105
+ });
106
+
107
+ test("#854 Semaphore bounds concurrency to `max` and admits waiters in FIFO order", async () => {
108
+ const sem = new Semaphore(2);
109
+ let active = 0;
110
+ let peak = 0;
111
+ const order: number[] = [];
112
+
113
+ // A holder records its admission order and the live peak, then yields a few microtasks before
114
+ // releasing so later acquirers genuinely have to queue behind the first `max`.
115
+ const run = async (id: number): Promise<void> => {
116
+ const release = await sem.acquire();
117
+ order.push(id);
118
+ active++;
119
+ peak = Math.max(peak, active);
120
+ try {
121
+ for (let i = 0; i < 5; i++) await Promise.resolve();
122
+ } finally {
123
+ active--;
124
+ release();
125
+ }
126
+ };
127
+
128
+ await Promise.all([run(1), run(2), run(3), run(4), run(5)]);
129
+
130
+ assert(peak <= 2, `at most 2 holders may run at once, saw ${peak}`);
131
+ // First two get the free slots immediately; 3,4,5 queue and are admitted in arrival order.
132
+ assert(JSON.stringify(order) === JSON.stringify([1, 2, 3, 4, 5]), `expected FIFO admission, got ${order.join(",")}`);
133
+ });
134
+
135
+ test("#854 Semaphore release is idempotent — a double release cannot over-admit", async () => {
136
+ const sem = new Semaphore(1);
137
+ const release = await sem.acquire();
138
+ release();
139
+ release(); // must be a no-op, not a second freed slot
140
+
141
+ // With max=1, exactly one holder may be active. Acquire twice without releasing the first; the
142
+ // second must still be waiting (never admitted by the stray double release above).
143
+ const first = await sem.acquire();
144
+ let secondAdmitted = false;
145
+ const second = sem.acquire().then((rel) => {
146
+ secondAdmitted = true;
147
+ return rel;
148
+ });
149
+ for (let i = 0; i < 10; i++) await Promise.resolve();
150
+ assert(!secondAdmitted, "the second acquire must block — a double release must not have over-admitted");
151
+ first();
152
+ (await second)();
153
+ });
154
+
155
+ test("#854 a holder that releases in `finally` frees its slot even when its work throws", async () => {
156
+ // This is the invariant `layoutBpmnOffThread` relies on: it `release()`s in a `finally`, so a
157
+ // rejecting/terminated layout returns its slot and the next queued layout proceeds instead of the
158
+ // gate wedging forever behind the failure. Prove it at max=1: a throwing holder that releases in
159
+ // `finally` must still admit the waiter behind it.
160
+ const sem = new Semaphore(1);
161
+ const failing = (async () => {
162
+ const release = await sem.acquire();
163
+ try {
164
+ throw new Error("layout blew up");
165
+ } finally {
166
+ release();
167
+ }
168
+ })();
169
+ await assertRejects(() => failing);
170
+
171
+ let nextAdmitted = false;
172
+ const next = sem.acquire().then((rel) => {
173
+ nextAdmitted = true;
174
+ rel();
175
+ });
176
+ for (let i = 0; i < 10; i++) await Promise.resolve();
177
+ assert(nextAdmitted, "the slot held by a throwing holder must be freed, admitting the next layout");
178
+ await next;
179
+ });
180
+
181
+ test("#854 a synchronous worker-construction failure releases the slot instead of wedging the gate", async () => {
182
+ // Regression for the review finding: `new Worker(...)` can throw synchronously (e.g.
183
+ // `ERR_WORKER_INIT_FAILED` under resource pressure). The slot is acquired BEFORE construction, so if
184
+ // construction isn't inside the release `finally`, each failure permanently consumes a slot and —
185
+ // after `layoutMaxConcurrency()` failures — every later layout queues on the shared singleton gate
186
+ // FOREVER. Force construction to throw more times than the bound: with the leak, the (max+1)-th call
187
+ // hangs on `acquire()` and never reaches the throwing factory; with the fix, every call rejects
188
+ // promptly. A per-call watchdog turns a wedge into a clean assertion failure rather than a suite hang.
189
+ const spawnThrows = (): never => {
190
+ throw new Error("ERR_WORKER_INIT_FAILED (simulated)");
191
+ };
192
+ const calls = layoutMaxConcurrency() + 2; // more than the bound, so a leak would exhaust the gate
193
+ for (let i = 0; i < calls; i++) {
194
+ const watchdog = new Promise<"hung">((resolve) => {
195
+ const t = setTimeout(() => resolve("hung"), 2000);
196
+ t.unref?.();
197
+ });
198
+ const attempt = layoutBpmnOffThread("<x/>", 60000, spawnThrows).then(
199
+ () => "resolved" as const,
200
+ () => "rejected" as const,
201
+ );
202
+ const outcome = await Promise.race([attempt, watchdog]);
203
+ assert(
204
+ outcome === "rejected",
205
+ `construction-failure call #${i + 1} must reject and free its slot, not wedge the gate (got "${outcome}")`,
206
+ );
207
+ }
208
+ });