@nanobpm/nano-workforce 0.187.6 → 0.188.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,7 @@ 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,
@@ -29,12 +30,15 @@ test("evaluateConvergeGate: a clean PR (no unresolved threads, no advisories) co
29
30
  const r = evaluateConvergeGate({ unresolvedThreadCount: 0, suppressedAdvisories: [], acknowledgedKeys: [] });
30
31
  assertEquals(r.convergeBlocked, false);
31
32
  assertEquals(r.convergeBlockReason, "");
33
+ assertEquals(r.ackOnly, false);
32
34
  });
33
35
 
34
36
  test("evaluateConvergeGate: an unresolved review thread blocks convergence", () => {
35
37
  const r = evaluateConvergeGate({ unresolvedThreadCount: 2, suppressedAdvisories: [], acknowledgedKeys: [] });
36
38
  assertEquals(r.convergeBlocked, true);
37
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);
38
42
  });
39
43
 
40
44
  test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks convergence", () => {
@@ -47,6 +51,8 @@ test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks converg
47
51
  assertStringIncludes(r.convergeBlockReason, "spec/a.json:613");
48
52
  // Singular noun for exactly one advisory (explicit, not "advisor" + "y/ies" concatenation).
49
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);
50
56
  });
51
57
 
52
58
  test("evaluateConvergeGate: an ACKNOWLEDGED suppressed advisory no longer blocks convergence", () => {
@@ -99,6 +105,9 @@ test("evaluateConvergeGate: reports both a thread and an advisory when both are
99
105
  assertStringIncludes(r.convergeBlockReason, "1 unresolved review thread");
100
106
  assertStringIncludes(r.convergeBlockReason, "y.ts:20");
101
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);
102
111
  });
103
112
 
104
113
  // ── The parsers (app/github.ts) ─────────────────────────────────────────────
@@ -139,6 +148,122 @@ test("parseSuppressedAdvisories: returns [] when there is no suppressed block",
139
148
  assertEquals(parseSuppressedAdvisories(undefined), []);
140
149
  });
141
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
+
142
267
  test("parseAckedAdvisories: only RESOLVED threads carrying a nano-ack marker count", () => {
143
268
  const threads: ReviewThread[] = [
144
269
  {
@@ -498,7 +623,7 @@ test("converge-gate: a clean PR is allowed to converge", async () => {
498
623
  readReviewBody: async () => "## Overview\nNo suppressed block.",
499
624
  });
500
625
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
501
- assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: false });
626
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
502
627
  });
503
628
 
504
629
  test("converge-gate: an unresolved thread blocks convergence", async () => {
@@ -509,6 +634,7 @@ test("converge-gate: an unresolved thread blocks convergence", async () => {
509
634
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
510
635
  assertEquals(out.convergeBlocked, true);
511
636
  assertStringIncludes(out.convergeBlockReason ?? "", "unresolved review thread");
637
+ assertEquals(out.convergeAckOnly, false);
512
638
  });
513
639
 
514
640
  test("converge-gate: an unacknowledged suppressed advisory blocks convergence", async () => {
@@ -519,6 +645,8 @@ test("converge-gate: an unacknowledged suppressed advisory blocks convergence",
519
645
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
520
646
  assertEquals(out.convergeBlocked, true);
521
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);
522
650
  });
523
651
 
524
652
  test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed", async () => {
@@ -540,7 +668,56 @@ test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed",
540
668
  readReviewBody: async () => SAMPLE_REVIEW_BODY,
541
669
  });
542
670
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
543
- assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: false });
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");
544
721
  });
545
722
 
546
723
  test("converge-gate: FAILS CLOSED when the threads read returns null (no transport)", async () => {
@@ -551,6 +728,8 @@ test("converge-gate: FAILS CLOSED when the threads read returns null (no transpo
551
728
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
552
729
  assertEquals(out.convergeBlocked, true);
553
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);
554
733
  });
555
734
 
556
735
  test("converge-gate: FAILS CLOSED when the review-body read returns null (no transport)", async () => {
@@ -596,7 +775,7 @@ test("converge-gate: a non-string prKey does not throw — resolves from repo/pr
596
775
  readReviewBody: async () => "",
597
776
  });
598
777
  const out = await handler({ variables: { repo: "o/r", prNumber: 1 } } as any, {} as any);
599
- assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: false });
778
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
600
779
  });
601
780
 
602
781
  test("converge-gate: FAILS CLOSED (no throw) when prKey is non-string and repo/prNumber are absent", async () => {
@@ -641,7 +820,7 @@ test("converge-gate #799: a STALE review (commit_id predates HEAD) does not bloc
641
820
  headSha: "newsha2222222222222222222222222222222222",
642
821
  });
643
822
  const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
644
- assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", reviewStale: true });
823
+ assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: true });
645
824
  });
646
825
 
647
826
  test("converge-gate #799: a HEAD-CURRENT review still blocks on an unacked advisory (control)", async () => {
@@ -705,10 +884,62 @@ test("check-converge runs the deterministic converge-gate job and feeds gw-conve
705
884
  test("gw-converge-gate blocks on an explicit convergeBlocked = true condition", () => {
706
885
  const f = flowElement("f_convergeBlocked");
707
886
  assert(f, "f_convergeBlocked flow missing");
708
- 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"');
709
889
  assertStringIncludes(f, "convergeBlocked = true");
710
890
  });
711
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
+
712
943
  test("gw-converge-gate routes a STALE review back to persist-round (re-solicit), not to escalation (#799)", () => {
713
944
  const f = flowElement("f_convergeStale");
714
945
  assert(f, "f_convergeStale flow 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
  }
@@ -52,6 +52,29 @@ test("implement-cell runs the senior:feature loop and delegates escalation to th
52
52
  assert(/targetRef="implement-task"/.test(xml), "implement-cell loops the answer back into implement-task");
53
53
  });
54
54
 
55
+ test("implement-cell reconciles from GitHub before escalating (issue #801)", () => {
56
+ const xml = flat("implement-cell");
57
+ // The escalate arm passes through the reconcile step BEFORE the human escalation recorder, so a
58
+ // machine-recoverable result (an open PR on the cell's branch) adopts-and-converges instead of
59
+ // dead-ending at a person.
60
+ assert(
61
+ /<zeebe:taskDefinition\b[^>]*\btype="pr.reconcile-implement"/.test(xml),
62
+ "implement-cell must reconcile via pr.reconcile-implement on the escalate arm",
63
+ );
64
+ assert(
65
+ /<bpmn:exclusiveGateway\b[^>]*\bid="ic_reconcile_gw"/.test(xml),
66
+ "implement-cell must gate the reconcile outcome (adopt vs escalate) on ic_reconcile_gw",
67
+ );
68
+ assert(
69
+ /<bpmn:sequenceFlow\b[^>]*\bid="ic_reconciled"[^>]*\btargetRef="ic_end"/.test(xml),
70
+ "a reconciled (adopted) PR routes straight to the cell's done end — no human escalation",
71
+ );
72
+ assert(
73
+ /<bpmn:sequenceFlow\b[^>]*\bid="ic_toRecordEscalation"[^>]*\btargetRef="record-escalation"/.test(xml),
74
+ "an unreconciled result still escalates through record-escalation",
75
+ );
76
+ });
77
+
55
78
  test("converge-cell and merge-cell keep their engine-native handoff task types", () => {
56
79
  assert(/<zeebe:taskDefinition\b[^>]*\btype="pr.converge-feature"/.test(flat("converge-cell")), "converge-cell hands off via pr.converge-feature");
57
80
  assert(/<zeebe:taskDefinition\b[^>]*\btype="senior:trial-merge"/.test(flat("merge-cell")), "merge-cell runs the senior:trial-merge agent");
package/app/github.ts CHANGED
@@ -281,25 +281,59 @@ export function parseSuppressedAdvisories(reviewBody: string | null | undefined)
281
281
  return out;
282
282
  }
283
283
 
284
+ /** The line-stable advisory keys carried by a SINGLE comment body's canonical `nano-ack: <path> ::
285
+ * <text>` markers. This is the SOLE recognizer of an acknowledgement, shared by `isAckThread` and
286
+ * `parseAckedAdvisories` so "is this an ack?" has ONE canonical implementation (derivation over
287
+ * duplication — no drift between the two consumers). The bare `nano-ack: <path>:<line>` form yields
288
+ * NOTHING here: `NEW_ACK` requires the ` :: <text>` prose (a bare `path:line` is prose-blind and
289
+ * would false-OPEN a new advisory re-emitted at a previously-acked line). */
290
+ function canonicalAckKeys(body: string): string[] {
291
+ const keys: string[] = [];
292
+ ACK_MARKER.lastIndex = 0;
293
+ let m: RegExpExecArray | null;
294
+ // biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
295
+ while ((m = ACK_MARKER.exec(body)) !== null) {
296
+ const nw = NEW_ACK.exec(m[1].trim());
297
+ if (nw) keys.push(advisoryStableKey(nw[1], nw[2]));
298
+ }
299
+ return keys;
300
+ }
301
+
302
+ /** True when a review thread is a DEDICATED `nano-ack:` acknowledgement thread — one whose ROOT
303
+ * comment (`bodies[0]`, the thread-opening comment) carries a valid canonical `nano-ack: <path> ::
304
+ * <text>` marker — rather than a substantive code-review thread. This is a CLASSIFICATION only: the
305
+ * converge gate never DROPS an unresolved thread on the strength of this predicate. An unresolved ack
306
+ * thread still BLOCKS convergence (it is a genuinely-open GitHub thread); the classification only
307
+ * routes that block onto the recoverable ack-only path — a partially-completed acknowledgement the
308
+ * bounded #796 auto-ack retry can finish (post-and-resolve) — instead of escalating a human. A
309
+ * substantive unresolved thread escalates to a human.
310
+ *
311
+ * Because the gate BLOCKS either way, this predicate is FAIL-CLOSED even under a false positive:
312
+ * 1. Only the canonical prose-keyed form counts (via `canonicalAckKeys`); the retired bare
313
+ * `nano-ack: <path>:<line>` form does NOT — matching `parseAckedAdvisories`.
314
+ * 2. Only the ROOT comment is inspected — a reviewer's substantive finding is ALWAYS its thread's
315
+ * root and (canonical-form) never carries this marker, so a substantive thread that merely
316
+ * quotes or replies `nano-ack:` in a later comment is not mis-classified.
317
+ * 3. Even if a root DID quote the canonical marker mid-prose and were mis-labelled an ack, the
318
+ * thread is NOT excluded — it still blocks (as ack-only), and the bounded auto-ack retry cannot
319
+ * ack a non-advisory, so it escalates to a human on exhaustion. Marker presence never finalizes
320
+ * the gate with an open thread (the fail-OPEN this design forecloses). */
321
+ export function isAckThread(thread: ReviewThread): boolean {
322
+ const root = thread.bodies[0];
323
+ return root !== undefined && canonicalAckKeys(root).length > 0;
324
+ }
325
+
284
326
  /** Extract the acknowledged advisory keys from a set of review threads (only RESOLVED threads
285
327
  * count — an open ack thread is not yet an acknowledgement). Returns line-stable keys (`<path>#<fp>`)
286
- * parsed from the `nano-ack: <path> :: <text>` form ONLY. A bare `nano-ack: <path>:<line>` marker is
287
- * intentionally NOT honoured: its `path:line` key is blind to the advisory prose and would false-OPEN
288
- * a genuinely new advisory re-emitted at a previously-acked line. The gate treats an advisory as
289
- * acked iff its stable key appears here. */
328
+ * parsed from the `nano-ack: <path> :: <text>` form ONLY (via the shared `canonicalAckKeys`). A bare
329
+ * `nano-ack: <path>:<line>` marker is intentionally NOT honoured: its `path:line` key is blind to the
330
+ * advisory prose and would false-OPEN a genuinely new advisory re-emitted at a previously-acked line.
331
+ * The gate treats an advisory as acked iff its stable key appears here. */
290
332
  export function parseAckedAdvisories(threads: ReviewThread[]): string[] {
291
333
  const acked = new Set<string>();
292
334
  for (const t of threads) {
293
335
  if (!t.isResolved) continue;
294
- for (const body of t.bodies) {
295
- ACK_MARKER.lastIndex = 0;
296
- let m: RegExpExecArray | null;
297
- // biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
298
- while ((m = ACK_MARKER.exec(body)) !== null) {
299
- const nw = NEW_ACK.exec(m[1].trim());
300
- if (nw) acked.add(advisoryStableKey(nw[1], nw[2]));
301
- }
302
- }
336
+ for (const body of t.bodies) for (const k of canonicalAckKeys(body)) acked.add(k);
303
337
  }
304
338
  return [...acked];
305
339
  }