@nanobpm/nano-workforce 0.205.1 → 0.205.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/app/convergeAdversarial.test.ts +54 -0
- package/app/convergeGate.test.ts +186 -6
- package/app/github.ts +53 -3
- package/app/mergeLoopBehaviour.test.ts +12 -0
- package/app/mergeRetry.test.ts +49 -0
- package/app/reviewWait.test.ts +37 -0
- package/app/reviewWait.ts +26 -0
- package/app/roundProgress.test.ts +63 -2
- package/app/roundProgress.ts +6 -0
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +5 -0
- package/resources/processes/merge-loop.bpmn +1 -1
- package/workers/converge-gate/worker.ts +61 -11
- package/workers/merge/worker.test.ts +192 -1
- package/workers/merge/worker.ts +118 -7
- package/workers/progress-check/worker.ts +40 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.205.2](https://github.com/nanobpm/nano-workforce/compare/v0.205.1...v0.205.2) (2026-11-08)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **convergence:** fail closed when no head-current Copilot review exists ([#924](https://github.com/nanobpm/nano-workforce/issues/924)) ([e0ded57](https://github.com/nanobpm/nano-workforce/commit/e0ded57e0888755fa6fd77495f3361e8dafc0980)), closes [#923](https://github.com/nanobpm/nano-workforce/issues/923) [nano-bpm#1443](https://github.com/nanobpm/nano-bpm/issues/1443) [#923](https://github.com/nanobpm/nano-workforce/issues/923) [#923](https://github.com/nanobpm/nano-workforce/issues/923) [#923](https://github.com/nanobpm/nano-workforce/issues/923) [#923](https://github.com/nanobpm/nano-workforce/issues/923)
|
|
6
|
+
|
|
1
7
|
## [0.205.1](https://github.com/nanobpm/nano-workforce/compare/v0.205.0...v0.205.1) (2026-11-06)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
|
@@ -292,3 +292,57 @@ test("the pass budget resets when the next review lands (new round)", async () =
|
|
|
292
292
|
// Round 2 gets its own full budget of 1 pass.
|
|
293
293
|
assert(completions(engine, "adversarial-review") === 2, `round 2 must get a fresh pass: ${runs(engine)}`);
|
|
294
294
|
});
|
|
295
|
+
|
|
296
|
+
// Issue #923 §4 (behavioural, not substring): a `converged` agent verdict with NO head-current review
|
|
297
|
+
// makes the converge-gate report `reviewStale`; the loop must record the round as `review-stale`
|
|
298
|
+
// (not the agent's self-reported `converged`) and park for a fresh review — never reaching
|
|
299
|
+
// `persist-converged`.
|
|
300
|
+
test("#923: a stale converge-gate verdict persists the round as review-stale and parks without converging", async () => {
|
|
301
|
+
const { engine, seen } = await boot({
|
|
302
|
+
responses: {
|
|
303
|
+
"senior:pr-review": [{ status: "converged", summary: "done" }, { status: "waiting", summary: "awaiting" }],
|
|
304
|
+
"pr.converge-gate": {
|
|
305
|
+
convergeBlocked: false,
|
|
306
|
+
convergeBlockReason: "",
|
|
307
|
+
convergeAckOnly: false,
|
|
308
|
+
reviewStale: true,
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
vars: { advMax: 0 },
|
|
312
|
+
});
|
|
313
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
314
|
+
assert(completions(engine, "persist-converged") === 0, `must not converge on a stale review: ${runs(engine)}`);
|
|
315
|
+
const recorded = seen["pr.persist-round"][0];
|
|
316
|
+
assert(recorded?.status === "review-stale", `persist-round status: ${JSON.stringify(recorded?.status)}`);
|
|
317
|
+
assert(completions(engine, "wait-review") === 0, "must be parked waiting for a fresh review");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// Issue #923 review ("previously missed", behavioural): the arrival-window cutoff the converge-gate
|
|
321
|
+
// emits on a stale verdict must PROPAGATE through the process to the check-progress park — the
|
|
322
|
+
// variable crosses persist-round untouched, so check-progress can stamp `waiting_since` at the
|
|
323
|
+
// gate's read time (not the later park time) and a head-current review landing in the read→park
|
|
324
|
+
// window still satisfies the wait. This is the process-level wiring the worker unit tests cannot
|
|
325
|
+
// see (each worker is tested in isolation with an injected variable).
|
|
326
|
+
test("#923 review: the converge-gate's reviewWaitCutoff reaches check-progress on a stale verdict", async () => {
|
|
327
|
+
const CUTOFF = "2026-11-08T01:00:00.000Z";
|
|
328
|
+
const { engine, seen } = await boot({
|
|
329
|
+
responses: {
|
|
330
|
+
"senior:pr-review": [{ status: "converged", summary: "done" }, { status: "waiting", summary: "awaiting" }],
|
|
331
|
+
"pr.converge-gate": {
|
|
332
|
+
convergeBlocked: false,
|
|
333
|
+
convergeBlockReason: "",
|
|
334
|
+
convergeAckOnly: false,
|
|
335
|
+
reviewStale: true,
|
|
336
|
+
reviewWaitCutoff: CUTOFF,
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
vars: { advMax: 0 },
|
|
340
|
+
});
|
|
341
|
+
assertThatInstance(engine, byProcessId("convergence-loop")).isActive().hasNoIncident();
|
|
342
|
+
const progressJobs = seen["pr.progress-check"];
|
|
343
|
+
assert(progressJobs.length >= 1, "check-progress ran");
|
|
344
|
+
assert(
|
|
345
|
+
progressJobs[0]?.reviewWaitCutoff === CUTOFF,
|
|
346
|
+
`check-progress must receive the gate's reviewWaitCutoff, got ${JSON.stringify(progressJobs[0]?.reviewWaitCutoff)}`,
|
|
347
|
+
);
|
|
348
|
+
});
|
package/app/convergeGate.test.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
advisoryStableKey,
|
|
18
18
|
isAckThread,
|
|
19
19
|
parseAckedAdvisories,
|
|
20
|
+
parseReviewOpenFindings,
|
|
20
21
|
parseReviewThreadsPage,
|
|
21
22
|
parseSuppressedAdvisories,
|
|
22
23
|
pickLatestCopilotReview,
|
|
@@ -820,7 +821,11 @@ test("converge-gate #799: a STALE review (commit_id predates HEAD) does not bloc
|
|
|
820
821
|
headSha: "newsha2222222222222222222222222222222222",
|
|
821
822
|
});
|
|
822
823
|
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
823
|
-
assertEquals(out
|
|
824
|
+
assertEquals(out.convergeBlocked, false);
|
|
825
|
+
assertEquals(out.reviewStale, true);
|
|
826
|
+
// The gate stamps the review-wait cutoff (the time of ITS review read) so a head-current review
|
|
827
|
+
// landing in the read→park window still satisfies the wait (issue #923 review).
|
|
828
|
+
assertEquals(typeof out.reviewWaitCutoff, "string");
|
|
824
829
|
});
|
|
825
830
|
|
|
826
831
|
test("converge-gate #799: a HEAD-CURRENT review still blocks on an unacked advisory (control)", async () => {
|
|
@@ -838,9 +843,14 @@ test("converge-gate #799: a HEAD-CURRENT review still blocks on an unacked advis
|
|
|
838
843
|
assertStringIncludes(out.convergeBlockReason ?? "", "spec-app/nano-app.schema.json:613");
|
|
839
844
|
});
|
|
840
845
|
|
|
841
|
-
test("converge-gate #799:
|
|
842
|
-
//
|
|
843
|
-
//
|
|
846
|
+
test("converge-gate #799/#923: a review commit_id GitHub did not carry is treated as MISSING — re-solicits (no fail-open)", async () => {
|
|
847
|
+
// #799's fail-safe ("a null review commit_id is not stale") was written for a gate that only
|
|
848
|
+
// handled a review that EXISTS. #923 closes the class: a null commit_id is ambiguous between "no
|
|
849
|
+
// review at all" and "a review GitHub carried no commit_id for", and the gate cannot distinguish
|
|
850
|
+
// them — converging on either is converging without a verifiable head-current verdict (the
|
|
851
|
+
// #1443 fail-open). With a readable head the gate now signals reviewStale for BOTH, so the
|
|
852
|
+
// poller re-solicits a review whose commit_id IS carried, un-wedging the loop. The head-side
|
|
853
|
+
// fail-safe is unchanged: an unreadable head still evaluates normally (see the #923 test below).
|
|
844
854
|
const handler = await makeUnderTest({
|
|
845
855
|
readThreads: async () => [],
|
|
846
856
|
readReviewBody: async () => SAMPLE_REVIEW_BODY,
|
|
@@ -848,8 +858,66 @@ test("converge-gate #799: an unknown review commit_id or unreadable HEAD is NOT
|
|
|
848
858
|
headSha: "newsha2222222222222222222222222222222222",
|
|
849
859
|
});
|
|
850
860
|
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
851
|
-
assertEquals(out.convergeBlocked,
|
|
852
|
-
assertEquals(out.reviewStale,
|
|
861
|
+
assertEquals(out.convergeBlocked, false);
|
|
862
|
+
assertEquals(out.reviewStale, true);
|
|
863
|
+
// The gate stamps the review-wait cutoff (the time of ITS review read) so a head-current review
|
|
864
|
+
// landing in the read→park window still satisfies the wait (issue #923 review).
|
|
865
|
+
assertEquals(typeof out.reviewWaitCutoff, "string");
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
// ── Fail closed on "no review exists" (issue #923 §1) ────────────────────────
|
|
869
|
+
// The gate used to treat `{ body: "", commitId: null }` — a VERIFIED "no Copilot review on this
|
|
870
|
+
// PR" — as "a verified clean review": zero threads + zero advisories → not blocked → merged with
|
|
871
|
+
// ZERO reviews (nano-bpm#1443). Convergence requires a HEAD-CURRENT reviewer verdict, so a missing
|
|
872
|
+
// review must signal `reviewStale` and route to the review wait (the poller solicits a review of
|
|
873
|
+
// the head), exactly like a stale one.
|
|
874
|
+
|
|
875
|
+
test("converge-gate #923: NO Copilot review at all signals reviewStale (never converges unreviewed)", async () => {
|
|
876
|
+
// `readReview` returns `{ body: "", commitId: null }` (verified: no Copilot review) and the head
|
|
877
|
+
// read succeeds. The old gate fell through as the most permissive case and converged.
|
|
878
|
+
const handler = await makeUnderTest({
|
|
879
|
+
readThreads: async () => [],
|
|
880
|
+
readReviewBody: async () => "",
|
|
881
|
+
reviewCommitId: null,
|
|
882
|
+
headSha: "headsha4444444444444444444444444444444444",
|
|
883
|
+
});
|
|
884
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
885
|
+
assertEquals(out.convergeBlocked, false);
|
|
886
|
+
assertEquals(out.reviewStale, true);
|
|
887
|
+
// The gate stamps the review-wait cutoff (the time of ITS review read) so a head-current review
|
|
888
|
+
// landing in the read→park window still satisfies the wait (issue #923 review).
|
|
889
|
+
assertEquals(typeof out.reviewWaitCutoff, "string");
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
test("converge-gate #923: a review GitHub carried no commit_id for is ALSO treated as missing (fail closed)", async () => {
|
|
893
|
+
// A null commit_id is ambiguous — "no review" vs "a review with no commit_id" — and the gate
|
|
894
|
+
// cannot distinguish them, so BOTH must take the re-solicit path. Converging on an unverifiable
|
|
895
|
+
// verdict is the fail-open this issue closes; the re-solicit produces a review whose commit_id
|
|
896
|
+
// IS carried, un-wedging the loop.
|
|
897
|
+
const handler = await makeUnderTest({
|
|
898
|
+
readThreads: async () => [],
|
|
899
|
+
readReviewBody: async () => "## Overview\nNo suppressed block.",
|
|
900
|
+
reviewCommitId: null,
|
|
901
|
+
headSha: "headsha4444444444444444444444444444444444",
|
|
902
|
+
});
|
|
903
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
904
|
+
assertEquals(out.reviewStale, true);
|
|
905
|
+
assertEquals(out.convergeBlocked, false);
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("converge-gate #923: a missing review with an UNREADABLE head still fails safe (no fabricated re-solicit)", async () => {
|
|
909
|
+
// Without the head we cannot prove anything — the gate keeps `isReviewStale`'s fail-safe and
|
|
910
|
+
// falls through to the ordinary evaluation (here: a clean empty review converges). A transport
|
|
911
|
+
// hiccup must never strand the loop re-soliciting forever; the review-wait timeout is the
|
|
912
|
+
// backstop.
|
|
913
|
+
const handler = await makeUnderTest({
|
|
914
|
+
readThreads: async () => [],
|
|
915
|
+
readReviewBody: async () => "",
|
|
916
|
+
reviewCommitId: null,
|
|
917
|
+
headSha: null,
|
|
918
|
+
});
|
|
919
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
920
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
|
|
853
921
|
});
|
|
854
922
|
|
|
855
923
|
// ── Structural guard over the committed BPMN (no engine) ─────────────────────
|
|
@@ -971,6 +1039,20 @@ test("the round cap does NOT escalate a stale-retry round — f_guardMax is gate
|
|
|
971
1039
|
assertStringIncludes(f, "maxRounds and reviewStale != true");
|
|
972
1040
|
});
|
|
973
1041
|
|
|
1042
|
+
test("persist-round records a stale re-entry as 'review-stale', not the agent's self-report (#923 §4)", () => {
|
|
1043
|
+
// Round accounting must reflect whether a reviewer verdict was obtained: a round that re-enters
|
|
1044
|
+
// persist-round via f_convergeStale (no head-current review existed, or it predated the head)
|
|
1045
|
+
// never received a verdict, so recording the agent's self-reported status ("converged") would
|
|
1046
|
+
// tell the operator a review round happened that never did (/status showed nano-bpm#1443 as
|
|
1047
|
+
// "round 2" with zero reviews). The stale re-entry maps `status` from `reviewStale` so the
|
|
1048
|
+
// durable round history reads `review-stale`.
|
|
1049
|
+
const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="persist-round"[^>]*>.*?<\/bpmn:serviceTask>/);
|
|
1050
|
+
assert(task, "persist-round task missing");
|
|
1051
|
+
assertStringIncludes(task[0], 'target="status"');
|
|
1052
|
+
assertStringIncludes(task[0], "reviewStale");
|
|
1053
|
+
assertStringIncludes(task[0], "review-stale");
|
|
1054
|
+
});
|
|
1055
|
+
|
|
974
1056
|
test("wait-review clears reviewStale when a fresh review lands, so the marker can't leak into a later round (#799)", () => {
|
|
975
1057
|
// reviewStale is written ONLY by the converge-gate; once a fresh review arrives it is no longer
|
|
976
1058
|
// known-stale, so wait-review resets it to false alongside the round increment. Without this reset
|
|
@@ -1196,3 +1278,101 @@ test("parseSuppressedAdvisories: v2 table rows without findings yield nothing; n
|
|
|
1196
1278
|
);
|
|
1197
1279
|
assertEquals(parseSuppressedAdvisories("| `a.ts` | Broken thing (moderate, 1 vote). |"), []);
|
|
1198
1280
|
});
|
|
1281
|
+
|
|
1282
|
+
// ── The reviewer-verdict gate (issue #923 §2) ────────────────────────────────
|
|
1283
|
+
// Convergence requires a head-current reviewer VERDICT, not just zero unresolved threads: a
|
|
1284
|
+
// head-current review whose own summary reports open findings must block the same way an
|
|
1285
|
+
// unresolved thread does. `parseReviewOpenFindings` reads that verdict out of the review body
|
|
1286
|
+
// (Copilot's "Changes recommended" / "N open issue(s)" / ccr-overview-v2 "Open (N)" section).
|
|
1287
|
+
|
|
1288
|
+
test("parseReviewOpenFindings: a 'Changes recommended' review reports open findings", () => {
|
|
1289
|
+
const body = "## Pull Request Overview\n🟡 Changes recommended — 7 findings (6 high).";
|
|
1290
|
+
assertEquals(parseReviewOpenFindings(body), ["changes recommended"]);
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
test("parseReviewOpenFindings: an 'N open issue(s)' summary reports N", () => {
|
|
1294
|
+
assertEquals(
|
|
1295
|
+
parseReviewOpenFindings("<details open>\n<summary><strong>3 open issues</strong></summary>\n</details>"),
|
|
1296
|
+
["3 open issues"],
|
|
1297
|
+
);
|
|
1298
|
+
assertEquals(parseReviewOpenFindings("<summary>1 open issue</summary>"), ["1 open issue"]);
|
|
1299
|
+
});
|
|
1300
|
+
|
|
1301
|
+
test("parseReviewOpenFindings: '0 open issues' is a clean verdict, not a finding", () => {
|
|
1302
|
+
assertEquals(parseReviewOpenFindings("Review complete. 0 open issues."), []);
|
|
1303
|
+
assertEquals(parseReviewOpenFindings("<details><summary><strong>0 open issues</strong></summary></details>"), []);
|
|
1304
|
+
});
|
|
1305
|
+
|
|
1306
|
+
test("parseReviewOpenFindings: a ccr-overview-v2 'Open (N)' section reports N", () => {
|
|
1307
|
+
assertEquals(parseReviewOpenFindings(V2_REVIEW_BODY), ["1 open finding"]);
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
test("parseReviewOpenFindings: a clean review (no open-issues signal) reports none", () => {
|
|
1311
|
+
assertEquals(parseReviewOpenFindings("## Pull Request Overview\n✅ Looks good — no issues found."), []);
|
|
1312
|
+
assertEquals(parseReviewOpenFindings(""), []);
|
|
1313
|
+
assertEquals(parseReviewOpenFindings(null), []);
|
|
1314
|
+
assertEquals(parseReviewOpenFindings(undefined), []);
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
test("parseReviewOpenFindings: verdict phrases in descriptive prose / table rows are NOT findings", () => {
|
|
1318
|
+
const clean = [
|
|
1319
|
+
"<!-- ccr-overview-v2 -->",
|
|
1320
|
+
"",
|
|
1321
|
+
"### ✅ Looks good",
|
|
1322
|
+
"",
|
|
1323
|
+
"<details>",
|
|
1324
|
+
"<summary><strong>What changed in this PR</strong></summary>",
|
|
1325
|
+
"",
|
|
1326
|
+
"| File | Summary and final review notes |",
|
|
1327
|
+
"|---|---|",
|
|
1328
|
+
'| `a.ts` | Recognizes "Changes recommended" and counts 3 open issues; parses Open (2) sections. |',
|
|
1329
|
+
"</details>",
|
|
1330
|
+
"<summary>Notes</summary> then later Open (4) outside any summary",
|
|
1331
|
+
].join("\n");
|
|
1332
|
+
assertEquals(parseReviewOpenFindings(clean), []);
|
|
1333
|
+
});
|
|
1334
|
+
|
|
1335
|
+
test("parseReviewOpenFindings: a v2 overview with NO Open section reports none", () => {
|
|
1336
|
+
const clean = [
|
|
1337
|
+
"<!-- ccr-overview-v2 -->",
|
|
1338
|
+
"",
|
|
1339
|
+
"## Copilot review overview",
|
|
1340
|
+
"",
|
|
1341
|
+
"<details>",
|
|
1342
|
+
"<summary><strong>What changed in this PR</strong></summary>",
|
|
1343
|
+
"",
|
|
1344
|
+
"| File | Summary and final review notes |",
|
|
1345
|
+
"|---|---|",
|
|
1346
|
+
"| `a.ts` | Adds a thing. |",
|
|
1347
|
+
"</details>",
|
|
1348
|
+
].join("\n");
|
|
1349
|
+
assertEquals(parseReviewOpenFindings(clean), []);
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
test("converge-gate #923: a head-current review whose body reports OPEN ISSUES blocks (verdict gate, no threads)", async () => {
|
|
1353
|
+
// The verdict gate: zero unresolved threads and no suppressed advisories, but the head-current
|
|
1354
|
+
// review's own summary says findings are open. This must block EXACTLY like an unresolved
|
|
1355
|
+
// thread — NOT ack-only (the round agent's code/reply work is needed, not a nano-ack repost).
|
|
1356
|
+
const handler = await makeUnderTest({
|
|
1357
|
+
readThreads: async () => [],
|
|
1358
|
+
readReviewBody: async () => "## Pull Request Overview\n🟡 Changes recommended — 7 findings (6 high).",
|
|
1359
|
+
reviewCommitId: "headsha4444444444444444444444444444444444",
|
|
1360
|
+
headSha: "headsha4444444444444444444444444444444444",
|
|
1361
|
+
});
|
|
1362
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
1363
|
+
assertEquals(out.convergeBlocked, true);
|
|
1364
|
+
assertEquals(out.reviewStale, false);
|
|
1365
|
+
assertEquals(out.convergeAckOnly, false);
|
|
1366
|
+
assertStringIncludes(out.convergeBlockReason ?? "", "changes recommended");
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
test("converge-gate #923: a head-current CLEAN review with no findings still converges (verdict-gate control)", async () => {
|
|
1370
|
+
const handler = await makeUnderTest({
|
|
1371
|
+
readThreads: async () => [],
|
|
1372
|
+
readReviewBody: async () => "## Pull Request Overview\n✅ Looks good — no issues found.",
|
|
1373
|
+
reviewCommitId: "headsha4444444444444444444444444444444444",
|
|
1374
|
+
headSha: "headsha4444444444444444444444444444444444",
|
|
1375
|
+
});
|
|
1376
|
+
const out = await handler({ variables: { prKey: "o/r#1", repo: "o/r", prNumber: 1 } } as any, {} as any);
|
|
1377
|
+
assertEquals(out, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: false });
|
|
1378
|
+
});
|
package/app/github.ts
CHANGED
|
@@ -290,6 +290,37 @@ export function parseSuppressedAdvisories(reviewBody: string | null | undefined)
|
|
|
290
290
|
return out;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
/** Read the reviewer VERDICT out of a Copilot review body (issue #923 §2): does the review's own
|
|
294
|
+
* summary report OPEN, actionable findings? Convergence requires a head-current verdict that is
|
|
295
|
+
* CLEAN — a review whose body says findings are still open must block the same way an unresolved
|
|
296
|
+
* thread does, even when every inline thread happens to be resolved (a review can carry findings
|
|
297
|
+
* with no unresolved threads at all: the nano-bpm#1443 review raised 7 findings as a summary-only
|
|
298
|
+
* "Changes recommended").
|
|
299
|
+
*
|
|
300
|
+
* Recognised open-finding signals (each returns a short human label for the block reason):
|
|
301
|
+
* • "Changes recommended" — Copilot's 🟡 verdict line (vs. the ✅ clean verdict);
|
|
302
|
+
* • "N open issue(s)" — the summary count Copilot emits when findings remain;
|
|
303
|
+
* • a ccr-overview-v2 `<summary>…Open (N)…</summary>` section (issue #835's format) with N ≥ 1.
|
|
304
|
+
* A body carrying NONE of these reports no open findings (empty array) — including the empty body
|
|
305
|
+
* of a comment-only review. Pure; unit-tested. */
|
|
306
|
+
export function parseReviewOpenFindings(reviewBody: string | null | undefined): string[] {
|
|
307
|
+
const body = reviewBody ?? "";
|
|
308
|
+
if (body.trim() === "") return [];
|
|
309
|
+
const out: string[] = [];
|
|
310
|
+
// Only VERDICT lines and dedicated finding-count <summary> sections count — never descriptive
|
|
311
|
+
// prose (a clean review's file table may legitimately say `Recognizes "Changes recommended"`).
|
|
312
|
+
// A verdict line STARTS with the verdict (`### 🟡 Changes recommended`, `🟡 Changes recommended`).
|
|
313
|
+
if (/^[ \t]*(?:#{1,6}[ \t]*)?(?:🟡[ \t]*)?changes recommended\b/imu.test(body)) out.push("changes recommended");
|
|
314
|
+
// Each <summary> match is bounded by its own closing tag ([^<]* cannot cross `</summary>`).
|
|
315
|
+
for (const m of body.matchAll(/<summary>(?:<strong>)?\s*(\d+)\s+open\s+issues?\s*(?:<\/strong>)?\s*<\/summary>/gi)) {
|
|
316
|
+
if (Number(m[1]) > 0) out.push(`${m[1]} open issue${m[1] === "1" ? "" : "s"}`);
|
|
317
|
+
}
|
|
318
|
+
for (const m of body.matchAll(/<summary>(?:<strong>)?\s*Open\s*\((\d+)\)\s*(?:<\/strong>)?\s*<\/summary>/gi)) {
|
|
319
|
+
if (Number(m[1]) > 0) out.push(`${m[1]} open finding${m[1] === "1" ? "" : "s"}`);
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
|
|
293
324
|
/** A finding clause's trailing `(<severity>, N vote[s])` tag in the ccr-overview-v2 file table. */
|
|
294
325
|
const V2_FINDING_TAG = /\s*\(\s*[a-z]+\s*,\s*\d+\s+votes?\s*\)/gi;
|
|
295
326
|
|
|
@@ -1665,6 +1696,10 @@ export type MergeMethod = "squash" | "merge" | "rebase";
|
|
|
1665
1696
|
export interface MergeOptions {
|
|
1666
1697
|
method: MergeMethod;
|
|
1667
1698
|
admin: boolean;
|
|
1699
|
+
/** The head SHA the caller verified (issue #923 backstop). When set, GitHub refuses the merge if
|
|
1700
|
+
* the PR head moved since — gh `--match-head-commit`, REST `sha` — so a later, unreviewed head
|
|
1701
|
+
* can never be landed by a check-then-merge race. The refusal surfaces as a transient `retry`. */
|
|
1702
|
+
expectedHeadSha?: string;
|
|
1668
1703
|
}
|
|
1669
1704
|
export interface MergeResult {
|
|
1670
1705
|
outcome: "merged" | "queued" | "blocked" | "retry";
|
|
@@ -1677,9 +1712,21 @@ export interface MergeResult {
|
|
|
1677
1712
|
* must re-attempt on the settled base, NOT page a human. Matches GitHub's stable message across
|
|
1678
1713
|
* both the GraphQL `mergePullRequest` error and its HTTP 405 REST variant. Kept narrow — the exact
|
|
1679
1714
|
* "<Base|Head> branch was modified" phrase — so a genuine block (conflict, failing required check,
|
|
1680
|
-
* 403 perms, 422 not-mergeable) is never swallowed as transient.
|
|
1715
|
+
* 403 perms, 422 not-mergeable) is never swallowed as transient.
|
|
1716
|
+
*
|
|
1717
|
+
* The same class covers the `--match-head-commit` / REST `sha` pin refusal (issue #923 review): the
|
|
1718
|
+
* PR head advanced past the verified head between the review backstop and the merge mutation, so
|
|
1719
|
+
* GitHub refused the pinned merge. That is a check-then-act race the loop is designed to absorb —
|
|
1720
|
+
* the bounded merge retry re-runs the review backstop on the new head — not a human-actionable
|
|
1721
|
+
* block. gh words it "Head commit of pull request #… does not match '--match-head-commit'"; the
|
|
1722
|
+
* REST `sha` mismatch surfaces as a 409 whose body carries the same "Head branch was modified"
|
|
1723
|
+
* phrase (matched by the first alternative) or a "SHA does not match" wording. */
|
|
1681
1724
|
export function isTransientMergeRace(detail: string): boolean {
|
|
1682
|
-
return
|
|
1725
|
+
return (
|
|
1726
|
+
/\b(?:base|head) branch was modified\b/i.test(detail) ||
|
|
1727
|
+
/does not match ['"]--match-head-commit['"]/i.test(detail) ||
|
|
1728
|
+
/\bsha does not match\b/i.test(detail)
|
|
1729
|
+
);
|
|
1683
1730
|
}
|
|
1684
1731
|
|
|
1685
1732
|
/** Attempt to land the PR. Returns `merged` (landed now), `queued` (added to the repo's merge
|
|
@@ -1697,6 +1744,7 @@ export async function mergePr(
|
|
|
1697
1744
|
if (await useGh()) {
|
|
1698
1745
|
const args = ["pr", "merge", String(number), "--repo", repo, methodFlag];
|
|
1699
1746
|
if (opts.admin) args.push("--admin");
|
|
1747
|
+
if (opts.expectedHeadSha) args.push("--match-head-commit", opts.expectedHeadSha);
|
|
1700
1748
|
try {
|
|
1701
1749
|
const out = await runGh(args);
|
|
1702
1750
|
// gh prints "… will be added to the merge queue" when the branch requires one.
|
|
@@ -1721,7 +1769,9 @@ export async function mergePr(
|
|
|
1721
1769
|
accept: "application/vnd.github+json",
|
|
1722
1770
|
"content-type": "application/json",
|
|
1723
1771
|
},
|
|
1724
|
-
body: JSON.stringify(
|
|
1772
|
+
body: JSON.stringify(
|
|
1773
|
+
opts.expectedHeadSha ? { merge_method: opts.method, sha: opts.expectedHeadSha } : { merge_method: opts.method },
|
|
1774
|
+
),
|
|
1725
1775
|
});
|
|
1726
1776
|
if (r.ok) {
|
|
1727
1777
|
// A 2xx from the REST merge endpoint does not guarantee the PR has *landed*: the body's
|
|
@@ -688,6 +688,18 @@ test("a retry that exhausts the budget escalates as a repeated race, not a gener
|
|
|
688
688
|
assertStringIncludes(String(escalation(engine).question ?? ""), "retry budget", "the retry escalation must read as a repeated race");
|
|
689
689
|
});
|
|
690
690
|
|
|
691
|
+
test("a worker-supplied block question (review backstop #923) reaches the human escalation verbatim", async () => {
|
|
692
|
+
const engine = await boot({
|
|
693
|
+
responses: { "pr.merge": { mergeStatus: "blocked", status: "blocked", question: "Merge refused: no head-current Copilot review exists." } },
|
|
694
|
+
});
|
|
695
|
+
await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
|
|
696
|
+
await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
|
|
697
|
+
await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
|
|
698
|
+
const q = String(escalation(engine).question ?? "");
|
|
699
|
+
assertStringIncludes(q, "no head-current Copilot review", "the backstop's recovery instructions must reach the human");
|
|
700
|
+
assert(!q.includes("Investigate why GitHub refused"), "must not be overwritten by the generic refusal text");
|
|
701
|
+
});
|
|
702
|
+
|
|
691
703
|
// ---------------------------------------------------------------------------
|
|
692
704
|
// CI-fix sub-process (SP_cifix) — mergeCiReattempt (#134), #329
|
|
693
705
|
// ---------------------------------------------------------------------------
|
package/app/mergeRetry.test.ts
CHANGED
|
@@ -43,6 +43,33 @@ test("isTransientMergeRace: genuine blocks are NOT transient (never swallowed)",
|
|
|
43
43
|
assertEquals(isTransientMergeRace("Merge conflict; base branch has conflicts"), false); // a base conflict is a real block
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
// #923 review (Copilot "previously missed"): the `--match-head-commit` / REST `sha` pin refuses the
|
|
47
|
+
// merge when the PR head advanced past the verified head between the review backstop and the merge
|
|
48
|
+
// mutation. That refusal is the SAME class as the base/head-moved race — a transient check-then-act
|
|
49
|
+
// race GitHub resolves by re-attempting — so it must classify as `retry` (the merge loop's bounded
|
|
50
|
+
// retry re-runs the review backstop on the new head), never `blocked` (which pages a human for a
|
|
51
|
+
// race the loop is designed to absorb).
|
|
52
|
+
test("isTransientMergeRace: the pinned-head mismatch refusal is transient", () => {
|
|
53
|
+
// gh CLI `--match-head-commit` refusal (gh's own wording).
|
|
54
|
+
assertEquals(
|
|
55
|
+
isTransientMergeRace(
|
|
56
|
+
"Head commit of pull request #924 does not match '--match-head-commit' expected f5a6f20",
|
|
57
|
+
),
|
|
58
|
+
true,
|
|
59
|
+
);
|
|
60
|
+
// gh wording variant (no "expected" tail).
|
|
61
|
+
assertEquals(
|
|
62
|
+
isTransientMergeRace("Head commit of pull request #924 does not match '--match-head-commit'"),
|
|
63
|
+
true,
|
|
64
|
+
);
|
|
65
|
+
// REST PUT /pulls/{n}/merge `sha` mismatch — GitHub returns 409 Conflict.
|
|
66
|
+
assertEquals(
|
|
67
|
+
isTransientMergeRace("github 409 Conflict: Head branch was modified. Review and try the merge again."),
|
|
68
|
+
true,
|
|
69
|
+
);
|
|
70
|
+
assertEquals(isTransientMergeRace("github 409 Conflict: SHA does not match expected head"), true);
|
|
71
|
+
});
|
|
72
|
+
|
|
46
73
|
// Drive `mergePr`'s REST (token) transport: the 405 base-moved race must surface as `retry`, while
|
|
47
74
|
// a genuine 405 refusal stays `blocked`.
|
|
48
75
|
async function withMergePut(
|
|
@@ -88,3 +115,25 @@ test("mergePr: a genuine 405 refusal → outcome 'blocked' (not swallowed as tra
|
|
|
88
115
|
assertEquals(res?.outcome, "blocked");
|
|
89
116
|
});
|
|
90
117
|
});
|
|
118
|
+
|
|
119
|
+
test("mergePr: a REST 409 sha-mismatch refusal → outcome 'retry' (not blocked)", async () => {
|
|
120
|
+
await withMergePut(409, "Conflict", "Head branch was modified. Review and try the merge again.", async () => {
|
|
121
|
+
const res = await mergePr("acme/widgets", 42, "test-token", {
|
|
122
|
+
method: "squash",
|
|
123
|
+
admin: false,
|
|
124
|
+
expectedHeadSha: "f5a6f20",
|
|
125
|
+
});
|
|
126
|
+
assertEquals(res?.outcome, "retry");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("mergePr: a genuine 409 refusal → outcome 'blocked' (not swallowed as transient)", async () => {
|
|
131
|
+
await withMergePut(409, "Conflict", "Pull Request is not mergeable", async () => {
|
|
132
|
+
const res = await mergePr("acme/widgets", 42, "test-token", {
|
|
133
|
+
method: "squash",
|
|
134
|
+
admin: false,
|
|
135
|
+
expectedHeadSha: "f5a6f20",
|
|
136
|
+
});
|
|
137
|
+
assertEquals(res?.outcome, "blocked");
|
|
138
|
+
});
|
|
139
|
+
});
|
package/app/reviewWait.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DEFAULT_REVIEW_NUDGE_MINUTES,
|
|
10
10
|
DEFAULT_REVIEW_WAIT_TIMEOUT,
|
|
11
11
|
isoDurationToMs,
|
|
12
|
+
isReviewMissingOrStale,
|
|
12
13
|
isReviewStale,
|
|
13
14
|
MAX_REVIEW_NUDGE_MINUTES,
|
|
14
15
|
reviewWaitTimeout,
|
|
@@ -128,3 +129,39 @@ test("isReviewStale: a missing HEAD sha fails safe to NOT stale", () => {
|
|
|
128
129
|
test("isReviewStale: both missing is NOT stale", () => {
|
|
129
130
|
assertEquals(isReviewStale(null, null), false);
|
|
130
131
|
});
|
|
132
|
+
|
|
133
|
+
// ── isReviewMissingOrStale (issue #923) ──────────────────────────────────────
|
|
134
|
+
// The CONVERGENCE-GATE variant of the staleness predicate. `isReviewStale` intentionally fails
|
|
135
|
+
// safe to "not stale" on a missing review commit_id (a review GitHub carried no `commit_id` for
|
|
136
|
+
// must not fabricate a re-solicit loop) — but that same fail-safe is a fail-OPEN when the null
|
|
137
|
+
// means "no Copilot review exists AT ALL" (nano-bpm#1443 merged with ZERO reviews). The gate's
|
|
138
|
+
// precondition is a HEAD-CURRENT reviewer verdict, so a missing review must route to the review
|
|
139
|
+
// wait exactly like a stale one.
|
|
140
|
+
|
|
141
|
+
test("isReviewMissingOrStale: NO review (null commit_id) with a readable HEAD is stale/missing — fail CLOSED", () => {
|
|
142
|
+
// The #923 case: `readReview` verified "no Copilot review" (`commitId: null`) and the head read
|
|
143
|
+
// succeeded — the gate must NOT treat "no review" as "a verified clean review".
|
|
144
|
+
assertEquals(isReviewMissingOrStale(null, "bbb2222"), true);
|
|
145
|
+
assertEquals(isReviewMissingOrStale(undefined, "bbb2222"), true);
|
|
146
|
+
assertEquals(isReviewMissingOrStale("", "bbb2222"), true);
|
|
147
|
+
assertEquals(isReviewMissingOrStale(" ", "bbb2222"), true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("isReviewMissingOrStale: a differing review commit_id and HEAD sha is stale (same as isReviewStale)", () => {
|
|
151
|
+
assertEquals(isReviewMissingOrStale("aaa1111", "bbb2222"), true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("isReviewMissingOrStale: a head-current review is NOT stale/missing", () => {
|
|
155
|
+
assertEquals(isReviewMissingOrStale("aaa1111", "aaa1111"), false);
|
|
156
|
+
assertEquals(isReviewMissingOrStale(" aaa1111 ", "aaa1111"), false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("isReviewMissingOrStale: an UNREADABLE head fails safe to NOT stale (never fabricates a re-solicit loop)", () => {
|
|
160
|
+
// Without the head we cannot prove the review predates it NOR that a missing review should
|
|
161
|
+
// re-solicit — a transport hiccup must not strand the loop re-requesting forever (the same
|
|
162
|
+
// fail-safe `isReviewStale` keeps; the review-wait timeout remains the backstop).
|
|
163
|
+
assertEquals(isReviewMissingOrStale(null, null), false);
|
|
164
|
+
assertEquals(isReviewMissingOrStale("aaa1111", null), false);
|
|
165
|
+
assertEquals(isReviewMissingOrStale("aaa1111", ""), false);
|
|
166
|
+
assertEquals(isReviewMissingOrStale(null, ""), false);
|
|
167
|
+
});
|
package/app/reviewWait.ts
CHANGED
|
@@ -114,3 +114,29 @@ export function isReviewStale(
|
|
|
114
114
|
if (rev === "" || head === "") return false;
|
|
115
115
|
return rev !== head;
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
/** Is there NO head-current Copilot review to gate on? (issue #923) — the CONVERGENCE-GATE /
|
|
119
|
+
* merge-backstop variant of {@link isReviewStale}.
|
|
120
|
+
*
|
|
121
|
+
* `isReviewStale` intentionally fails safe to "not stale" on a missing review `commit_id` — for
|
|
122
|
+
* the POLLER, a review GitHub carried no `commit_id` for must not fabricate a re-solicit loop. But
|
|
123
|
+
* the gate's consumer is different: `fetchLatestCopilotReview` returns `commitId: null` both when
|
|
124
|
+
* no Copilot review exists AT ALL and when the newest one lacks a `commit_id`, and the gate's
|
|
125
|
+
* precondition is a HEAD-CURRENT reviewer verdict. Treating "no review" as "a verified clean
|
|
126
|
+
* review" is the fail-open that merged nano-bpm#1443 with ZERO reviews — so for the GATE, a
|
|
127
|
+
* missing review commit is stale/missing, not current.
|
|
128
|
+
*
|
|
129
|
+
* The head-side fail-safe is kept: with an UNREADABLE head we can prove nothing, so we must not
|
|
130
|
+
* fabricate a re-solicit loop on a transport hiccup (the review-wait timeout remains the
|
|
131
|
+
* backstop). Only a READABLE head + a missing/mismatched review commit routes to the review wait. */
|
|
132
|
+
export function isReviewMissingOrStale(
|
|
133
|
+
reviewCommitId: string | null | undefined,
|
|
134
|
+
headSha: string | null | undefined,
|
|
135
|
+
): boolean {
|
|
136
|
+
const head = (headSha ?? "").trim();
|
|
137
|
+
if (head === "") return false; // unverifiable head → fail safe, exactly as isReviewStale
|
|
138
|
+
const rev = (reviewCommitId ?? "").trim();
|
|
139
|
+
// A missing review commit with a readable head = NO head-current verdict → stale/missing.
|
|
140
|
+
if (rev === "") return true;
|
|
141
|
+
return rev !== head;
|
|
142
|
+
}
|
|
@@ -24,8 +24,11 @@ test("routeProgress: an addressed round whose head advanced continues", () => {
|
|
|
24
24
|
|
|
25
25
|
test("routeProgress: an explicit non-addressed round always continues (no push is expected)", () => {
|
|
26
26
|
// These are the statuses gw-status routes AWAY from the addressed/default arm — a legit no-push
|
|
27
|
-
// round. They always continue regardless of the head.
|
|
28
|
-
|
|
27
|
+
// round. They always continue regardless of the head. `review-stale` (#923 §4) joins them: the
|
|
28
|
+
// round re-entered via the converge gate's stale/missing-review path, so no reviewer verdict was
|
|
29
|
+
// obtained and no push was claimed — it must continue to the review wait, never escalate as
|
|
30
|
+
// no-progress.
|
|
31
|
+
for (const status of ["waiting", "converged", "needs_input", "blocked", "review-stale"]) {
|
|
29
32
|
assertEquals(
|
|
30
33
|
routeProgress(status, "sha-1", "sha-1"),
|
|
31
34
|
"continue",
|
|
@@ -280,6 +283,64 @@ test("progress-check: a non-addressed round records the baseline and consumes th
|
|
|
280
283
|
);
|
|
281
284
|
});
|
|
282
285
|
|
|
286
|
+
test("progress-check: a review-stale re-entry parks with waiting_since at the gate's reviewWaitCutoff, not the park time", async () => {
|
|
287
|
+
// Issue #923 review ("previously missed"): the converge-gate decides stale/missing from ITS review
|
|
288
|
+
// read, but the loop parks several tasks later — and the old park stamped `waiting_since` at the
|
|
289
|
+
// (later) park time. A head-current review landing in that read→park window was then REJECTED by
|
|
290
|
+
// `pollReviews` (`submitted_at >= waiting_since`), so the loop re-solicited and could time out
|
|
291
|
+
// despite a valid verdict already sitting unconsumed on the PR. The gate emits `reviewWaitCutoff`
|
|
292
|
+
// (its read time); the park must stamp `waiting_since` at THAT cutoff so the in-window review
|
|
293
|
+
// satisfies the wait.
|
|
294
|
+
const CUTOFF = "2026-11-08T01:00:00.000Z"; // the gate's review-read time (earlier than "now")
|
|
295
|
+
const handler = await makeUnderTest(
|
|
296
|
+
async () => "sha-2",
|
|
297
|
+
async () => ({ work: true, consumedKey: "9" }),
|
|
298
|
+
);
|
|
299
|
+
const { app, updates } = fakeApp({ last_round_head: "sha-1" });
|
|
300
|
+
const out = await handler(
|
|
301
|
+
{
|
|
302
|
+
processInstanceKey: "pik",
|
|
303
|
+
variables: {
|
|
304
|
+
prKey: "o/r#1",
|
|
305
|
+
status: "waiting",
|
|
306
|
+
repo: "o/r",
|
|
307
|
+
prNumber: 1,
|
|
308
|
+
reviewWaitCutoff: CUTOFF,
|
|
309
|
+
},
|
|
310
|
+
} as any,
|
|
311
|
+
app as any,
|
|
312
|
+
);
|
|
313
|
+
assertEquals(out, { progressed: true, huskRetries: 0 });
|
|
314
|
+
assertEquals(updates.length, 1, "one atomic park write");
|
|
315
|
+
assertEquals(updates[0]!.patch.status, "waiting_review", "the round parks for review");
|
|
316
|
+
assertEquals(
|
|
317
|
+
updates[0]!.patch.waiting_since,
|
|
318
|
+
CUTOFF,
|
|
319
|
+
"waiting_since is stamped at the gate's review-read cutoff, so a review landing in the read→park window satisfies the wait",
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("progress-check: a normal round (no reviewWaitCutoff) still stamps waiting_since at the park time", async () => {
|
|
324
|
+
// The cutoff is only emitted on a review-stale re-entry. A normal round carries no
|
|
325
|
+
// `reviewWaitCutoff`, so the park falls back to "now" — the pre-fix behaviour every existing test
|
|
326
|
+
// relies on.
|
|
327
|
+
const handler = await makeUnderTest(
|
|
328
|
+
async () => "sha-2",
|
|
329
|
+
async () => ({ work: true, consumedKey: "9" }),
|
|
330
|
+
);
|
|
331
|
+
const { app, updates } = fakeApp({ last_round_head: "sha-1" });
|
|
332
|
+
await handler(
|
|
333
|
+
{ processInstanceKey: "pik", variables: { prKey: "o/r#1", status: "waiting", repo: "o/r", prNumber: 1 } } as any,
|
|
334
|
+
app as any,
|
|
335
|
+
);
|
|
336
|
+
assertEquals(updates[0]!.patch.status, "waiting_review");
|
|
337
|
+
const stamped = updates[0]!.patch.waiting_since;
|
|
338
|
+
assertEquals(typeof stamped, "string", "a park time is stamped");
|
|
339
|
+
// Not the (absent) cutoff — a fresh "now" timestamp, i.e. close to the wall clock.
|
|
340
|
+
const driftMs = Math.abs(Date.now() - Date.parse(stamped as string));
|
|
341
|
+
assertEquals(driftMs < 60_000, true, "waiting_since falls back to the park time when no cutoff is carried");
|
|
342
|
+
});
|
|
343
|
+
|
|
283
344
|
test("progress-check: a blank/unknown status is treated as addressed — reads the head and can report no progress", async () => {
|
|
284
345
|
let called = false;
|
|
285
346
|
const handler = await makeUnderTest(async () => {
|
package/app/roundProgress.ts
CHANGED
|
@@ -39,6 +39,12 @@ const NON_ADDRESSED_STATUSES: ReadonlySet<string> = new Set([
|
|
|
39
39
|
"converged",
|
|
40
40
|
"needs_input",
|
|
41
41
|
"blocked",
|
|
42
|
+
// `review-stale` (issue #923 §4): the round re-entered persist-round via the converge gate's
|
|
43
|
+
// stale/missing-review path (f_convergeStale) — no head-current reviewer verdict was ever
|
|
44
|
+
// obtained, so the round claims no push and must always continue to the review wait (the poller
|
|
45
|
+
// re-solicits a fresh review of the current head). Treating it as `addressed` would fabricate a
|
|
46
|
+
// no-progress escalation on a round that never claimed progress.
|
|
47
|
+
"review-stale",
|
|
42
48
|
]);
|
|
43
49
|
|
|
44
50
|
/** True when a round's status is `addressed` for no-progress purposes — i.e. NOT one of the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.205.
|
|
3
|
+
"version": "0.205.2",
|
|
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",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
<nano:extend name="round" type="integer" optional="true" />
|
|
43
43
|
<nano:extend name="huskRetries" type="integer" optional="true" />
|
|
44
44
|
<nano:extend name="roundEntryHead" type="string" optional="true" />
|
|
45
|
+
<nano:extend name="reviewWaitCutoff" type="string" optional="true" />
|
|
45
46
|
</nano:shape>
|
|
46
47
|
<nano:shape id="PrCaptureHeadIn" name="Capture round-entry head — input">
|
|
47
48
|
<nano:extend name="prKey" type="string" />
|
|
@@ -68,6 +69,7 @@
|
|
|
68
69
|
<nano:extend name="convergeBlockReason" type="string" optional="true" />
|
|
69
70
|
<nano:extend name="convergeAckOnly" type="boolean" optional="true" />
|
|
70
71
|
<nano:extend name="reviewStale" type="boolean" optional="true" />
|
|
72
|
+
<nano:extend name="reviewWaitCutoff" type="string" optional="true" />
|
|
71
73
|
</nano:shape>
|
|
72
74
|
<nano:shape id="EscalationIn" name="Record escalation — input">
|
|
73
75
|
<nano:extend name="prKey" type="string" />
|
|
@@ -307,6 +309,9 @@
|
|
|
307
309
|
<zeebe:properties>
|
|
308
310
|
<zeebe:property name="io.nanobpm.dataEnvelope.in" value="PrPersistRoundIn" />
|
|
309
311
|
</zeebe:properties>
|
|
312
|
+
<zeebe:ioMapping>
|
|
313
|
+
<zeebe:input source="=if reviewStale = true then "review-stale" else status" target="status" />
|
|
314
|
+
</zeebe:ioMapping>
|
|
310
315
|
</bpmn:extensionElements>
|
|
311
316
|
<bpmn:incoming>f_advSkip</bpmn:incoming>
|
|
312
317
|
<bpmn:incoming>f_advClean</bpmn:incoming>
|
|
@@ -282,7 +282,7 @@
|
|
|
282
282
|
</zeebe:properties>
|
|
283
283
|
<zeebe:ioMapping>
|
|
284
284
|
<zeebe:input source="=status" target="agentVerdict" />
|
|
285
|
-
<zeebe:input source="="This PR cannot be merged and needs a human decision. " + (if mergeState = "conflict" then "The automated rebase agent could not resolve the merge conflicts on this branch. Resolve them manually (or reply with guidance), then reply to retry." else if mergeStatus = "retry" then "The merge kept losing a race with concurrent updates to the base branch and exhausted its automatic retry budget (" + string(mergeRetryMax) + " retries). This is a repeated transient race, not a conflict, failing check, or permissions block. Reply to retry, or investigate why the base branch keeps moving." else if mergeState = "ready" then "The merge attempt did not land (merge result: " + mergeStatus + "). Investigate why GitHub refused the merge, then reply to retry." else if status = "blocked" then "The automated CI-fix agent could not make the required checks green. Fix the failing checks on the branch (or reply with guidance), then reply to retry." else "The automated CI-fix agent exceeded its time budget (SLA) before the required checks went green. Check its progress or intervene, then reply to retry.")" target="question" />
|
|
285
|
+
<zeebe:input source="="This PR cannot be merged and needs a human decision. " + (if mergeState = "conflict" then "The automated rebase agent could not resolve the merge conflicts on this branch. Resolve them manually (or reply with guidance), then reply to retry." else if mergeStatus = "retry" then "The merge kept losing a race with concurrent updates to the base branch and exhausted its automatic retry budget (" + string(mergeRetryMax) + " retries). This is a repeated transient race, not a conflict, failing check, or permissions block. Reply to retry, or investigate why the base branch keeps moving." else if mergeState = "ready" and mergeStatus = "blocked" and question != null then question else if mergeState = "ready" then "The merge attempt did not land (merge result: " + mergeStatus + "). Investigate why GitHub refused the merge, then reply to retry." else if status = "blocked" then "The automated CI-fix agent could not make the required checks green. Fix the failing checks on the branch (or reply with guidance), then reply to retry." else "The automated CI-fix agent exceeded its time budget (SLA) before the required checks went green. Check its progress or intervene, then reply to retry.")" target="question" />
|
|
286
286
|
<zeebe:input source="="blocked"" target="status" />
|
|
287
287
|
</zeebe:ioMapping>
|
|
288
288
|
</bpmn:extensionElements>
|
|
@@ -29,6 +29,15 @@
|
|
|
29
29
|
// re-enters the review wait for a fresh review of the current HEAD. The head read fails OPEN, so a
|
|
30
30
|
// transport hiccup can never fabricate a stale verdict.
|
|
31
31
|
//
|
|
32
|
+
// MISSING-REVIEW GUARD (issue #923): the same `reviewStale` signal fires when NO Copilot review
|
|
33
|
+
// exists at all — the gate's single most important precondition. The old code treated
|
|
34
|
+
// `{ body: "", commitId: null }` as "a verified clean review" and converged, merging
|
|
35
|
+
// nano-bpm#1443 with ZERO reviews. A missing review now fails CLOSED into the re-solicit path.
|
|
36
|
+
//
|
|
37
|
+
// VERDICT GATE (issue #923 §2): even a head-current review BLOCKS when its own summary reports
|
|
38
|
+
// open, actionable findings ("Changes recommended" / "N open issue(s)" / a v2 "Open (N)" section)
|
|
39
|
+
// — zero unresolved threads is not a clean verdict.
|
|
40
|
+
//
|
|
32
41
|
// Scope integrity is NO LONGER judged here. A deterministic regex over the PR description could not
|
|
33
42
|
// read the closed issue's acceptance criteria, so it false-positived on any body that merely
|
|
34
43
|
// *mentioned* deferral (an ADR non-goal, a PR whose subject is scope tooling) and forced needless
|
|
@@ -49,10 +58,11 @@ import {
|
|
|
49
58
|
fetchReviewThreads,
|
|
50
59
|
isAckThread,
|
|
51
60
|
parseAckedAdvisories,
|
|
61
|
+
parseReviewOpenFindings,
|
|
52
62
|
parseSuppressedAdvisories,
|
|
53
63
|
type ReviewThread,
|
|
54
64
|
} from "../../app/github.ts";
|
|
55
|
-
import {
|
|
65
|
+
import { isReviewMissingOrStale } from "../../app/reviewWait.ts";
|
|
56
66
|
import { parsePr } from "../../app/service.ts";
|
|
57
67
|
import type { WorkerInputs, WorkerOutputs } from "../../nano-generated/worker-io.d.ts";
|
|
58
68
|
import { type HeadReader, makeDefaultReadHead } from "../progress-check/worker.ts";
|
|
@@ -118,17 +128,57 @@ export function makeHandler(deps: {
|
|
|
118
128
|
if (review === null) {
|
|
119
129
|
return { convergeBlocked: true, convergeBlockReason: BLOCK_UNVERIFIABLE, convergeAckOnly: false, reviewStale: false };
|
|
120
130
|
}
|
|
121
|
-
// STALE-REVIEW GUARD (
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
131
|
+
// STALE-OR-MISSING-REVIEW GUARD (issues #799 / #923). Convergence requires a HEAD-CURRENT
|
|
132
|
+
// reviewer verdict. Two states fail that precondition and must route to the review wait (the
|
|
133
|
+
// poller (re-)solicits a review of the current HEAD) rather than be gated on:
|
|
134
|
+
// • STALE (#799): the PR HEAD has advanced PAST the commit the latest Copilot review was
|
|
135
|
+
// submitted against — its suppressed advisories describe code the head has moved past
|
|
136
|
+
// (e.g. an advisory the agent already FIXED IN CODE but did not ack), so blocking on the
|
|
137
|
+
// obsolete body re-escalates a human indefinitely (PR #789).
|
|
138
|
+
// • MISSING (#923): NO Copilot review exists at all (`commitId: null` with a readable
|
|
139
|
+
// head). The old gate treated that as "a verified clean review" — zero threads, zero
|
|
140
|
+
// advisories → not blocked — and merged nano-bpm#1443 with ZERO reviews. A missing
|
|
141
|
+
// review is the gate's single most important precondition, so it fails CLOSED into the
|
|
142
|
+
// same re-solicit path (`isReviewMissingOrStale`), never through to finalize.
|
|
143
|
+
// The head read still fails OPEN (null → not stale): without the head we can prove nothing,
|
|
144
|
+
// so a transport hiccup can never fabricate a stale/missing verdict that would loop the loop
|
|
145
|
+
// re-soliciting forever — the review-wait timeout remains the backstop.
|
|
146
|
+
//
|
|
147
|
+
// ARRIVAL-WINDOW CUTOFF (issue #923 review, "previously missed"): the stale/missing verdict
|
|
148
|
+
// is decided from the review read ABOVE — but the loop only PARKS for the next review several
|
|
149
|
+
// tasks later (persist-round → check-progress), and check-progress stamps `waiting_since` at
|
|
150
|
+
// THAT (later) park time. A head-current review that lands in the window BETWEEN this read
|
|
151
|
+
// and the park would then be REJECTED by `pollReviews` (it filters
|
|
152
|
+
// `submitted_at >= waiting_since`), so the loop re-solicits and can time out despite a valid
|
|
153
|
+
// verdict already sitting unconsumed on the PR. Emit the time of THIS read as
|
|
154
|
+
// `reviewWaitCutoff` so check-progress can park with `waiting_since` at the cutoff — any
|
|
155
|
+
// review that arrived in the window then satisfies the wait instead of being discarded.
|
|
129
156
|
const headSha = await readHeadSha(ghRepo, ghNumber).catch(() => null);
|
|
130
|
-
if (
|
|
131
|
-
return {
|
|
157
|
+
if (isReviewMissingOrStale(review.commitId, headSha)) {
|
|
158
|
+
return {
|
|
159
|
+
convergeBlocked: false,
|
|
160
|
+
convergeBlockReason: "",
|
|
161
|
+
convergeAckOnly: false,
|
|
162
|
+
reviewStale: true,
|
|
163
|
+
reviewWaitCutoff: new Date().toISOString(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
// VERDICT GATE (issue #923 §2): a head-current review whose OWN summary reports open,
|
|
167
|
+
// actionable findings (Copilot's "Changes recommended" / "N open issue(s)" / a v2 "Open (N)"
|
|
168
|
+
// section) blocks the same way an unresolved thread does — even with zero unresolved threads
|
|
169
|
+
// (a review can carry findings as summary-only, as #1443's 7-finding review did). This block
|
|
170
|
+
// is NOT ack-only: the findings need the round agent's code/reply work, so it escalates to a
|
|
171
|
+
// human rather than entering the bounded auto-ack path.
|
|
172
|
+
const openFindings = parseReviewOpenFindings(review.body);
|
|
173
|
+
if (openFindings.length > 0) {
|
|
174
|
+
return {
|
|
175
|
+
convergeBlocked: true,
|
|
176
|
+
convergeBlockReason:
|
|
177
|
+
`Convergence blocked: the latest head-current Copilot review reports ${openFindings.join("; ")}. ` +
|
|
178
|
+
`Address every finding the review raised (or reply-and-resolve why each is a false positive) before converging.`,
|
|
179
|
+
convergeAckOnly: false,
|
|
180
|
+
reviewStale: false,
|
|
181
|
+
};
|
|
132
182
|
}
|
|
133
183
|
// Split the unresolved threads into SUBSTANTIVE reviewer findings vs partially-completed
|
|
134
184
|
// `nano-ack:` ACK threads (root carries a canonical `nano-ack: <path> :: <text>` marker — see
|
|
@@ -8,7 +8,7 @@ import { test } from "node:test";
|
|
|
8
8
|
import { assertEquals } from "#test-assert";
|
|
9
9
|
import { _clearMergeProtocolCache } from "../../app/mergeProtocol.ts";
|
|
10
10
|
import { noopLog } from "../../test/log.ts";
|
|
11
|
-
import handler from "./worker.ts";
|
|
11
|
+
import handler, { makeHandler } from "./worker.ts";
|
|
12
12
|
|
|
13
13
|
function fakeApp() {
|
|
14
14
|
const stores: Record<string, Record<string, unknown>[]> = {
|
|
@@ -294,3 +294,194 @@ test("pr.merge abandons a closed-not-merged PR without escalating (terminal aban
|
|
|
294
294
|
},
|
|
295
295
|
);
|
|
296
296
|
});
|
|
297
|
+
|
|
298
|
+
// ── Merge-loop backstop (issue #923 §3) ──────────────────────────────────────
|
|
299
|
+
// Just before landing, the worker re-checks that a HEAD-CURRENT Copilot review exists. A head that
|
|
300
|
+
// moved between convergence and landing — or a review that never existed (the nano-bpm#1443
|
|
301
|
+
// fail-open: merged with ZERO reviews) — must block the landing, not slip through. These tests
|
|
302
|
+
// drive the backstop through `makeHandler`'s injected readers; the fetch stub only needs the
|
|
303
|
+
// single-PR GET (liveness) since the injected readers replace the review/head fetches.
|
|
304
|
+
|
|
305
|
+
function backstopRoutes(url: string): Response | null {
|
|
306
|
+
if (/\/pulls\/\d+$/.test(url)) {
|
|
307
|
+
return new Response(JSON.stringify({ merged: false, mergeable_state: "clean" }));
|
|
308
|
+
}
|
|
309
|
+
return null; // no AGENTS.md / merge-protocol.json → DEFAULT gh-merge protocol
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
test("pr.merge #923: refuses to land when NO Copilot review exists (the #1443 fail-open)", async () => {
|
|
313
|
+
await withGithub(backstopRoutes, async (calls) => {
|
|
314
|
+
const { app, stores } = fakeApp();
|
|
315
|
+
const backstop = makeHandler({
|
|
316
|
+
// Verified "no Copilot review on this PR" + a readable head → missing → BLOCK.
|
|
317
|
+
readReview: async () => ({ body: "", commitId: null }),
|
|
318
|
+
readHeadSha: async () => "headsha4444444444444444444444444444444444",
|
|
319
|
+
});
|
|
320
|
+
const out = (await backstop(
|
|
321
|
+
{ variables: { prKey: "acme/widgets#21", repo: "acme/widgets", prNumber: 21 } } as any,
|
|
322
|
+
app,
|
|
323
|
+
)) as Record<string, unknown>;
|
|
324
|
+
|
|
325
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
326
|
+
assertEquals(out.status, "blocked");
|
|
327
|
+
assertEquals(typeof out.question, "string");
|
|
328
|
+
assertEquals((out.question as string).includes("no head-current Copilot review"), true);
|
|
329
|
+
|
|
330
|
+
// The refusal is audited, and NO land was attempted (no merge PUT, no enqueue comment).
|
|
331
|
+
assertEquals(stores.merges.length, 1);
|
|
332
|
+
assertEquals(stores.merges[0].outcome, "blocked");
|
|
333
|
+
assertEquals(stores.merges[0].method, "review-backstop");
|
|
334
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
335
|
+
assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("pr.merge #923: refuses to land when the head MOVED past the latest review (stale at land time)", async () => {
|
|
340
|
+
await withGithub(backstopRoutes, async (calls) => {
|
|
341
|
+
const { app, stores } = fakeApp();
|
|
342
|
+
const backstop = makeHandler({
|
|
343
|
+
// The review was submitted against the OLD head; the branch has since advanced.
|
|
344
|
+
readReview: async () => ({ body: "lgtm", commitId: "oldsha1111111111111111111111111111111111" }),
|
|
345
|
+
readHeadSha: async () => "newsha2222222222222222222222222222222222",
|
|
346
|
+
});
|
|
347
|
+
const out = (await backstop(
|
|
348
|
+
{ variables: { prKey: "acme/widgets#23", repo: "acme/widgets", prNumber: 23 } } as any,
|
|
349
|
+
app,
|
|
350
|
+
)) as Record<string, unknown>;
|
|
351
|
+
|
|
352
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
353
|
+
assertEquals(stores.merges[0].method, "review-backstop");
|
|
354
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("pr.merge #923: a HEAD-CURRENT review lands normally (backstop control)", async () => {
|
|
359
|
+
await withGithub(
|
|
360
|
+
(url) => {
|
|
361
|
+
if (/\/pulls\/\d+\/merge$/.test(url)) return new Response(JSON.stringify({ merged: true }), { status: 200 });
|
|
362
|
+
return backstopRoutes(url);
|
|
363
|
+
},
|
|
364
|
+
async (calls) => {
|
|
365
|
+
const { app, stores } = fakeApp();
|
|
366
|
+
const backstop = makeHandler({
|
|
367
|
+
readReview: async () => ({ body: "lgtm", commitId: "samesha33333333333333333333333333333333" }),
|
|
368
|
+
readHeadSha: async () => "samesha33333333333333333333333333333333",
|
|
369
|
+
});
|
|
370
|
+
const out = (await backstop(
|
|
371
|
+
{ variables: { prKey: "acme/widgets#25", repo: "acme/widgets", prNumber: 25 } } as any,
|
|
372
|
+
app,
|
|
373
|
+
)) as Record<string, unknown>;
|
|
374
|
+
|
|
375
|
+
// The backstop passes and the ordinary land protocol runs (direct merge).
|
|
376
|
+
assertEquals(out, { mergeStatus: "merged" });
|
|
377
|
+
assertEquals(
|
|
378
|
+
calls.some((c) => /\/pulls\/\d+\/merge$/.test(c.url) && c.method === "PUT"),
|
|
379
|
+
true,
|
|
380
|
+
);
|
|
381
|
+
assertEquals(stores.merges.some((m) => m.method === "review-backstop"), false);
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("pr.merge #923: an UNREADABLE review/head fails OPEN (a transport hiccup never wedges a merge)", async () => {
|
|
387
|
+
await withGithub(
|
|
388
|
+
(url) => {
|
|
389
|
+
if (/\/pulls\/\d+\/merge$/.test(url)) return new Response(JSON.stringify({ merged: true }), { status: 200 });
|
|
390
|
+
return backstopRoutes(url);
|
|
391
|
+
},
|
|
392
|
+
async (calls) => {
|
|
393
|
+
const { app, stores } = fakeApp();
|
|
394
|
+
const backstop = makeHandler({
|
|
395
|
+
readReview: async () => null, // no usable transport → unverifiable, NOT "no review"
|
|
396
|
+
readHeadSha: async () => {
|
|
397
|
+
throw new Error("boom");
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
const out = (await backstop(
|
|
401
|
+
{ variables: { prKey: "acme/widgets#27", repo: "acme/widgets", prNumber: 27 } } as any,
|
|
402
|
+
app,
|
|
403
|
+
)) as Record<string, unknown>;
|
|
404
|
+
|
|
405
|
+
assertEquals(out, { mergeStatus: "merged" });
|
|
406
|
+
assertEquals(
|
|
407
|
+
calls.some((c) => /\/pulls\/\d+\/merge$/.test(c.url) && c.method === "PUT"),
|
|
408
|
+
true,
|
|
409
|
+
);
|
|
410
|
+
assertEquals(stores.merges.some((m) => m.method === "review-backstop"), false);
|
|
411
|
+
},
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("pr.merge #923: a head-current review whose summary reports OPEN findings blocks landing", async () => {
|
|
416
|
+
await withGithub(backstopRoutes, async (calls) => {
|
|
417
|
+
const { app, stores } = fakeApp();
|
|
418
|
+
const backstop = makeHandler({
|
|
419
|
+
readReview: async () => ({ body: "### 🟡 Changes recommended\n<summary>2 open issues</summary>", commitId: "h1" }),
|
|
420
|
+
readHeadSha: async () => "h1",
|
|
421
|
+
});
|
|
422
|
+
const out = (await backstop({ variables: { prKey: "acme/widgets#29", repo: "acme/widgets", prNumber: 29 } } as any, app)) as Record<string, unknown>;
|
|
423
|
+
assertEquals(out.mergeStatus, "blocked");
|
|
424
|
+
assertEquals((out.question as string).includes("open findings"), true);
|
|
425
|
+
assertEquals(stores.merges[0].method, "review-backstop");
|
|
426
|
+
assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
for (const [label, readHeadSha] of [
|
|
431
|
+
["returns null", async () => null],
|
|
432
|
+
["throws", async () => { throw new Error("transient"); }],
|
|
433
|
+
] as const) {
|
|
434
|
+
test(`pr.merge #923: an unverifiable head (read ${label}) never blocks on a possibly-stale review's open findings`, async () => {
|
|
435
|
+
await withGithub(backstopRoutes, async () => {
|
|
436
|
+
const { app, stores } = fakeApp();
|
|
437
|
+
const backstop = makeHandler({
|
|
438
|
+
readReview: async () => ({ body: "### 🟡 Changes recommended\n<summary>2 open issues</summary>", commitId: "old" }),
|
|
439
|
+
readHeadSha,
|
|
440
|
+
});
|
|
441
|
+
await backstop({ variables: { prKey: "acme/widgets#29", repo: "acme/widgets", prNumber: 29 } } as any, app);
|
|
442
|
+
assertEquals(stores.merges.some((m) => m.method === "review-backstop"), false);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
test("pr.merge #923: the direct merge pins the verified head SHA (no check-then-merge race)", async () => {
|
|
448
|
+
let putBody = "";
|
|
449
|
+
await withGithub(
|
|
450
|
+
(url, init) => {
|
|
451
|
+
if (/\/pulls\/\d+\/merge$/.test(url)) {
|
|
452
|
+
putBody = String(init?.body ?? "");
|
|
453
|
+
return new Response(JSON.stringify({ merged: true }), { status: 200 });
|
|
454
|
+
}
|
|
455
|
+
return backstopRoutes(url);
|
|
456
|
+
},
|
|
457
|
+
async () => {
|
|
458
|
+
const { app } = fakeApp();
|
|
459
|
+
const backstop = makeHandler({ readReview: async () => ({ body: "lgtm", commitId: "h2" }), readHeadSha: async () => "h2" });
|
|
460
|
+
await backstop({ variables: { prKey: "acme/widgets#31", repo: "acme/widgets", prNumber: 31 } } as any, app);
|
|
461
|
+
assertEquals(JSON.parse(putBody).sha, "h2");
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("pr.merge #923: a queue-comment repo whose head moved after the backstop retries instead of enqueueing", async () => {
|
|
467
|
+
const protocol =
|
|
468
|
+
"# repo\n\n```merge-protocol\n{ \"land\": { \"method\": \"mergify-queue\", \"comment\": \"@mergifyio queue\" } }\n```\n";
|
|
469
|
+
await withGithub(
|
|
470
|
+
(url) => {
|
|
471
|
+
if (/\/contents\/AGENTS\.md$/.test(url)) return new Response(protocol);
|
|
472
|
+
if (/\/issues\/\d+\/comments$/.test(url)) return new Response(JSON.stringify({ id: 1 }), { status: 201 });
|
|
473
|
+
return backstopRoutes(url);
|
|
474
|
+
},
|
|
475
|
+
async (calls) => {
|
|
476
|
+
const { app } = fakeApp();
|
|
477
|
+
const heads = ["h3", "h4"];
|
|
478
|
+
const backstop = makeHandler({
|
|
479
|
+
readReview: async () => ({ body: "lgtm", commitId: "h3" }),
|
|
480
|
+
readHeadSha: async () => heads.shift() ?? "h4",
|
|
481
|
+
});
|
|
482
|
+
const out = (await backstop({ variables: { prKey: "acme/widgets#33", repo: "acme/widgets", prNumber: 33 } } as any, app)) as Record<string, unknown>;
|
|
483
|
+
assertEquals(out.mergeStatus, "retry");
|
|
484
|
+
assertEquals(calls.some((c) => /\/issues\/\d+\/comments$/.test(c.url) && c.method === "POST"), false);
|
|
485
|
+
},
|
|
486
|
+
);
|
|
487
|
+
});
|
package/workers/merge/worker.ts
CHANGED
|
@@ -10,12 +10,32 @@
|
|
|
10
10
|
// waiting for the queue, NOT a direct `gh pr merge` — which that repo refuses. The actual gh/API
|
|
11
11
|
// calls live in app/github.ts; this worker records the attempt in the `merges` audit table and
|
|
12
12
|
// shapes the escalation payload on a block.
|
|
13
|
+
//
|
|
14
|
+
// MERGE-LOOP BACKSTOP (issue #923 §3): the convergence gate's verdict is only as fresh as the head
|
|
15
|
+
// it reviewed — a head that MOVED between convergence and landing, or a review that never existed
|
|
16
|
+
// (the #1443 fail-open), must not slip through at land time. Before running ANY land protocol this
|
|
17
|
+
// worker re-checks that a HEAD-CURRENT Copilot review exists; a missing/stale review blocks the
|
|
18
|
+
// landing (escalating to a human) instead of enqueueing/merging unreviewed code. The check fails
|
|
19
|
+
// OPEN only when the GitHub state is UNREADABLE (no transport / a hiccup) — the merge-stage
|
|
20
|
+
// mergeability polling is the independent backstop there, and a hard block on a transient read
|
|
21
|
+
// would wedge every merge on a network blip.
|
|
13
22
|
import type { AppJobHandler } from "@nanobpm/urban";
|
|
14
23
|
import { matchTags, tag } from "@nanobpm/urban/effect";
|
|
15
24
|
import { abandonTokenFromUrl } from "../../app/abandon.ts";
|
|
16
25
|
import { checkBaseTarget, classifyBaseGuard } from "../../app/baseGuard.ts";
|
|
17
|
-
import {
|
|
26
|
+
import { type HeadReader, makeDefaultReadHead } from "../../app/currentHead.ts";
|
|
27
|
+
import {
|
|
28
|
+
classifyPrLiveness,
|
|
29
|
+
enqueueViaComment,
|
|
30
|
+
fetchBranchHead,
|
|
31
|
+
fetchLatestCopilotReview,
|
|
32
|
+
fetchPrHead,
|
|
33
|
+
fetchPrState,
|
|
34
|
+
mergePr,
|
|
35
|
+
parseReviewOpenFindings,
|
|
36
|
+
} from "../../app/github.ts";
|
|
18
37
|
import { classifyMergeLanding, DEFAULT_MERGE_PROTOCOL, loadMergeProtocol } from "../../app/mergeProtocol.ts";
|
|
38
|
+
import { isReviewMissingOrStale } from "../../app/reviewWait.ts";
|
|
19
39
|
import { abandonClosedPr, ensurePr, MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
|
|
20
40
|
import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
|
|
21
41
|
|
|
@@ -28,7 +48,25 @@ interface Out extends Record<string, unknown> {
|
|
|
28
48
|
question?: string;
|
|
29
49
|
}
|
|
30
50
|
|
|
31
|
-
|
|
51
|
+
// The backstop's review reader — the SAME shape the converge-gate binds (`{ body, commitId }`,
|
|
52
|
+
// `null` = unverifiable). Injectable so tests drive the missing/stale/head-current matrix without
|
|
53
|
+
// a transport.
|
|
54
|
+
export type MergeReviewReader = (repo: string, prNumber: number) => Promise<{ body: string; commitId: string | null } | null>;
|
|
55
|
+
|
|
56
|
+
const defaultReadReview: MergeReviewReader = (repo, prNumber) =>
|
|
57
|
+
fetchLatestCopilotReview(repo, prNumber, process.env.GITHUB_TOKEN ?? "");
|
|
58
|
+
// The SAME branch-ref-over-stale-`head.sha` reader the converge-gate / progress-check / poller
|
|
59
|
+
// bind (#786/#799), so the backstop's staleness comparison sees the exact head the gate did.
|
|
60
|
+
const defaultReadHead: HeadReader = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
|
|
61
|
+
|
|
62
|
+
const BLOCK_NO_HEAD_CURRENT_REVIEW =
|
|
63
|
+
"no head-current Copilot review exists for the PR's current HEAD (the head moved after the last review, or no review was ever submitted). A head-current reviewer verdict is required before landing";
|
|
64
|
+
|
|
65
|
+
/** Build the handler with injectable GitHub readers. The default export binds the real readers;
|
|
66
|
+
* tests inject stubs for the backstop matrix. */
|
|
67
|
+
export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha: HeadReader }): AppJobHandler<In, Out> {
|
|
68
|
+
const { readReview, readHeadSha } = deps;
|
|
69
|
+
const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
32
70
|
const { prKey, repo, prNumber, round, abandonUrl } = job.variables;
|
|
33
71
|
const token = process.env.GITHUB_TOKEN ?? "";
|
|
34
72
|
const now = new Date().toISOString();
|
|
@@ -88,6 +126,68 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
88
126
|
return { mergeStatus: "abandoned" };
|
|
89
127
|
}
|
|
90
128
|
|
|
129
|
+
// MERGE-LOOP BACKSTOP (issue #923 §3): refuse to land without a HEAD-CURRENT Copilot review. The
|
|
130
|
+
// convergence gate's verdict is only as fresh as the head it reviewed — a head that MOVED between
|
|
131
|
+
// convergence and landing (a fix-ci / rebase push, an out-of-band commit), or a review that never
|
|
132
|
+
// existed at all (the nano-bpm#1443 fail-open: merged with ZERO reviews), must not slip through
|
|
133
|
+
// at land time. Runs AFTER the merged/closed short-circuits (an already-terminal PR needs no
|
|
134
|
+
// verdict) and BEFORE any land protocol (the `@mergifyio queue` comment is itself the landing
|
|
135
|
+
// for a queue repo). Fails OPEN only when the GitHub state is UNREADABLE (no transport / a
|
|
136
|
+
// hiccup → `null` review or head): a transient read failure must not wedge every merge — the
|
|
137
|
+
// merge-stage mergeability polling is the independent backstop there.
|
|
138
|
+
const [backstopReview, backstopHead] = await Promise.all([
|
|
139
|
+
readReview(repo, prNumber).catch(() => null),
|
|
140
|
+
readHeadSha(repo, prNumber).catch(() => null),
|
|
141
|
+
]);
|
|
142
|
+
if (backstopReview !== null && isReviewMissingOrStale(backstopReview.commitId, backstopHead)) {
|
|
143
|
+
await app.data.table("merges", "id").insert({
|
|
144
|
+
pr_key: prKey,
|
|
145
|
+
outcome: "blocked",
|
|
146
|
+
method: "review-backstop",
|
|
147
|
+
detail: BLOCK_NO_HEAD_CURRENT_REVIEW,
|
|
148
|
+
at: now,
|
|
149
|
+
});
|
|
150
|
+
return {
|
|
151
|
+
mergeStatus: "blocked",
|
|
152
|
+
status: "blocked",
|
|
153
|
+
question:
|
|
154
|
+
`Merge refused: ${BLOCK_NO_HEAD_CURRENT_REVIEW}. ` +
|
|
155
|
+
`Push no further commits, wait for the Copilot review of the current HEAD to land (or request one: ` +
|
|
156
|
+
`gh api repos/${repo}/pulls/${prNumber}/requested_reviewers -X POST -f "reviewers[]=copilot-pull-request-reviewer[bot]"), ` +
|
|
157
|
+
`let the convergence loop gate on it, then reply to retry.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// A head-current review whose OWN summary still reports open findings (the same canonical
|
|
162
|
+
// verdict parser the converge-gate uses) must block too: a summary-only "Changes recommended"
|
|
163
|
+
// review that landed after convergence has no unresolved thread for mergeability polling to see.
|
|
164
|
+
// Only evaluate the verdict once head-currentness is VERIFIED (review read AND head read succeeded
|
|
165
|
+
// and match) — an unreadable head fails open here like the stale check above, never blocking on a
|
|
166
|
+
// possibly-stale review's body.
|
|
167
|
+
const reviewIsHeadCurrent =
|
|
168
|
+
backstopReview !== null && backstopHead !== null && backstopHead !== "" && backstopReview.commitId === backstopHead;
|
|
169
|
+
const openFindings = reviewIsHeadCurrent ? parseReviewOpenFindings(backstopReview.body) : [];
|
|
170
|
+
if (openFindings.length > 0) {
|
|
171
|
+
const detail = `the head-current Copilot review reports open findings (${openFindings.join(", ")})`;
|
|
172
|
+
await app.data.table("merges", "id").insert({
|
|
173
|
+
pr_key: prKey,
|
|
174
|
+
outcome: "blocked",
|
|
175
|
+
method: "review-backstop",
|
|
176
|
+
detail,
|
|
177
|
+
at: now,
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
mergeStatus: "blocked",
|
|
181
|
+
status: "blocked",
|
|
182
|
+
question:
|
|
183
|
+
`Merge refused: ${detail}. Address the review's findings on the branch (or reply with ` +
|
|
184
|
+
`guidance on which to push back on), let the convergence loop gate on a clean review, then reply to retry.`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// The head the backstop verified — pinned through the land call so a push between this check
|
|
188
|
+
// and the merge can never land a later, unreviewed head.
|
|
189
|
+
const verifiedHead = backstopReview !== null && backstopHead ? backstopHead : undefined;
|
|
190
|
+
|
|
91
191
|
// Dead-end-base guard (#60): never land a PR into a base branch that has itself already merged
|
|
92
192
|
// to the default branch — the merge would land into a dead branch and never reach `main`.
|
|
93
193
|
// GitHub only auto-retargets a PR when its base is *deleted* on merge; a merged-but-undeleted
|
|
@@ -125,9 +225,17 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
125
225
|
// Land via the repo's on-demand queue: post the enqueue comment; the poller's queued→landed
|
|
126
226
|
// watch (service.ts block 3) then advances the process when the queue merges it.
|
|
127
227
|
const comment = protocol?.land.comment ?? "@mergifyio queue";
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
228
|
+
// A comment can't pin a SHA, so re-read the head right before enqueueing: if it moved past the
|
|
229
|
+
// verified head, re-enter via the bounded retry (which re-runs the backstop on the new head).
|
|
230
|
+
const headNow = verifiedHead ? await readHeadSha(repo, prNumber).catch(() => null) : null;
|
|
231
|
+
if (verifiedHead && headNow !== null && headNow !== verifiedHead) {
|
|
232
|
+
outcome = "retry";
|
|
233
|
+
detail = `head moved from ${verifiedHead} to ${headNow} after the review backstop; re-verifying before enqueue`;
|
|
234
|
+
} else {
|
|
235
|
+
const ok = await enqueueViaComment(repo, prNumber, token, comment);
|
|
236
|
+
outcome = ok ? "queued" : "blocked";
|
|
237
|
+
detail = ok ? `enqueued via "${comment}"` : `failed to post enqueue comment "${comment}"`;
|
|
238
|
+
}
|
|
131
239
|
auditMethod = "queue-comment";
|
|
132
240
|
} else if (classifyMergeLanding(protocol ?? DEFAULT_MERGE_PROTOCOL) === "decision-required") {
|
|
133
241
|
// The repo requires a human to click Merge (`land.method=ui`); Merlin can't. This is the
|
|
@@ -137,7 +245,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
137
245
|
auditMethod = "ui";
|
|
138
246
|
} else {
|
|
139
247
|
const admin = method === "admin" || MERGE_ADMIN;
|
|
140
|
-
const res = await mergePr(repo, prNumber, token, { method: MERGE_METHOD, admin });
|
|
248
|
+
const res = await mergePr(repo, prNumber, token, { method: MERGE_METHOD, admin, expectedHeadSha: verifiedHead });
|
|
141
249
|
// No usable transport → treat as a block so a human is asked to configure/merge, rather than
|
|
142
250
|
// silently completing the process without landing the PR.
|
|
143
251
|
outcome = res?.outcome ?? "blocked";
|
|
@@ -180,6 +288,9 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
|
|
|
180
288
|
`Resolve it on GitHub (rebase / fix a required check / grant merge rights), then reply to retry.${docHint}`,
|
|
181
289
|
}),
|
|
182
290
|
});
|
|
183
|
-
};
|
|
291
|
+
};
|
|
292
|
+
return handler;
|
|
293
|
+
}
|
|
184
294
|
|
|
295
|
+
const handler = makeHandler({ readReview: defaultReadReview, readHeadSha: defaultReadHead });
|
|
185
296
|
export default handler;
|
|
@@ -296,7 +296,7 @@ export function makeHandler(deps: {
|
|
|
296
296
|
readRounds?: RoundsReader;
|
|
297
297
|
}): AppJobHandler<In, Out> {
|
|
298
298
|
return async (job, app) => {
|
|
299
|
-
const { prKey, status, repo, prNumber, round, huskRetries, roundEntryHead } = job.variables;
|
|
299
|
+
const { prKey, status, repo, prNumber, round, huskRetries, roundEntryHead, reviewWaitCutoff } = job.variables;
|
|
300
300
|
const jobKey = job.jobKey;
|
|
301
301
|
|
|
302
302
|
const prs = app.data.table<{
|
|
@@ -356,6 +356,13 @@ export function makeHandler(deps: {
|
|
|
356
356
|
status?: "waiting_review" | "converging";
|
|
357
357
|
agentWatermark?: string | null;
|
|
358
358
|
churnEscalatedRound?: number;
|
|
359
|
+
/** The review-wait start to stamp instead of "now" (issue #923 review): the converge-gate
|
|
360
|
+
* emits `reviewWaitCutoff` (the time of ITS review read) when it signals `reviewStale`, so
|
|
361
|
+
* a head-current review that lands in the window between that read and this park still
|
|
362
|
+
* satisfies `pollReviews`' `submitted_at >= waiting_since` filter instead of being
|
|
363
|
+
* discarded — closing the arrival-window race that otherwise re-solicits and times out
|
|
364
|
+
* despite a valid verdict already sitting unconsumed on the PR. */
|
|
365
|
+
waitingSince?: string;
|
|
359
366
|
},
|
|
360
367
|
): Promise<Out> => {
|
|
361
368
|
const ts = new Date().toISOString();
|
|
@@ -368,7 +375,9 @@ export function makeHandler(deps: {
|
|
|
368
375
|
// Only overwrite the baseline when we actually read a head, so a null (unreadable) head
|
|
369
376
|
// never clobbers a good `last_round_head`.
|
|
370
377
|
...(opts.head ? { last_round_head: opts.head } : {}),
|
|
371
|
-
...(opts.status === "waiting_review"
|
|
378
|
+
...(opts.status === "waiting_review"
|
|
379
|
+
? { status: "waiting_review", waiting_since: opts.waitingSince ?? ts }
|
|
380
|
+
: {}),
|
|
372
381
|
...(opts.status === "converging" ? { status: "converging" } : {}),
|
|
373
382
|
// Advance the attempt watermark ONLY when an agent-work read consumed a `review-round`
|
|
374
383
|
// instance (Copilot #789); `undefined` leaves the persisted watermark untouched (an
|
|
@@ -392,8 +401,18 @@ export function makeHandler(deps: {
|
|
|
392
401
|
const parsed = parsePr(prKey);
|
|
393
402
|
const ghRepo = repo ?? parsed?.repo;
|
|
394
403
|
const ghNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
|
|
404
|
+
// The review-wait start to stamp on a park. When the converge-gate signalled `reviewStale` it
|
|
405
|
+
// emitted `reviewWaitCutoff` (the time of ITS review read); parking with `waiting_since` at that
|
|
406
|
+
// cutoff — rather than "now" — lets a head-current review that landed in the read→park window
|
|
407
|
+
// satisfy the wait (issue #923 review). Only a non-empty string is honoured; anything else
|
|
408
|
+
// (absent on a normal round, or a malformed value) falls back to the park time.
|
|
409
|
+
const waitCutoff =
|
|
410
|
+
typeof reviewWaitCutoff === "string" && reviewWaitCutoff !== "" ? reviewWaitCutoff : undefined;
|
|
395
411
|
if (!ghRepo || typeof ghNumber !== "number") {
|
|
396
|
-
return commit(
|
|
412
|
+
return commit(
|
|
413
|
+
{ progressed: true, huskRetries: 0 },
|
|
414
|
+
{ status: "waiting_review", ...(waitCutoff ? { waitingSince: waitCutoff } : {}) },
|
|
415
|
+
);
|
|
397
416
|
}
|
|
398
417
|
|
|
399
418
|
// Read the head and record the baseline on EVERY round — including a non-addressed `waiting`
|
|
@@ -457,7 +476,12 @@ export function makeHandler(deps: {
|
|
|
457
476
|
if (!isAddressedStatus(status)) {
|
|
458
477
|
return commit(
|
|
459
478
|
{ progressed: true, huskRetries: 0 },
|
|
460
|
-
{
|
|
479
|
+
{
|
|
480
|
+
head: currentHead,
|
|
481
|
+
status: "waiting_review",
|
|
482
|
+
agentWatermark,
|
|
483
|
+
...(waitCutoff ? { waitingSince: waitCutoff } : {}),
|
|
484
|
+
},
|
|
461
485
|
);
|
|
462
486
|
}
|
|
463
487
|
|
|
@@ -528,7 +552,12 @@ export function makeHandler(deps: {
|
|
|
528
552
|
}
|
|
529
553
|
// Genuine progress → the loop parks at wait-review. THIS is the review-wait park, written only
|
|
530
554
|
// once the husk retry has been ruled out, so a husk-retry round never transits `waiting_review`.
|
|
531
|
-
return commit(out, {
|
|
555
|
+
return commit(out, {
|
|
556
|
+
head: currentHead,
|
|
557
|
+
status: "waiting_review",
|
|
558
|
+
agentWatermark,
|
|
559
|
+
...(waitCutoff ? { waitingSince: waitCutoff } : {}),
|
|
560
|
+
});
|
|
532
561
|
}
|
|
533
562
|
|
|
534
563
|
// NO-ADVANCE SELF-HEAL (#818, Layer 2). Before escalating to a human, reconcile the round's work
|
|
@@ -576,7 +605,12 @@ export function makeHandler(deps: {
|
|
|
576
605
|
// (husk counter reset), so the poller solicits the next Copilot review against the healed head.
|
|
577
606
|
return commit(
|
|
578
607
|
{ progressed: true, huskRetries: 0 },
|
|
579
|
-
{
|
|
608
|
+
{
|
|
609
|
+
head: heal.sha,
|
|
610
|
+
status: "waiting_review",
|
|
611
|
+
agentWatermark,
|
|
612
|
+
...(waitCutoff ? { waitingSince: waitCutoff } : {}),
|
|
613
|
+
},
|
|
580
614
|
);
|
|
581
615
|
}
|
|
582
616
|
}
|