@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 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
+ });
@@ -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, { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: true });
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: an unknown review commit_id or unreadable HEAD is NOT stale (evaluates normally)", async () => {
842
- // Fail-safe: without both SHAs we cannot prove staleness, so the gate must fall through to the
843
- // ordinary evaluation (here: block on the unacked advisory), never fabricate a re-solicit loop.
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, true);
852
- assertEquals(out.reviewStale, false);
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 /\b(?:base|head) branch was modified\b/i.test(detail);
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({ merge_method: opts.method }),
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
  // ---------------------------------------------------------------------------
@@ -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
+ });
@@ -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
- for (const status of ["waiting", "converged", "needs_input", "blocked"]) {
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 () => {
@@ -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.1",
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 &#34;review-stale&#34; 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="=&#34;This PR cannot be merged and needs a human decision. &#34; + (if mergeState = &#34;conflict&#34; then &#34;The automated rebase agent could not resolve the merge conflicts on this branch. Resolve them manually (or reply with guidance), then reply to retry.&#34; else if mergeStatus = &#34;retry&#34; then &#34;The merge kept losing a race with concurrent updates to the base branch and exhausted its automatic retry budget (&#34; + string(mergeRetryMax) + &#34; 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.&#34; else if mergeState = &#34;ready&#34; then &#34;The merge attempt did not land (merge result: &#34; + mergeStatus + &#34;). Investigate why GitHub refused the merge, then reply to retry.&#34; else if status = &#34;blocked&#34; then &#34;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.&#34; else &#34;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.&#34;)" target="question" />
285
+ <zeebe:input source="=&#34;This PR cannot be merged and needs a human decision. &#34; + (if mergeState = &#34;conflict&#34; then &#34;The automated rebase agent could not resolve the merge conflicts on this branch. Resolve them manually (or reply with guidance), then reply to retry.&#34; else if mergeStatus = &#34;retry&#34; then &#34;The merge kept losing a race with concurrent updates to the base branch and exhausted its automatic retry budget (&#34; + string(mergeRetryMax) + &#34; 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.&#34; else if mergeState = &#34;ready&#34; and mergeStatus = &#34;blocked&#34; and question != null then question else if mergeState = &#34;ready&#34; then &#34;The merge attempt did not land (merge result: &#34; + mergeStatus + &#34;). Investigate why GitHub refused the merge, then reply to retry.&#34; else if status = &#34;blocked&#34; then &#34;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.&#34; else &#34;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.&#34;)" target="question" />
286
286
  <zeebe:input source="=&#34;blocked&#34;" 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 { isReviewStale } from "../../app/reviewWait.ts";
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 (issue #799). When the PR HEAD has advanced PAST the commit the latest
122
- // Copilot review was submitted against, that review's suppressed advisories describe code the
123
- // head has moved past — e.g. an advisory the agent already FIXED IN CODE (but did not ack) is
124
- // still re-listed in the obsolete body. Blocking/escalating on it re-escalates a human
125
- // indefinitely (PR #789). Instead of gating on the stale body, signal `reviewStale` so the
126
- // process re-enters the review wait and the poller re-solicits a fresh review of the current
127
- // HEAD to gate on. The head read fails OPEN (null → not stale), so a transport hiccup can never
128
- // fabricate a stale verdict; the ordinary gate below still runs on a HEAD-current review.
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 (isReviewStale(review.commitId, headSha)) {
131
- return { convergeBlocked: false, convergeBlockReason: "", convergeAckOnly: false, reviewStale: true };
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
+ });
@@ -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 { classifyPrLiveness, enqueueViaComment, fetchPrState, mergePr } from "../../app/github.ts";
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
- const handler: AppJobHandler<In, Out> = async (job, app) => {
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
- const ok = await enqueueViaComment(repo, prNumber, token, comment);
129
- outcome = ok ? "queued" : "blocked";
130
- detail = ok ? `enqueued via "${comment}"` : `failed to post enqueue comment "${comment}"`;
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" ? { status: "waiting_review", waiting_since: ts } : {}),
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({ progressed: true, huskRetries: 0 }, { status: "waiting_review" });
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
- { head: currentHead, status: "waiting_review", agentWatermark },
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, { head: currentHead, status: "waiting_review", agentWatermark });
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
- { head: heal.sha, status: "waiting_review", agentWatermark },
608
+ {
609
+ head: heal.sha,
610
+ status: "waiting_review",
611
+ agentWatermark,
612
+ ...(waitCutoff ? { waitingSince: waitCutoff } : {}),
613
+ },
580
614
  );
581
615
  }
582
616
  }