@nanobpm/nano-workforce 0.187.5 → 0.188.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.
@@ -15,9 +15,11 @@ import { assert, assertEquals, assertNotEquals, assertStringIncludes } from "#te
15
15
  import { evaluateConvergeGate } from "./convergeGate.ts";
16
16
  import {
17
17
  advisoryStableKey,
18
+ isAckThread,
18
19
  parseAckedAdvisories,
19
20
  parseReviewThreadsPage,
20
21
  parseSuppressedAdvisories,
22
+ pickLatestCopilotReview,
21
23
  pickLatestCopilotReviewBody,
22
24
  type ReviewThread,
23
25
  } from "./github.ts";
@@ -28,12 +30,15 @@ test("evaluateConvergeGate: a clean PR (no unresolved threads, no advisories) co
28
30
  const r = evaluateConvergeGate({ unresolvedThreadCount: 0, suppressedAdvisories: [], acknowledgedKeys: [] });
29
31
  assertEquals(r.convergeBlocked, false);
30
32
  assertEquals(r.convergeBlockReason, "");
33
+ assertEquals(r.ackOnly, false);
31
34
  });
32
35
 
33
36
  test("evaluateConvergeGate: an unresolved review thread blocks convergence", () => {
34
37
  const r = evaluateConvergeGate({ unresolvedThreadCount: 2, suppressedAdvisories: [], acknowledgedKeys: [] });
35
38
  assertEquals(r.convergeBlocked, true);
36
39
  assertStringIncludes(r.convergeBlockReason, "2 unresolved review threads");
40
+ // An unresolved inline thread needs the round agent's code/reply work — NOT ack-only (#796).
41
+ assertEquals(r.ackOnly, false);
37
42
  });
38
43
 
39
44
  test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks convergence", () => {
@@ -46,6 +51,8 @@ test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks converg
46
51
  assertStringIncludes(r.convergeBlockReason, "spec/a.json:613");
47
52
  // Singular noun for exactly one advisory (explicit, not "advisor" + "y/ies" concatenation).
48
53
  assertStringIncludes(r.convergeBlockReason, "1 unacknowledged suppressed advisory (");
54
+ // Blocked SOLELY on an unacked advisory → the recoverable, bounded-auto-ack case (#796).
55
+ assertEquals(r.ackOnly, true);
49
56
  });
50
57
 
51
58
  test("evaluateConvergeGate: an ACKNOWLEDGED suppressed advisory no longer blocks convergence", () => {
@@ -98,6 +105,9 @@ test("evaluateConvergeGate: reports both a thread and an advisory when both are
98
105
  assertStringIncludes(r.convergeBlockReason, "1 unresolved review thread");
99
106
  assertStringIncludes(r.convergeBlockReason, "y.ts:20");
100
107
  assert(!r.convergeBlockReason.includes("x.ts:10"), "an acknowledged advisory must not be listed");
108
+ // A mix of an unresolved thread AND an unacked advisory is NOT ack-only — the thread still needs
109
+ // the round agent's code/reply work, so it escalates to a human as before (#796).
110
+ assertEquals(r.ackOnly, false);
101
111
  });
102
112
 
103
113
  // ── The parsers (app/github.ts) ─────────────────────────────────────────────
@@ -138,6 +148,122 @@ test("parseSuppressedAdvisories: returns [] when there is no suppressed block",
138
148
  assertEquals(parseSuppressedAdvisories(undefined), []);
139
149
  });
140
150
 
151
+ // An UNRESOLVED ack thread (one the round agent posted but has not resolved yet) must NOT count as
152
+ // an unresolved *review* thread: it is a partially-completed acknowledgement the bounded #796
153
+ // auto-ack retry can finish, so counting it would flip an otherwise ack-only block off the
154
+ // recoverable path and escalate to a human despite there being no substantive open code-review
155
+ // thread. `isAckThread` is the single source of truth the converge-gate worker filters on.
156
+ test("isAckThread: a thread carrying a nano-ack marker is an ack thread (resolved or not)", () => {
157
+ assert(
158
+ isAckThread({
159
+ isResolved: false,
160
+ path: "a.ts",
161
+ bodies: ["Applied. nano-ack: app/x.ts :: Guard the empty input."],
162
+ }),
163
+ );
164
+ assert(
165
+ isAckThread({ isResolved: true, path: "a.ts", bodies: ["Declined. nano-ack: app/x.ts :: Narrow this type."] }),
166
+ );
167
+ });
168
+
169
+ test("isAckThread: a substantive review thread (no nano-ack marker) is NOT an ack thread", () => {
170
+ assertEquals(isAckThread({ isResolved: false, path: "a.ts", bodies: ["This can NPE on empty input."] }), false);
171
+ assertEquals(isAckThread({ isResolved: false, path: "a.ts", bodies: [] }), false);
172
+ });
173
+
174
+ // FAIL-CLOSED guard #1 — the retired bare `nano-ack: <path>:<line>` form is prose-blind and NOT
175
+ // honoured by `parseAckedAdvisories`; classifying it as an ack thread would drop a genuine unresolved
176
+ // thread from the count and let the gate finalize with it still open. It must stay counted.
177
+ test("isAckThread: a bare `<path>:<line>` marker is NOT a dedicated ack thread (stays counted)", () => {
178
+ assertEquals(
179
+ isAckThread({ isResolved: false, path: "a.ts", bodies: ["Applied. nano-ack: app/x.ts:12"] }),
180
+ false,
181
+ );
182
+ });
183
+
184
+ // FAIL-CLOSED guard #2 — a substantive reviewer thread whose ROOT is the finding, with a later reply
185
+ // merely QUOTING a canonical marker, must NOT be classified as an ack thread. Only the thread root is
186
+ // inspected, and the root here carries no marker, so the substantive thread stays counted.
187
+ test("isAckThread: a substantive thread that only quotes nano-ack in a reply is NOT an ack thread", () => {
188
+ assertEquals(
189
+ isAckThread({
190
+ isResolved: false,
191
+ path: "a.ts",
192
+ bodies: [
193
+ "This can NPE on empty input.",
194
+ "Re: your `nano-ack: app/x.ts :: Guard the empty input.` — that is unrelated to this finding.",
195
+ ],
196
+ }),
197
+ false,
198
+ );
199
+ });
200
+
201
+ // Mirrors the converge-gate worker's split: an unresolved ack thread is classified separately (it
202
+ // still BLOCKS but stays ack-only/recoverable), while a substantive unresolved thread escalates.
203
+ test("converge gate: an unresolved ack thread does not count as a SUBSTANTIVE thread (stays ack-only)", () => {
204
+ const threads: ReviewThread[] = [
205
+ { isResolved: false, path: "a.ts", bodies: ["Applied. nano-ack: app/x.ts :: Guard the empty input."] },
206
+ { isResolved: true, path: "b.ts", bodies: ["already fixed"] },
207
+ ];
208
+ const unresolved = threads.filter((t) => !t.isResolved);
209
+ const unresolvedAckThreadCount = unresolved.filter((t) => isAckThread(t)).length;
210
+ const unresolvedThreadCount = unresolved.length - unresolvedAckThreadCount;
211
+ assertEquals(unresolvedThreadCount, 0);
212
+ assertEquals(unresolvedAckThreadCount, 1);
213
+ const r = evaluateConvergeGate({
214
+ unresolvedThreadCount,
215
+ unresolvedAckThreadCount,
216
+ suppressedAdvisories: [{ key: "app/x.ts#deadbeef", label: "app/x.ts:12" }],
217
+ acknowledgedKeys: [],
218
+ });
219
+ assertEquals(r.convergeBlocked, true);
220
+ assertEquals(r.ackOnly, true);
221
+ });
222
+
223
+ // FAIL-CLOSED regression (thread 2): an unresolved ack thread with NO outstanding advisory must NOT
224
+ // finalize the gate — dropping it entirely (the old `!isAckThread` filter) let the process converge
225
+ // with a genuinely-open GitHub thread. It now BLOCKS, classified ack-only (recoverable).
226
+ test("converge gate: a lone unresolved ack thread (no advisory) BLOCKS, ack-only (no fail-open finalize)", () => {
227
+ const threads: ReviewThread[] = [
228
+ { isResolved: false, path: "a.ts", bodies: ["Applied. nano-ack: app/x.ts :: Guard the empty input."] },
229
+ ];
230
+ const unresolved = threads.filter((t) => !t.isResolved);
231
+ const unresolvedAckThreadCount = unresolved.filter((t) => isAckThread(t)).length;
232
+ const unresolvedThreadCount = unresolved.length - unresolvedAckThreadCount;
233
+ assertEquals(unresolvedThreadCount, 0);
234
+ assertEquals(unresolvedAckThreadCount, 1);
235
+ const r = evaluateConvergeGate({
236
+ unresolvedThreadCount,
237
+ unresolvedAckThreadCount,
238
+ suppressedAdvisories: [],
239
+ acknowledgedKeys: [],
240
+ });
241
+ assertEquals(r.convergeBlocked, true);
242
+ assertEquals(r.ackOnly, true);
243
+ assertStringIncludes(r.convergeBlockReason, "unresolved acknowledgement thread");
244
+ });
245
+
246
+ // A genuine reviewer thread left open still blocks off the ack-only path (fail-closed intact).
247
+ test("converge gate: a substantive unresolved thread still counts (not ack-only)", () => {
248
+ const threads: ReviewThread[] = [
249
+ { isResolved: false, path: "a.ts", bodies: ["This can NPE on empty input."] },
250
+ { isResolved: false, path: "b.ts", bodies: ["Applied. nano-ack: app/x.ts :: Guard the empty input."] },
251
+ ];
252
+ const unresolved = threads.filter((t) => !t.isResolved);
253
+ const unresolvedAckThreadCount = unresolved.filter((t) => isAckThread(t)).length;
254
+ const unresolvedThreadCount = unresolved.length - unresolvedAckThreadCount;
255
+ assertEquals(unresolvedThreadCount, 1);
256
+ assertEquals(unresolvedAckThreadCount, 1);
257
+ const r = evaluateConvergeGate({
258
+ unresolvedThreadCount,
259
+ unresolvedAckThreadCount,
260
+ suppressedAdvisories: [{ key: "app/x.ts#deadbeef", label: "app/x.ts:12" }],
261
+ acknowledgedKeys: [],
262
+ });
263
+ assertEquals(r.convergeBlocked, true);
264
+ assertEquals(r.ackOnly, false);
265
+ });
266
+
141
267
  test("parseAckedAdvisories: only RESOLVED threads carrying a nano-ack marker count", () => {
142
268
  const threads: ReviewThread[] = [
143
269
  {
@@ -432,12 +558,63 @@ test("pickLatestCopilotReviewBody: FAILS CLOSED (null) when the reviews read was
432
558
  );
433
559
  });
434
560
 
561
+ // The COMMIT-ID-carrying picker (`pickLatestCopilotReview`) is the ONLY production path that carries
562
+ // GitHub's `commit_id` into the stale-review guard (#799); the worker tests inject `{ commitId }`
563
+ // directly, so without these the picker's commit_id selection could regress (disabling stale
564
+ // detection) while every other test stays green. These lock: the NEWEST Copilot review's commit_id
565
+ // (and body) is returned; a no-Copilot-review read is verified `{ body: "", commitId: null }`; a
566
+ // truncated read fails closed to `null`.
567
+ test("pickLatestCopilotReview: returns the NEWEST Copilot review's body AND commit_id (oldest\u2192newest)", () => {
568
+ const picked = pickLatestCopilotReview(
569
+ [
570
+ { user: { login: "human" }, body: "human review", commit_id: "humansha" },
571
+ { user: { login: "Copilot" }, body: "old copilot review", commit_id: "oldsha" },
572
+ { user: { login: "Copilot" }, body: "newest copilot review", commit_id: "newsha" },
573
+ ],
574
+ false,
575
+ );
576
+ assertEquals(picked, { body: "newest copilot review", commitId: "newsha" });
577
+ });
578
+
579
+ test('pickLatestCopilotReview: a complete read with NO Copilot review is verified { body: "", commitId: null }', () => {
580
+ assertEquals(pickLatestCopilotReview([{ user: { login: "human" }, body: "hi", commit_id: "x" }], false), {
581
+ body: "",
582
+ commitId: null,
583
+ });
584
+ assertEquals(pickLatestCopilotReview([], false), { body: "", commitId: null });
585
+ });
586
+
587
+ test("pickLatestCopilotReview: a Copilot review missing commit_id yields commitId null (not stale)", () => {
588
+ // A review with no commit_id must not fabricate a stale verdict: `isReviewStale` fails safe on a
589
+ // null review commit_id, and this picker must surface that null rather than an empty string.
590
+ assertEquals(pickLatestCopilotReview([{ user: { login: "Copilot" }, body: "b" }], false), {
591
+ body: "b",
592
+ commitId: null,
593
+ });
594
+ });
595
+
596
+ test("pickLatestCopilotReview: FAILS CLOSED (null) when the reviews read was TRUNCATED", () => {
597
+ assertEquals(pickLatestCopilotReview([{ user: { login: "Copilot" }, body: "possibly stale", commit_id: "s" }], true), null);
598
+ });
599
+
435
600
  async function makeUnderTest(deps: {
436
601
  readThreads: (repo: string, n: number) => Promise<ReviewThread[] | null>;
437
602
  readReviewBody: (repo: string, n: number) => Promise<string | null>;
603
+ // The commit SHA the latest Copilot review was submitted against, and the PR's current HEAD SHA
604
+ // (issue #799). Absent ⇒ both null ⇒ never stale (the pre-#799 behaviour every existing test
605
+ // relies on). A stale-review test supplies a `reviewCommitId` that differs from `headSha`.
606
+ reviewCommitId?: string | null;
607
+ headSha?: string | null;
438
608
  }) {
439
609
  const { makeHandler } = await import("../workers/converge-gate/worker.ts");
440
- return makeHandler(deps);
610
+ return makeHandler({
611
+ readThreads: deps.readThreads,
612
+ readReview: async (repo, n) => {
613
+ const body = await deps.readReviewBody(repo, n);
614
+ return body === null ? null : { body, commitId: deps.reviewCommitId ?? null };
615
+ },
616
+ readHeadSha: async () => deps.headSha ?? null,
617
+ });
441
618
  }
442
619
 
443
620
  test("converge-gate: a clean PR is allowed to converge", async () => {
@@ -446,7 +623,7 @@ test("converge-gate: a clean PR is allowed to converge", async () => {
446
623
  readReviewBody: async () => "## Overview\nNo suppressed block.",
447
624
  });
448
625
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
449
- assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
626
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
450
627
  });
451
628
 
452
629
  test("converge-gate: an unresolved thread blocks convergence", async () => {
@@ -457,6 +634,7 @@ test("converge-gate: an unresolved thread blocks convergence", async () => {
457
634
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
458
635
  assertEquals(out.convergeBlocked, true);
459
636
  assertStringIncludes(out.convergeBlockReason ?? "", "unresolved review thread");
637
+ assertEquals(out.convergeAckOnly, false);
460
638
  });
461
639
 
462
640
  test("converge-gate: an unacknowledged suppressed advisory blocks convergence", async () => {
@@ -467,6 +645,8 @@ test("converge-gate: an unacknowledged suppressed advisory blocks convergence",
467
645
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
468
646
  assertEquals(out.convergeBlocked, true);
469
647
  assertStringIncludes(out.convergeBlockReason ?? "", "spec-app/nano-app.schema.json:613");
648
+ // Blocked SOLELY on unacked advisories → ack-only, so the loop auto-acks before a human (#796).
649
+ assertEquals(out.convergeAckOnly, true);
470
650
  });
471
651
 
472
652
  test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed", async () => {
@@ -488,7 +668,56 @@ test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed",
488
668
  readReviewBody: async () => SAMPLE_REVIEW_BODY,
489
669
  });
490
670
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
491
- assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
671
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
672
+ });
673
+
674
+ // WIRING regression guard (through makeHandler, not a local re-implementation of the filter): an
675
+ // UNRESOLVED ack thread is classified separately (ack-only), so a block whose only substantive cause
676
+ // is unacked advisories stays ack-only. If the worker regressed to counting an unresolved ack thread
677
+ // as a SUBSTANTIVE thread, convergeAckOnly would flip to false and this handler-level test would fail.
678
+ test("converge-gate: an unresolved ack thread is classified ack-only, not substantive (through the handler)", async () => {
679
+ const handler = await makeUnderTest({
680
+ readThreads: async () => [
681
+ // A partially-completed acknowledgement (posted, not yet resolved) — its root carries the
682
+ // canonical marker, so isAckThread classifies it as an (unresolved) ack thread, not substantive.
683
+ {
684
+ isResolved: false,
685
+ path: "spec-app/nano-app.schema.json",
686
+ bodies: [
687
+ "Applied. nano-ack: spec-app/nano-app.schema.json :: The description could be clearer about the loopback default.",
688
+ ],
689
+ },
690
+ ],
691
+ readReviewBody: async () => SAMPLE_REVIEW_BODY,
692
+ });
693
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
694
+ assertEquals(out.convergeBlocked, true);
695
+ // No SUBSTANTIVE unresolved thread was counted → the block is ack-only (recoverable).
696
+ assertEquals(out.convergeAckOnly, true);
697
+ assertStringIncludes(out.convergeBlockReason ?? "", "unacknowledged suppressed");
698
+ });
699
+
700
+ // FAIL-CLOSED wiring guard (thread 2, through the handler): a lone UNRESOLVED ack thread with NO
701
+ // outstanding advisory must NOT let the gate finalize. The old `!isAckThread` filter dropped it
702
+ // entirely (unresolvedThreadCount = 0, no advisory) → convergeBlocked = false → the process could
703
+ // converge with a genuinely-open GitHub thread. It must now BLOCK (ack-only, recoverable).
704
+ test("converge-gate: a lone unresolved ack thread (no advisory) BLOCKS, ack-only — no fail-open finalize (through the handler)", async () => {
705
+ const handler = await makeUnderTest({
706
+ readThreads: async () => [
707
+ {
708
+ isResolved: false,
709
+ path: "spec-app/nano-app.schema.json",
710
+ bodies: [
711
+ "Applied. nano-ack: spec-app/nano-app.schema.json :: The description could be clearer about the loopback default.",
712
+ ],
713
+ },
714
+ ],
715
+ readReviewBody: async () => "## Overview\nNo suppressed block.",
716
+ });
717
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
718
+ assertEquals(out.convergeBlocked, true);
719
+ assertEquals(out.convergeAckOnly, true);
720
+ assertStringIncludes(out.convergeBlockReason ?? "", "unresolved acknowledgement thread");
492
721
  });
493
722
 
494
723
  test("converge-gate: FAILS CLOSED when the threads read returns null (no transport)", async () => {
@@ -499,6 +728,8 @@ test("converge-gate: FAILS CLOSED when the threads read returns null (no transpo
499
728
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
500
729
  assertEquals(out.convergeBlocked, true);
501
730
  assertStringIncludes(out.convergeBlockReason ?? "", "could not verify");
731
+ // An unverifiable block is NOT ack-only — it must go to a human, never the auto-ack path (#796).
732
+ assertEquals(out.convergeAckOnly, false);
502
733
  });
503
734
 
504
735
  test("converge-gate: FAILS CLOSED when the review-body read returns null (no transport)", async () => {
@@ -544,7 +775,7 @@ test("converge-gate: a non-string prKey does not throw — resolves from repo/pr
544
775
  readReviewBody: async () => "",
545
776
  });
546
777
  const out = await handler({ variables: { repo: "o/r", prNumber: 1 } } as any, {} as any);
547
- assertEquals(out, { convergeBlocked: false, convergeBlockReason: "" });
778
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
548
779
  });
549
780
 
550
781
  test("converge-gate: FAILS CLOSED (no throw) when prKey is non-string and repo/prNumber are absent", async () => {
@@ -570,6 +801,57 @@ test("converge-gate: resolves repo/prNumber from the prKey when the vars are abs
570
801
  assertEquals(seen, ["o/r", 7]);
571
802
  });
572
803
 
804
+ // ── Stale-review guard (issue #799) ──────────────────────────────────────────
805
+ // FM2: when the PR HEAD has advanced past the commit the latest Copilot review was submitted
806
+ // against, that review is stale — its suppressed advisories may already be fixed in code. The gate
807
+ // must NOT block/escalate on it; it must signal `reviewStale` so the loop re-solicits a fresh
808
+ // review of the current HEAD. FM1 (an applied-but-unacked advisory re-escalating forever) is
809
+ // structurally cured by this: once the fixing commit lands, the review that still lists the
810
+ // advisory is stale, so the gate re-solicits instead of re-blocking.
811
+
812
+ test("converge-gate #799: a STALE review (commit_id predates HEAD) does not block — signals reviewStale", async () => {
813
+ // The review still lists an unacked suppressed advisory (would block if evaluated), but it was
814
+ // submitted against an OLD commit — the agent has since pushed a fix. The gate must re-solicit,
815
+ // not escalate.
816
+ const handler = await makeUnderTest({
817
+ readThreads: async () => [],
818
+ readReviewBody: async () => SAMPLE_REVIEW_BODY,
819
+ reviewCommitId: "oldsha1111111111111111111111111111111111",
820
+ headSha: "newsha2222222222222222222222222222222222",
821
+ });
822
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
823
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: true });
824
+ });
825
+
826
+ test("converge-gate #799: a HEAD-CURRENT review still blocks on an unacked advisory (control)", async () => {
827
+ // Same unacked advisory, but the review's commit_id MATCHES HEAD — it is fresh, so the ordinary
828
+ // gate runs and blocks. Proves the stale guard does not swallow a genuine block.
829
+ const handler = await makeUnderTest({
830
+ readThreads: async () => [],
831
+ readReviewBody: async () => SAMPLE_REVIEW_BODY,
832
+ reviewCommitId: "samesha33333333333333333333333333333333",
833
+ headSha: "samesha33333333333333333333333333333333",
834
+ });
835
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
836
+ assertEquals(out.convergeBlocked, true);
837
+ assertEquals(out.reviewStale, false);
838
+ assertStringIncludes(out.convergeBlockReason ?? "", "spec-app/nano-app.schema.json:613");
839
+ });
840
+
841
+ test("converge-gate #799: an unknown review commit_id or unreadable HEAD is NOT stale (evaluates normally)", async () => {
842
+ // Fail-safe: without both SHAs we cannot prove staleness, so the gate must fall through to the
843
+ // ordinary evaluation (here: block on the unacked advisory), never fabricate a re-solicit loop.
844
+ const handler = await makeUnderTest({
845
+ readThreads: async () => [],
846
+ readReviewBody: async () => SAMPLE_REVIEW_BODY,
847
+ reviewCommitId: null,
848
+ headSha: "newsha2222222222222222222222222222222222",
849
+ });
850
+ const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
851
+ assertEquals(out.convergeBlocked, true);
852
+ assertEquals(out.reviewStale, false);
853
+ });
854
+
573
855
  // ── Structural guard over the committed BPMN (no engine) ─────────────────────
574
856
 
575
857
  const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
@@ -602,10 +884,116 @@ test("check-converge runs the deterministic converge-gate job and feeds gw-conve
602
884
  test("gw-converge-gate blocks on an explicit convergeBlocked = true condition", () => {
603
885
  const f = flowElement("f_convergeBlocked");
604
886
  assert(f, "f_convergeBlocked flow missing");
605
- assertStringIncludes(f, 'targetRef="persist-escalation-blockedcomments"');
887
+ // A block now routes to the bounded auto-ack gateway first (#796), not straight to the human.
888
+ assertStringIncludes(f, 'targetRef="gw-ack-retry"');
606
889
  assertStringIncludes(f, "convergeBlocked = true");
607
890
  });
608
891
 
892
+ // ── Bounded agent auto-ack before human escalation (#796) ────────────────────
893
+ // A converge-gate block whose SOLE cause is unacked suppressed advisories is recoverable by
894
+ // re-dispatching the review-round agent to post the missing acks. gw-ack-retry sends such an
895
+ // ack-only block back into review-round (bounded by ackRetryMax), and only a non-ack-only block
896
+ // (an unresolved inline thread) or an exhausted budget escalates to the human wait-answer.
897
+
898
+ test("gw-ack-retry routes an ack-only block (within budget) back into the review-round agent", () => {
899
+ const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-ack-retry"[^>]*>/);
900
+ assert(gw, "gw-ack-retry gateway missing");
901
+ assertStringIncludes(gw[0], 'default="f_ackEscalate"');
902
+ const retry = flowElement("f_ackRetry");
903
+ assert(retry, "f_ackRetry flow missing");
904
+ assertStringIncludes(retry, 'sourceRef="gw-ack-retry"');
905
+ // The re-entry rejoins the loop at `capture-head` (the loop head that captures the round-entry
906
+ // head, #786) which then flows straight into `review-round` — so the ack-only block re-dispatches
907
+ // the review-round agent, freshly baselined.
908
+ assertStringIncludes(retry, 'targetRef="capture-head"');
909
+ assertStringIncludes(retry, "convergeAckOnly = true");
910
+ // Bounded: re-dispatch only while the ack-retry budget is not exhausted.
911
+ assert(/ackRetryRound &lt;= ackRetryMax|ackRetryRound <= ackRetryMax/.test(retry), "f_ackRetry must be budget-bounded");
912
+ });
913
+
914
+ test("gw-ack-retry default arm escalates to the human (threads or exhausted budget)", () => {
915
+ const esc = flowElement("f_ackEscalate");
916
+ assert(esc, "f_ackEscalate flow missing");
917
+ assertStringIncludes(esc, 'sourceRef="gw-ack-retry"');
918
+ assertStringIncludes(esc, 'targetRef="persist-escalation-blockedcomments"');
919
+ assert(!/conditionExpression/.test(esc), "the escalate arm is the default — no conditionExpression");
920
+ });
921
+
922
+ test("capture-head accepts the ack-retry re-entry flow", () => {
923
+ const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="capture-head"[^>]*>.*?<\/bpmn:serviceTask>/);
924
+ assert(task, "capture-head task missing");
925
+ assertStringIncludes(task[0], "<bpmn:incoming>f_ackRetry</bpmn:incoming>");
926
+ });
927
+
928
+ test("check-converge advances the ack-retry counter only on an ack-only block (bounded)", () => {
929
+ const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="check-converge"[^>]*>.*?<\/bpmn:serviceTask>/);
930
+ assert(task, "check-converge task missing");
931
+ assertStringIncludes(
932
+ task[0],
933
+ "if convergeBlocked = true and convergeAckOnly = true then ackRetryRound + 1 else ackRetryRound",
934
+ );
935
+ });
936
+
937
+ test("PrConvergeGateOut carries the convergeAckOnly signal for the auto-ack router", () => {
938
+ const shape = flat.match(/<nano:shape\b[^>]*\bid="PrConvergeGateOut"[^>]*>.*?<\/nano:shape>/);
939
+ assert(shape, "PrConvergeGateOut envelope missing");
940
+ assertStringIncludes(shape[0], 'name="convergeAckOnly"');
941
+ });
942
+
943
+ test("gw-converge-gate routes a STALE review back to persist-round (re-solicit), not to escalation (#799)", () => {
944
+ const f = flowElement("f_convergeStale");
945
+ assert(f, "f_convergeStale flow missing");
946
+ assertStringIncludes(f, 'sourceRef="gw-converge-gate"');
947
+ // A stale review re-enters the round loop via persist-round → check-progress, which parks
948
+ // waiting_review (the single writer) and the poller re-solicits a fresh review.
949
+ assertStringIncludes(f, 'targetRef="persist-round"');
950
+ assertStringIncludes(f, "reviewStale = true");
951
+ // persist-round must accept the stale re-entry as an incoming.
952
+ const pr = flat.match(/<bpmn:serviceTask\b[^>]*\bid="persist-round"[^>]*>.*?<\/bpmn:serviceTask>/);
953
+ assert(pr, "persist-round task missing");
954
+ assertStringIncludes(pr[0], "<bpmn:incoming>f_convergeStale</bpmn:incoming>");
955
+ // The gate's output envelope must declare reviewStale for the FEEL condition to read it.
956
+ assertStringIncludes(flat, 'id="PrConvergeGateOut"');
957
+ assert(
958
+ /<nano:shape\b[^>]*\bid="PrConvergeGateOut"[^>]*>.*?name="reviewStale".*?<\/nano:shape>/.test(flat),
959
+ "PrConvergeGateOut must declare reviewStale",
960
+ );
961
+ });
962
+
963
+ test("the round cap does NOT escalate a stale-retry round — f_guardMax is gated on reviewStale != true (#799)", () => {
964
+ // A stale review re-enters persist-round → check-progress → gw-guard. On the FINAL configured round
965
+ // the cap would escalate a human instead of soliciting a fresh review — but a stale review is not a
966
+ // failure to converge, it is a review of obsolete code. The round cap must therefore bypass the
967
+ // stale-retry path (the review-wait timeout remains the backstop against an indefinitely stalled
968
+ // re-solicitation).
969
+ const f = flowElement("f_guardMax");
970
+ assert(f, "f_guardMax flow missing");
971
+ assertStringIncludes(f, "maxRounds and reviewStale != true");
972
+ });
973
+
974
+ test("wait-review clears reviewStale when a fresh review lands, so the marker can't leak into a later round (#799)", () => {
975
+ // reviewStale is written ONLY by the converge-gate; once a fresh review arrives it is no longer
976
+ // known-stale, so wait-review resets it to false alongside the round increment. Without this reset
977
+ // a stale marker would persist and disable the round cap for a subsequent addressed round.
978
+ const wr = flat.match(/<bpmn:intermediateCatchEvent\b[^>]*\bid="wait-review"[^>]*>.*?<\/bpmn:intermediateCatchEvent>/);
979
+ assert(wr, "wait-review catch event missing");
980
+ assertStringIncludes(wr[0], '<zeebe:output source="=round + 1" target="round" />');
981
+ assertStringIncludes(wr[0], '<zeebe:output source="=false" target="reviewStale" />');
982
+ });
983
+
984
+ test("record-answer clears reviewStale so a human-resumed round after a review-stall timeout is re-capped (#799)", () => {
985
+ // reviewStale is cleared on the wait-review MESSAGE arm, but the review-wait TIMER arm bypasses it:
986
+ // wait-review-timeout → persist-review-stalled → wait-answer → record-answer → capture-head. A
987
+ // stale-retry round that times out and is resumed by a human therefore re-enters the loop still
988
+ // carrying reviewStale = true, which — via `f_guardMax`'s `reviewStale != true` guard — would keep
989
+ // the round cap disabled for that (and every subsequent) human-directed addressed round. Once a
990
+ // human is answering escalations the automated stale-retry is over, so record-answer must reset the
991
+ // marker; a genuinely-still-stale next review re-sets it at the converge-gate.
992
+ const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="record-answer"[^>]*>.*?<\/bpmn:serviceTask>/);
993
+ assert(task, "record-answer task missing");
994
+ assertStringIncludes(task[0], '<zeebe:output source="=false" target="reviewStale" />');
995
+ });
996
+
609
997
  test("gw-converge-gate default arm routes to the scope classifier (not straight to finalize)", () => {
610
998
  const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-converge-gate"[^>]*>/);
611
999
  assert(gw, "gw-converge-gate gateway missing");
@@ -4,17 +4,35 @@
4
4
  // to only say "converged" once every Copilot comment is addressed — which failed on
5
5
  // Magikcraft/nano-bpm#770 (20 rounds, a suppressed advisory never applied, then auto-merged with
6
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
7
+ // while any of:
8
+ // • any SUBSTANTIVE review THREAD is still unresolved, or
9
+ // • any partially-completed `nano-ack:` ACK THREAD is still unresolved (blocks, but recoverable —
10
+ // see `unresolvedAckThreadCount`), or
9
11
  // • any SUPPRESSED advisory (in the latest Copilot review body) lacks a matching
10
12
  // RESOLVED ack thread (a thread carrying a line-stable `nano-ack: <path> :: <text>` marker; the
11
13
  // bare `nano-ack: <path>:<line>` form is NOT honoured — its `path:line` key is prose-blind and
12
14
  // would false-OPEN a new advisory re-emitted at a previously-acked line).
13
- // A blocked gate escalates to the human wait-answer task (recoverable), never a hard wedge.
15
+ // A blocked gate is recoverable, never a hard wedge — but the route depends on WHY it blocked: an
16
+ // ack-only block (sole cause unacked advisories and/or an unresolved `nano-ack:` thread) re-enters
17
+ // the `review-round` agent first, bounded by `ackRetryMax`, and only escalates to the human
18
+ // wait-answer task once that budget is exhausted; a substantive unresolved thread escalates to
19
+ // wait-answer immediately.
14
20
 
15
21
  export interface ConvergeGateInput {
16
- /** Count of review threads with `isResolved === false`. */
22
+ /** Count of unresolved review threads that are NOT `nano-ack:` ack threads (substantive reviewer
23
+ * findings). Any of these forces the block off the ack-only path — it needs the round agent's
24
+ * code/reply work, so it escalates to a human. */
17
25
  unresolvedThreadCount: number;
26
+ /** Count of unresolved threads the worker classified as `nano-ack:` ACK threads (partially-completed
27
+ * acknowledgements: posted but not yet resolved). These NEVER silently drop out of the gate — an
28
+ * unresolved ack thread is still a genuinely-open GitHub thread, so it BLOCKS convergence; but the
29
+ * block stays ack-only (recoverable by the bounded #796 auto-ack retry, which re-posts/resolves).
30
+ * Keeping this a blocking condition (rather than filtering the thread away) is what makes the
31
+ * `isAckThread` root-marker classifier FAIL-CLOSED: even if it mis-labels a substantive thread as
32
+ * an ack, that thread still BLOCKS (as ack-only) instead of finalizing with the finding open — and
33
+ * the bounded retry cannot ack a non-advisory, so it escalates to a human on exhaustion. Optional
34
+ * (defaults to 0) so the pure function stays total for callers that only track substantive threads. */
35
+ unresolvedAckThreadCount?: number;
18
36
  /** Copilot's suppressed advisories (latest review body), each with its line-stable key + label. */
19
37
  suppressedAdvisories: { key: string; label: string }[];
20
38
  /** Acknowledged line-stable keys (`<path>#<fp>`) from RESOLVED `nano-ack:` threads. An advisory is
@@ -25,6 +43,15 @@ export interface ConvergeGateInput {
25
43
  export interface ConvergeGateResult {
26
44
  convergeBlocked: boolean;
27
45
  convergeBlockReason: string;
46
+ /** True when the block is caused SOLELY by recoverable, agent-fixable state and no SUBSTANTIVE
47
+ * (non-ack) reviewer thread is open: unacknowledged suppressed advisories and/or a
48
+ * partially-completed (unresolved) `nano-ack:` ack thread. This is the case the loop can auto-ack:
49
+ * re-dispatching the review-round agent posts/resolves the missing ack threads and converges, so
50
+ * the process routes an ack-only block through a bounded agent auto-ack step BEFORE the human
51
+ * `wait-answer` (issue #796). A block that includes any unresolved SUBSTANTIVE inline thread (which
52
+ * needs the round agent's code/reply work) is NOT ack-only and escalates to a human as before.
53
+ * Always false when not blocked. */
54
+ ackOnly: boolean;
28
55
  }
29
56
 
30
57
  /** Decide whether a self-reported "converged" round may proceed to finalize. Pure; the worker
@@ -36,20 +63,29 @@ export function evaluateConvergeGate(input: ConvergeGateInput): ConvergeGateResu
36
63
  // NOT consulted: it would let a resolved ack for one advisory silently acknowledge a genuinely new
37
64
  // advisory re-emitted at the same line (a false-OPEN this gate exists to prevent).
38
65
  const unacked = input.suppressedAdvisories.filter((a) => !acked.has(a.key));
66
+ const unresolvedAck = input.unresolvedAckThreadCount ?? 0;
39
67
  const reasons: string[] = [];
40
68
  if (input.unresolvedThreadCount > 0) {
41
69
  const n = input.unresolvedThreadCount;
42
70
  reasons.push(`${n} unresolved review thread${n === 1 ? "" : "s"}`);
43
71
  }
72
+ if (unresolvedAck > 0) {
73
+ reasons.push(`${unresolvedAck} unresolved acknowledgement thread${unresolvedAck === 1 ? "" : "s"}`);
74
+ }
44
75
  if (unacked.length > 0) {
45
76
  const noun = unacked.length === 1 ? "advisory" : "advisories";
46
77
  reasons.push(`${unacked.length} unacknowledged suppressed ${noun} (${unacked.map((a) => a.label).join(", ")})`);
47
78
  }
48
79
  if (reasons.length === 0) {
49
- return { convergeBlocked: false, convergeBlockReason: "" };
80
+ return { convergeBlocked: false, convergeBlockReason: "", ackOnly: false };
50
81
  }
51
82
  return {
52
83
  convergeBlocked: true,
53
84
  convergeBlockReason: `Convergence blocked: ${reasons.join("; ")}. Resolve every review thread and reply-and-resolve an ack thread (nano-ack: <path> :: <verbatim advisory text>) for each suppressed advisory before converging.`,
85
+ // Ack-only iff NO substantive (non-ack) reviewer thread is open — the only remaining causes are
86
+ // recoverable by re-dispatching the review-round agent (#796): unacked suppressed advisories
87
+ // and/or a partially-completed (unresolved) ack thread it can re-post and resolve. A substantive
88
+ // unresolved thread needs the round agent's code/reply work and escalates to a human.
89
+ ackOnly: input.unresolvedThreadCount === 0 && (unacked.length > 0 || unresolvedAck > 0),
54
90
  };
55
91
  }