@nanobpm/nano-workforce 0.70.0 → 0.70.2

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,17 @@
1
+ ## [0.70.2](https://github.com/nanobpm/nano-workforce/compare/v0.70.1...v0.70.2) (2026-08-15)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **merge-loop:** add agent-task liveness SLA backstop for rebase/fix-ci ([#237](https://github.com/nanobpm/nano-workforce/issues/237)) ([c77cdca](https://github.com/nanobpm/nano-workforce/commit/c77cdca974a1e53ef2f9757cc5cedf95068713f1))
7
+
8
+ ## [0.70.1](https://github.com/nanobpm/nano-workforce/compare/v0.70.0...v0.70.1) (2026-08-15)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **convergence:** don't re-review a no-progress round ([#230](https://github.com/nanobpm/nano-workforce/issues/230)) ([a8ea844](https://github.com/nanobpm/nano-workforce/commit/a8ea84444b351989f716124d0b485b42e1ec2a35)), closes [#231](https://github.com/nanobpm/nano-workforce/issues/231) [Magikcraft/nano-bpm#770](https://github.com/Magikcraft/nano-bpm/issues/770) [#770](https://github.com/nanobpm/nano-workforce/issues/770) [nanobpm/nano-workforce#225](https://github.com/nanobpm/nano-workforce/issues/225) [#2](https://github.com/nanobpm/nano-workforce/issues/2) [#770](https://github.com/nanobpm/nano-workforce/issues/770)
14
+
1
15
  # [0.70.0](https://github.com/nanobpm/nano-workforce/compare/v0.69.1...v0.70.0) (2026-08-15)
2
16
 
3
17
 
@@ -0,0 +1,39 @@
1
+ // Unit coverage for the agent-task liveness SLA duration policy. The value is baked into every
2
+ // merge-loop instance's `agentSlaTimeout` process variable and evaluated by the rebase / fix-ci
3
+ // agent tasks' interrupting timer boundary, so a malformed operator env must never deploy an
4
+ // uninterpretable `<bpmn:timeDuration>` — it falls back to the default instead. Run with
5
+ // `node --test`.
6
+
7
+ import assert from "node:assert/strict";
8
+ import { test } from "node:test";
9
+ import { agentSlaTimeout, DEFAULT_AGENT_SLA_TIMEOUT } from "./agentSla.ts";
10
+
11
+ test("agentSlaTimeout: blank / absent / malformed → default", () => {
12
+ assert.equal(agentSlaTimeout(undefined), DEFAULT_AGENT_SLA_TIMEOUT);
13
+ assert.equal(agentSlaTimeout(""), DEFAULT_AGENT_SLA_TIMEOUT);
14
+ assert.equal(agentSlaTimeout(" "), DEFAULT_AGENT_SLA_TIMEOUT);
15
+ assert.equal(agentSlaTimeout("2h"), DEFAULT_AGENT_SLA_TIMEOUT); // missing leading P/T
16
+ assert.equal(agentSlaTimeout("P"), DEFAULT_AGENT_SLA_TIMEOUT); // no component
17
+ assert.equal(agentSlaTimeout("PT"), DEFAULT_AGENT_SLA_TIMEOUT); // T with no time part
18
+ assert.equal(agentSlaTimeout("garbage"), DEFAULT_AGENT_SLA_TIMEOUT);
19
+ });
20
+
21
+ test("agentSlaTimeout: a valid ISO-8601 duration is honoured and upper-cased", () => {
22
+ assert.equal(agentSlaTimeout("PT30M"), "PT30M");
23
+ assert.equal(agentSlaTimeout("pt4h"), "PT4H");
24
+ assert.equal(agentSlaTimeout("P1D"), "P1D");
25
+ assert.equal(agentSlaTimeout(" pt90m "), "PT90M");
26
+ });
27
+
28
+ test("agentSlaTimeout: an explicit fallback is honoured for a bad value", () => {
29
+ assert.equal(agentSlaTimeout("nope", "PT10M"), "PT10M");
30
+ assert.equal(agentSlaTimeout("PT45M", "PT10M"), "PT45M");
31
+ });
32
+
33
+ test("the default is itself a well-formed ISO-8601 duration (never an uninterpretable timer)", () => {
34
+ // Validate the default against the grammar with a *distinct* fallback: if the default were
35
+ // malformed it would fall through to the sentinel, so equality to itself proves it parses.
36
+ const sentinel = "PT1S";
37
+ assert.notEqual(DEFAULT_AGENT_SLA_TIMEOUT, sentinel);
38
+ assert.equal(agentSlaTimeout(DEFAULT_AGENT_SLA_TIMEOUT, sentinel), DEFAULT_AGENT_SLA_TIMEOUT);
39
+ });
@@ -0,0 +1,33 @@
1
+ // Agent-task liveness SLA policy — kept as a pure module (no env, no I/O) so it is trivially
2
+ // testable, mirroring app/escalationSla.ts. `app/service.ts` seeds the validated `agentSlaTimeout`
3
+ // process variable when it starts the merge-loop; the merge-loop's AGENT service tasks (rebase,
4
+ // fix-ci) carry an interrupting timer boundary whose `<bpmn:timeDuration>=agentSlaTimeout` evaluates
5
+ // it at timer creation (FEEL-expression timer durations, engine-native).
6
+ //
7
+ // This closes the agent-task liveness gap: unlike an escalation *user* task (whose SLA the
8
+ // escalationSla policy already bounds), an AGENT service task has no human in the loop — if no
9
+ // worker holds its capability, or the agent hangs/crashes without failing the job, the token parks
10
+ // on the task forever (no incident, no escalation). The boundary timer makes that impossible: when
11
+ // the SLA elapses the boundary fires, cancels the stuck job, and routes the token to the existing
12
+ // merge escalation so a human is pulled in. It is a durable, in-process backstop — no external
13
+ // watchdog required.
14
+
15
+ import { isoDuration } from "./reviewWait.ts";
16
+
17
+ /** Default agent-task SLA (ISO-8601 duration): how long a merge-loop agent service task (rebase /
18
+ * fix-ci) may sit without completing before its interrupting timer boundary fires and the process
19
+ * escalates for human attention. Deliberately much shorter than the human-decision escalation SLA
20
+ * (PT24H): an agent that has not even started (unstaffed capability) or is stuck should surface to a
21
+ * human quickly, while still being generous enough not to interrupt a legitimately long rebase. */
22
+ export const DEFAULT_AGENT_SLA_TIMEOUT = "PT2H";
23
+
24
+ /** Validate the operator-supplied agent SLA (env `NANO_PR_AGENT_SLA_TIMEOUT`, ISO-8601 duration),
25
+ * falling back to {@link DEFAULT_AGENT_SLA_TIMEOUT} when absent, blank, or malformed — a bad env
26
+ * value must never deploy an uninterpretable timer expression. Derives its validation from the
27
+ * single canonical {@link isoDuration}. */
28
+ export function agentSlaTimeout(
29
+ raw: string | undefined,
30
+ def: string = DEFAULT_AGENT_SLA_TIMEOUT,
31
+ ): string {
32
+ return isoDuration(raw, def);
33
+ }
@@ -0,0 +1,406 @@
1
+ // Convergence comment-gate — unit tests for the canonical router (app/convergeGate.ts), the
2
+ // suppressed-advisory / ack-marker parsers + review-thread fetch helpers (app/github.ts), the
3
+ // pr.converge-gate worker (fail-closed, with injected GitHub readers), and a structural guard over
4
+ // the committed convergence-loop BPMN.
5
+ //
6
+ // The loop used to declare convergence on the agent's self-reported `status = "converged"` with no
7
+ // deterministic check that Copilot's comments were addressed. On Magikcraft/nano-bpm#770 a
8
+ // suppressed advisory was never applied across 20 rounds, yet the PR converged and auto-merged. The
9
+ // fix inserts a deterministic `pr.converge-gate` step on the converged path that blocks convergence
10
+ // while any review thread is unresolved OR any suppressed advisory lacks a RESOLVED `nano-ack:`
11
+ // thread, escalating to the human `wait-answer` task instead of finalizing.
12
+ import { readFileSync } from "node:fs";
13
+ import { test } from "node:test";
14
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
15
+ import { evaluateConvergeGate } from "./convergeGate.ts";
16
+ import {
17
+ parseAckedAdvisories,
18
+ parseReviewThreadsPage,
19
+ parseSuppressedAdvisories,
20
+ pickLatestCopilotReviewBody,
21
+ type ReviewThread,
22
+ } from "./github.ts";
23
+
24
+ // ── The canonical router ────────────────────────────────────────────────────
25
+
26
+ test("evaluateConvergeGate: a clean PR (no unresolved threads, no advisories) converges", () => {
27
+ const r = evaluateConvergeGate({ unresolvedThreadCount: 0, suppressedKeys: [], acknowledgedKeys: [] });
28
+ assertEquals(r.convergeBlocked, false);
29
+ assertEquals(r.convergeBlockReason, "");
30
+ });
31
+
32
+ test("evaluateConvergeGate: an unresolved review thread blocks convergence", () => {
33
+ const r = evaluateConvergeGate({ unresolvedThreadCount: 2, suppressedKeys: [], acknowledgedKeys: [] });
34
+ assertEquals(r.convergeBlocked, true);
35
+ assertStringIncludes(r.convergeBlockReason, "2 unresolved review threads");
36
+ });
37
+
38
+ test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks convergence", () => {
39
+ const r = evaluateConvergeGate({
40
+ unresolvedThreadCount: 0,
41
+ suppressedKeys: ["spec/a.json:613"],
42
+ acknowledgedKeys: [],
43
+ });
44
+ assertEquals(r.convergeBlocked, true);
45
+ assertStringIncludes(r.convergeBlockReason, "spec/a.json:613");
46
+ // Singular noun for exactly one advisory (explicit, not "advisor" + "y/ies" concatenation).
47
+ assertStringIncludes(r.convergeBlockReason, "1 unacknowledged suppressed advisory (");
48
+ });
49
+
50
+ test("evaluateConvergeGate: an ACKNOWLEDGED suppressed advisory no longer blocks convergence", () => {
51
+ const r = evaluateConvergeGate({
52
+ unresolvedThreadCount: 0,
53
+ suppressedKeys: ["spec/a.json:613"],
54
+ acknowledgedKeys: ["spec/a.json:613"],
55
+ });
56
+ assertEquals(r.convergeBlocked, false);
57
+ });
58
+
59
+ test("evaluateConvergeGate: multiple unacknowledged advisories use the plural noun", () => {
60
+ const r = evaluateConvergeGate({
61
+ unresolvedThreadCount: 0,
62
+ suppressedKeys: ["x.ts:10", "y.ts:20"],
63
+ acknowledgedKeys: [],
64
+ });
65
+ assertEquals(r.convergeBlocked, true);
66
+ assertStringIncludes(r.convergeBlockReason, "2 unacknowledged suppressed advisories (");
67
+ });
68
+
69
+ test("evaluateConvergeGate: reports both a thread and an advisory when both are outstanding", () => {
70
+ const r = evaluateConvergeGate({
71
+ unresolvedThreadCount: 1,
72
+ suppressedKeys: ["x.ts:10", "y.ts:20"],
73
+ acknowledgedKeys: ["x.ts:10"],
74
+ });
75
+ assertEquals(r.convergeBlocked, true);
76
+ assertStringIncludes(r.convergeBlockReason, "1 unresolved review thread");
77
+ assertStringIncludes(r.convergeBlockReason, "y.ts:20");
78
+ assert(!r.convergeBlockReason.includes("x.ts:10"), "an acknowledged advisory must not be listed");
79
+ });
80
+
81
+ // ── The parsers (app/github.ts) ─────────────────────────────────────────────
82
+
83
+ const SAMPLE_REVIEW_BODY = [
84
+ "## Pull Request Overview",
85
+ "Some prose that mentions **not/an/advisory:1** in passing.",
86
+ "",
87
+ "<details>",
88
+ "<summary>Suppressed comments (2)</summary>",
89
+ "",
90
+ "**spec-app/nano-app.schema.json:613**",
91
+ "- The description could be clearer about the loopback default.",
92
+ "",
93
+ "**server/src/main.rs:42**",
94
+ "- Consider narrowing this type.",
95
+ "</details>",
96
+ ].join("\n");
97
+
98
+ test("parseSuppressedAdvisories: extracts only the keys inside the Suppressed comments block", () => {
99
+ const keys = parseSuppressedAdvisories(SAMPLE_REVIEW_BODY);
100
+ assertEquals(keys, ["spec-app/nano-app.schema.json:613", "server/src/main.rs:42"]);
101
+ });
102
+
103
+ test("parseSuppressedAdvisories: returns [] when there is no suppressed block", () => {
104
+ assertEquals(parseSuppressedAdvisories("## Overview\nLooks good, **file.ts:1** is fine."), []);
105
+ assertEquals(parseSuppressedAdvisories(null), []);
106
+ assertEquals(parseSuppressedAdvisories(undefined), []);
107
+ });
108
+
109
+ test("parseAckedAdvisories: only RESOLVED threads carrying a nano-ack marker count", () => {
110
+ const threads: ReviewThread[] = [
111
+ { isResolved: true, path: "a.ts", bodies: ["Fixed. nano-ack: spec-app/nano-app.schema.json:613"] },
112
+ { isResolved: false, path: "b.ts", bodies: ["nano-ack: server/src/main.rs:42"] }, // open -> ignored
113
+ { isResolved: true, path: "c.ts", bodies: ["unrelated resolved comment"] },
114
+ ];
115
+ const acked = parseAckedAdvisories(threads);
116
+ assertEquals(acked, ["spec-app/nano-app.schema.json:613"]);
117
+ });
118
+
119
+ test("parseReviewThreadsPage: maps nodes and reports a complete (final) page", () => {
120
+ const page = parseReviewThreadsPage({
121
+ data: {
122
+ repository: {
123
+ pullRequest: {
124
+ reviewThreads: {
125
+ pageInfo: { hasNextPage: false, endCursor: null },
126
+ nodes: [{ isResolved: false, path: "a.ts", comments: { nodes: [{ body: "please fix" }] } }],
127
+ },
128
+ },
129
+ },
130
+ },
131
+ });
132
+ assertEquals(page, {
133
+ threads: [{ isResolved: false, path: "a.ts", bodies: ["please fix"] }],
134
+ hasNextPage: false,
135
+ endCursor: null,
136
+ });
137
+ });
138
+
139
+ test("parseReviewThreadsPage: a TRUNCATED page reports hasNextPage + its cursor (caller pages on)", () => {
140
+ // >100 threads: the first:100 page cannot see thread 101+, so instead of silently dropping the
141
+ // overflow the mapper surfaces `hasNextPage`/`endCursor` and `fetchReviewThreads` pages to
142
+ // completeness (or fails closed once its bounded page cap is exhausted).
143
+ const page = parseReviewThreadsPage({
144
+ data: {
145
+ repository: {
146
+ pullRequest: {
147
+ reviewThreads: {
148
+ pageInfo: { hasNextPage: true, endCursor: "CURSOR123" },
149
+ nodes: [{ isResolved: true, path: "a.ts", comments: { nodes: [{ body: "ok" }] } }],
150
+ },
151
+ },
152
+ },
153
+ },
154
+ });
155
+ assertEquals(page, {
156
+ threads: [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
157
+ hasNextPage: true,
158
+ endCursor: "CURSOR123",
159
+ });
160
+ });
161
+
162
+ test("parseReviewThreadsPage: FAILS CLOSED (null) when the reviewThreads block is MISSING", () => {
163
+ // GraphQL errors, permission issues, or a malformed payload can omit `reviewThreads`. Treating that
164
+ // as "no threads" (empty array) is a fail-OPEN — an unverifiable read must return null so the worker
165
+ // blocks/escalates rather than converging on a read that never happened.
166
+ assertEquals(parseReviewThreadsPage({}), null);
167
+ assertEquals(parseReviewThreadsPage({ data: { repository: { pullRequest: {} } } }), null);
168
+ });
169
+
170
+ test("parseReviewThreadsPage: FAILS CLOSED (null) when the completeness signal is UNREADABLE", () => {
171
+ // A present block whose `pageInfo.hasNextPage` is not a readable boolean is unverifiable — we cannot
172
+ // tell whether more pages exist, so we cannot safely page or map it.
173
+ const page = parseReviewThreadsPage({
174
+ data: {
175
+ repository: {
176
+ pullRequest: {
177
+ reviewThreads: {
178
+ nodes: [{ isResolved: true, path: "a.ts", comments: { nodes: [{ body: "ok" }] } }],
179
+ },
180
+ },
181
+ },
182
+ },
183
+ });
184
+ assertEquals(page, null);
185
+ });
186
+
187
+ test("pickLatestCopilotReviewBody: picks the NEWEST Copilot review body (oldest\u2192newest order)", () => {
188
+ const body = pickLatestCopilotReviewBody(
189
+ [
190
+ { user: { login: "human" }, body: "human review" },
191
+ { user: { login: "Copilot" }, body: "old copilot review" },
192
+ { user: { login: "Copilot" }, body: "newest copilot review" },
193
+ ],
194
+ false,
195
+ );
196
+ assertEquals(body, "newest copilot review");
197
+ });
198
+
199
+ test('pickLatestCopilotReviewBody: a complete read with NO Copilot review is verified empty ("")', () => {
200
+ assertEquals(pickLatestCopilotReviewBody([{ user: { login: "human" }, body: "hi" }], false), "");
201
+ assertEquals(pickLatestCopilotReviewBody([], false), "");
202
+ });
203
+
204
+ test("pickLatestCopilotReviewBody: FAILS CLOSED (null) when the reviews read was TRUNCATED", () => {
205
+ // >100 reviews (a long convergence loop): a first-page-only read returns the OLDEST 100 and misses
206
+ // the genuinely newest Copilot review, so an unverifiable (truncated) read must block, never return
207
+ // a stale page's body \u2014 a fail-OPEN on the advisory dimension is the class this gate prevents.
208
+ assertEquals(
209
+ pickLatestCopilotReviewBody(
210
+ [{ user: { login: "Copilot" }, body: "possibly stale" }],
211
+ true,
212
+ ),
213
+ null,
214
+ );
215
+ });
216
+
217
+ async function makeUnderTest(deps: {
218
+ readThreads: (repo: string, n: number) => Promise<ReviewThread[] | null>;
219
+ readReviewBody: (repo: string, n: number) => Promise<string | null>;
220
+ }) {
221
+ const { makeHandler } = await import("../workers/converge-gate/worker.ts");
222
+ return makeHandler(deps);
223
+ }
224
+
225
+ test("converge-gate: a clean PR is allowed to converge", async () => {
226
+ const handler = await makeUnderTest({
227
+ readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
228
+ readReviewBody: async () => "## Overview\nNo suppressed block.",
229
+ });
230
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
231
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
232
+ });
233
+
234
+ test("converge-gate: an unresolved thread blocks convergence", async () => {
235
+ const handler = await makeUnderTest({
236
+ readThreads: async () => [{ isResolved: false, path: "a.ts", bodies: ["please fix"] }],
237
+ readReviewBody: async () => "",
238
+ });
239
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
240
+ assertEquals(out.convergeBlocked, true);
241
+ assertStringIncludes(out.convergeBlockReason ?? "", "unresolved review thread");
242
+ });
243
+
244
+ test("converge-gate: an unacknowledged suppressed advisory blocks convergence", async () => {
245
+ const handler = await makeUnderTest({
246
+ readThreads: async () => [],
247
+ readReviewBody: async () => SAMPLE_REVIEW_BODY,
248
+ });
249
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
250
+ assertEquals(out.convergeBlocked, true);
251
+ assertStringIncludes(out.convergeBlockReason ?? "", "spec-app/nano-app.schema.json:613");
252
+ });
253
+
254
+ test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed", async () => {
255
+ const handler = await makeUnderTest({
256
+ readThreads: async () => [
257
+ { isResolved: true, path: "spec-app/nano-app.schema.json", bodies: ["Applied. nano-ack: spec-app/nano-app.schema.json:613"] },
258
+ { isResolved: true, path: "server/src/main.rs", bodies: ["Declined, false positive. nano-ack: server/src/main.rs:42"] },
259
+ ],
260
+ readReviewBody: async () => SAMPLE_REVIEW_BODY,
261
+ });
262
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
263
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
264
+ });
265
+
266
+ test("converge-gate: FAILS CLOSED when the threads read returns null (no transport)", async () => {
267
+ const handler = await makeUnderTest({
268
+ readThreads: async () => null,
269
+ readReviewBody: async () => "",
270
+ });
271
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
272
+ assertEquals(out.convergeBlocked, true);
273
+ assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
274
+ });
275
+
276
+ test("converge-gate: FAILS CLOSED when the review-body read returns null (no transport)", async () => {
277
+ // A null review body is unverifiable, not "no advisories" — the gate must block, not fail open on
278
+ // the suppressed-advisory dimension while the threads read happened to succeed.
279
+ const handler = await makeUnderTest({
280
+ readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
281
+ readReviewBody: async () => null,
282
+ });
283
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
284
+ assertEquals(out.convergeBlocked, true);
285
+ assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
286
+ });
287
+
288
+ test("converge-gate: FAILS CLOSED when a reader throws", async () => {
289
+ const handler = await makeUnderTest({
290
+ readThreads: async () => {
291
+ throw new Error("boom");
292
+ },
293
+ readReviewBody: async () => "",
294
+ });
295
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
296
+ assertEquals(out.convergeBlocked, true);
297
+ assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
298
+ });
299
+
300
+ test("converge-gate: FAILS CLOSED when the target cannot be resolved", async () => {
301
+ const handler = await makeUnderTest({
302
+ readThreads: async () => [],
303
+ readReviewBody: async () => "",
304
+ });
305
+ const out = await handler({ variables: { prKey: "not-a-pr-key" } } as any, {} as any);
306
+ assertEquals(out.convergeBlocked, true);
307
+ assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
308
+ });
309
+
310
+ test("converge-gate: a non-string prKey does not throw — resolves from repo/prNumber vars", async () => {
311
+ // `parsePr` calls `.trim()`, so a missing/non-string prKey must not reach it: otherwise the job
312
+ // throws and retries instead of running the fail-closed gate. A well-formed job carrying valid
313
+ // repo + prNumber but no prKey must still evaluate normally.
314
+ const handler = await makeUnderTest({
315
+ readThreads: async () => [{ isResolved: true, path: "a.ts", bodies: ["ok"] }],
316
+ readReviewBody: async () => "",
317
+ });
318
+ const out = await handler({ variables: { repo: "o/r", prNumber: 1 } } as any, {} as any);
319
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
320
+ });
321
+
322
+ test("converge-gate: FAILS CLOSED (no throw) when prKey is non-string and repo/prNumber are absent", async () => {
323
+ const handler = await makeUnderTest({
324
+ readThreads: async () => [],
325
+ readReviewBody: async () => "",
326
+ });
327
+ const out = await handler({ variables: { prKey: 123 } } as any, {} as any);
328
+ assertEquals(out.convergeBlocked, true);
329
+ assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
330
+ });
331
+
332
+ test("converge-gate: resolves repo/prNumber from the prKey when the vars are absent", async () => {
333
+ let seen: [string, number] | null = null;
334
+ const handler = await makeUnderTest({
335
+ readThreads: async (repo, n) => {
336
+ seen = [repo, n];
337
+ return [];
338
+ },
339
+ readReviewBody: async () => "",
340
+ });
341
+ await handler({ variables: { prKey: "o/r#7" } } as any, {} as any);
342
+ assertEquals(seen, ["o/r", 7]);
343
+ });
344
+
345
+ // ── Structural guard over the committed BPMN (no engine) ─────────────────────
346
+
347
+ const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
348
+ const flat = bpmn.replace(/\s+/g, " ");
349
+
350
+ function flowElement(id: string): string | null {
351
+ const re = new RegExp(
352
+ `<bpmn:sequenceFlow\\b[^>]*?\\bid="${id}"[^>]*?(?:/>|>(?:(?!<bpmn:sequenceFlow\\b).)*?</bpmn:sequenceFlow>)`,
353
+ );
354
+ const m = flat.match(re);
355
+ return m ? m[0] : null;
356
+ }
357
+
358
+ test("the converged status arm routes through the check-converge gate, not straight to finalize", () => {
359
+ const f = flowElement("f_converged");
360
+ assert(f, "f_converged flow missing");
361
+ assertStringIncludes(f, 'sourceRef="gw-status"');
362
+ assertStringIncludes(f, 'targetRef="check-converge"');
363
+ assertStringIncludes(f, 'status = "converged"');
364
+ });
365
+
366
+ test("check-converge runs the deterministic converge-gate job and feeds gw-converge-gate", () => {
367
+ const f = flowElement("f_toConvergeGate");
368
+ assert(f, "f_toConvergeGate flow missing");
369
+ assertStringIncludes(f, 'sourceRef="check-converge"');
370
+ assertStringIncludes(f, 'targetRef="gw-converge-gate"');
371
+ assertStringIncludes(flat, 'type="pr.converge-gate"');
372
+ });
373
+
374
+ test("gw-converge-gate blocks on an explicit convergeBlocked = true condition", () => {
375
+ const f = flowElement("f_convergeBlocked");
376
+ assert(f, "f_convergeBlocked flow missing");
377
+ assertStringIncludes(f, 'targetRef="persist-escalation-blockedcomments"');
378
+ assertStringIncludes(f, "convergeBlocked = true");
379
+ });
380
+
381
+ test("gw-converge-gate default arm finalizes with no condition", () => {
382
+ const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-converge-gate"[^>]*>/);
383
+ assert(gw, "gw-converge-gate gateway missing");
384
+ assertStringIncludes(gw[0], 'default="f_convergeOk"');
385
+ const ok = flowElement("f_convergeOk");
386
+ assert(ok, "f_convergeOk flow missing");
387
+ assertStringIncludes(ok, 'targetRef="persist-converged"');
388
+ assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
389
+ });
390
+
391
+ test("the blocked-comments escalation lands on the human wait-answer task with an answerable escalation", () => {
392
+ const f = flowElement("f_blockedWait");
393
+ assert(f, "f_blockedWait flow missing");
394
+ assertStringIncludes(f, 'sourceRef="persist-escalation-blockedcomments"');
395
+ assertStringIncludes(f, 'targetRef="wait-answer"');
396
+ const task = flat.match(
397
+ /<bpmn:serviceTask\b[^>]*\bid="persist-escalation-blockedcomments"[^>]*>.*?<\/bpmn:serviceTask>/,
398
+ );
399
+ assert(task, "persist-escalation-blockedcomments task missing");
400
+ assertStringIncludes(task[0], 'type="pr.persist-escalation"');
401
+ assertStringIncludes(task[0], 'target="status"');
402
+ assertStringIncludes(task[0], 'target="question"');
403
+ assertStringIncludes(task[0], 'target="recordRound"');
404
+ // The human sees the gate's own reason (the unresolved threads / unacknowledged advisories).
405
+ assertStringIncludes(task[0], "convergeBlockReason");
406
+ });
@@ -0,0 +1,48 @@
1
+ // Convergence comment-gate (issue: don't converge with unaddressed review comments).
2
+ //
3
+ // The review loop declares convergence on the AGENT's self-reported status. That trusts the agent
4
+ // to only say "converged" once every Copilot comment is addressed — which failed on
5
+ // Magikcraft/nano-bpm#770 (20 rounds, a suppressed advisory never applied, then auto-merged with
6
+ // the comment unaddressed). This deterministic gate runs on the converged path and blocks handoff
7
+ // while either:
8
+ // • any review THREAD is still unresolved, or
9
+ // • any SUPPRESSED advisory (`path:line` in the latest Copilot review body) lacks a matching
10
+ // RESOLVED ack thread (a thread carrying a `nano-ack: <path>:<line>` marker).
11
+ // A blocked gate escalates to the human wait-answer task (recoverable), never a hard wedge.
12
+
13
+ export interface ConvergeGateInput {
14
+ /** Count of review threads with `isResolved === false`. */
15
+ unresolvedThreadCount: number;
16
+ /** `path:line` keys of Copilot's suppressed advisories (latest review body). */
17
+ suppressedKeys: string[];
18
+ /** `path:line` keys acknowledged by RESOLVED `nano-ack:` threads. */
19
+ acknowledgedKeys: string[];
20
+ }
21
+
22
+ export interface ConvergeGateResult {
23
+ convergeBlocked: boolean;
24
+ convergeBlockReason: string;
25
+ }
26
+
27
+ /** Decide whether a self-reported "converged" round may proceed to finalize. Pure; the worker
28
+ * feeds it live GitHub state and fails CLOSED (blocks) when that state cannot be read. */
29
+ export function evaluateConvergeGate(input: ConvergeGateInput): ConvergeGateResult {
30
+ const acked = new Set(input.acknowledgedKeys);
31
+ const unacked = input.suppressedKeys.filter((k) => !acked.has(k));
32
+ const reasons: string[] = [];
33
+ if (input.unresolvedThreadCount > 0) {
34
+ const n = input.unresolvedThreadCount;
35
+ reasons.push(`${n} unresolved review thread${n === 1 ? "" : "s"}`);
36
+ }
37
+ if (unacked.length > 0) {
38
+ const noun = unacked.length === 1 ? "advisory" : "advisories";
39
+ reasons.push(`${unacked.length} unacknowledged suppressed ${noun} (${unacked.join(", ")})`);
40
+ }
41
+ if (reasons.length === 0) {
42
+ return { convergeBlocked: false, convergeBlockReason: "" };
43
+ }
44
+ return {
45
+ convergeBlocked: true,
46
+ convergeBlockReason: `Convergence blocked: ${reasons.join("; ")}. Resolve every review thread and reply-and-resolve an ack thread (nano-ack: <path>:<line>) for each suppressed advisory before converging.`,
47
+ };
48
+ }