@nanobpm/nano-workforce 0.198.1 → 0.200.0

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.0](https://github.com/nanobpm/nano-workforce/compare/v0.199.0...v0.200.0) (2026-10-02)
2
+
3
+ ### Features
4
+
5
+ * **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)
6
+
7
+ ## [0.199.0](https://github.com/nanobpm/nano-workforce/compare/v0.198.1...v0.199.0) (2026-10-02)
8
+
9
+ ### Features
10
+
11
+ * **review-round:** fix the bug class + self-review before push; heredoc result examples ([#843](https://github.com/nanobpm/nano-workforce/issues/843)) ([0b2f26f](https://github.com/nanobpm/nano-workforce/commit/0b2f26f817f15191ef530075d34f6131c99e43cb)), closes [#842](https://github.com/nanobpm/nano-workforce/issues/842)
12
+
1
13
  ## [0.198.1](https://github.com/nanobpm/nano-workforce/compare/v0.198.0...v0.198.1) (2026-10-02)
2
14
 
3
15
  ### Bug Fixes
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
@@ -140,6 +140,14 @@ export const ENV_CONTRACTS = {
140
140
  "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
141
  default: "2",
142
142
  },
143
+ NANO_PR_MAX_ADVERSARIAL_PASSES: {
144
+ category: "env",
145
+ name: "NANO_PR_MAX_ADVERSARIAL_PASSES",
146
+ owner: "app/service.ts",
147
+ semantics:
148
+ "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.",
149
+ default: "1",
150
+ },
143
151
  NANO_PR_MAX_MERGE_STALL_ROUNDS: {
144
152
  category: "env",
145
153
  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
+ });
@@ -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}` });