@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 +12 -0
- package/SPEC.md +23 -1
- package/app/contracts.ts +24 -0
- package/app/convergeAdversarial.test.ts +294 -0
- package/app/deliveryGraphCompiler.ts +9 -3
- package/app/layoutOffThread.test.ts +208 -0
- package/app/layoutOffThread.ts +182 -0
- package/app/layoutWorker.ts +34 -0
- package/app/roundProgress.test.ts +7 -1
- package/app/roundResultDefault.test.ts +24 -2
- package/app/service.test.ts +178 -1
- package/app/service.ts +70 -11
- package/nano.app.json +2 -1
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +291 -163
- package/resources/prompts/adversarial-review.md +116 -0
- package/resources/prompts/review-round.md +15 -1
- package/test/derivation-parity/derivation-parity.test.ts +2 -1
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 →
|
|
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
|
|
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
|
+
});
|