@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.
@@ -0,0 +1,182 @@
1
+ // app/layoutOffThread.ts — the ONE canonical off-main-thread bridge to the CPU-bound BPMN autolayout
2
+ // (`layoutBpmn` → `bpmn-auto-layout`), issue #854.
3
+ //
4
+ // WHY: `layoutBpmn` is superlinear in node/edge count and, run inline on the event loop, blocked the
5
+ // whole app (no HTTP, no poll passes) for the entire layout — ~52s on merlin, >4min under load on a
6
+ // 54-node delivery graph, a hard lock-up the operator had to kill mid-launch (#852). This module runs
7
+ // the layout in a `node:worker_threads` worker so the main loop keeps ticking, and BOUNDS it with a
8
+ // timeout that TERMINATES the worker on expiry so a pathological graph fails the launch CLEANLY
9
+ // instead of hanging forever.
10
+ //
11
+ // `layoutDeliveryDiagram` (app/deliveryGraphCompiler.ts) — the single layout entry point BOTH the
12
+ // dispatch path (`dispatchDeliveryGraphRun` → `compileDeliveryGraph`) and the preview path
13
+ // (`previewProposalBpmn` → `compileDeliveryGraph`) funnel through — calls `layoutBpmnOffThread` here,
14
+ // so there is exactly ONE place the autolayout runs and exactly ONE timeout/termination policy.
15
+
16
+ import { Worker } from "node:worker_threads";
17
+ import { readEnvOr } from "./contracts.ts";
18
+
19
+ /** Node's `setTimeout` delay ceiling (a signed 32-bit ms value). A delay above this silently wraps to
20
+ * 1ms (emitting `TimeoutOverflowWarning`), so an over-large override would abort layouts IMMEDIATELY
21
+ * instead of extending the bound — a value that large is treated as garbage and degrades to the
22
+ * registered default. */
23
+ const MAX_TIMER_MS = 2_147_483_647;
24
+
25
+ /** The registered default, resolved once from the schema (empty env ⇒ registry `default`) so the
26
+ * invalid-override fallback and the timeout message stay single-sourced — not a literal `300000`
27
+ * duplicated here that would drift from `ENV_CONTRACTS`. Mirrors `cloneTimeoutMs` in
28
+ * `app/repoEnvelope.ts`. */
29
+ const envDefaultLayoutTimeoutMs = Number(readEnvOr("NANO_DELIVERY_LAYOUT_TIMEOUT_MS", "300000", {}));
30
+
31
+ /** Resolve the layout timeout (ms) from `NANO_DELIVERY_LAYOUT_TIMEOUT_MS` (registered default 300000
32
+ * = 5 min), degrading to the registered default for any value that is non-positive, garbage, or above
33
+ * Node's timer ceiling ({@link MAX_TIMER_MS} — an overflow would wrap to 1ms and abort every layout).
34
+ * Read per call so an operator can retune it without a restart; the layout is rare (dispatch/preview),
35
+ * so the lookup cost is irrelevant. */
36
+ export function layoutTimeoutMs(env: Record<string, string | undefined> = process.env): number {
37
+ const raw = Number(readEnvOr("NANO_DELIVERY_LAYOUT_TIMEOUT_MS", "300000", env));
38
+ return Number.isFinite(raw) && raw > 0 && raw <= MAX_TIMER_MS ? raw : envDefaultLayoutTimeoutMs;
39
+ }
40
+
41
+ /** A sanity ceiling on the concurrency bound: a value this large is effectively unbounded and is
42
+ * treated as garbage (degrading to the registered default) rather than honoured — the whole point of
43
+ * the bound is to keep concurrent CPU-bound layouts SMALL. */
44
+ const MAX_LAYOUT_CONCURRENCY = 1024;
45
+
46
+ /** The registered default concurrency, resolved once from the schema (empty env ⇒ registry `default`)
47
+ * so the invalid-override fallback stays single-sourced — not a literal duplicated here that would
48
+ * drift from `ENV_CONTRACTS`. Mirrors {@link envDefaultLayoutTimeoutMs}. */
49
+ const envDefaultLayoutMaxConcurrency = Number(readEnvOr("NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY", "2", {}));
50
+
51
+ /** Resolve the max number of concurrent layout workers from `NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY`
52
+ * (registered default 2), degrading to the registered default for any value that is non-integer,
53
+ * non-positive, garbage, or above {@link MAX_LAYOUT_CONCURRENCY}. Mirrors {@link layoutTimeoutMs}'s
54
+ * fail-safe parsing so a bad override can never make the bound 0/∞ (either of which defeats it). */
55
+ export function layoutMaxConcurrency(env: Record<string, string | undefined> = process.env): number {
56
+ const raw = Number(readEnvOr("NANO_DELIVERY_LAYOUT_MAX_CONCURRENCY", "2", env));
57
+ return Number.isInteger(raw) && raw > 0 && raw <= MAX_LAYOUT_CONCURRENCY ? raw : envDefaultLayoutMaxConcurrency;
58
+ }
59
+
60
+ /** A minimal FIFO counting semaphore: at most `max` holders run concurrently, the rest queue in
61
+ * arrival order. Bounds how many CPU-bound layout workers exist at once so a burst of overlapping
62
+ * preview/dispatch layouts cannot spawn an UNBOUNDED number of superlinear workers and saturate
63
+ * CPU/memory — the very starvation this off-thread move exists to prevent (issue #854). Each
64
+ * `acquire()` resolves with an idempotent `release` that hands the freed slot to the next waiter. */
65
+ export class Semaphore {
66
+ private active = 0;
67
+ private readonly waiters: Array<() => void> = [];
68
+ private readonly max: number;
69
+
70
+ constructor(max: number) {
71
+ if (!Number.isInteger(max) || max < 1) {
72
+ throw new Error(`Semaphore: max must be a positive integer, got ${String(max)}`);
73
+ }
74
+ this.max = max;
75
+ }
76
+
77
+ /** Wait (FIFO) for a slot, then return a one-shot `release`. Always `release()` in a `finally` so a
78
+ * rejecting holder cannot leak its slot and wedge every queued layout behind it. */
79
+ async acquire(): Promise<() => void> {
80
+ await new Promise<void>((resolve) => {
81
+ if (this.active < this.max) {
82
+ this.active++;
83
+ resolve();
84
+ } else {
85
+ this.waiters.push(() => {
86
+ this.active++;
87
+ resolve();
88
+ });
89
+ }
90
+ });
91
+ let released = false;
92
+ return () => {
93
+ if (released) return; // idempotent: a double release must not free a slot it never held
94
+ released = true;
95
+ this.active--;
96
+ this.waiters.shift()?.();
97
+ };
98
+ }
99
+ }
100
+
101
+ /** The ONE shared gate bounding concurrent layout workers, sized lazily on first use from
102
+ * {@link layoutMaxConcurrency}. A singleton so EVERY `layoutBpmnOffThread` call — across both the
103
+ * dispatch and preview paths — contends for the same small pool of slots. */
104
+ let layoutGate: Semaphore | undefined;
105
+ function layoutConcurrencyGate(): Semaphore {
106
+ if (!layoutGate) layoutGate = new Semaphore(layoutMaxConcurrency());
107
+ return layoutGate;
108
+ }
109
+
110
+ /** The worker message shape: a laid-out XML payload on success, or a server-side error string. */
111
+ type LayoutWorkerMessage = { ok: true; laidOut: string } | { ok: false; error: string };
112
+
113
+ /** Run `layoutBpmn(semanticBpmn)` in a dedicated `worker_threads` worker, OFF the main event loop,
114
+ * bounded by `timeoutMs`. Resolves with the laid-out XML; rejects (and TERMINATES the worker — the
115
+ * bound that stops a runaway layout) on timeout, a worker error, an early exit, or a layout failure
116
+ * reported by the worker. The worker is always torn down before this resolves/rejects, so no isolate
117
+ * leaks across calls. At most {@link layoutMaxConcurrency} workers run at once (the shared
118
+ * {@link Semaphore} gate) — a burst of overlapping preview/dispatch layouts queues for a slot rather
119
+ * than spawning an unbounded number of superlinear CPU-bound workers that would saturate the host.
120
+ * The timeout covers only the ACTUAL layout (it starts after a slot is acquired), so queueing behind
121
+ * other layouts is never charged against a graph's bound.
122
+ *
123
+ * `spawnWorker` is an injectable seam (default: the real `layoutWorker.ts` worker) so a test can force
124
+ * a SYNCHRONOUS construction failure — `new Worker(...)` can throw (e.g. `ERR_WORKER_INIT_FAILED`
125
+ * under resource pressure) — and prove the slot is still released (see below). */
126
+ export async function layoutBpmnOffThread(
127
+ semanticBpmn: string,
128
+ timeoutMs: number = layoutTimeoutMs(),
129
+ spawnWorker: () => Worker = () =>
130
+ new Worker(new URL("./layoutWorker.ts", import.meta.url), { workerData: { semanticBpmn } }),
131
+ ): Promise<string> {
132
+ // Acquire the slot, then wrap EVERYTHING — including worker construction — in the release `finally`.
133
+ // `new Worker(...)` can throw synchronously (e.g. `ERR_WORKER_INIT_FAILED` under resource pressure);
134
+ // if construction sat outside this guard, each such failure would permanently consume a slot and,
135
+ // after `max` failures, wedge every later layout waiting on the gate forever (issue #854 review).
136
+ const release = await layoutConcurrencyGate().acquire();
137
+ try {
138
+ const worker = spawnWorker();
139
+ let timer: ReturnType<typeof setTimeout> | undefined;
140
+ try {
141
+ return await new Promise<string>((resolve, reject) => {
142
+ let settled = false;
143
+ const settle = (fn: () => void) => {
144
+ if (settled) return;
145
+ settled = true;
146
+ if (timer) clearTimeout(timer);
147
+ fn();
148
+ };
149
+ timer = setTimeout(() => {
150
+ settle(() =>
151
+ reject(
152
+ new Error(
153
+ `layoutDeliveryDiagram: BPMN autolayout exceeded its ${timeoutMs}ms bound and was aborted — the ` +
154
+ "delivery graph is too large/dense to lay out within the timeout (bpmn-auto-layout is superlinear in " +
155
+ "node/edge count, issue #854). The launch was failed cleanly rather than hung; retune " +
156
+ `\`NANO_DELIVERY_LAYOUT_TIMEOUT_MS\` (default ${envDefaultLayoutTimeoutMs}) or shrink the graph.`,
157
+ ),
158
+ ),
159
+ );
160
+ }, timeoutMs);
161
+ // A setTimeout in a hot dispatch path should not itself keep a draining process alive.
162
+ timer.unref?.();
163
+ worker.once("message", (msg: LayoutWorkerMessage) => {
164
+ settle(() => {
165
+ if (msg.ok) resolve(msg.laidOut);
166
+ else reject(new Error(msg.error));
167
+ });
168
+ });
169
+ worker.once("error", (err) => settle(() => reject(err)));
170
+ worker.once("exit", (code) => {
171
+ if (code === 0) return; // a clean exit after a delivered message is expected on teardown
172
+ settle(() => reject(new Error(`layoutDeliveryDiagram: layout worker exited early with code ${code}`)));
173
+ });
174
+ });
175
+ } finally {
176
+ if (timer) clearTimeout(timer);
177
+ await worker.terminate();
178
+ }
179
+ } finally {
180
+ release(); // hand our slot to the next queued layout — ALWAYS, even on a construction throw/reject/terminate
181
+ }
182
+ }
@@ -0,0 +1,34 @@
1
+ // app/layoutWorker.ts — the `node:worker_threads` entry that runs the CPU-bound BPMN autolayout
2
+ // (`layoutBpmn` → `bpmn-auto-layout`) OFF the app's main event loop (issue #854).
3
+ //
4
+ // `bpmn-auto-layout` is superlinear in node/edge count: on the 54-node merlin delivery graph it ran
5
+ // ~52s on merlin and >4min under real load, and because it ran SYNCHRONOUSLY on the event loop in
6
+ // `layoutDeliveryDiagram`, the whole app served no HTTP and ran no poll passes for that entire window
7
+ // — the operator saw a hard lock-up (#852). Hoisting the layout into this worker keeps the main loop
8
+ // free to serve pages/API/MCP and tick its poll loop while a large graph lays out; the parent bounds
9
+ // the work with a timeout and TERMINATES this worker on expiry (see `app/layoutOffThread.ts`), so a
10
+ // pathological graph fails the launch cleanly instead of hanging forever.
11
+ //
12
+ // Protocol: `workerData.semanticBpmn` carries the pre-layout semantic BPMN; we post back either the
13
+ // laid-out XML (a string) on success or `{ error }` on failure. We never `process.exit` — resolving
14
+ // the message lets the parent `terminate()` us.
15
+
16
+ import { parentPort, workerData } from "node:worker_threads";
17
+ import { layoutBpmn } from "@nanobpm/urban";
18
+
19
+ async function run(): Promise<void> {
20
+ const port = parentPort;
21
+ if (!port) throw new Error("layoutWorker: must be spawned as a worker_threads worker (no parentPort)");
22
+ const { semanticBpmn } = workerData ?? {};
23
+ if (typeof semanticBpmn !== "string") {
24
+ throw new Error("layoutWorker: workerData.semanticBpmn must be a string");
25
+ }
26
+ try {
27
+ const laidOut = await layoutBpmn(semanticBpmn);
28
+ port.postMessage({ ok: true, laidOut });
29
+ } catch (err) {
30
+ port.postMessage({ ok: false, error: err instanceof Error ? err.message : String(err) });
31
+ }
32
+ }
33
+
34
+ void run();
@@ -945,8 +945,14 @@ test("the round-cap guard is downstream of progress classification, so a husk au
945
945
  const retry = flowElement("f_huskRetry");
946
946
  assert(retry, "f_huskRetry flow missing");
947
947
  assertStringIncludes(retry, 'targetRef="capture-head"');
948
- // The addressed/waiting status arms feed persist-round directly (not the guard).
948
+ // (#844: the addressed AND waiting arms detour through the bounded adversarial gate, whose arms all
949
+ // land on persist-round or loop to review-round — never gw-guard.)
949
950
  for (const id of ["f_addressed", "f_waiting"]) {
951
+ const f = flowElement(id);
952
+ assert(f, `${id} flow missing`);
953
+ assertStringIncludes(f, 'targetRef="gw-adv-due"');
954
+ }
955
+ for (const id of ["f_advSkip", "f_advClean"]) {
950
956
  const f = flowElement(id);
951
957
  assert(f, `${id} flow missing`);
952
958
  assertStringIncludes(f, 'targetRef="persist-round"');
@@ -77,14 +77,35 @@ test("escalation is an explicit needs_input/blocked arm gated on a non-blank que
77
77
  test("the default (addressed) arm carries no condition and re-enters round processing", () => {
78
78
  const addressed = flowElement("f_addressed");
79
79
  assert(addressed, "f_addressed flow missing");
80
- assertStringIncludes(addressed, 'targetRef="persist-round"');
80
+ // #844: the addressed arm first passes the (bounded) local adversarial-review gate, whose
81
+ // unconditional defaults — budget spent / findings clean — both land on round processing.
82
+ assertStringIncludes(addressed, 'targetRef="gw-adv-due"');
81
83
  // A default flow must have NO conditionExpression.
82
84
  assert(
83
85
  !/conditionExpression/.test(addressed),
84
86
  "f_addressed is the default flow and must not carry a conditionExpression",
85
87
  );
88
+ assertAdversarialDefaultsReenterRound();
86
89
  });
87
90
 
91
+ /** The adversarial-review detour (#844) on the addressed arm must always drain to round
92
+ * processing: both gateways' DEFAULT arms are unconditional and target persist-round, and the only
93
+ * conditional arm loops back into review-round (bounded by advPass/advMax) — never escalation. */
94
+ function assertAdversarialDefaultsReenterRound() {
95
+ for (const [gw, dflt] of [["gw-adv-due", "f_advSkip"], ["gw-adv-result", "f_advClean"]]) {
96
+ const g = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${gw}"[^>]*>`));
97
+ assert(g, `${gw} gateway missing`);
98
+ assertStringIncludes(g[0], `default="${dflt}"`);
99
+ const f = flowElement(dflt);
100
+ assert(f, `${dflt} flow missing`);
101
+ assertStringIncludes(f, 'targetRef="persist-round"');
102
+ assert(!/conditionExpression/.test(f), `${dflt} is a default arm and must not carry a condition`);
103
+ }
104
+ const loop = flowElement("f_advFindings");
105
+ assert(loop, "f_advFindings flow missing");
106
+ assertStringIncludes(loop, 'targetRef="review-round"');
107
+ }
108
+
88
109
  test("regression: an empty/unknown status no longer routes to persist-escalation", () => {
89
110
  // Escalation is reachable ONLY via an explicit condition (the needs_input/blocked arm),
90
111
  // never as a catch-all default. Enumerate EVERY sequenceFlow into `persist-escalation`
@@ -98,7 +119,8 @@ test("regression: an empty/unknown status no longer routes to persist-escalation
98
119
  // The addressed default must land on round processing (persist-round → check-progress, which
99
120
  // re-solicits the review after the round-cap gate downstream), not escalation.
100
121
  const addressed = flowElement("f_addressed");
101
- assert(addressed && /targetRef="persist-round"/.test(addressed), "default arm must re-enter round processing");
122
+ assert(addressed && /targetRef="gw-adv-due"/.test(addressed), "default arm must re-enter round processing");
123
+ assertAdversarialDefaultsReenterRound();
102
124
  });
103
125
 
104
126
  // The canonical router (app/roundResultDefault.ts) mirrors the gw-status routing above, with the
@@ -11,7 +11,7 @@ import { memDataFor } from "../test/worldDb.ts";
11
11
  import { withTrackingViews } from "../test/trackingViews.ts";
12
12
  import { DurableResumeRegistry } from "./durableResume.ts";
13
13
  import { WorldStore } from "./world/index.ts";
14
- import { abandonClosedPr, isPrSettled, MAX_ACK_RETRIES, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollReviews, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
14
+ import { abandonClosedPr, isPrSettled, MAX_ACK_RETRIES, MAX_ADVERSARIAL_PASSES, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollJobActivationImpl, pollReviews, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
15
15
  import { trackingTargetFor } from "./instanceTracking.ts";
16
16
  import type { DataLayer } from "@nanobpm/urban";
17
17
  import { READINESS_READY_MESSAGE } from "./readiness.ts";
@@ -669,6 +669,168 @@ test("pollIncidents picks the oldest incident by creationTime, sorting a missing
669
669
  });
670
670
 
671
671
 
672
+ // Red/green regression (nano-workforce#845 Copilot review): the convergence loop runs TWO external
673
+ // agent task types while the PR reads `converging` — `review-round` (`senior:pr-review`) and, since
674
+ // issue #844, `adversarial-review` (`senior:adversarial-review`). `pollJobActivation` originally
675
+ // searched only for `senior:pr-review`, so during an adversarial pass it found no matching job and
676
+ // cleared `active_worker`/`lease_until` — the Home/Overview grids reported "queued" while an agent
677
+ // was actively working. This drives the pass's reconciliation core against a stubbed
678
+ // `/v2/jobs/search`:
679
+ // 1. an activated `senior:adversarial-review` job surfaces its leasing worker (the defect),
680
+ // 2. the review-round → adversarial-review handoff keeps a live worker visible across the switch,
681
+ // 3. a non-agent job (an internal `pr.*` worker) is defensively excluded even if the wire `$in`
682
+ // filter is ignored,
683
+ // 4. a merely-created (unactivated) agent job — no `worker` yet — reads as "queued" (lease null).
684
+ function jobActivationFetch(byInstance: Record<string, unknown[]>, opts: { ignoreTypeFilter?: boolean } = {}) {
685
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
686
+ const u = typeof url === "string" ? url : url.toString();
687
+ if (!u.endsWith("/jobs/search")) {
688
+ throw new Error(`unexpected fetch: ${u}`);
689
+ }
690
+ const body = JSON.parse(String(init?.body ?? "{}")) as {
691
+ filter?: { processInstanceKey?: string; type?: string | { $in?: string[] } };
692
+ };
693
+ const all = (byInstance[body.filter?.processInstanceKey ?? ""] ?? []) as { type?: string }[];
694
+ // Honor the wire `type` filter (scalar `$eq` or `{ $in: [...] }`) the way the engine does, so the
695
+ // test reproduces the real defect: a single-type `senior:pr-review` filter returns ZERO items for
696
+ // an adversarial-pass job (the engine filters server-side), which is exactly what cleared the
697
+ // lease pre-fix. A stub that ignores `type` would pass against the old code and prove nothing.
698
+ // `ignoreTypeFilter` simulates an engine that IGNORES the `$in` filter, so the non-agent item
699
+ // reaches `pollJobActivationImpl` and the test exercises the defensive client-side `.includes`
700
+ // re-filter (service.ts) rather than the wire filter doing the work.
701
+ const t = body.filter?.type;
702
+ const wanted: string[] | null = typeof t === "string" ? [t] : Array.isArray(t?.$in) ? t.$in : null;
703
+ const items = opts.ignoreTypeFilter || wanted == null ? all : all.filter((j) => wanted.includes(j.type ?? ""));
704
+ return Promise.resolve(
705
+ new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } }),
706
+ );
707
+ };
708
+ }
709
+
710
+ function jobActivationData(rows: unknown[]) {
711
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
712
+ pull_requests: { rows, key: "pr_key" },
713
+ };
714
+ return {
715
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
716
+ open: () => memOpen(stores),
717
+ } as any;
718
+ }
719
+
720
+ test("pollJobActivation surfaces an activated adversarial-review agent (not just senior:pr-review)", async () => {
721
+ const row = {
722
+ pr_key: "owner/repo#20",
723
+ status: "converging",
724
+ process_key: "PI-20",
725
+ active_worker: null as string | null,
726
+ lease_until: null as string | null,
727
+ updated_at: "t0",
728
+ };
729
+ const data = jobActivationData([row]);
730
+ const headers = { "content-type": "application/json" };
731
+
732
+ const prevFetch = globalThis.fetch;
733
+ // The PR is mid adversarial pass: the only open job is `senior:adversarial-review`, leased by a
734
+ // worker. Before the fix the pass filtered on `senior:pr-review` alone, found nothing, and left
735
+ // `active_worker` null — the grid showed "queued" while this agent was actively working.
736
+ globalThis.fetch = jobActivationFetch({
737
+ "PI-20": [
738
+ { type: "senior:adversarial-review", worker: "agent-adv", deadline: "2024-01-01T00:15:00Z", state: "CREATED" },
739
+ ],
740
+ }) as typeof fetch;
741
+ try {
742
+ await pollJobActivationImpl(data, "http://engine/v2", headers);
743
+ } finally {
744
+ globalThis.fetch = prevFetch;
745
+ }
746
+ assertEquals(row.active_worker, "agent-adv");
747
+ assertEquals(row.lease_until, "2024-01-01T00:15:00Z");
748
+ });
749
+
750
+ test("pollJobActivation keeps a live worker visible across the review-round ↔ adversarial handoff", async () => {
751
+ const row = {
752
+ pr_key: "owner/repo#21",
753
+ status: "converging",
754
+ process_key: "PI-21",
755
+ active_worker: null as string | null,
756
+ lease_until: null as string | null,
757
+ updated_at: "t0",
758
+ };
759
+ const data = jobActivationData([row]);
760
+ const headers = { "content-type": "application/json" };
761
+
762
+ const prevFetch = globalThis.fetch;
763
+ try {
764
+ // Leg 1: the review-round agent (`senior:pr-review`) is working.
765
+ globalThis.fetch = jobActivationFetch({
766
+ "PI-21": [
767
+ { type: "senior:pr-review", worker: "agent-review", deadline: "2024-01-01T00:10:00Z", state: "CREATED" },
768
+ ],
769
+ }) as typeof fetch;
770
+ await pollJobActivationImpl(data, "http://engine/v2", headers);
771
+ assertEquals(row.active_worker, "agent-review");
772
+
773
+ // Leg 2: the loop handed off to the adversarial pass (`senior:adversarial-review`) — same
774
+ // `converging` status, different job type. The grid must keep showing "agent working", not
775
+ // flash "queued".
776
+ globalThis.fetch = jobActivationFetch({
777
+ "PI-21": [
778
+ { type: "senior:adversarial-review", worker: "agent-adv", deadline: "2024-01-01T00:20:00Z", state: "CREATED" },
779
+ ],
780
+ }) as typeof fetch;
781
+ await pollJobActivationImpl(data, "http://engine/v2", headers);
782
+ assertEquals(row.active_worker, "agent-adv");
783
+ assertEquals(row.lease_until, "2024-01-01T00:20:00Z");
784
+ } finally {
785
+ globalThis.fetch = prevFetch;
786
+ }
787
+ });
788
+
789
+ test("pollJobActivation excludes a non-agent job and reports an unactivated agent job as queued", async () => {
790
+ const row = {
791
+ pr_key: "owner/repo#22",
792
+ status: "converging",
793
+ process_key: "PI-22",
794
+ active_worker: "stale-worker" as string | null,
795
+ lease_until: "2024-01-01T00:05:00Z" as string | null,
796
+ updated_at: "t0",
797
+ };
798
+ const data = jobActivationData([row]);
799
+ const headers = { "content-type": "application/json" };
800
+
801
+ const prevFetch = globalThis.fetch;
802
+ try {
803
+ // An internal `pr.*` worker job is open and "leased" — but it is not a convergence agent, so it
804
+ // must NOT keep `active_worker` lit even if the engine ignores the wire `$in` type filter. Pass
805
+ // `ignoreTypeFilter` so the mock returns the non-agent item UNFILTERED (an engine that ignores
806
+ // `$in`): the defensive client-side `.includes(j.type)` re-filter in `pollJobActivationImpl` is
807
+ // then what excludes it — so the assertion genuinely guards that branch (a wire-filtered stub
808
+ // would pass even if the defensive check were deleted).
809
+ globalThis.fetch = jobActivationFetch(
810
+ {
811
+ "PI-22": [
812
+ { type: "pr.capture-head", worker: "host-internal", deadline: "2024-01-01T00:30:00Z", state: "CREATED" },
813
+ ],
814
+ },
815
+ { ignoreTypeFilter: true },
816
+ ) as typeof fetch;
817
+ await pollJobActivationImpl(data, "http://engine/v2", headers);
818
+ assertEquals(row.active_worker, null);
819
+ assertEquals(row.lease_until, null);
820
+
821
+ // A merely-created (queued) agent job carries no worker yet → still "queued" (lease null).
822
+ globalThis.fetch = jobActivationFetch({
823
+ "PI-22": [{ type: "senior:adversarial-review", state: "CREATED" }],
824
+ }) as typeof fetch;
825
+ await pollJobActivationImpl(data, "http://engine/v2", headers);
826
+ assertEquals(row.active_worker, null);
827
+ assertEquals(row.lease_until, null);
828
+ } finally {
829
+ globalThis.fetch = prevFetch;
830
+ }
831
+ });
832
+
833
+
672
834
  // Red/green regression (nano-workforce#102 review): the engine can hand back a numeric
673
835
  // `processInstanceKey`, but the OpenAPI `SubmitResult.processKey` contract is `string | null`, so
674
836
  // under api `validateResponses:"dev"` a raw number fails response validation. Stringifying at the
@@ -876,6 +1038,21 @@ test("submitPr seeds the #796 auto-ack budget onto the instance (ackRetryRound=0
876
1038
  });
877
1039
  });
878
1040
 
1041
+ test("submitPr seeds the #844 adversarial-review budget (advPass=0, advMax=MAX_ADVERSARIAL_PASSES)", async () => {
1042
+ await withGithubOff(async () => {
1043
+ const { data, engine, get } = captureVars();
1044
+ await submitPr(data, engine, {
1045
+ repo: "owner/repo",
1046
+ number: 14,
1047
+ url: "https://github.com/owner/repo/pull/14",
1048
+ prKey: "owner/repo#14",
1049
+ });
1050
+ const vars = get();
1051
+ assertEquals(vars?.advPass, 0);
1052
+ assertEquals(vars?.advMax, MAX_ADVERSARIAL_PASSES);
1053
+ });
1054
+ });
1055
+
879
1056
  test("submitPr seeds humanApproval (issue #826), pinned off for a converge-only run that never merges", async () => {
880
1057
  await withGithubOff(async () => {
881
1058
  const pr = (n: number) => ({ repo: "owner/repo", number: n, url: `https://github.com/owner/repo/pull/${n}`, prKey: `owner/repo#${n}` });
package/app/service.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  CONFORMANCE_ESCALATION_ELEMENT,
28
28
  conformanceEscalationQuestion,
29
29
  } from "./conformance.ts";
30
+ import { envContract, readEnvOr } from "./contracts.ts";
30
31
  import { makeDefaultReadHead } from "./currentHead.ts";
31
32
  import { isUniqueConstraintFence } from "./dbFence.ts";
32
33
  import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
@@ -136,6 +137,18 @@ export const MERGE_PROCESS_ID = "merge-loop";
136
137
  * in convergence-loop.bpmn). Deliberately NOT hosted here — an external harness services it; the
137
138
  * activation poll keys off it to tell "agent working" from "queued". */
138
139
  const REVIEW_JOB_TYPE = "senior:pr-review";
140
+ /** Job type of the local adversarial-review agent (the `adversarial-review` service task's
141
+ * `zeebe:taskDefinition` in convergence-loop.bpmn, issue #844). Like {@link REVIEW_JOB_TYPE} it is
142
+ * serviced by an external harness while the PR stays `converging`, so the activation poll must
143
+ * treat it as another live convergence agent — otherwise the grid reports "queued" for the whole
144
+ * adversarial pass even though an agent has leased the job. */
145
+ const ADVERSARIAL_JOB_TYPE = "senior:adversarial-review";
146
+ /** Every external-agent job type the convergence loop parks on while the PR reads `converging`.
147
+ * `pollJobActivation` reconciles `active_worker`/`lease_until` against ANY of these so the
148
+ * review-round ↔ adversarial-review handoff (same `converging` status, different job type) never
149
+ * flashes a phantom "queued". Typed `readonly string[]` (not `as const`) so `.includes(aString)`
150
+ * needs no `as` cast at the read site. */
151
+ const CONVERGENCE_AGENT_JOB_TYPES: readonly string[] = [REVIEW_JOB_TYPE, ADVERSARIAL_JOB_TYPE];
139
152
 
140
153
  /** Default round cap before the loop escalates to a human. A per-submit override (submit form /
141
154
  * webhook / start action) takes precedence; this env var sets the fleet-wide default. The cap
@@ -179,6 +192,18 @@ export const MAX_MERGE_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_MERGE_
179
192
  * (allows 0 = disable, ceiling-capped). */
180
193
  export const MAX_ACK_RETRIES = clampCiFixBudget(process.env.NANO_PR_MAX_ACK_RETRIES, 2);
181
194
 
195
+ /** Local adversarial-review passes per convergence round (issue #844). An `addressed` round runs the
196
+ * `senior:adversarial-review` agent over its diff BEFORE the round parks on `waiting_review` (the only
197
+ * status the poller solicits a Copilot review for); non-blank findings re-dispatch `review-round`, up
198
+ * to this many passes per round. Default 1; `NANO_PR_MAX_ADVERSARIAL_PASSES=0` disables the stage.
199
+ * Reuses the CI-fix budget clamp (allows 0 = disable, ceiling-capped). The clamp fallback is
200
+ * derived from the registered `default` so the schema stays the single source of truth (a
201
+ * registry edit can't drift from a second literal here). */
202
+ export const MAX_ADVERSARIAL_PASSES = clampCiFixBudget(
203
+ readEnvOr("NANO_PR_MAX_ADVERSARIAL_PASSES"),
204
+ Number(envContract("NANO_PR_MAX_ADVERSARIAL_PASSES").default),
205
+ );
206
+
182
207
  /** How many times the mergeable-wait timeout backstop (`merge-stall-probe`) will re-derive
183
208
  * mergeability from ground truth and re-arm the merge stage before giving up and escalating to a
184
209
  * human. Bounds the timer arm of the `gw-merge-wait` event-based gateway so a dead in-process poller
@@ -740,6 +765,10 @@ async function submitPrCritical(
740
765
  // solely on unacked ones, before escalating to a human. `ackRetryRound` counts those passes.
741
766
  ackRetryRound: 0,
742
767
  ackRetryMax: MAX_ACK_RETRIES,
768
+ // Local adversarial review (issue #844): `advPass` counts this round's adversarial passes (reset
769
+ // by `capture-head` each round); `advMax` bounds them. 0 skips the stage.
770
+ advPass: 0,
771
+ advMax: MAX_ADVERSARIAL_PASSES,
743
772
  // Lineage (issue #245): carry the origin identity onto the convergence instance so every
744
773
  // descendant (and any message it correlates) is stitched back to the originating request.
745
774
  // A human/webhook PR that is its own root carries its own `pr_key` (never NULL — see above).
@@ -1669,15 +1698,19 @@ interface JobSearchItem {
1669
1698
  worker?: string;
1670
1699
  deadline?: string | null;
1671
1700
  state?: string;
1701
+ /** The job's `zeebe:taskDefinition` type — read to defensively re-filter the wire `$in` type
1702
+ * filter so a non-agent job can never masquerade as a leasing convergence agent. */
1703
+ type?: string;
1672
1704
  }
1673
1705
 
1674
- /** One job-activation poll pass. The `converging` status means the process is parked at the
1675
- * `review-round` service task with a `senior:pr-review` job outstanding — but it does not say
1676
- * whether an external agent has *activated* (leased) that job yet. This pass reads that off the
1677
- * engine's Camunda-8 `/v2/jobs/search`: an activated job carries a leasing `worker` + a lock
1678
- * `deadline`; a merely-created (queued) one carries neither. (The wire `state` can't tell them
1679
- * apart — Camunda's JobStateEnum has no ACTIVATED value, so the engine projects Activated ->
1680
- * CREATED; the `worker`/`deadline` fields are the compatible activation signal.)
1706
+ /** One job-activation poll pass. The `converging` status means the process is parked at a
1707
+ * convergence-agent service task (`review-round`'s `senior:pr-review`, or `adversarial-review`'s
1708
+ * `senior:adversarial-review` — issue #844) with a job outstanding — but it does not say whether an
1709
+ * external agent has *activated* (leased) that job yet. This pass reads that off the engine's
1710
+ * Camunda-8 `/v2/jobs/search`: an activated job carries a leasing `worker` + a lock `deadline`; a
1711
+ * merely-created (queued) one carries neither. (The wire `state` can't tell them apart — Camunda's
1712
+ * JobStateEnum has no ACTIVATED value, so the engine projects Activated -> CREATED; the
1713
+ * `worker`/`deadline` fields are the compatible activation signal.)
1681
1714
  *
1682
1715
  * It writes `active_worker` + `lease_until` onto the PR row so the pages surface can show
1683
1716
  * "agent working" vs "queued (awaiting an agent)", updating (and bumping `updated_at`) only on
@@ -1691,10 +1724,21 @@ async function pollJobActivation(
1691
1724
  const base = restAddress.replace(/\/+$/, "");
1692
1725
  const headers: Record<string, string> = { "content-type": "application/json" };
1693
1726
  if (engineToken) headers.authorization = `Bearer ${engineToken}`;
1727
+ await pollJobActivationImpl(data, base, headers);
1728
+ }
1694
1729
 
1730
+ /** Testable core of {@link pollJobActivation}: given the normalised `base` URL and prepared auth
1731
+ * `headers`, reconcile every `converging` PR row against the engine's open convergence-agent jobs.
1732
+ * Split out so tests can exercise the reconciliation with a stubbed `fetch` without re-deriving
1733
+ * transport wiring. */
1734
+ export async function pollJobActivationImpl(
1735
+ data: DataLayer,
1736
+ base: string,
1737
+ headers: Record<string, string>,
1738
+ ) {
1695
1739
  const all = await prsTracking(data).all();
1696
1740
  for (const pr of all) {
1697
- // Only a `converging` PR has a live review-round job. Any other status with a stale worker
1741
+ // Only a `converging` PR has a live convergence-agent job. Any other status with a stale worker
1698
1742
  // set (e.g. it just moved to `waiting_review`) gets cleared so the grid can't show a
1699
1743
  // phantom "agent working". ADR-0065: classify on the DERIVED status, not the base transient —
1700
1744
  // a PR whose instance was terminated out-of-band keeps its base `status` frozen at `converging`
@@ -1731,7 +1775,15 @@ async function pollJobActivation(
1731
1775
  method: "POST",
1732
1776
  headers,
1733
1777
  body: JSON.stringify({
1734
- filter: { type: REVIEW_JOB_TYPE, processInstanceKey: pr.process_key, state: "CREATED" },
1778
+ filter: {
1779
+ // ANY convergence-agent job type: the loop moves between `review-round`
1780
+ // (`senior:pr-review`) and `adversarial-review` (`senior:adversarial-review`) without
1781
+ // leaving `converging`, so filtering on only one would clear the lease mid-handoff and
1782
+ // report "queued" while the other agent is actively working (issue #845 review).
1783
+ type: { $in: [...CONVERGENCE_AGENT_JOB_TYPES] },
1784
+ processInstanceKey: pr.process_key,
1785
+ state: "CREATED",
1786
+ },
1735
1787
  page: { limit: 20 },
1736
1788
  }),
1737
1789
  });
@@ -1739,9 +1791,16 @@ async function pollJobActivation(
1739
1791
  // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
1740
1792
  const body = (await res.json()) as { items?: JobSearchItem[] };
1741
1793
  // An open job with a leasing worker means an agent has activated it. Prefer the one with
1742
- // the latest deadline if several are open (there is normally at most one).
1794
+ // the latest deadline if several are open (there is normally at most one). Re-filter on the
1795
+ // job type defensively in case the wire `$in` filter is ignored, so a non-agent job (e.g. an
1796
+ // internal `pr.*` worker) can never masquerade as a leasing convergence agent.
1743
1797
  const activated = (body.items ?? [])
1744
- .filter((j) => typeof j.worker === "string" && j.worker.length > 0)
1798
+ .filter(
1799
+ (j) =>
1800
+ typeof j.worker === "string" &&
1801
+ j.worker.length > 0 &&
1802
+ CONVERGENCE_AGENT_JOB_TYPES.includes(j.type ?? ""),
1803
+ )
1745
1804
  .sort((a, b) => (a.deadline ?? "").localeCompare(b.deadline ?? ""))
1746
1805
  .pop();
1747
1806
  if (activated) {
package/nano.app.json CHANGED
@@ -255,7 +255,8 @@
255
255
  "senior:trial-merge",
256
256
  "senior:retro",
257
257
  "senior:conformance",
258
- "senior:scope-classify"
258
+ "senior:scope-classify",
259
+ "senior:adversarial-review"
259
260
  ],
260
261
  "surfaces": {
261
262
  "taskInbox": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.199.0",
3
+ "version": "0.200.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",