@nanobpm/nano-workforce 0.205.0 → 0.205.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.
@@ -0,0 +1,551 @@
1
+ // #921: a PR whose merge is BLOCKED solely by unresolved review threads (branch protection
2
+ // `required_conversation_resolution`; `mergeable=MERGEABLE`, `mergeStateStatus=BLOCKED`, all
3
+ // required checks green) must be CLASSIFIED as such and routed back into convergence — never
4
+ // escalated as "GitHub never settled this PR's mergeability". These are poller-level tests: they
5
+ // drive `pollMerges` over a `waiting_merge` PR row against a stubbed GitHub transport serving the
6
+ // PR view + the GraphQL review-threads probe, and assert:
7
+ // • the verdict parks the PR (no `merge-ready` publish — the merge step is gated on zero
8
+ // unresolved threads);
9
+ // • once the threads resolve, the next pass publishes `merge-ready {ready}` and the merge
10
+ // proceeds with no human in the loop;
11
+ // • a `review-threads` verdict hands the PR DURABLY back into convergence (PR #922 review): the
12
+ // row flips to `waiting_review` and a fresh convergence instance is started, so the
13
+ // review-fix agent actually activates on the open threads — a bare re-review request leaves
14
+ // the PR in `waiting_merge`, which `pollReviews` never scans.
15
+ import { test } from "node:test";
16
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { assert, assertEquals } from "#test-assert";
20
+ import type { DataLayer, EngineClient } from "@nanobpm/urban";
21
+ import { pollMerges } from "./service.ts";
22
+
23
+ /** A single-page GraphQL review-threads payload with `n` unresolved threads, for the gh stub. */
24
+ function graphqlThreads(n: number): string {
25
+ const threads = Array.from({ length: n }, (_, i) => ({
26
+ isResolved: false,
27
+ path: `f${i}.ts`,
28
+ comments: { nodes: [{ body: `thread ${i}` }] },
29
+ }));
30
+ return JSON.stringify({
31
+ data: {
32
+ repository: {
33
+ pullRequest: { reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: threads } },
34
+ },
35
+ },
36
+ });
37
+ }
38
+
39
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
40
+ const stores: Record<string, any[]> = {};
41
+ function tbl(name: string, pk = "id") {
42
+ const rows = (stores[name] ??= [] as any[]);
43
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
44
+ return {
45
+ async all() {
46
+ return rows.slice();
47
+ },
48
+ async get(id: any) {
49
+ return rows.find((r) => r[pk] === id);
50
+ },
51
+ async find(where: any = {}) {
52
+ return rows.filter((r) => match(r, where));
53
+ },
54
+ async insert(row: any) {
55
+ rows.push({ ...row });
56
+ return row[pk];
57
+ },
58
+ async update(id: any, patch: any) {
59
+ const r = rows.find((row) => row[pk] === id);
60
+ if (r) Object.assign(r, patch);
61
+ },
62
+ async delete(id: any) {
63
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][pk] === id) rows.splice(i, 1);
64
+ },
65
+ };
66
+ }
67
+ // `data.open()` is the raw-statement escape hatch a few reconcilers use. The in-memory stub has
68
+ // no SQL store, so apply the one statement this suite's domain flow issues — `submitPr`'s re-open
69
+ // path runs `resetAdjudications` (`DELETE FROM "pr_adjudications" WHERE "pr_key" = ?`) — against
70
+ // the in-memory rows, so the handoff test below genuinely observes the adjudication reset rather
71
+ // than a no-op. Any other statement throws loudly instead of silently passing.
72
+ const data = {
73
+ table: (n: string, pk?: string) => tbl(n, pk),
74
+ open: () => ({
75
+ exec: async (sql: string, params: any[] = []) => {
76
+ const m = sql.match(/^\s*DELETE\s+FROM\s+"?(\w+)"?\s+WHERE\s+"?(\w+)"?\s*=\s*\?\s*$/i);
77
+ if (!m) throw new Error(`memData.open().exec: unsupported statement: ${sql}`);
78
+ const rows = (stores[m[1]] ??= [] as any[]);
79
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][m[2]] === params[0]) rows.splice(i, 1);
80
+ },
81
+ }),
82
+ } as any as DataLayer;
83
+ return { data, stores };
84
+ }
85
+
86
+ function recordingEngine(): { engine: EngineClient; messages: any[]; cancelled: string[] } {
87
+ const messages: any[] = [];
88
+ const cancelled: string[] = [];
89
+ const engine = {
90
+ async publishMessage(msg: any) {
91
+ messages.push(msg);
92
+ },
93
+ async cancelInstance(args: { processInstanceKey: string }) {
94
+ cancelled.push(String(args.processInstanceKey));
95
+ },
96
+ } as any as EngineClient;
97
+ return { engine, messages, cancelled };
98
+ }
99
+
100
+ // A token-transport GitHub stub serving the REST PR view (`mergeable_state: "blocked"`, the
101
+ // conversation-resolution block), the GraphQL review-threads probe (`unresolved` open threads),
102
+ // and the REST reviewers POST (the Copilot re-request nudge). `failingChecks` is -1 in token mode
103
+ // …which would keep the classifier conservative — so these tests ALSO stub the check rollup via
104
+ // the GraphQL threads response only, and rely on the REST view. To keep the classifier out of
105
+ // token mode's "can't enumerate checks" branch, the stub serves a REST `check-runs` free PR whose
106
+ // merge block is purely the threads — but the REST single-PR view never enumerates checks, so the
107
+ // classifier reads `failingChecks: -1`. That is exactly the production shape on a token-transport
108
+ // deploy; the `review-threads` verdict therefore requires the gh transport in production. These
109
+ // tests pin the TOKEN-transport behaviour (conservative: park + nudge + never escalate) and the
110
+ // classifier-level verdict is covered by the gh-mode unit tests in app/github.test.ts.
111
+ interface Fixture {
112
+ unresolved: number; // threads with isResolved=false
113
+ mergeableState?: string;
114
+ }
115
+ function githubFetch(fx: Fixture, calls: { reviewPosts: number }) {
116
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
117
+ const u = new URL(String(url));
118
+ const json = (obj: unknown, status = 200) =>
119
+ Promise.resolve(new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } }));
120
+ if (u.pathname === "/graphql") {
121
+ const threads = Array.from({ length: fx.unresolved }, (_, i) => ({
122
+ isResolved: false,
123
+ path: `f${i}.ts`,
124
+ comments: { nodes: [{ body: `thread ${i}` }] },
125
+ }));
126
+ return json({
127
+ data: {
128
+ repository: {
129
+ pullRequest: { reviewThreads: { pageInfo: { hasNextPage: false, endCursor: null }, nodes: threads } },
130
+ },
131
+ },
132
+ });
133
+ }
134
+ if (u.pathname.endsWith("/requested_reviewers") && init?.method === "POST") {
135
+ calls.reviewPosts += 1;
136
+ return json({}, 201);
137
+ }
138
+ const m = u.pathname.match(/\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)$/);
139
+ if (m) {
140
+ return json({
141
+ merged: false,
142
+ merged_at: null,
143
+ state: "open",
144
+ mergeable_state: fx.mergeableState ?? "blocked",
145
+ draft: false,
146
+ head: { sha: "deadbeef" },
147
+ base: { ref: "main" },
148
+ });
149
+ }
150
+ return Promise.resolve(new Response(`unexpected ${u.pathname}`, { status: 500 }));
151
+ };
152
+ }
153
+
154
+ async function withGithub<T>(fx: Fixture, calls: { reviewPosts: number }, fn: () => Promise<T>): Promise<T> {
155
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
156
+ const prevFetch = globalThis.fetch;
157
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
158
+ globalThis.fetch = githubFetch(fx, calls) as typeof fetch;
159
+ try {
160
+ return await fn();
161
+ } finally {
162
+ globalThis.fetch = prevFetch;
163
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
164
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
165
+ }
166
+ }
167
+
168
+ function waitingMergeRow(prKey: string, number: number) {
169
+ const ts = "2026-08-20T00:00:00Z";
170
+ return {
171
+ pr_key: prKey,
172
+ repo: "o/r",
173
+ number,
174
+ url: `https://github.com/o/r/pull/${number}`,
175
+ title: "t",
176
+ status: "waiting_merge",
177
+ current_round: 0,
178
+ // A real parked merge wait ALWAYS carries a live `process_key` (startMerge/arm-merge set it after
179
+ // the merge instance is created). `null` is the UNFINISHED-handoff marker the dead-owner guard keys
180
+ // on, so the realistic default here is a live key — the ownerless-handoff test overrides it to null.
181
+ process_key: "merge-live-1",
182
+ waiting_since: null,
183
+ last_review_id: null,
184
+ outcome: null,
185
+ created_at: ts,
186
+ updated_at: ts,
187
+ converged_at: null,
188
+ merged_at: null,
189
+ fresh_head_run_head: null,
190
+ };
191
+ }
192
+
193
+ test("#921 poller: a threads-BLOCKED PR parks (no merge-ready) — the merge stays gated on resolution", async () => {
194
+ // Token transport can't enumerate checks, so the classifier stays conservative and the verdict
195
+ // is "waiting" even with threads probed — the PR must NOT advance to attempt-merge, must NOT
196
+ // escalate, and must be re-read next pass. (The gh-transport `review-threads` verdict is pinned
197
+ // in app/github.test.ts; the merge-loop routing of that verdict in app/mergeLoopBehaviour.test.ts.)
198
+ const { data, stores } = memData();
199
+ const { engine, messages } = recordingEngine();
200
+ stores["pull_requests"] = [waitingMergeRow("o/r#100", 100)];
201
+ const calls = { reviewPosts: 0 };
202
+ await withGithub({ unresolved: 2 }, calls, async () => {
203
+ await pollMerges(data, engine, "tok");
204
+ });
205
+ assertEquals(
206
+ messages.filter((m) => m.name === "merge-ready").length,
207
+ 0,
208
+ "a threads-BLOCKED PR must not publish merge-ready — the merge step is gated on zero unresolved threads",
209
+ );
210
+ assertEquals(stores["pull_requests"][0].status, "waiting_merge", "the PR stays parked for the next pass");
211
+ });
212
+
213
+ test("#921 poller: once the threads resolve the next pass publishes merge-ready and the merge proceeds", async () => {
214
+ const { data, stores } = memData();
215
+ const { engine, messages } = recordingEngine();
216
+ stores["pull_requests"] = [waitingMergeRow("o/r#100", 100)];
217
+ const calls = { reviewPosts: 0 };
218
+ // Threads resolved (none open) and the branch otherwise mergeable → the gate lifts.
219
+ await withGithub({ unresolved: 0, mergeableState: "clean" }, calls, async () => {
220
+ await pollMerges(data, engine, "tok");
221
+ });
222
+ const ready = messages.filter((m) => m.name === "merge-ready");
223
+ assertEquals(ready.length, 1, "a clean PR with zero unresolved threads must advance to the merge step");
224
+ assertEquals(ready[0].variables.mergeState, "ready");
225
+ assertEquals(ready[0].variables.reviewThreads, 0, "the payload carries the probed thread count");
226
+ assertEquals(ready[0].variables.pendingChecks, 0, "the payload carries the pending-check count (token mode: unenumerable → 0)");
227
+ });
228
+
229
+ test("#921/PR#922 poller: a 'review-threads' verdict hands the PR back into convergence — the remediation agent actually activates", async () => {
230
+ // gh transport: checks enumerable and green, merge BLOCKED, two unresolved threads → the settled
231
+ // `review-threads` verdict. The poller must NOT merely request another Copilot review (that
232
+ // leaves the PR in `waiting_merge`, which `pollReviews` never scans — no review-fix agent would
233
+ // ever activate); it re-enters the PR into the canonical convergence loop: the row flips to
234
+ // `waiting_review` and a FRESH convergence instance is created.
235
+ //
236
+ // The row's `process_key` names the LIVE merge instance (the realistic merge-stage shape). The
237
+ // handoff must RETIRE it first (PR #922 review, thread 3): left running, `submitPr`'s idempotency
238
+ // gate would classify the PR `alreadyRunning` and start NOTHING — no convergence instance would
239
+ // ever launch and the old merge timer would keep running. Cancelling it flips the derived
240
+ // tracking status terminal, so the gate falls through and the fresh convergence run takes over.
241
+ const { data, stores } = memData();
242
+ const instances: any[] = [];
243
+ const { engine, cancelled } = recordingEngine();
244
+ (engine as any).createInstance = async (args: any) => {
245
+ instances.push(args);
246
+ return { processInstanceKey: "conv-1" };
247
+ };
248
+ const mergeRow = { ...waitingMergeRow("o/r#100", 100), process_key: "merge-1", auto_resolve_escalations: 1 };
249
+ stores["pull_requests"] = [mergeRow];
250
+ // The derived tracking VIEW the idempotency gate reads: the merge instance is LIVE (non-terminal)
251
+ // until the handoff's `cancelInstance` retires it. The stub folds the row to a terminal edge
252
+ // (`abandoned`) once its `process_key` is cancelled, mirroring the ADR-0065 reconciler.
253
+ stores["pull_requests__tracking"] = [{ ...mergeRow, derived_status: "waiting_merge" }];
254
+ (engine as any).cancelInstance = async (args: { processInstanceKey: string }) => {
255
+ cancelled.push(String(args.processInstanceKey));
256
+ for (const t of stores["pull_requests__tracking"]) {
257
+ if (t.process_key === String(args.processInstanceKey)) t.derived_status = "abandoned";
258
+ }
259
+ };
260
+ // A stale adjudication from the prior convergence run must be wiped by the re-open, so the fresh
261
+ // run never replays a prior decision (issue #806 reset, exercised through the handoff path).
262
+ stores["pr_adjudications"] = [{ pr_key: "o/r#100", question: "q", answer: "a" }];
263
+ const calls = { reviewPosts: 0 };
264
+ // The gh transport runs the `gh` CLI; intercept it with a stub on PATH that serves the PR view
265
+ // (checks green, merge blocked) and the GraphQL review-threads probe from the fixture.
266
+ const bin = await mkdtemp(join(tmpdir(), "gh-stub-"));
267
+ const script = `#!/usr/bin/env bash
268
+ set -euo pipefail
269
+ if [[ "$*" == *"pr view"* ]]; then
270
+ echo '{"state":"OPEN","mergedAt":null,"mergeStateStatus":"BLOCKED","statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}],"isDraft":false,"headRefOid":"deadbeef","baseRefName":"main"}'
271
+ elif [[ "$*" == *"graphql"* ]]; then
272
+ echo '${graphqlThreads(2)}'
273
+ elif [[ "$*" == *"requested_reviewers"* ]]; then
274
+ exit 0
275
+ else
276
+ echo "unexpected gh args: $*" >&2
277
+ exit 1
278
+ fi
279
+ `;
280
+ await writeFile(join(bin, "gh"), script, { mode: 0o755 });
281
+ const prevPath = process.env.PATH;
282
+ const prevFetch = globalThis.fetch;
283
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
284
+ process.env.PATH = `${bin}:${prevPath ?? ""}`;
285
+ // Pin the gh transport: `auto` would work too (the PATH stub answers the availability probe),
286
+ // but the explicit mode is immune to the memoized probe result leaking in from another test file.
287
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "gh";
288
+ globalThis.fetch = (() => Promise.resolve(new Response("no fetch in gh mode", { status: 500 }))) as typeof fetch;
289
+ try {
290
+ await pollMerges(data, engine, "tok");
291
+ } finally {
292
+ globalThis.fetch = prevFetch;
293
+ if (prevPath === undefined) delete process.env.PATH;
294
+ else process.env.PATH = prevPath;
295
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
296
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
297
+ await rm(bin, { recursive: true, force: true });
298
+ }
299
+ const row = stores["pull_requests"][0];
300
+ assertEquals(cancelled, ["merge-1"], "the live merge instance is retired before the re-open");
301
+ assertEquals(row.status, "converging", "the re-opened PR row presents the fresh convergence run");
302
+ assertEquals(row.process_key, "conv-1", "the row is re-pointed at the fresh convergence instance");
303
+ assertEquals(instances.length, 1, "exactly one fresh convergence instance is started");
304
+ assertEquals(instances[0].processDefinitionId, "convergence-loop", "the canonical convergence loop is re-entered");
305
+ assertEquals(instances[0].variables.prKey, "o/r#100");
306
+ assertEquals(instances[0].variables.round, 1, "the fresh run restarts at round 1");
307
+ assertEquals(calls.reviewPosts, 0, "no bare re-review request — the handoff, not a nudge, drives remediation");
308
+ assertEquals(stores["pr_adjudications"].length, 0, "the re-open wipes the prior run's adjudication memory (#806)");
309
+ // PR #922 review (the "previously missed" advisory): the handoff is an INTERNAL resubmission, so it
310
+ // must carry the row's existing escalation-steward opt-in through — a PR enrolled with automated
311
+ // escalation resolution must not silently lose that behaviour merely because late review threads
312
+ // appeared. `submitPr` defaults `autoResolveEscalations` to false and would overwrite the row to 0;
313
+ // the handoff passes the row's existing opt-in (as the feature/plan enrollment paths already do).
314
+ assertEquals(row.auto_resolve_escalations, 1, "the handoff preserves the escalation-steward opt-in on the row");
315
+ assertEquals(
316
+ instances[0].variables.autoResolveEscalations,
317
+ true,
318
+ "the fresh convergence instance carries the escalation-steward opt-in",
319
+ );
320
+ });
321
+
322
+ test("#921/PR#922 poller: a FAILED convergence handoff stays retryable — the row returns to waiting_merge, not a stranded waiting_review (PR #922 review, thread 3)", async () => {
323
+ // If the merge-instance cancel SUCCEEDS but `submitPr` then fails to create the replacement
324
+ // convergence instance, the row must NOT be left in `waiting_review` carrying the cancelled merge
325
+ // key: `pollMerges` no longer scans a `waiting_review` row, and `pollReviews` can only RESUME a
326
+ // live instance (its `readiness-ready` message correlates to nothing once the merge instance is
327
+ // cancelled and no fresh one exists), so the row would wedge with no handoff retry and no live
328
+ // review-timeout timer. The handoff must instead leave the row RETRYABLE — back in `waiting_merge`
329
+ // — so the next pass re-attempts the handoff against the already-retired (terminal) merge key.
330
+ const { data, stores } = memData();
331
+ const { engine, cancelled } = recordingEngine();
332
+ const instances: any[] = [];
333
+ // `submitPr` fails on the FIRST pass: the convergence instance cannot be created. A second pass
334
+ // (below) succeeds once the engine recovers.
335
+ let failCreate = true;
336
+ (engine as any).createInstance = async (args: any) => {
337
+ if (failCreate) throw new Error("engine unavailable");
338
+ instances.push(args);
339
+ return { processInstanceKey: "conv-retry-1" };
340
+ };
341
+ const mergeRow = { ...waitingMergeRow("o/r#100", 100), process_key: "merge-1" };
342
+ stores["pull_requests"] = [mergeRow];
343
+ // The derived tracking VIEW: the merge instance folds to a terminal edge once cancelled.
344
+ stores["pull_requests__tracking"] = [{ ...mergeRow, derived_status: "waiting_merge" }];
345
+ (engine as any).cancelInstance = async (args: { processInstanceKey: string }) => {
346
+ cancelled.push(String(args.processInstanceKey));
347
+ for (const t of stores["pull_requests__tracking"]) {
348
+ if (t.process_key === String(args.processInstanceKey)) t.derived_status = "abandoned";
349
+ }
350
+ };
351
+ const calls = { reviewPosts: 0 };
352
+ const bin = await mkdtemp(join(tmpdir(), "gh-stub-"));
353
+ const script = `#!/usr/bin/env bash
354
+ set -euo pipefail
355
+ if [[ "$*" == *"pr view"* ]]; then
356
+ echo '{"state":"OPEN","mergedAt":null,"mergeStateStatus":"BLOCKED","statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}],"isDraft":false,"headRefOid":"deadbeef","baseRefName":"main"}'
357
+ elif [[ "$*" == *"graphql"* ]]; then
358
+ echo '${graphqlThreads(2)}'
359
+ elif [[ "$*" == *"requested_reviewers"* ]]; then
360
+ exit 0
361
+ else
362
+ echo "unexpected gh args: $*" >&2
363
+ exit 1
364
+ fi
365
+ `;
366
+ await writeFile(join(bin, "gh"), script, { mode: 0o755 });
367
+ const prevPath = process.env.PATH;
368
+ const prevFetch = globalThis.fetch;
369
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
370
+ process.env.PATH = `${bin}:${prevPath ?? ""}`;
371
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "gh";
372
+ globalThis.fetch = (() => Promise.resolve(new Response("no fetch in gh mode", { status: 500 }))) as typeof fetch;
373
+ try {
374
+ // Pass 1: the handoff fails (createInstance throws) AFTER the merge instance is retired.
375
+ await pollMerges(data, engine, "tok");
376
+ const rowAfterFail = stores["pull_requests"][0];
377
+ assertEquals(cancelled, ["merge-1"], "the live merge instance is still retired");
378
+ assertEquals(
379
+ rowAfterFail.status,
380
+ "waiting_merge",
381
+ "a failed handoff leaves the row retryable in waiting_merge (re-scanned next pass), not stranded in waiting_review",
382
+ );
383
+ assertEquals(instances.length, 0, "no convergence instance was created on the failed pass");
384
+
385
+ // Pass 2: the engine recovers. The retry re-derives the `review-threads` verdict and re-attempts
386
+ // the handoff; the merge key is already terminal, so the idempotency gate falls through and the
387
+ // fresh convergence instance starts — the PR is NOT wedged.
388
+ failCreate = false;
389
+ await pollMerges(data, engine, "tok");
390
+ } finally {
391
+ globalThis.fetch = prevFetch;
392
+ if (prevPath === undefined) delete process.env.PATH;
393
+ else process.env.PATH = prevPath;
394
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
395
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
396
+ await rm(bin, { recursive: true, force: true });
397
+ }
398
+ const row = stores["pull_requests"][0];
399
+ assertEquals(instances.length, 1, "the retry starts the fresh convergence instance");
400
+ assertEquals(instances[0].processDefinitionId, "convergence-loop", "the canonical convergence loop is re-entered on retry");
401
+ assertEquals(row.status, "converging", "the retried handoff completes — the row presents the fresh convergence run");
402
+ assertEquals(row.process_key, "conv-retry-1", "the row is re-pointed at the fresh convergence instance");
403
+ });
404
+
405
+ test("#921/PR#922 poller: an UNFINISHED handoff (dead merge instance) never publishes merge-ready to the cancelled owner — it re-establishes a live convergence owner first (PR #922 review, round 5)", async () => {
406
+ // The round-4 retry-restore leaves a failed handoff's row back in `waiting_merge` — but its
407
+ // `process_key` still names the CANCELLED merge instance (derived_status `abandoned`). If the next
408
+ // pass's GitHub verdict has moved off `review-threads` (the threads were resolved out-of-band, so the
409
+ // PR now reads `ready`), the verdict switch would fall through to `flipToMergingThenPublish` and
410
+ // publish `merge-ready {ready}` correlated to the DEAD merge instance — the message is dropped and the
411
+ // row wedges at `merging` with no live owner. The handoff must instead be persisted INDEPENDENTLY of
412
+ // the current verdict: a `waiting_merge` row whose merge instance is already terminal has no live
413
+ // merge-loop to signal, so the poller must re-establish a live convergence owner (re-open via
414
+ // `submitPr`) BEFORE any merge message is contemplated.
415
+ const { data, stores } = memData();
416
+ const { engine, messages, cancelled } = recordingEngine();
417
+ const instances: any[] = [];
418
+ (engine as any).createInstance = async (args: any) => {
419
+ instances.push(args);
420
+ return { processInstanceKey: "conv-fresh-1" };
421
+ };
422
+ // The unfinished-handoff shape: a prior pass's handoff retired the merge instance and its `submitPr`
423
+ // failed, so the restore left the row in `waiting_merge` with `process_key` NULL — no live owner.
424
+ const mergeRow = { ...waitingMergeRow("o/r#100", 100), process_key: null };
425
+ stores["pull_requests"] = [mergeRow];
426
+ stores["pull_requests__tracking"] = [{ ...mergeRow, derived_status: "waiting_merge" }];
427
+ const calls = { reviewPosts: 0 };
428
+ const bin = await mkdtemp(join(tmpdir(), "gh-stub-"));
429
+ // A clean READY verdict: MERGEABLE, all checks green, ZERO unresolved threads — the threads that
430
+ // triggered the original handoff were resolved out-of-band. This is the verdict that would send the
431
+ // PR through the normal `ready` branch and publish to the dead owner.
432
+ const script = `#!/usr/bin/env bash
433
+ set -euo pipefail
434
+ if [[ "$*" == *"pr view"* ]]; then
435
+ echo '{"state":"OPEN","mergedAt":null,"mergeStateStatus":"CLEAN","statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}],"isDraft":false,"headRefOid":"deadbeef","baseRefName":"main"}'
436
+ elif [[ "$*" == *"graphql"* ]]; then
437
+ echo '${graphqlThreads(0)}'
438
+ elif [[ "$*" == *"requested_reviewers"* ]]; then
439
+ exit 0
440
+ else
441
+ echo "unexpected gh args: $*" >&2
442
+ exit 1
443
+ fi
444
+ `;
445
+ await writeFile(join(bin, "gh"), script, { mode: 0o755 });
446
+ const prevPath = process.env.PATH;
447
+ const prevFetch = globalThis.fetch;
448
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
449
+ process.env.PATH = `${bin}:${prevPath ?? ""}`;
450
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "gh";
451
+ globalThis.fetch = (() => Promise.resolve(new Response("no fetch in gh mode", { status: 500 }))) as typeof fetch;
452
+ try {
453
+ await pollMerges(data, engine, "tok");
454
+ } finally {
455
+ globalThis.fetch = prevFetch;
456
+ if (prevPath === undefined) delete process.env.PATH;
457
+ else process.env.PATH = prevPath;
458
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
459
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
460
+ await rm(bin, { recursive: true, force: true });
461
+ }
462
+ const row = stores["pull_requests"][0];
463
+ assertEquals(
464
+ messages.filter((m) => m.name === "merge-ready").length,
465
+ 0,
466
+ "an unfinished handoff must NOT publish merge-ready to the cancelled merge instance — the message would be dropped and the row wedged at merging",
467
+ );
468
+ assertEquals(
469
+ instances.length,
470
+ 1,
471
+ "the poller re-establishes a live convergence owner before any merge message, independently of the ready verdict",
472
+ );
473
+ assertEquals(instances[0].processDefinitionId, "convergence-loop", "the canonical convergence loop is re-entered");
474
+ assertEquals(row.process_key, "conv-fresh-1", "the row is re-pointed at the fresh live convergence instance");
475
+ assertEquals(row.status, "converging", "the row presents the fresh convergence run, not a wedged merging");
476
+ });
477
+
478
+ test("#921/PR#922 poller: a HUMAN-CANCELLED merge-stage PR is NOT resurrected — the dead-owner guard keys on the NULL process_key, not the terminal derived_status", async () => {
479
+ // Anti-regression for the round-5 guard: a human cancel of a parked merge wait terminates the merge
480
+ // instance, so the row keeps base `status: "waiting_merge"` (the reconciler no longer writes the
481
+ // terminal edge onto the base row) and `process_key` STILL naming the terminated instance, while the
482
+ // derived VIEW folds to `abandoned`. That shape must NOT be re-opened into convergence — a cancelled
483
+ // run stays cancelled (the abandon protocol exists precisely to stop orphaned side effects). The guard
484
+ // therefore keys on `process_key === null` (the unfinished-handoff marker the restore writes), NOT on
485
+ // the terminal `derived_status` — which a human cancel shares. This row keeps `process_key` set, so the
486
+ // guard must not fire and no convergence instance may be created.
487
+ const { data, stores } = memData();
488
+ const { engine, messages } = recordingEngine();
489
+ const instances: any[] = [];
490
+ (engine as any).createInstance = async (args: any) => {
491
+ instances.push(args);
492
+ return { processInstanceKey: "conv-should-not-start" };
493
+ };
494
+ // Human-cancelled shape: base waiting_merge, process_key STILL set (names the terminated instance),
495
+ // derived_status folded terminal. Distinct from the unfinished handoff (which NULLs process_key).
496
+ const mergeRow = { ...waitingMergeRow("o/r#100", 100), process_key: "merge-cancelled-by-human" };
497
+ stores["pull_requests"] = [mergeRow];
498
+ stores["pull_requests__tracking"] = [{ ...mergeRow, derived_status: "abandoned" }];
499
+ const calls = { reviewPosts: 0 };
500
+ const bin = await mkdtemp(join(tmpdir(), "gh-stub-"));
501
+ const script = `#!/usr/bin/env bash
502
+ set -euo pipefail
503
+ if [[ "$*" == *"pr view"* ]]; then
504
+ echo '{"state":"OPEN","mergedAt":null,"mergeStateStatus":"CLEAN","statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}],"isDraft":false,"headRefOid":"deadbeef","baseRefName":"main"}'
505
+ elif [[ "$*" == *"graphql"* ]]; then
506
+ echo '${graphqlThreads(0)}'
507
+ elif [[ "$*" == *"requested_reviewers"* ]]; then
508
+ exit 0
509
+ else
510
+ echo "unexpected gh args: $*" >&2
511
+ exit 1
512
+ fi
513
+ `;
514
+ await writeFile(join(bin, "gh"), script, { mode: 0o755 });
515
+ const prevPath = process.env.PATH;
516
+ const prevFetch = globalThis.fetch;
517
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
518
+ process.env.PATH = `${bin}:${prevPath ?? ""}`;
519
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "gh";
520
+ globalThis.fetch = (() => Promise.resolve(new Response("no fetch in gh mode", { status: 500 }))) as typeof fetch;
521
+ try {
522
+ await pollMerges(data, engine, "tok");
523
+ } finally {
524
+ globalThis.fetch = prevFetch;
525
+ if (prevPath === undefined) delete process.env.PATH;
526
+ else process.env.PATH = prevPath;
527
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
528
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
529
+ await rm(bin, { recursive: true, force: true });
530
+ }
531
+ assertEquals(
532
+ instances.length,
533
+ 0,
534
+ "a human-cancelled merge-stage PR must NOT be resurrected into convergence — its process_key still names the terminated instance, so the dead-owner guard does not fire",
535
+ );
536
+ assertEquals(
537
+ messages.filter((m) => m.name === "merge-ready").length,
538
+ 0,
539
+ "no merge-ready is published to the cancelled (dead) merge instance — publishing it would be dropped and wedge the row at merging",
540
+ );
541
+ assertEquals(
542
+ stores["pull_requests"][0].process_key,
543
+ "merge-cancelled-by-human",
544
+ "the cancelled row's process_key is left untouched (not nulled, not re-pointed)",
545
+ );
546
+ assertEquals(
547
+ stores["pull_requests"][0].status,
548
+ "waiting_merge",
549
+ "the cancelled row is left in place (not flipped to the transient merging) — the abandon terminal edge is derived, not poller-written",
550
+ );
551
+ });