@nanobpm/nano-workforce 0.206.0 → 0.206.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.206.1](https://github.com/nanobpm/nano-workforce/compare/v0.206.0...v0.206.1) (2026-11-08)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **merge:** fail the review backstop closed and retire review waits on merged PRs ([#928](https://github.com/nanobpm/nano-workforce/issues/928)) ([973c161](https://github.com/nanobpm/nano-workforce/commit/973c161d8b279db21915601647c5e8c43f3cac2f)), closes [nano-bpm#1467](https://github.com/nanobpm/nano-bpm/issues/1467) [#927](https://github.com/nanobpm/nano-workforce/issues/927)
6
+
1
7
  ## [0.206.0](https://github.com/nanobpm/nano-workforce/compare/v0.205.2...v0.206.0) (2026-11-08)
2
8
 
3
9
  ### Features
@@ -0,0 +1,175 @@
1
+ // Regression for issue #927 §3 (nano-bpm#1467): after the PR merged, the convergence loop kept
2
+ // re-requesting Copilot reviews on the MERGED PR for six rounds and finally escalated a review
3
+ // timeout. `pollReviews` must check the PR's live state before re-soliciting: a merged/closed PR
4
+ // retires the parked convergence instance and closes the row out — no reviewer-request POST.
5
+ import { test } from "node:test";
6
+ import { assertEquals } from "#test-assert";
7
+ import { withTrackingViews } from "../test/trackingViews.ts";
8
+ import { pollReviews } from "./service.ts";
9
+
10
+ const REPO = "owner/repo";
11
+ const NUMBER = 9;
12
+
13
+ function memTable(rows: any[], key: string, lookups: unknown[] = []) {
14
+ return {
15
+ get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
16
+ all: () => Promise.resolve([...rows]),
17
+ find: (q: any) => Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
18
+ findOne: (q: any) => {
19
+ lookups.push(q);
20
+ return Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null);
21
+ },
22
+ insert: (r: any) => {
23
+ rows.push(r);
24
+ return Promise.resolve(r);
25
+ },
26
+ update: (k: any, patch: any) => {
27
+ const r = rows.find((x) => x[key] === k);
28
+ if (r) Object.assign(r, patch);
29
+ return Promise.resolve(r);
30
+ },
31
+ delete: () => Promise.resolve(),
32
+ };
33
+ }
34
+
35
+ type Opts = { reviewsFail?: boolean; reviews?: unknown[]; cancelFails?: boolean; searchState?: string | null };
36
+
37
+ async function run(prState: { merged: boolean; state: string }, opts: Opts = {}) {
38
+ const row: any = {
39
+ pr_key: `${REPO}#${NUMBER}`,
40
+ repo: REPO,
41
+ number: NUMBER,
42
+ status: "waiting_review",
43
+ last_review_id: 0,
44
+ waiting_since: "2026-11-08T15:08:58Z",
45
+ last_nudge_at: "2000-01-01T00:00:00Z",
46
+ process_key: "589000",
47
+ updated_at: "t0",
48
+ };
49
+ const planLookups: unknown[] = [];
50
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
51
+ pull_requests: { rows: [row], key: "pr_key" },
52
+ merges: { rows: [], key: "id" },
53
+ plan_tasks: { rows: [], key: "id" },
54
+ };
55
+ const data = {
56
+ table: withTrackingViews((name: string, key: string) => {
57
+ stores[name] ??= { rows: [], key };
58
+ return memTable(stores[name].rows, stores[name].key, name === "plan_tasks" ? planLookups : []);
59
+ }),
60
+ } as any;
61
+ const cancelled: string[] = [];
62
+ const published: string[] = [];
63
+ const engine = {
64
+ publishMessage: (m: { name: string }) => {
65
+ published.push(m.name);
66
+ return Promise.resolve();
67
+ },
68
+ cancelInstance: (a: { processInstanceKey: string }) => {
69
+ if (opts.cancelFails) return Promise.reject(new Error("transient engine error"));
70
+ cancelled.push(a.processInstanceKey);
71
+ return Promise.resolve();
72
+ },
73
+ searchProcessInstances: () =>
74
+ Promise.resolve(
75
+ opts.searchState === null ? [] : [{ processInstanceKey: "589000", state: opts.searchState ?? "ACTIVE" }],
76
+ ),
77
+ } as any;
78
+ const posts: string[] = [];
79
+ const prevFetch = globalThis.fetch;
80
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
81
+ const prevTok = process.env["GITHUB_TOKEN"];
82
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
83
+ process.env["GITHUB_TOKEN"] = "test-token";
84
+ globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => {
85
+ const u = String(url);
86
+ const method = (init?.method ?? "GET").toUpperCase();
87
+ const json = (b: unknown, status = 200) => Promise.resolve(new Response(JSON.stringify(b), { status }));
88
+ if (method !== "GET") posts.push(`${method} ${u}`);
89
+ if (u.includes(`/pulls/${NUMBER}/requested_reviewers`)) return method === "GET" ? json({ users: [] }) : json({}, 201);
90
+ if (u.includes(`/pulls/${NUMBER}/reviews`) && opts.reviewsFail) return json({ message: "bad gateway" }, 502);
91
+ if (u.includes(`/pulls/${NUMBER}/reviews`)) return json(opts.reviews ?? []); // default: review requested, none arrived
92
+ if (u.includes("/git/ref")) return json({ object: { sha: "e597b399" } });
93
+ if (u.endsWith(`/pulls/${NUMBER}`)) return json({ ...prState, mergeable_state: "clean", head: { sha: "e597b399" } });
94
+ return json({}, 404);
95
+ }) as typeof fetch;
96
+ try {
97
+ await pollReviews(data, engine, "test-token");
98
+ } finally {
99
+ globalThis.fetch = prevFetch;
100
+ if (prevMode !== undefined) process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
101
+ else delete process.env["NANO_PR_GITHUB_TRANSPORT"];
102
+ if (prevTok !== undefined) process.env["GITHUB_TOKEN"] = prevTok;
103
+ else delete process.env["GITHUB_TOKEN"];
104
+ }
105
+ return { row, posts, cancelled, published, stores, planLookups };
106
+ }
107
+
108
+ test("pollReviews #927: a PR MERGED while awaiting review retires the loop — no review re-request", async () => {
109
+ const { row, posts, cancelled, published } = await run({ merged: true, state: "closed" });
110
+ assertEquals(posts.length, 0, "no reviewer-request POST on a merged PR");
111
+ assertEquals(cancelled, ["589000"], "the parked convergence instance is retired");
112
+ assertEquals(published.length, 0);
113
+ assertEquals(row.status, "merged");
114
+ assertEquals(typeof row.merged_at, "string");
115
+ });
116
+
117
+ test("pollReviews #927: a PR CLOSED unmerged while awaiting review is abandoned — no review re-request", async () => {
118
+ const { row, posts, cancelled, stores } = await run({ merged: false, state: "closed" });
119
+ assertEquals(posts.length, 0);
120
+ assertEquals(cancelled, ["589000"]);
121
+ assertEquals((stores.merges.rows as any[]).some((m) => m.method === "pr-closed"), true);
122
+ assertEquals(row.status === "waiting_review", false);
123
+ });
124
+
125
+ test("pollReviews #927: an OPEN PR awaiting review is still re-solicited (control)", async () => {
126
+ const { row, posts, cancelled } = await run({ merged: false, state: "open" });
127
+ assertEquals(cancelled.length, 0);
128
+ assertEquals(row.status, "waiting_review");
129
+ assertEquals(posts.length, 1, "the review nudge still fires for a live PR");
130
+ });
131
+
132
+ const HEAD_CURRENT_REVIEW = [
133
+ { id: 77, state: "COMMENTED", submitted_at: "2026-11-08T16:00:00Z", commit_id: "e597b399", user: { login: "copilot-pull-request-reviewer[bot]" } },
134
+ ];
135
+
136
+ for (const [label, prState] of [
137
+ ["MERGED", { merged: true, state: "closed" }],
138
+ ["CLOSED", { merged: false, state: "closed" }],
139
+ ] as const) {
140
+ test(`pollReviews #927: a HEAD-CURRENT review on a ${label} PR retires the loop — never resumes a round`, async () => {
141
+ const { row, posts, cancelled, published } = await run(prState, { reviews: HEAD_CURRENT_REVIEW });
142
+ assertEquals(published.length, 0, "no readiness-ready published for a terminal PR");
143
+ assertEquals(posts.length, 0);
144
+ assertEquals(cancelled, ["589000"]);
145
+ assertEquals(row.status === "waiting_review" || row.status === "converging", false);
146
+ });
147
+ }
148
+
149
+ test("pollReviews #927: a failed cancel of a still-LIVE instance leaves the row pollable", async () => {
150
+ const { row, posts, published } = await run({ merged: true, state: "closed" }, { cancelFails: true, searchState: "ACTIVE" });
151
+ assertEquals(row.status, "waiting_review", "not finalized while the instance (and its timer) lives");
152
+ assertEquals(posts.length, 0, "no re-solicit this pass");
153
+ assertEquals(published.length, 0);
154
+ });
155
+
156
+ for (const searchState of ["COMPLETED", null] as const) {
157
+ test(`pollReviews #927: a failed cancel of an already-${searchState ?? "gone"} instance still finalizes`, async () => {
158
+ const { row } = await run({ merged: true, state: "closed" }, { cancelFails: true, searchState });
159
+ assertEquals(row.status, "merged");
160
+ });
161
+ }
162
+
163
+ test("pollReviews #927: a PR merged externally while awaiting review runs the retro hook", async () => {
164
+ // maybeStartRetro resolves the PR's plan via `plan_tasks`; the lookup proves the hook ran.
165
+ const { planLookups } = await run({ merged: true, state: "closed" });
166
+ assertEquals(planLookups, [{ pr_key: `${REPO}#${NUMBER}` }]);
167
+ });
168
+
169
+ test("pollReviews #927: a MERGED PR whose review fetch FAILS is still retired (terminal check precedes fetch)", async () => {
170
+ const { row, posts, cancelled, published } = await run({ merged: true, state: "closed" }, { reviewsFail: true });
171
+ assertEquals(cancelled, ["589000"]);
172
+ assertEquals(published.length, 0);
173
+ assertEquals(posts.length, 0);
174
+ assertEquals(row.status, "merged");
175
+ });
package/app/service.ts CHANGED
@@ -100,6 +100,7 @@ import { ENGINE_TERMINAL_STATES } from "./reconcile.ts";
100
100
  // (submitPr/startMerge) and re-exported below so the long-standing `import { repoEnvelopeVars } from
101
101
  // "./service.ts"` call sites (and its tests) keep resolving.
102
102
  import { repoEnvelopeVars } from "./repoEnvelope.ts";
103
+ import { maybeStartRetro } from "./retro.ts";
103
104
  import { clampNudgeMinutes, isoDurationToMs, isReviewStale, reviewWaitTimeout } from "./reviewWait.ts";
104
105
  import { trialMergeAudits } from "./trialMerge.ts";
105
106
  import {
@@ -1283,6 +1284,13 @@ export async function pollReviews(data: DataLayer, engine: EngineClient, token:
1283
1284
  const { repo, number, pr_key: prKey } = pr;
1284
1285
  const lastId = pr.last_review_id ?? 0;
1285
1286
  try {
1287
+ // #927: a PR that already landed/closed must never resume or re-solicit a review — whatever
1288
+ // the review freshness (no review, stale review, OR a head-current one landing after the
1289
+ // merge). Check terminal state FIRST — before even fetching reviews, so a failing
1290
+ // reviews endpoint (e.g. 502) can't strand a merged PR in `waiting_review` with a live timer — so no branch below can route a merged PR back into a
1291
+ // review round. `null` → handled-but-not-finalized (cancel failed): skip this pass, stay pollable.
1292
+ const retired = await retireTerminalReviewWait(data, engine, pr, token);
1293
+ if (retired !== false) continue;
1286
1294
  const reviews = await fetchPrReviews(repo, number, token);
1287
1295
  if (reviews === null) return; // no usable transport (no gh, no token) → idle
1288
1296
  const fresh = reviews
@@ -1342,6 +1350,60 @@ export async function pollReviews(data: DataLayer, engine: EngineClient, token:
1342
1350
  }
1343
1351
  }
1344
1352
 
1353
+ /** Review-wait out-of-band terminal check (issue #927). nano-bpm#1467 landed while its convergence
1354
+ * loop was parked at `wait-review`; the poller then kept re-requesting Copilot reviews on the MERGED
1355
+ * PR for six rounds and finally escalated a review timeout no human could act on. Before any
1356
+ * re-solicitation, read the PR's live state: if it is terminal (merged or closed) the review wait is
1357
+ * moot, so RETIRE the parked convergence instance (`process_key`) and close the row out — `merged`
1358
+ * (with `merged_at`) for a landed PR, the canonical `abandonClosedPr` for a closed-unmerged one.
1359
+ * Conservative: an unreadable/open state returns `false` and the caller re-solicits as before.
1360
+ * Exported for tests. */
1361
+ export async function retireTerminalReviewWait(
1362
+ data: DataLayer,
1363
+ engine: EngineClient,
1364
+ pr: PullRequest,
1365
+ token: string,
1366
+ ): Promise<boolean | null> {
1367
+ const st = await fetchPrState(pr.repo, pr.number, token).catch(() => null);
1368
+ const liveness = classifyPrLiveness(st);
1369
+ if (liveness !== "merged" && liveness !== "closed") return false;
1370
+ if (pr.process_key != null) {
1371
+ const key = String(pr.process_key);
1372
+ try {
1373
+ await engine.cancelInstance({ processInstanceKey: key });
1374
+ } catch (err) {
1375
+ // Only finalize the row once the instance is confirmed gone/terminal: a transiently-failed
1376
+ // cancel would otherwise drop the row from `waiting_review` scans while the live instance's
1377
+ // review-timeout timer still escalates a human. Unconfirmed → stay pollable, retry next pass.
1378
+ let confirmedTerminal = false;
1379
+ try {
1380
+ const match = (await engine.searchProcessInstances({ processInstanceKeys: [key] })).find(
1381
+ (s) => String(s.processInstanceKey) === key,
1382
+ );
1383
+ confirmedTerminal = !match || ENGINE_TERMINAL_STATES.has(String(match.state ?? "").trim().toUpperCase());
1384
+ } catch {
1385
+ confirmedTerminal = false;
1386
+ }
1387
+ if (!confirmedTerminal) {
1388
+ console.error(`[poller] ${pr.pr_key}: retire review-wait instance ${key} failed, retrying next pass: ${err}`);
1389
+ return null;
1390
+ }
1391
+ }
1392
+ }
1393
+ if (liveness === "merged") {
1394
+ const ts = now();
1395
+ await prs(data).update(pr.pr_key, { status: "merged", merged_at: ts, updated_at: ts });
1396
+ // Reuse the merged-completion side effect `pr.mark-merged`/`pr.finalize` run: an externally
1397
+ // merged PR may be its plan's last to settle, so its automatic retrospective must still start.
1398
+ // `maybeStartRetro` is best-effort and never throws.
1399
+ await maybeStartRetro(data, engine, pr.pr_key);
1400
+ } else {
1401
+ await abandonClosedPr(data, pr.pr_key, "PR was closed on GitHub while awaiting review — retiring the convergence loop");
1402
+ }
1403
+ console.log(`[poller] ${liveness} while waiting for review -> retired loop ${pr.pr_key}`);
1404
+ return true;
1405
+ }
1406
+
1345
1407
  /** Ensure a Copilot review is in flight for a PR still waiting, throttled to one attempt per
1346
1408
  * REVIEW_NUDGE_MS window. Skips when Copilot is already a pending reviewer (a review is coming);
1347
1409
  * otherwise re-requests one and records the nudge. This is the primary liveness mechanism — the
package/build-info.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.206.0",
3
- "gitSha": "e5eedca0ee28db1f54167b4bb962ce4c06776e9f",
4
- "gitTag": "v0.206.0",
5
- "builtAt": "2026-11-08T12:12:11.017Z"
2
+ "version": "0.206.1",
3
+ "gitSha": "973c161d8b279db21915601647c5e8c43f3cac2f",
4
+ "gitTag": "v0.206.1",
5
+ "builtAt": "2026-11-08T20:26:54.809Z"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.206.0",
3
+ "version": "0.206.1",
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",
@@ -112,6 +112,13 @@ test("pr.merge short-circuits an already-merged PR without re-running the land p
112
112
  // would stay green if the verb regressed.
113
113
  type GithubCall = { url: string; method: string };
114
114
 
115
+ // A handler whose review backstop sees a VERIFIED head-current review, so land-protocol tests
116
+ // exercise the protocol dispatch (the backstop now fails CLOSED on an unreadable read, #927).
117
+ const reviewedHandler = makeHandler({
118
+ readReview: async () => ({ body: "lgtm", commitId: "reviewedhead" }),
119
+ readHeadSha: async () => "reviewedhead",
120
+ });
121
+
115
122
  function withGithub(
116
123
  routes: (url: string, init: RequestInit | undefined) => Response | null,
117
124
  run: (calls: GithubCall[]) => Promise<void>,
@@ -153,7 +160,7 @@ test("pr.merge routes a mergify-queue repo through the queued branch (enqueue co
153
160
  },
154
161
  async (calls) => {
155
162
  const { app, stores } = fakeApp();
156
- const out = (await handler(
163
+ const out = (await reviewedHandler(
157
164
  { variables: { prKey: "acme/widgets#7", repo: "acme/widgets", prNumber: 7 } } as any,
158
165
  app,
159
166
  )) as Record<string, unknown>;
@@ -189,7 +196,7 @@ test("pr.merge routes a refused merge through the blocked branch (escalation pay
189
196
  },
190
197
  async (calls) => {
191
198
  const { app, stores } = fakeApp();
192
- const out = (await handler(
199
+ const out = (await reviewedHandler(
193
200
  { variables: { prKey: "acme/widgets#9", repo: "acme/widgets", prNumber: 9 } } as any,
194
201
  app,
195
202
  )) as Record<string, unknown>;
@@ -229,7 +236,7 @@ test("pr.merge routes a transient base-moved race through the retry branch (no e
229
236
  },
230
237
  async (calls) => {
231
238
  const { app, stores } = fakeApp();
232
- const out = (await handler(
239
+ const out = (await reviewedHandler(
233
240
  { variables: { prKey: "acme/widgets#11", repo: "acme/widgets", prNumber: 11 } } as any,
234
241
  app,
235
242
  )) as Record<string, unknown>;
@@ -383,33 +390,63 @@ test("pr.merge #923: a HEAD-CURRENT review lands normally (backstop control)", a
383
390
  );
384
391
  });
385
392
 
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>;
393
+ for (const [label, readReview, readHeadSha] of [
394
+ ["review read null", async () => null, async () => "h9"],
395
+ ["review read throws", async () => { throw new Error("boom"); }, async () => "h9"],
396
+ ["head read null", async () => ({ body: "lgtm", commitId: "h9" }), async () => null],
397
+ ["head read throws", async () => ({ body: "lgtm", commitId: "h9" }), async () => { throw new Error("boom"); }],
398
+ ["head read empty", async () => ({ body: "lgtm", commitId: "h9" }), async () => " "],
399
+ ] as const) {
400
+ test(`pr.merge #927: an UNVERIFIABLE backstop read (${label}) fails CLOSED — no land, audited refusal`, async () => {
401
+ await withGithub(
402
+ (url) => {
403
+ if (/\/pulls\/\d+\/merge$/.test(url)) return new Response(JSON.stringify({ merged: true }), { status: 200 });
404
+ return backstopRoutes(url);
405
+ },
406
+ async (calls) => {
407
+ const { app, stores } = fakeApp();
408
+ const backstop = makeHandler({ readReview, readHeadSha } as any);
409
+ const out = (await backstop(
410
+ { variables: { prKey: "acme/widgets#27", repo: "acme/widgets", prNumber: 27 } } as any,
411
+ app,
412
+ )) as Record<string, unknown>;
413
+ assertEquals(out.mergeStatus, "blocked");
414
+ assertEquals(out.status, "blocked");
415
+ assertEquals((out.question as string).includes("could not be read"), true);
416
+ assertEquals(stores.merges.length, 1);
417
+ assertEquals(stores.merges[0].outcome, "blocked");
418
+ assertEquals(stores.merges[0].method, "review-backstop");
419
+ assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
420
+ assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
421
+ },
422
+ );
423
+ });
424
+ }
404
425
 
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
- );
426
+ test("pr.merge #927: reproduces nano-bpm#1467 — review REQUESTED but not yet arrived on a mergify-queue repo → no `@mergifyio queue`", async () => {
427
+ const protocol =
428
+ "# repo\n\n```merge-protocol\n{ \"land\": { \"method\": \"mergify-queue\", \"comment\": \"@mergifyio queue\" } }\n```\n";
429
+ for (const readReview of [async () => ({ body: "", commitId: null }), async () => null]) {
430
+ await withGithub(
431
+ (url) => {
432
+ if (/\/contents\/AGENTS\.md$/.test(url)) return new Response(protocol);
433
+ if (/\/issues\/\d+\/comments$/.test(url)) return new Response(JSON.stringify({ id: 1 }), { status: 201 });
434
+ return backstopRoutes(url);
435
+ },
436
+ async (calls) => {
437
+ const { app, stores } = fakeApp();
438
+ const backstop = makeHandler({ readReview, readHeadSha: async () => "e597b399" } as any);
439
+ const out = (await backstop(
440
+ { variables: { prKey: "acme/widgets#1467", repo: "acme/widgets", prNumber: 1467 } } as any,
441
+ app,
442
+ )) as Record<string, unknown>;
443
+ assertEquals(out.mergeStatus, "blocked");
444
+ assertEquals(stores.merges[0].method, "review-backstop");
445
+ assertEquals(calls.some((c) => /\/comments$/.test(c.url)), false);
446
+ assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false);
447
+ },
448
+ );
449
+ }
413
450
  });
414
451
 
415
452
  test("pr.merge #923: a head-current review whose summary reports OPEN findings blocks landing", async () => {
@@ -427,23 +464,6 @@ test("pr.merge #923: a head-current review whose summary reports OPEN findings b
427
464
  });
428
465
  });
429
466
 
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
467
  test("pr.merge #923: the direct merge pins the verified head SHA (no check-then-merge race)", async () => {
448
468
  let putBody = "";
449
469
  await withGithub(
@@ -485,3 +505,36 @@ test("pr.merge #923: a queue-comment repo whose head moved after the backstop re
485
505
  },
486
506
  );
487
507
  });
508
+
509
+ for (const [label, finalRead] of [
510
+ ["null", async () => null],
511
+ ["throwing", async () => {
512
+ throw new Error("502");
513
+ }],
514
+ ["blank", async () => " "],
515
+ ] as const) {
516
+ test(`pr.merge #927: a queue-comment repo whose FINAL head re-read is ${label} retries instead of enqueueing`, async () => {
517
+ const protocol =
518
+ "# repo\n\n```merge-protocol\n{ \"land\": { \"method\": \"mergify-queue\", \"comment\": \"@mergifyio queue\" } }\n```\n";
519
+ await withGithub(
520
+ (url) => {
521
+ if (/\/contents\/AGENTS\.md$/.test(url)) return new Response(protocol);
522
+ if (/\/issues\/\d+\/comments$/.test(url)) return new Response(JSON.stringify({ id: 1 }), { status: 201 });
523
+ return backstopRoutes(url);
524
+ },
525
+ async (calls) => {
526
+ const { app, stores } = fakeApp();
527
+ let n = 0;
528
+ const backstop = makeHandler({
529
+ readReview: async () => ({ body: "lgtm", commitId: "h3" }),
530
+ readHeadSha: async () => (n++ === 0 ? "h3" : finalRead()),
531
+ } as any);
532
+ const out = (await backstop({ variables: { prKey: "acme/widgets#34", repo: "acme/widgets", prNumber: 34 } } as any, app)) as Record<string, unknown>;
533
+ assertEquals(out.mergeStatus, "retry");
534
+ assertEquals(stores.merges.some((m) => m.outcome === "retry"), true, "retry audit row");
535
+ assertEquals(calls.some((c) => /\/issues\/\d+\/comments$/.test(c.url) && c.method === "POST"), false);
536
+ assertEquals(calls.some((c) => /\/merge$/.test(c.url)), false, "no merge call");
537
+ },
538
+ );
539
+ });
540
+ }
@@ -15,10 +15,9 @@
15
15
  // it reviewed — a head that MOVED between convergence and landing, or a review that never existed
16
16
  // (the #1443 fail-open), must not slip through at land time. Before running ANY land protocol this
17
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.
18
+ // landing (escalating to a human) instead of enqueueing/merging unreviewed code. An UNREADABLE
19
+ // review/head ALSO blocks (fails CLOSED, issue #927) — the old fail-open let nano-bpm#1467 land
20
+ // 49 s before its first review.
22
21
  import type { AppJobHandler } from "@nanobpm/urban";
23
22
  import { matchTags, tag } from "@nanobpm/urban/effect";
24
23
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
@@ -59,6 +58,9 @@ const defaultReadReview: MergeReviewReader = (repo, prNumber) =>
59
58
  // bind (#786/#799), so the backstop's staleness comparison sees the exact head the gate did.
60
59
  const defaultReadHead: HeadReader = makeDefaultReadHead({ fetchPrHead, fetchBranchHead });
61
60
 
61
+ const BLOCK_UNVERIFIABLE_REVIEW =
62
+ "the PR's Copilot review state or current HEAD could not be read, so a head-current reviewer verdict cannot be verified (the backstop fails CLOSED on an unverifiable read)";
63
+
62
64
  const BLOCK_NO_HEAD_CURRENT_REVIEW =
63
65
  "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
66
 
@@ -132,26 +134,31 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
132
134
  // existed at all (the nano-bpm#1443 fail-open: merged with ZERO reviews), must not slip through
133
135
  // at land time. Runs AFTER the merged/closed short-circuits (an already-terminal PR needs no
134
136
  // 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.
137
+ // for a queue repo). Fails CLOSED on an UNREADABLE review/head too (issue #927).
138
138
  const [backstopReview, backstopHead] = await Promise.all([
139
139
  readReview(repo, prNumber).catch(() => null),
140
140
  readHeadSha(repo, prNumber).catch(() => null),
141
141
  ]);
142
- if (backstopReview !== null && isReviewMissingOrStale(backstopReview.commitId, backstopHead)) {
142
+ // FAIL CLOSED (issue #927): a gate whose job is "never land unreviewed" must not land on an
143
+ // UNVERIFIABLE read. nano-bpm#1467 merged 49 s before its first Copilot review because a `null`
144
+ // review/head read (or an empty head) skipped this check entirely. An unreadable review or head now
145
+ // blocks (escalating to a human, who replies to retry) exactly like a missing/stale review.
146
+ const headTrimmed = (backstopHead ?? "").trim();
147
+ const unverifiable = backstopReview === null || headTrimmed === "";
148
+ if (unverifiable || isReviewMissingOrStale(backstopReview.commitId, headTrimmed)) {
149
+ const detail = unverifiable ? BLOCK_UNVERIFIABLE_REVIEW : BLOCK_NO_HEAD_CURRENT_REVIEW;
143
150
  await app.data.table("merges", "id").insert({
144
151
  pr_key: prKey,
145
152
  outcome: "blocked",
146
153
  method: "review-backstop",
147
- detail: BLOCK_NO_HEAD_CURRENT_REVIEW,
154
+ detail,
148
155
  at: now,
149
156
  });
150
157
  return {
151
158
  mergeStatus: "blocked",
152
159
  status: "blocked",
153
160
  question:
154
- `Merge refused: ${BLOCK_NO_HEAD_CURRENT_REVIEW}. ` +
161
+ `Merge refused: ${detail}. ` +
155
162
  `Push no further commits, wait for the Copilot review of the current HEAD to land (or request one: ` +
156
163
  `gh api repos/${repo}/pulls/${prNumber}/requested_reviewers -X POST -f "reviewers[]=copilot-pull-request-reviewer[bot]"), ` +
157
164
  `let the convergence loop gate on it, then reply to retry.`,
@@ -161,12 +168,8 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
161
168
  // A head-current review whose OWN summary still reports open findings (the same canonical
162
169
  // verdict parser the converge-gate uses) must block too: a summary-only "Changes recommended"
163
170
  // 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) : [];
171
+ // Head-currentness is VERIFIED at this point (the fail-closed check above returned otherwise).
172
+ const openFindings = parseReviewOpenFindings(backstopReview.body);
170
173
  if (openFindings.length > 0) {
171
174
  const detail = `the head-current Copilot review reports open findings (${openFindings.join(", ")})`;
172
175
  await app.data.table("merges", "id").insert({
@@ -186,7 +189,7 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
186
189
  }
187
190
  // The head the backstop verified — pinned through the land call so a push between this check
188
191
  // and the merge can never land a later, unreviewed head.
189
- const verifiedHead = backstopReview !== null && backstopHead ? backstopHead : undefined;
192
+ const verifiedHead: string | undefined = headTrimmed;
190
193
 
191
194
  // Dead-end-base guard (#60): never land a PR into a base branch that has itself already merged
192
195
  // to the default branch — the merge would land into a dead branch and never reach `main`.
@@ -227,10 +230,14 @@ export function makeHandler(deps: { readReview: MergeReviewReader; readHeadSha:
227
230
  const comment = protocol?.land.comment ?? "@mergifyio queue";
228
231
  // A comment can't pin a SHA, so re-read the head right before enqueueing: if it moved past the
229
232
  // 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) {
233
+ // Fail CLOSED (#927): an unreadable re-read retries rather than enqueueing an unverified head.
234
+ const headNow = await readHeadSha(repo, prNumber).catch(() => null);
235
+ if (headNow === null || headNow.trim() !== verifiedHead) {
232
236
  outcome = "retry";
233
- detail = `head moved from ${verifiedHead} to ${headNow} after the review backstop; re-verifying before enqueue`;
237
+ detail =
238
+ headNow === null
239
+ ? `head could not be re-read after the review backstop; re-verifying before enqueue`
240
+ : `head moved from ${verifiedHead} to ${headNow} after the review backstop; re-verifying before enqueue`;
234
241
  } else {
235
242
  const ok = await enqueueViaComment(repo, prNumber, token, comment);
236
243
  outcome = ok ? "queued" : "blocked";