@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.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -0
- package/SPEC.md +90 -18
- package/app/contracts.ts +8 -0
- package/app/convergeAutoAck.test.ts +252 -0
- package/app/convergeGate.test.ts +393 -5
- package/app/convergeGate.ts +41 -5
- package/app/currentHead.ts +60 -0
- package/app/github.test.ts +65 -1
- package/app/github.ts +138 -32
- package/app/pollReviewsStale.test.ts +187 -0
- package/app/reviewWait.test.ts +33 -0
- package/app/reviewWait.ts +21 -0
- package/app/service.test.ts +40 -1
- package/app/service.ts +56 -2
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +176 -121
- package/resources/prompts/review-round.md +10 -0
- package/test/derivation-parity/README.md +5 -2
- package/test/derivation-parity/derivation-parity.test.ts +10 -8
- package/test/derivation-parity/flows.ts +6 -4
- package/workers/converge-gate/worker.ts +84 -25
- package/workers/progress-check/worker.ts +6 -44
package/app/convergeGate.test.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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 <= 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");
|
package/app/convergeGate.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
}
|