@nanobpm/nano-workforce 0.187.1 → 0.187.3
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/app/convergeGate.test.ts +245 -17
- package/app/convergeGate.ts +15 -8
- package/app/github.ts +149 -19
- package/app/prEscalationForm.test.ts +84 -0
- package/package.json +1 -1
- package/resources/forms/pr-escalation.form +4 -6
- package/resources/prompts/review-round.md +30 -6
- package/workers/converge-gate/worker.ts +7 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.187.3](https://github.com/nanobpm/nano-workforce/compare/v0.187.2...v0.187.3) (2026-09-14)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* pr-escalation Tasks form no longer renders a blank question field ([#785](https://github.com/nanobpm/nano-workforce/issues/785)) ([eb6bc0e](https://github.com/nanobpm/nano-workforce/commit/eb6bc0e9a677effed0e4d93a3c37033f7819d943)), closes [#767](https://github.com/nanobpm/nano-workforce/issues/767)
|
|
6
|
+
|
|
7
|
+
## [0.187.2](https://github.com/nanobpm/nano-workforce/compare/v0.187.1...v0.187.2) (2026-09-13)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **convergence:** key suppressed-advisory acks on line-stable identity ([#788](https://github.com/nanobpm/nano-workforce/issues/788)) ([9b9d124](https://github.com/nanobpm/nano-workforce/commit/9b9d1241dbd725cfe9b8ac88f5a9f5b949dc4fc4)), closes [#787](https://github.com/nanobpm/nano-workforce/issues/787) [#787](https://github.com/nanobpm/nano-workforce/issues/787) [pre-#787](https://github.com/nanobpm/pre-/issues/787) [#787](https://github.com/nanobpm/nano-workforce/issues/787)
|
|
12
|
+
|
|
1
13
|
## [0.187.1](https://github.com/nanobpm/nano-workforce/compare/v0.187.0...v0.187.1) (2026-09-12)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/app/convergeGate.test.ts
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
// thread, escalating to the human `wait-answer` task instead of finalizing.
|
|
12
12
|
import { readFileSync } from "node:fs";
|
|
13
13
|
import { test } from "node:test";
|
|
14
|
-
import { assert, assertEquals, assertStringIncludes } from "#test-assert";
|
|
14
|
+
import { assert, assertEquals, assertNotEquals, assertStringIncludes } from "#test-assert";
|
|
15
15
|
import { evaluateConvergeGate } from "./convergeGate.ts";
|
|
16
16
|
import {
|
|
17
|
+
advisoryStableKey,
|
|
17
18
|
parseAckedAdvisories,
|
|
18
19
|
parseReviewThreadsPage,
|
|
19
20
|
parseSuppressedAdvisories,
|
|
@@ -24,13 +25,13 @@ import {
|
|
|
24
25
|
// ── The canonical router ────────────────────────────────────────────────────
|
|
25
26
|
|
|
26
27
|
test("evaluateConvergeGate: a clean PR (no unresolved threads, no advisories) converges", () => {
|
|
27
|
-
const r = evaluateConvergeGate({ unresolvedThreadCount: 0,
|
|
28
|
+
const r = evaluateConvergeGate({ unresolvedThreadCount: 0, suppressedAdvisories: [], acknowledgedKeys: [] });
|
|
28
29
|
assertEquals(r.convergeBlocked, false);
|
|
29
30
|
assertEquals(r.convergeBlockReason, "");
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
test("evaluateConvergeGate: an unresolved review thread blocks convergence", () => {
|
|
33
|
-
const r = evaluateConvergeGate({ unresolvedThreadCount: 2,
|
|
34
|
+
const r = evaluateConvergeGate({ unresolvedThreadCount: 2, suppressedAdvisories: [], acknowledgedKeys: [] });
|
|
34
35
|
assertEquals(r.convergeBlocked, true);
|
|
35
36
|
assertStringIncludes(r.convergeBlockReason, "2 unresolved review threads");
|
|
36
37
|
});
|
|
@@ -38,7 +39,7 @@ test("evaluateConvergeGate: an unresolved review thread blocks convergence", ()
|
|
|
38
39
|
test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks convergence", () => {
|
|
39
40
|
const r = evaluateConvergeGate({
|
|
40
41
|
unresolvedThreadCount: 0,
|
|
41
|
-
|
|
42
|
+
suppressedAdvisories: [{ key: "spec/a.json#deadbeef", label: "spec/a.json:613" }],
|
|
42
43
|
acknowledgedKeys: [],
|
|
43
44
|
});
|
|
44
45
|
assertEquals(r.convergeBlocked, true);
|
|
@@ -50,16 +51,34 @@ test("evaluateConvergeGate: an unacknowledged suppressed advisory blocks converg
|
|
|
50
51
|
test("evaluateConvergeGate: an ACKNOWLEDGED suppressed advisory no longer blocks convergence", () => {
|
|
51
52
|
const r = evaluateConvergeGate({
|
|
52
53
|
unresolvedThreadCount: 0,
|
|
53
|
-
|
|
54
|
-
acknowledgedKeys: ["spec/a.json
|
|
54
|
+
suppressedAdvisories: [{ key: "spec/a.json#deadbeef", label: "spec/a.json:613" }],
|
|
55
|
+
acknowledgedKeys: ["spec/a.json#deadbeef"],
|
|
55
56
|
});
|
|
56
57
|
assertEquals(r.convergeBlocked, false);
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
test("evaluateConvergeGate: a prose-blind legacy `path:line` ack does NOT acknowledge an advisory (no false-OPEN)", () => {
|
|
61
|
+
// A resolved `nano-ack: spec/a.json:613` for a PRIOR advisory would yield only the `path:line`
|
|
62
|
+
// string — never a stable `<path>#<fp>` key. A genuinely new advisory at that same line carries a
|
|
63
|
+
// different stable key, so a bare-line ack can never satisfy it: the gate stays blocked. This is
|
|
64
|
+
// the guard for issue #787's re-review finding — honouring `path:line` let a resolved ack for
|
|
65
|
+
// advisory A silently converge a NEW advisory B re-emitted at the same line.
|
|
66
|
+
const r = evaluateConvergeGate({
|
|
67
|
+
unresolvedThreadCount: 0,
|
|
68
|
+
suppressedAdvisories: [{ key: "spec/a.json#deadbeef", label: "spec/a.json:613" }],
|
|
69
|
+
acknowledgedKeys: ["spec/a.json:613"],
|
|
70
|
+
});
|
|
71
|
+
assertEquals(r.convergeBlocked, true);
|
|
72
|
+
assertStringIncludes(r.convergeBlockReason, "spec/a.json:613");
|
|
73
|
+
});
|
|
74
|
+
|
|
59
75
|
test("evaluateConvergeGate: multiple unacknowledged advisories use the plural noun", () => {
|
|
60
76
|
const r = evaluateConvergeGate({
|
|
61
77
|
unresolvedThreadCount: 0,
|
|
62
|
-
|
|
78
|
+
suppressedAdvisories: [
|
|
79
|
+
{ key: "x.ts#a", label: "x.ts:10" },
|
|
80
|
+
{ key: "y.ts#b", label: "y.ts:20" },
|
|
81
|
+
],
|
|
63
82
|
acknowledgedKeys: [],
|
|
64
83
|
});
|
|
65
84
|
assertEquals(r.convergeBlocked, true);
|
|
@@ -69,8 +88,11 @@ test("evaluateConvergeGate: multiple unacknowledged advisories use the plural no
|
|
|
69
88
|
test("evaluateConvergeGate: reports both a thread and an advisory when both are outstanding", () => {
|
|
70
89
|
const r = evaluateConvergeGate({
|
|
71
90
|
unresolvedThreadCount: 1,
|
|
72
|
-
|
|
73
|
-
|
|
91
|
+
suppressedAdvisories: [
|
|
92
|
+
{ key: "x.ts#a", label: "x.ts:10" },
|
|
93
|
+
{ key: "y.ts#b", label: "y.ts:20" },
|
|
94
|
+
],
|
|
95
|
+
acknowledgedKeys: ["x.ts#a"],
|
|
74
96
|
});
|
|
75
97
|
assertEquals(r.convergeBlocked, true);
|
|
76
98
|
assertStringIncludes(r.convergeBlockReason, "1 unresolved review thread");
|
|
@@ -95,9 +117,19 @@ const SAMPLE_REVIEW_BODY = [
|
|
|
95
117
|
"</details>",
|
|
96
118
|
].join("\n");
|
|
97
119
|
|
|
98
|
-
test("parseSuppressedAdvisories: extracts
|
|
99
|
-
const
|
|
100
|
-
assertEquals(
|
|
120
|
+
test("parseSuppressedAdvisories: extracts advisories inside the Suppressed comments block", () => {
|
|
121
|
+
const advisories = parseSuppressedAdvisories(SAMPLE_REVIEW_BODY);
|
|
122
|
+
assertEquals(
|
|
123
|
+
advisories.map((a) => a.label),
|
|
124
|
+
["spec-app/nano-app.schema.json:613", "server/src/main.rs:42"],
|
|
125
|
+
);
|
|
126
|
+
// The line-stable key is `<path>#<fingerprint>` of the prose; `label` remains the human `path:line`.
|
|
127
|
+
assertEquals(advisories[0].label, "spec-app/nano-app.schema.json:613");
|
|
128
|
+
assertEquals(
|
|
129
|
+
advisories[0].key,
|
|
130
|
+
advisoryStableKey("spec-app/nano-app.schema.json", "The description could be clearer about the loopback default."),
|
|
131
|
+
);
|
|
132
|
+
assert(advisories[0].key.startsWith("spec-app/nano-app.schema.json#"), "stable key is path#fingerprint");
|
|
101
133
|
});
|
|
102
134
|
|
|
103
135
|
test("parseSuppressedAdvisories: returns [] when there is no suppressed block", () => {
|
|
@@ -108,12 +140,198 @@ test("parseSuppressedAdvisories: returns [] when there is no suppressed block",
|
|
|
108
140
|
|
|
109
141
|
test("parseAckedAdvisories: only RESOLVED threads carrying a nano-ack marker count", () => {
|
|
110
142
|
const threads: ReviewThread[] = [
|
|
111
|
-
{
|
|
112
|
-
|
|
143
|
+
{
|
|
144
|
+
isResolved: true,
|
|
145
|
+
path: "a.ts",
|
|
146
|
+
bodies: ["Fixed. nano-ack: spec-app/nano-app.schema.json :: Clarify the loopback default."],
|
|
147
|
+
},
|
|
148
|
+
{ isResolved: false, path: "b.ts", bodies: ["nano-ack: server/src/main.rs :: Narrow this type."] }, // open -> ignored
|
|
113
149
|
{ isResolved: true, path: "c.ts", bodies: ["unrelated resolved comment"] },
|
|
114
150
|
];
|
|
115
151
|
const acked = parseAckedAdvisories(threads);
|
|
116
|
-
assertEquals(acked, ["spec-app/nano-app.schema.json
|
|
152
|
+
assertEquals(acked, [advisoryStableKey("spec-app/nano-app.schema.json", "Clarify the loopback default.")]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// A resolved bare `<path>:<line>` ack (the retired legacy form) must NOT count: it carries no
|
|
156
|
+
// advisory prose, so it cannot identify WHICH advisory it acknowledged. Honouring it would false-OPEN
|
|
157
|
+
// a genuinely new advisory re-emitted at that same line (issue #787's re-review finding).
|
|
158
|
+
test("parseAckedAdvisories: a bare legacy `<path>:<line>` marker is NOT honoured (no prose-blind ack)", () => {
|
|
159
|
+
const threads: ReviewThread[] = [
|
|
160
|
+
{ isResolved: true, path: "a.ts", bodies: ["Fixed. nano-ack: spec-app/nano-app.schema.json:613"] },
|
|
161
|
+
];
|
|
162
|
+
assertEquals(parseAckedAdvisories(threads), []);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("parseAckedAdvisories: the new `<path> :: <text>` form yields the line-stable key", () => {
|
|
166
|
+
const threads: ReviewThread[] = [
|
|
167
|
+
{ isResolved: true, path: "a.ts", bodies: ["Declined. nano-ack: server/src/main.rs :: Consider narrowing this type."] },
|
|
168
|
+
];
|
|
169
|
+
const acked = parseAckedAdvisories(threads);
|
|
170
|
+
assertEquals(acked, [advisoryStableKey("server/src/main.rs", "Consider narrowing this type.")]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// A valid GitHub path can contain spaces; the ack form must not reject it (regression for the
|
|
174
|
+
// critical review finding — `\S+`/`[^\s]` path groups silently dropped a spaced-path ack, so the
|
|
175
|
+
// gate could never observe the acknowledgement and escalated forever).
|
|
176
|
+
test("parseAckedAdvisories: a path containing spaces is honoured in the new `<path> :: <text>` form", () => {
|
|
177
|
+
const newForm: ReviewThread[] = [
|
|
178
|
+
{ isResolved: true, path: "d.md", bodies: ["Applied. nano-ack: docs/my file.md :: Clarify the loopback default."] },
|
|
179
|
+
];
|
|
180
|
+
assertEquals(parseAckedAdvisories(newForm), [advisoryStableKey("docs/my file.md", "Clarify the loopback default.")]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// A valid GitHub path can itself contain `::` (e.g. `src/a::b.ts`); the ` :: ` separator must be
|
|
184
|
+
// whitespace-delimited so a bare `::` inside the path is not mistaken for the delimiter (regression
|
|
185
|
+
// for the suppressed finding: `\s*::\s*` split `src/a::b.ts :: text` at the wrong `::`, mangling the
|
|
186
|
+
// path and producing a key that could never match the advisory).
|
|
187
|
+
// The `<path> :: <text>` separator is whitespace-delimited, so a path containing a bare `::` is not
|
|
188
|
+
// split at the interior `::` (a legacy `<path>:<line>` interior-colon test was retired with the
|
|
189
|
+
// prose-blind legacy form).
|
|
190
|
+
test("parseAckedAdvisories: a path containing `::` is not split at the interior `::`", () => {
|
|
191
|
+
const threads: ReviewThread[] = [
|
|
192
|
+
{ isResolved: true, path: "a.ts", bodies: ["Applied. nano-ack: src/a::b.ts :: Narrow the return type here."] },
|
|
193
|
+
];
|
|
194
|
+
assertEquals(parseAckedAdvisories(threads), [advisoryStableKey("src/a::b.ts", "Narrow the return type here.")]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// Normalization must preserve word boundaries, punctuation, and Unicode so distinct advisories on
|
|
198
|
+
// one path do not alias to the same key (regression for the suppressed findings: deleting every
|
|
199
|
+
// separator made `foo-bar`/`foobar` collide and non-ASCII-only prose normalized to an empty key;
|
|
200
|
+
// collapsing punctuation-into-space aliased `Use foo() here` with `Use foo here` — a false-OPEN).
|
|
201
|
+
test("advisoryStableKey: word boundaries, punctuation and Unicode are preserved (distinct prose -> distinct keys)", () => {
|
|
202
|
+
const p = "app/x.ts";
|
|
203
|
+
assertNotEquals(advisoryStableKey(p, "foo-bar"), advisoryStableKey(p, "foobar"));
|
|
204
|
+
// Two different non-ASCII-only advisories must not both collapse to the empty-string key.
|
|
205
|
+
assertNotEquals(advisoryStableKey(p, "café"), advisoryStableKey(p, "naïve"));
|
|
206
|
+
// Punctuation must NOT collapse into whitespace: prose differing only by punctuation-vs-space
|
|
207
|
+
// stays distinct, so a resolved ack for one cannot silently acknowledge the other (false-OPEN).
|
|
208
|
+
assertNotEquals(advisoryStableKey(p, "Use foo() here"), advisoryStableKey(p, "Use foo here"));
|
|
209
|
+
assertNotEquals(advisoryStableKey(p, "foo/bar"), advisoryStableKey(p, "foo bar"));
|
|
210
|
+
// Only case and whitespace runs are normalized (verbatim copy modulo reflow -> same key).
|
|
211
|
+
assertEquals(advisoryStableKey(p, "Foo bar, baz."), advisoryStableKey(p, "foo bar, baz."));
|
|
212
|
+
// NFC (not NFKC): compatibility variants must stay DISTINCT, or acking one false-OPENs the other.
|
|
213
|
+
// Full-width `!` vs ASCII `!` (NFKC would fold them together); ligature `fi` vs `fi`.
|
|
214
|
+
assertNotEquals(advisoryStableKey(p, "Use foo\uFF01"), advisoryStableKey(p, "Use foo!"));
|
|
215
|
+
assertNotEquals(advisoryStableKey(p, "The \uFB01le"), advisoryStableKey(p, "The file"));
|
|
216
|
+
// A precomposed vs decomposed accent IS canonically equivalent (NFC unifies) -> same key.
|
|
217
|
+
assertEquals(advisoryStableKey(p, "caf\u00E9"), advisoryStableKey(p, "cafe\u0301"));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// The advisory side strips a leading markdown bullet (Copilot renders suppressed prose as `* …`),
|
|
221
|
+
// and the prompt tells the agent to copy that first line VERBATIM — so an ack marker legitimately
|
|
222
|
+
// carries the `* ` bullet. Keying must therefore be bullet-insensitive on BOTH sides, else the ack
|
|
223
|
+
// key never matches the advisory key and the gate livelocks (fail-CLOSED). Regression for the
|
|
224
|
+
// suppressed finding that `parseSuppressedAdvisories` stripped the bullet but the ack path did not.
|
|
225
|
+
test("advisoryStableKey: a leading markdown bullet is stripped so bulleted ack text matches", () => {
|
|
226
|
+
const p = "app/x.ts";
|
|
227
|
+
assertEquals(advisoryStableKey(p, "* Consider narrowing this type."), advisoryStableKey(p, "Consider narrowing this type."));
|
|
228
|
+
assertEquals(advisoryStableKey(p, "- Consider narrowing this type."), advisoryStableKey(p, "Consider narrowing this type."));
|
|
229
|
+
// But a leading `-`/`*` with NO trailing whitespace is NOT a bullet: it is preserved, so distinct
|
|
230
|
+
// first-line prose keeps a distinct key (else `-foo` false-acks `foo`). Regression for the finding
|
|
231
|
+
// that the greedy `[-*]\s*` stripped a non-bullet leading punctuation char.
|
|
232
|
+
assertNotEquals(advisoryStableKey(p, "-foo"), advisoryStableKey(p, "foo"));
|
|
233
|
+
assertNotEquals(advisoryStableKey(p, "*foo"), advisoryStableKey(p, "foo"));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// End-to-end: an ack whose marker copies Copilot's bulleted first line verbatim acknowledges the
|
|
237
|
+
// advisory parsed from that same rendered bullet (the ack key == the parsed advisory key).
|
|
238
|
+
test("parseAckedAdvisories: an ack copying the rendered `* ` bullet verbatim matches the advisory key", () => {
|
|
239
|
+
const advisories = parseSuppressedAdvisories(SAMPLE_REVIEW_BODY);
|
|
240
|
+
const bulleted = advisories.map((a) => a.label); // ["…schema.json:613", "…main.rs:42"]
|
|
241
|
+
assert(bulleted.length === 2);
|
|
242
|
+
const threads: ReviewThread[] = [
|
|
243
|
+
{
|
|
244
|
+
isResolved: true,
|
|
245
|
+
path: "a.ts",
|
|
246
|
+
// Verbatim copy of Copilot's rendered first line, bullet included.
|
|
247
|
+
bodies: ["Declined. nano-ack: server/src/main.rs :: - Consider narrowing this type."],
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
const acked = parseAckedAdvisories(threads);
|
|
251
|
+
const mainRs = advisories.find((a) => a.path === "server/src/main.rs");
|
|
252
|
+
assert(mainRs !== undefined);
|
|
253
|
+
assert(acked.includes(mainRs.key), "bulleted verbatim ack resolves to the advisory's stable key");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// A collision in the advisory fingerprint would let a NEWER, unacknowledged advisory on the same
|
|
257
|
+
// path pass the gate on a resolved ack for a DIFFERENT advisory — a false-OPEN. The former 32-bit
|
|
258
|
+
// FNV-1a digest was cheaply collidable; the key now carries a 128-bit (32-hex) SHA-256 slice.
|
|
259
|
+
test("advisoryStableKey: digest is a collision-resistant 128-bit (32-hex) SHA-256 slice", () => {
|
|
260
|
+
const p = "app/x.ts";
|
|
261
|
+
const hash = advisoryStableKey(p, "Some advisory prose.").split("#")[1];
|
|
262
|
+
assertEquals(hash.length, 32, "digest is 32 hex chars (128 bits)");
|
|
263
|
+
assert(/^[0-9a-f]{32}$/.test(hash), "digest is lowercase hex");
|
|
264
|
+
// Distinct prose on the same path yields distinct keys (no cheap collision surface).
|
|
265
|
+
assertNotEquals(
|
|
266
|
+
advisoryStableKey(p, "Narrow this return type."),
|
|
267
|
+
advisoryStableKey(p, "Guard against a null argument here."),
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// ── Issue #787: a DECLINED advisory must not livelock the gate when its line drifts ──────────
|
|
272
|
+
//
|
|
273
|
+
// A declined advisory is re-emitted by Copilot every round; any unrelated edit shifts its line, so
|
|
274
|
+
// Copilot re-anchors it to a new line. Keying the ack on the line-stable prose fingerprint (not
|
|
275
|
+
// path:line) keeps a prior-round ack matching the re-emitted advisory across the drift.
|
|
276
|
+
test("converge gate #787: a stable-key ack survives a line drift and keeps the advisory acknowledged", () => {
|
|
277
|
+
const proseText = "Consider narrowing this type.";
|
|
278
|
+
// Round 1: Copilot suppressed the advisory at line 360; the agent acked it with the new form.
|
|
279
|
+
const round1Body = [
|
|
280
|
+
"<details>",
|
|
281
|
+
"<summary>Suppressed comments (1)</summary>",
|
|
282
|
+
"",
|
|
283
|
+
"**app/deliveryRunner.ts:360**",
|
|
284
|
+
`- ${proseText}`,
|
|
285
|
+
"</details>",
|
|
286
|
+
].join("\n");
|
|
287
|
+
// Round 2: an unrelated edit shifted the SAME advisory to line 369; Copilot re-emitted it there.
|
|
288
|
+
const round2Body = round1Body.replace("app/deliveryRunner.ts:360", "app/deliveryRunner.ts:369");
|
|
289
|
+
// The resolved ack thread from round 1 persists (its marker text is line-independent).
|
|
290
|
+
const ackThreads: ReviewThread[] = [
|
|
291
|
+
{ isResolved: true, path: "app/deliveryRunner.ts", bodies: [`Declined, false positive. nano-ack: app/deliveryRunner.ts :: ${proseText}`] },
|
|
292
|
+
];
|
|
293
|
+
const acknowledgedKeys = parseAckedAdvisories(ackThreads);
|
|
294
|
+
|
|
295
|
+
const round1 = evaluateConvergeGate({
|
|
296
|
+
unresolvedThreadCount: 0,
|
|
297
|
+
suppressedAdvisories: parseSuppressedAdvisories(round1Body),
|
|
298
|
+
acknowledgedKeys,
|
|
299
|
+
});
|
|
300
|
+
assertEquals(round1.convergeBlocked, false);
|
|
301
|
+
|
|
302
|
+
// The drift MUST NOT re-block: the round-1 ack still acknowledges the round-2 re-emission.
|
|
303
|
+
const round2 = evaluateConvergeGate({
|
|
304
|
+
unresolvedThreadCount: 0,
|
|
305
|
+
suppressedAdvisories: parseSuppressedAdvisories(round2Body),
|
|
306
|
+
acknowledgedKeys,
|
|
307
|
+
});
|
|
308
|
+
assertEquals(round2.convergeBlocked, false);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("converge gate #787: a genuinely new, never-acked advisory still blocks (no false-open)", () => {
|
|
312
|
+
const body = [
|
|
313
|
+
"<details>",
|
|
314
|
+
"<summary>Suppressed comments (2)</summary>",
|
|
315
|
+
"",
|
|
316
|
+
"**app/x.ts:10**",
|
|
317
|
+
"- The declined advisory that was acknowledged.",
|
|
318
|
+
"",
|
|
319
|
+
"**app/x.ts:20**", // SAME path, DIFFERENT advisory — never acknowledged.
|
|
320
|
+
"- A brand-new concern that was never triaged.",
|
|
321
|
+
"</details>",
|
|
322
|
+
].join("\n");
|
|
323
|
+
const ackThreads: ReviewThread[] = [
|
|
324
|
+
{ isResolved: true, path: "app/x.ts", bodies: ["Declined. nano-ack: app/x.ts :: The declined advisory that was acknowledged."] },
|
|
325
|
+
];
|
|
326
|
+
const r = evaluateConvergeGate({
|
|
327
|
+
unresolvedThreadCount: 0,
|
|
328
|
+
suppressedAdvisories: parseSuppressedAdvisories(body),
|
|
329
|
+
acknowledgedKeys: parseAckedAdvisories(ackThreads),
|
|
330
|
+
});
|
|
331
|
+
assertEquals(r.convergeBlocked, true);
|
|
332
|
+
// Only the un-acked advisory on the same path is reported; the acked one is not.
|
|
333
|
+
assertStringIncludes(r.convergeBlockReason, "app/x.ts:20");
|
|
334
|
+
assert(!r.convergeBlockReason.includes("app/x.ts:10"), "the acknowledged advisory must not be listed");
|
|
117
335
|
});
|
|
118
336
|
|
|
119
337
|
test("parseReviewThreadsPage: maps nodes and reports a complete (final) page", () => {
|
|
@@ -254,8 +472,18 @@ test("converge-gate: an unacknowledged suppressed advisory blocks convergence",
|
|
|
254
472
|
test("converge-gate: an acknowledged advisory (resolved ack thread) is allowed", async () => {
|
|
255
473
|
const handler = await makeUnderTest({
|
|
256
474
|
readThreads: async () => [
|
|
257
|
-
{
|
|
258
|
-
|
|
475
|
+
{
|
|
476
|
+
isResolved: true,
|
|
477
|
+
path: "spec-app/nano-app.schema.json",
|
|
478
|
+
bodies: [
|
|
479
|
+
"Applied. nano-ack: spec-app/nano-app.schema.json :: The description could be clearer about the loopback default.",
|
|
480
|
+
],
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
isResolved: true,
|
|
484
|
+
path: "server/src/main.rs",
|
|
485
|
+
bodies: ["Declined, false positive. nano-ack: server/src/main.rs :: Consider narrowing this type."],
|
|
486
|
+
},
|
|
259
487
|
],
|
|
260
488
|
readReviewBody: async () => SAMPLE_REVIEW_BODY,
|
|
261
489
|
});
|
package/app/convergeGate.ts
CHANGED
|
@@ -6,16 +6,19 @@
|
|
|
6
6
|
// the comment unaddressed). This deterministic gate runs on the converged path and blocks handoff
|
|
7
7
|
// while either:
|
|
8
8
|
// • any review THREAD is still unresolved, or
|
|
9
|
-
// • any SUPPRESSED advisory (
|
|
10
|
-
// RESOLVED ack thread (a thread carrying a `nano-ack: <path
|
|
9
|
+
// • any SUPPRESSED advisory (in the latest Copilot review body) lacks a matching
|
|
10
|
+
// RESOLVED ack thread (a thread carrying a line-stable `nano-ack: <path> :: <text>` marker; the
|
|
11
|
+
// bare `nano-ack: <path>:<line>` form is NOT honoured — its `path:line` key is prose-blind and
|
|
12
|
+
// would false-OPEN a new advisory re-emitted at a previously-acked line).
|
|
11
13
|
// A blocked gate escalates to the human wait-answer task (recoverable), never a hard wedge.
|
|
12
14
|
|
|
13
15
|
export interface ConvergeGateInput {
|
|
14
16
|
/** Count of review threads with `isResolved === false`. */
|
|
15
17
|
unresolvedThreadCount: number;
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
/**
|
|
18
|
+
/** Copilot's suppressed advisories (latest review body), each with its line-stable key + label. */
|
|
19
|
+
suppressedAdvisories: { key: string; label: string }[];
|
|
20
|
+
/** Acknowledged line-stable keys (`<path>#<fp>`) from RESOLVED `nano-ack:` threads. An advisory is
|
|
21
|
+
* acked iff its stable key appears here. */
|
|
19
22
|
acknowledgedKeys: string[];
|
|
20
23
|
}
|
|
21
24
|
|
|
@@ -28,7 +31,11 @@ export interface ConvergeGateResult {
|
|
|
28
31
|
* feeds it live GitHub state and fails CLOSED (blocks) when that state cannot be read. */
|
|
29
32
|
export function evaluateConvergeGate(input: ConvergeGateInput): ConvergeGateResult {
|
|
30
33
|
const acked = new Set(input.acknowledgedKeys);
|
|
31
|
-
|
|
34
|
+
// An advisory is acknowledged iff its line-stable prose key was acked — the stable key survives a
|
|
35
|
+
// line drift across rounds (issue #787). The prose-blind legacy `path:line` key is intentionally
|
|
36
|
+
// NOT consulted: it would let a resolved ack for one advisory silently acknowledge a genuinely new
|
|
37
|
+
// advisory re-emitted at the same line (a false-OPEN this gate exists to prevent).
|
|
38
|
+
const unacked = input.suppressedAdvisories.filter((a) => !acked.has(a.key));
|
|
32
39
|
const reasons: string[] = [];
|
|
33
40
|
if (input.unresolvedThreadCount > 0) {
|
|
34
41
|
const n = input.unresolvedThreadCount;
|
|
@@ -36,13 +43,13 @@ export function evaluateConvergeGate(input: ConvergeGateInput): ConvergeGateResu
|
|
|
36
43
|
}
|
|
37
44
|
if (unacked.length > 0) {
|
|
38
45
|
const noun = unacked.length === 1 ? "advisory" : "advisories";
|
|
39
|
-
reasons.push(`${unacked.length} unacknowledged suppressed ${noun} (${unacked.join(", ")})`);
|
|
46
|
+
reasons.push(`${unacked.length} unacknowledged suppressed ${noun} (${unacked.map((a) => a.label).join(", ")})`);
|
|
40
47
|
}
|
|
41
48
|
if (reasons.length === 0) {
|
|
42
49
|
return { convergeBlocked: false, convergeBlockReason: "" };
|
|
43
50
|
}
|
|
44
51
|
return {
|
|
45
52
|
convergeBlocked: true,
|
|
46
|
-
convergeBlockReason: `Convergence blocked: ${reasons.join("; ")}. Resolve every review thread and reply-and-resolve an ack thread (nano-ack: <path
|
|
53
|
+
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.`,
|
|
47
54
|
};
|
|
48
55
|
}
|
package/app/github.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
// The poller is app-side host glue (main.ts), so host-specific subprocess I/O is allowed here.
|
|
12
12
|
// Cross-runtime: runs under Node (`node:child_process`).
|
|
13
13
|
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
|
|
14
16
|
// Type-only import (erased at runtime, so no runtime cycle with mergeProtocol.ts, which imports
|
|
15
17
|
// `fetchRepoFile` from here): `classifyMergeability` reads a repo's declared required checks to gate
|
|
16
18
|
// a merge independently of GitHub branch protection.
|
|
@@ -90,9 +92,23 @@ export async function fetchPrReviews(
|
|
|
90
92
|
// • SUPPRESSED / low-confidence advisories — Copilot folds these into the review BODY under a
|
|
91
93
|
// "Suppressed comments (N)" block; they are NOT threads, cannot be resolved, and are re-listed
|
|
92
94
|
// every round. To make "acknowledged" trackable, the review-round agent must post a RESOLVED
|
|
93
|
-
// review thread carrying a `nano-ack
|
|
94
|
-
//
|
|
95
|
-
//
|
|
95
|
+
// review thread carrying a `nano-ack:` marker for each advisory it applies or declines. The gate
|
|
96
|
+
// then treats an advisory as addressed iff a resolved thread carries a matching ack marker.
|
|
97
|
+
//
|
|
98
|
+
// The ack key must be LINE-STABLE. Keying it on `path:line` (issue #787) livelocks a DECLINED
|
|
99
|
+
// advisory: Copilot re-emits a declined advisory every round, but any unrelated edit in the PR
|
|
100
|
+
// shifts its line, so Copilot re-anchors it to a new line. A prior-round `nano-ack: path:OLD` no
|
|
101
|
+
// longer matches the re-emitted `path:NEW`, the gate sees a freshly "unacknowledged" advisory, and
|
|
102
|
+
// escalates to a human every round. The fix keys acknowledgement on a line-independent identity —
|
|
103
|
+
// `<path>#<fingerprint>` of the advisory's PROSE — so a drifted line still matches. The resolved
|
|
104
|
+
// ack thread is itself the durable store: its marker text survives across rounds regardless of the
|
|
105
|
+
// line, so a decline stays acknowledged without the agent re-acking each round. The new marker is
|
|
106
|
+
// `nano-ack: <path> :: <verbatim advisory text>`. This line-stable prose key is the SOLE ack
|
|
107
|
+
// identity. A bare `nano-ack: <path>:<line>` form is NOT honoured: keyed only on `path:line`, it is
|
|
108
|
+
// blind to the advisory's prose, so a resolved legacy ack for advisory A at a line would silently
|
|
109
|
+
// acknowledge a genuinely NEW advisory B re-emitted at that same line — a false-OPEN this gate exists
|
|
110
|
+
// to prevent. (Issue #787 introduces the ack mechanism itself in this change, so there is no pre-#787
|
|
111
|
+
// legacy-ack corpus to protect by honouring the prose-blind form.)
|
|
96
112
|
|
|
97
113
|
/** One PR review thread, narrowed to what the convergence gate needs. */
|
|
98
114
|
export interface ReviewThread {
|
|
@@ -101,30 +117,141 @@ export interface ReviewThread {
|
|
|
101
117
|
bodies: string[];
|
|
102
118
|
}
|
|
103
119
|
|
|
104
|
-
/**
|
|
105
|
-
*
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
120
|
+
/** A suppressed / low-confidence Copilot advisory parsed out of a review body. Its `key` is the
|
|
121
|
+
* line-stable identity (survives a line drift); `label` is the human-facing `path:line` shown in
|
|
122
|
+
* block reasons. */
|
|
123
|
+
export interface SuppressedAdvisory {
|
|
124
|
+
path: string;
|
|
125
|
+
line: number;
|
|
126
|
+
/** The advisory prose (first non-empty line after the header), used for the stable fingerprint. */
|
|
127
|
+
text: string;
|
|
128
|
+
/** Line-stable identity: `<path>#<fingerprint>` of the normalized prose. Survives line drift. */
|
|
129
|
+
key: string;
|
|
130
|
+
/** Human-facing `path:line` label for block-reason messages. */
|
|
131
|
+
label: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Any `nano-ack:` marker — captures the rest of the marker's line (path + optional `:: text`). */
|
|
135
|
+
const ACK_MARKER = /nano-ack:\s*([^\n\r]+)/gi;
|
|
136
|
+
/** The ONLY honoured ack form: line-stable `<path> :: <advisory text>`. The delimiter is ` :: ` with
|
|
137
|
+
* REQUIRED surrounding whitespace (matching the canonical marker the agent authors), so a bare `::`
|
|
138
|
+
* inside a valid GitHub path (e.g. `src/a::b.ts`) is NOT mistaken for the separator — the path group
|
|
139
|
+
* parses non-greedily up to the first *whitespace-delimited* ` :: `, so a path containing spaces
|
|
140
|
+
* (e.g. `docs/my file.md`) is still honoured. A bare `<path>:<line>` marker is intentionally not
|
|
141
|
+
* parsed: keyed only on `path:line`, it is blind to the advisory prose and would false-OPEN a new
|
|
142
|
+
* advisory re-emitted at a previously-acked line. */
|
|
143
|
+
const NEW_ACK = /^(.+?)\s+::\s+(.+)$/s;
|
|
144
|
+
|
|
145
|
+
/** Normalize advisory prose to a line-/format-independent form before fingerprinting: strip a
|
|
146
|
+
* leading markdown bullet, NFC-normalize, lowercase, and collapse runs of WHITESPACE to a single
|
|
147
|
+
* space. Punctuation is PRESERVED, NOT collapsed: the prompt requires the agent to copy the
|
|
148
|
+
* advisory's first line VERBATIM, so whitespace/case tolerance is all that is needed to absorb
|
|
149
|
+
* trivial markdown/whitespace reflow. Collapsing every non-word run into a space (as an earlier
|
|
150
|
+
* revision did) instead ALIASES genuinely-distinct advisories whose prose differs only by
|
|
151
|
+
* punctuation-vs-space — e.g. `Use foo() here` vs `Use foo here`, or `foo/bar` vs `foo bar` — so a
|
|
152
|
+
* resolved ack for advisory A would silently acknowledge a DIFFERENT advisory B that normalizes to
|
|
153
|
+
* the same key: a false-OPEN this gate exists to prevent. Preserving punctuation errs toward a
|
|
154
|
+
* stricter match, which is fail-CLOSED: a benign punctuation mismatch merely re-escalates to a
|
|
155
|
+
* human, and never converges an unacknowledged advisory.
|
|
156
|
+
*
|
|
157
|
+
* NFC — canonical composition — is used deliberately in preference to NFKC. NFKC additionally folds
|
|
158
|
+
* COMPATIBILITY variants (full-width `!` → ASCII `!`, ligatures, super/subscripts, …), which would
|
|
159
|
+
* ALIAS genuinely-distinct advisories such as `Use foo!` and `Use foo!` to one key — the very
|
|
160
|
+
* false-OPEN this fingerprint exists to prevent, and a contradiction with "punctuation is
|
|
161
|
+
* preserved". NFC only unifies sequences that are canonically equivalent (visually and semantically
|
|
162
|
+
* identical, e.g. a precomposed `é` vs `e`+combining-acute), so verbatim copies still match while
|
|
163
|
+
* distinct compatibility forms stay distinct (fail-CLOSED). Unicode letters/digits are preserved
|
|
164
|
+
* rather than stripped, so non-ASCII-only prose still yields a non-empty, distinct key.
|
|
165
|
+
*
|
|
166
|
+
* The leading-bullet strip keeps the ADVISORY side (Copilot renders suppressed prose as `* …`, which
|
|
167
|
+
* `parseSuppressedAdvisories` also strips for display) and the ACK side SYMMETRIC: the prompt tells
|
|
168
|
+
* the agent to copy the advisory's first line verbatim, so an ack marker legitimately carries the
|
|
169
|
+
* `* ` bullet — without stripping it here the ack key would differ from the advisory key and the
|
|
170
|
+
* gate would never converge (fail-CLOSED livelock). Applying it in this shared canonicaliser is the
|
|
171
|
+
* SINGLE source of truth for both sides.
|
|
172
|
+
*
|
|
173
|
+
* The bullet marker REQUIRES trailing whitespace (`[-*]\s+`): a genuine markdown bullet is always
|
|
174
|
+
* `- ` / `* ` followed by a space, so `-foo` / `*foo` (leading punctuation, no separator) is NOT a
|
|
175
|
+
* bullet and its leading char is PRESERVED. A greedy `\s*` there would strip the `-`/`*` off such
|
|
176
|
+
* prose too, collapsing distinct first lines like `-foo` and `foo` to one key — a false-ACK
|
|
177
|
+
* (false-OPEN) where acking one silently satisfies the other. */
|
|
178
|
+
function normalizeAdvisoryText(text: string): string {
|
|
179
|
+
return text
|
|
180
|
+
.normalize("NFC")
|
|
181
|
+
.replace(/^\s*[-*]\s+/u, "")
|
|
182
|
+
.toLowerCase()
|
|
183
|
+
.replace(/\s+/gu, " ")
|
|
184
|
+
.trim();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** COLLISION-RESISTANT fingerprint of a string → 32-hex-char (128-bit) digest, the leading half of
|
|
188
|
+
* a SHA-256 hash. Deterministic and dependency-free (Node's built-in `node:crypto`, no npm dep).
|
|
189
|
+
*
|
|
190
|
+
* The gate treats an advisory whose key `<path>#<fingerprint>` matches a resolved ack as addressed,
|
|
191
|
+
* so a *collision* would let a NEWER, unacknowledged advisory on the same path pass without its own
|
|
192
|
+
* ack — a false-OPEN that violates the gate's no-false-open guarantee. The former 32-bit FNV-1a
|
|
193
|
+
* digest was cheap to collide (birthday bound ~2^16); a 128-bit SHA-256 slice makes an accidental
|
|
194
|
+
* collision (~2^-64 for realistic advisory counts) infeasible. The digest is INTERNAL to the key —
|
|
195
|
+
* it never appears in a human-authored `nano-ack:` marker (those carry the verbatim prose, which is
|
|
196
|
+
* re-fingerprinted at read time), so widening it neither lengthens any marker nor breaks a
|
|
197
|
+
* previously-issued one: both the advisory side and the ack side recompute with this same function. */
|
|
198
|
+
function fingerprint(s: string): string {
|
|
199
|
+
return createHash("sha256").update(s, "utf8").digest("hex").slice(0, 32);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** The line-stable acknowledgement key for an advisory: `<path>#<fingerprint(normalized prose)>`.
|
|
203
|
+
* Exported so the review-round agent's contract and tests share one canonical implementation. */
|
|
204
|
+
export function advisoryStableKey(path: string, text: string): string {
|
|
205
|
+
return `${path.trim()}#${fingerprint(normalizeAdvisoryText(text))}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Parse Copilot's suppressed / low-confidence advisories out of a review body. Copilot renders them
|
|
209
|
+
* under a `<summary>Suppressed comments (N)</summary>` block, each as a bold `**path:line**` header
|
|
210
|
+
* followed by the advisory prose. Returns de-duplicated advisories (empty when there is no block). */
|
|
211
|
+
export function parseSuppressedAdvisories(reviewBody: string | null | undefined): SuppressedAdvisory[] {
|
|
112
212
|
const body = reviewBody ?? "";
|
|
113
213
|
const idx = body.search(/Suppressed comments\s*\(/i);
|
|
114
214
|
if (idx < 0) return [];
|
|
115
215
|
// Scan only from the "Suppressed comments" marker onward so a `**path:line**` elsewhere in the
|
|
116
216
|
// overview prose can never be mistaken for an advisory.
|
|
117
217
|
const region = body.slice(idx);
|
|
118
|
-
const
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
218
|
+
const lines = region.split(/\r?\n/);
|
|
219
|
+
const headerRe = /\*\*([^*]+?):(\d+)\*\*/;
|
|
220
|
+
const out: SuppressedAdvisory[] = [];
|
|
221
|
+
const seen = new Set<string>();
|
|
222
|
+
for (let i = 0; i < lines.length; i++) {
|
|
223
|
+
const h = headerRe.exec(lines[i]);
|
|
224
|
+
if (!h) continue;
|
|
225
|
+
const path = h[1].trim();
|
|
226
|
+
const line = Number(h[2]);
|
|
227
|
+
const label = `${path}:${line}`;
|
|
228
|
+
// The advisory prose is the first non-empty line after the header (up to the next header). A
|
|
229
|
+
// single bullet is the common shape; strip a leading markdown bullet marker for the display
|
|
230
|
+
// `text`. (Keying is bullet-insensitive regardless: `normalizeAdvisoryText` strips a leading
|
|
231
|
+
// bullet too, so the ack side — which copies the bulleted first line verbatim — keys the same.)
|
|
232
|
+
let text = "";
|
|
233
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
234
|
+
if (headerRe.test(lines[j])) break;
|
|
235
|
+
const t = lines[j].replace(/^\s*[-*]\s+/, "").trim();
|
|
236
|
+
if (t) {
|
|
237
|
+
text = t;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const key = advisoryStableKey(path, text);
|
|
242
|
+
if (seen.has(key)) continue;
|
|
243
|
+
seen.add(key);
|
|
244
|
+
out.push({ path, line, text, key, label });
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
124
247
|
}
|
|
125
248
|
|
|
126
249
|
/** Extract the acknowledged advisory keys from a set of review threads (only RESOLVED threads
|
|
127
|
-
* count — an open ack thread is not yet an acknowledgement).
|
|
250
|
+
* count — an open ack thread is not yet an acknowledgement). Returns line-stable keys (`<path>#<fp>`)
|
|
251
|
+
* parsed from the `nano-ack: <path> :: <text>` form ONLY. A bare `nano-ack: <path>:<line>` marker is
|
|
252
|
+
* intentionally NOT honoured: its `path:line` key is blind to the advisory prose and would false-OPEN
|
|
253
|
+
* a genuinely new advisory re-emitted at a previously-acked line. The gate treats an advisory as
|
|
254
|
+
* acked iff its stable key appears here. */
|
|
128
255
|
export function parseAckedAdvisories(threads: ReviewThread[]): string[] {
|
|
129
256
|
const acked = new Set<string>();
|
|
130
257
|
for (const t of threads) {
|
|
@@ -133,7 +260,10 @@ export function parseAckedAdvisories(threads: ReviewThread[]): string[] {
|
|
|
133
260
|
ACK_MARKER.lastIndex = 0;
|
|
134
261
|
let m: RegExpExecArray | null;
|
|
135
262
|
// biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
|
|
136
|
-
while ((m = ACK_MARKER.exec(body)) !== null)
|
|
263
|
+
while ((m = ACK_MARKER.exec(body)) !== null) {
|
|
264
|
+
const nw = NEW_ACK.exec(m[1].trim());
|
|
265
|
+
if (nw) acked.add(advisoryStableKey(nw[1], nw[2]));
|
|
266
|
+
}
|
|
137
267
|
}
|
|
138
268
|
}
|
|
139
269
|
return [...acked];
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Regression guard for issue #767 — the shared PR-escalation Tasks form must not render a blank
|
|
2
|
+
// read-only "Escalation question" field. The Tasks surface (`detail.engineForm`) seeds NO form
|
|
3
|
+
// variables, so the form's `question` control could never be populated at render time: it showed up
|
|
4
|
+
// blank while the actual request was already rendered separately as "Decision context" (the
|
|
5
|
+
// `user_tasks.question` read-model column, sourced from `latestOpenEscalationQuestion`). This mirrors
|
|
6
|
+
// the fix for the sibling `delivery-human-generic.form` (#773): make the form static / input-only —
|
|
7
|
+
// no `{{tokens}}`, no data-dependent `conditional`, and exactly one editable, required `answer` — and
|
|
8
|
+
// rely on the already-populated Decision context as the read-only prompt.
|
|
9
|
+
//
|
|
10
|
+
// This is the task/form-boundary guard the issue asks for, distinct from the durable `user_tasks`
|
|
11
|
+
// projection coverage in `pollUserTasks.test.ts`: it ties the committed `.form`, the BPMN
|
|
12
|
+
// `formDefinition` linkage, and the completer's typed contract together so none can drift back to a
|
|
13
|
+
// blank-question render.
|
|
14
|
+
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { assert, assertEquals } from "#test-assert";
|
|
18
|
+
import { validateEscalationVariables } from "./agentCompletion.ts";
|
|
19
|
+
|
|
20
|
+
// biome-ignore lint/suspicious/noExplicitAny: form-js component shape is untyped JSON.
|
|
21
|
+
type FormComponent = { type: string; key?: string; readonly?: boolean; conditional?: unknown; validate?: any };
|
|
22
|
+
type Form = { id: string; components: FormComponent[] };
|
|
23
|
+
|
|
24
|
+
const formText = readFileSync("resources/forms/pr-escalation.form", "utf8");
|
|
25
|
+
const form = JSON.parse(formText) as Form;
|
|
26
|
+
|
|
27
|
+
test("pr-escalation.form: has exactly one editable, required input — `answer` — and no blank readonly question field", () => {
|
|
28
|
+
assertEquals(form.id, "pr-escalation");
|
|
29
|
+
// Only ONE keyed (data-bearing) control, and it is the editable required `answer`.
|
|
30
|
+
const inputs = form.components.filter((c) => typeof c.key === "string");
|
|
31
|
+
assertEquals(
|
|
32
|
+
inputs.map((c) => c.key),
|
|
33
|
+
["answer"],
|
|
34
|
+
"the form must expose exactly one input control, keyed `answer`",
|
|
35
|
+
);
|
|
36
|
+
const answer = inputs[0];
|
|
37
|
+
assert(answer.readonly !== true, "the `answer` control must be editable, not read-only");
|
|
38
|
+
assertEquals(answer.validate?.required, true, "the `answer` control must be required");
|
|
39
|
+
// The blank read-only `question` field the operator could never fill (issue #767) must be gone —
|
|
40
|
+
// Decision context is the canonical read-only prompt now.
|
|
41
|
+
assert(
|
|
42
|
+
!form.components.some((c) => c.key === "question"),
|
|
43
|
+
"the blank readonly `question` field must be removed (Decision context is the prompt)",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("pr-escalation.form: is static — no `{{token}}` templating and no data-dependent `conditional`", () => {
|
|
48
|
+
// Deploy-time `{{token}}` templating is removed repo-wide (AGENTS.md), and the Tasks `engineForm`
|
|
49
|
+
// seeds no variables, so any data-dependent gate would never resolve — the exact failure mode #773
|
|
50
|
+
// fixed for the sibling form. Guard against a reappearance on this one.
|
|
51
|
+
assert(!/\{\{[^}]*\}\}/.test(formText), "the form must not contain `{{token}}` templating");
|
|
52
|
+
assert(
|
|
53
|
+
!form.components.some((c) => c.conditional !== undefined),
|
|
54
|
+
"the form must not use a data-dependent `conditional` the Tasks surface can never resolve",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("pr-escalation.form: both PR escalation elements still complete with the `{ answer }` contract", () => {
|
|
59
|
+
// Preserve the existing typed completion contract: `wait-answer` and `wait-merge-answer` both map to
|
|
60
|
+
// this form and require a non-blank `answer` (validated via `validateEscalationVariables`). A missing
|
|
61
|
+
// answer is rejected; a present one accepted — proving the input-only rewrite kept the contract.
|
|
62
|
+
for (const element of ["wait-answer", "wait-merge-answer"]) {
|
|
63
|
+
assert(
|
|
64
|
+
validateEscalationVariables(element, {}) !== null,
|
|
65
|
+
`${element} must reject a missing answer (pr-escalation form contract)`,
|
|
66
|
+
);
|
|
67
|
+
assert(
|
|
68
|
+
validateEscalationVariables(element, { answer: "rerun the round" }) === null,
|
|
69
|
+
`${element} must accept a valid { answer }`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("drift guard: both PR escalation user tasks link the pr-escalation form", () => {
|
|
75
|
+
// The form the code validates against must be the SAME one both models render, or the operator sees
|
|
76
|
+
// a different (blank-question) form than the contract guards.
|
|
77
|
+
for (const model of ["convergence-loop", "merge-loop"]) {
|
|
78
|
+
const bpmn = readFileSync(`resources/processes/${model}.bpmn`, "utf8");
|
|
79
|
+
assert(
|
|
80
|
+
/formId="pr-escalation"/.test(bpmn),
|
|
81
|
+
`${model}.bpmn must link the pr-escalation form`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.187.
|
|
3
|
+
"version": "0.187.3",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -4,17 +4,15 @@
|
|
|
4
4
|
"type": "default",
|
|
5
5
|
"components": [
|
|
6
6
|
{
|
|
7
|
-
"type": "
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"description": "A human decision is needed to resume the PR review-convergence loop.",
|
|
11
|
-
"readonly": true
|
|
7
|
+
"type": "text",
|
|
8
|
+
"label": "Respond to this escalation",
|
|
9
|
+
"text": "A human decision is needed to resume the PR process. The requested decision is shown as **Decision context** above. Read it, then enter your reply below."
|
|
12
10
|
},
|
|
13
11
|
{
|
|
14
12
|
"type": "textarea",
|
|
15
13
|
"key": "answer",
|
|
16
14
|
"label": "Your answer",
|
|
17
|
-
"description": "Your reply resumes the
|
|
15
|
+
"description": "Your reply resumes the PR process and is handed to its next step. Answer the request shown in Decision context above.",
|
|
18
16
|
"validate": {
|
|
19
17
|
"required": true
|
|
20
18
|
}
|
|
@@ -119,19 +119,43 @@ Because several agents may run on the same host at once:
|
|
|
119
119
|
carries a **resolved** acknowledgement, so a decision you only wrote into your
|
|
120
120
|
`summary` is invisible to the gate. For each suppressed advisory you applied or
|
|
121
121
|
declined (step 2), post a **new review comment thread** whose body contains the
|
|
122
|
-
verbatim marker **`nano-ack: <path
|
|
123
|
-
bold `**<path>:<line>**` header
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
verbatim marker **`nano-ack: <path> :: <advisory text>`** — the `<path>` from
|
|
123
|
+
Copilot's bold `**<path>:<line>**` header and `<advisory text>` copied
|
|
124
|
+
**verbatim** from the first line of that advisory's prose — then **resolve** that
|
|
125
|
+
thread. The gate keys the acknowledgement on the advisory **text** (a
|
|
126
|
+
line-independent fingerprint of `<path> + <advisory text>`), *not* on the line
|
|
127
|
+
number: this is deliberate. A **declined** advisory is re-emitted every round,
|
|
128
|
+
and any unrelated edit you make shifts its line, so Copilot re-anchors it to a
|
|
129
|
+
new line — a line-based ack would go stale and the gate would escalate to a human
|
|
130
|
+
every round (issue #787). Because the ack is keyed on the prose, a decline you
|
|
131
|
+
made in an earlier round stays acknowledged across the drift and you need **not**
|
|
132
|
+
re-ack it. The ack thread may sit on any valid diff line. Example:
|
|
126
133
|
|
|
127
134
|
```sh
|
|
128
135
|
# Post the ack thread (pick any changed line in the diff for path/line). Use the PR's real HEAD
|
|
129
136
|
# SHA as commit_id — `git rev-parse HEAD` can drift from the PR head; ask GitHub:
|
|
130
137
|
CID=$(gh api repos/OWNER/REPO/pulls/PR --jq .head.sha)
|
|
131
|
-
|
|
132
|
-
|
|
138
|
+
# Build the body via a QUOTED heredoc so the verbatim advisory prose is never re-interpreted by
|
|
139
|
+
# the shell — a single-quoted `-f body='...'` breaks the moment the prose contains a `'` (e.g.
|
|
140
|
+
# "doesn't handle ..."), and a double-quoted one breaks on `$`/backticks. `<<'EOF'` (quoted
|
|
141
|
+
# delimiter) disables ALL expansion, so any advisory text is safe:
|
|
142
|
+
BODY=$(cat <<'EOF'
|
|
143
|
+
Applied. nano-ack: <path> :: <verbatim advisory text>
|
|
144
|
+
EOF
|
|
145
|
+
) # to DECLINE instead, build the body the same quoted-heredoc way (never a single-quoted
|
|
146
|
+
# `-f body='...'`, which breaks the moment the reason or advisory prose contains a `'`):
|
|
147
|
+
# BODY=$(cat <<'EOF'
|
|
148
|
+
# Declined, false positive — <reason>. nano-ack: <path> :: <verbatim advisory text>
|
|
149
|
+
# EOF
|
|
150
|
+
# )
|
|
151
|
+
gh api repos/OWNER/REPO/pulls/PR/comments -f commit_id="$CID" -f path="PATH" -F line=LINE -f side=RIGHT -f body="$BODY"
|
|
133
152
|
# Then resolve it exactly like any other thread (map its databaseId -> thread node id -> resolveReviewThread).
|
|
134
153
|
```
|
|
154
|
+
Only the `nano-ack: <path> :: <text>` (prose-keyed) form is honoured. A bare
|
|
155
|
+
`nano-ack: <path>:<line>` marker is **not** an acknowledgement: keyed only on
|
|
156
|
+
`path:line`, it is blind to the advisory's prose, so it would let a resolved ack
|
|
157
|
+
for one advisory silently acknowledge a genuinely new advisory re-emitted at that
|
|
158
|
+
same line. Always use the `<path> :: <text>` form.
|
|
135
159
|
6. **Do NOT request, re-request, or remove the reviewer yourself.** Keeping
|
|
136
160
|
Copilot attached is the **process's** job: a deterministic poller ensures a
|
|
137
161
|
Copilot review is requested (idempotently) whenever this PR is waiting, and it
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
// comments:
|
|
9
9
|
// • any review THREAD is still unresolved (GraphQL `isResolved = false`), or
|
|
10
10
|
// • any SUPPRESSED advisory in the latest Copilot review body lacks a matching RESOLVED ack
|
|
11
|
-
// thread (a `nano-ack: <path
|
|
11
|
+
// thread (a `nano-ack: <path> :: <verbatim advisory text>` marker whose line-stable prose
|
|
12
|
+
// fingerprint matches Copilot's advisory). The bare legacy `nano-ack: <path>:<line>` form is
|
|
13
|
+
// NOT honoured: keyed only on `path:line`, it is blind to the advisory prose and would let a
|
|
14
|
+
// resolved ack for one advisory silently acknowledge a genuinely new advisory re-emitted at
|
|
15
|
+
// that same line (a false-OPEN). Only the prose-keyed `<path> :: <text>` form acknowledges.
|
|
12
16
|
// A blocked gate returns `convergeBlocked = true`; the model's `gw-converge-gate` gateway routes to
|
|
13
17
|
// the human `wait-answer` escalation (recoverable), never a hard wedge.
|
|
14
18
|
//
|
|
@@ -87,9 +91,10 @@ export function makeHandler(deps: {
|
|
|
87
91
|
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE };
|
|
88
92
|
}
|
|
89
93
|
const unresolvedThreadCount = threads.filter((t) => !t.isResolved).length;
|
|
94
|
+
const advisories = parseSuppressedAdvisories(reviewBody);
|
|
90
95
|
result = evaluateConvergeGate({
|
|
91
96
|
unresolvedThreadCount,
|
|
92
|
-
|
|
97
|
+
suppressedAdvisories: advisories.map((a) => ({ key: a.key, label: a.label })),
|
|
93
98
|
acknowledgedKeys: parseAckedAdvisories(threads),
|
|
94
99
|
});
|
|
95
100
|
} catch {
|