@nanobpm/nano-workforce 0.208.1 → 0.208.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.208.3](https://github.com/nanobpm/nano-workforce/compare/v0.208.2...v0.208.3) (2026-11-11)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **merge:** never retire a merge loop into nothing on the convergence hand-off ([#944](https://github.com/nanobpm/nano-workforce/issues/944)) ([ff42e2a](https://github.com/nanobpm/nano-workforce/commit/ff42e2a6fa77fd780949822adf3d4f2b9caefc38)), closes [nanobpm/nano-bpm#1452](https://github.com/nanobpm/nano-bpm/issues/1452) [#939](https://github.com/nanobpm/nano-workforce/issues/939) [#943](https://github.com/nanobpm/nano-workforce/issues/943)
6
+
7
+ ## [0.208.2](https://github.com/nanobpm/nano-workforce/compare/v0.208.1...v0.208.2) (2026-11-10)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **status:** batch the /status merge-policy engine read into one call ([#937](https://github.com/nanobpm/nano-workforce/issues/937)) ([a9778e1](https://github.com/nanobpm/nano-workforce/commit/a9778e1c7db7205fe0b3b5dfebc597468c4e66f4)), closes [issue-#916](https://github.com/nanobpm/issue-/issues/916) [#916](https://github.com/nanobpm/nano-workforce/issues/916) [#936](https://github.com/nanobpm/nano-workforce/issues/936)
12
+
1
13
  ## [0.208.1](https://github.com/nanobpm/nano-workforce/compare/v0.208.0...v0.208.1) (2026-11-10)
2
14
 
3
15
  ### Bug Fixes
@@ -0,0 +1,412 @@
1
+ // Issue #943 — defect-class guard: a convergence/merge loop must never end without an outcome.
2
+ // A merge loop (nanobpm/nano-bpm#1452, instance 607414) was terminated in-app by the waiting-merge →
3
+ // convergence hand-off with NO successor, NO escalation and NO recorded reason, and silently dropped
4
+ // off `/status`. This suite guards the whole class:
5
+ // 1. every `cancelInstance(` call site in the app source is ENUMERATED and classified — a new site
6
+ // fails here until it is routed through `terminatePrLoop` (which records a reason naming the
7
+ // successor/outcome) or justified in the registry below;
8
+ // 2. `terminatePrLoop` records the reason BEFORE cancelling;
9
+ // 3. an orphaned PR (loop ended, base row still in flight, no reason, not merged) is VISIBLE in
10
+ // `/status` as `orphaned` instead of being filtered out.
11
+ import { test } from "node:test";
12
+ import { readdir, readFile } from "node:fs/promises";
13
+ import { join, relative } from "node:path";
14
+ import { assert, assertEquals } from "#test-assert";
15
+ import type { DataLayer } from "@nanobpm/urban";
16
+ import {
17
+ activePrs,
18
+ handoffWaitingMergeToConvergence,
19
+ isOrphanedPr,
20
+ ORPHANED_PR_STATUS,
21
+ recordOperatorCancelProvenance,
22
+ retireHandedOffMergeLoop,
23
+ retryPendingRetirements,
24
+ terminatePrLoop,
25
+ } from "./service.ts";
26
+
27
+ const ROOT = join(import.meta.dirname, "..");
28
+
29
+ /** Every sanctioned `cancelInstance(` call site, keyed `file` → count, with its outcome contract. */
30
+ const SANCTIONED_CANCEL_SITES: Record<string, { count: number; outcome: string }> = {
31
+ // (a) `terminatePrLoop` — THE canonical PR-loop door: stamps `termination_reason` (naming the
32
+ // successor instance or the terminal GitHub state) before cancelling. Callers: the merge →
33
+ // convergence hand-off (successor started FIRST) and the terminal review-wait retire (row
34
+ // settled merged/abandoned).
35
+ // (b) `submitPr` rollback — cancels the instance THIS submit just created and never published as
36
+ // owner; the prior row (owner key + status) is restored, so the prior owner remains the outcome.
37
+ "app/service.ts": { count: 2, outcome: "terminatePrLoop door + submitPr rollback (prior owner restored)" },
38
+ // Delivery-graph reconcile-before-relaunch: not a PR loop; cancels a duplicate original under the
39
+ // stale-claim CAS lease so the run is relaunched (the successor) at-most-once.
40
+ "app/deliveryGraphRun.ts": { count: 1, outcome: "duplicate original retired; relaunch is the successor" },
41
+ };
42
+
43
+ async function sourceFiles(dir: string): Promise<string[]> {
44
+ const out: string[] = [];
45
+ for (const e of await readdir(dir, { withFileTypes: true })) {
46
+ const p = join(dir, e.name);
47
+ if (e.isDirectory()) {
48
+ if (e.name === "node_modules" || e.name.startsWith(".")) continue;
49
+ out.push(...(await sourceFiles(p)));
50
+ } else if (e.name.endsWith(".ts") && !e.name.endsWith(".test.ts") && !e.name.endsWith(".e2e.ts") && !e.name.endsWith(".d.ts")) {
51
+ out.push(p);
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ test("#943 guard: every cancelInstance call site that can end a loop is enumerated and records an outcome", async () => {
58
+ const found: Record<string, number> = {};
59
+ for (const dir of ["app", "workers", "actions", "operations"]) {
60
+ let files: string[] = [];
61
+ try {
62
+ files = await sourceFiles(join(ROOT, dir));
63
+ } catch {
64
+ continue; // directory absent
65
+ }
66
+ for (const f of files) {
67
+ const n = ((await readFile(f, "utf8")).match(/\.cancelInstance\(/g) ?? []).length;
68
+ if (n > 0) found[relative(ROOT, f)] = n;
69
+ }
70
+ }
71
+ const expected = Object.fromEntries(Object.entries(SANCTIONED_CANCEL_SITES).map(([k, v]) => [k, v.count]));
72
+ assertEquals(
73
+ found,
74
+ expected,
75
+ "a cancelInstance call site was added/removed: route a PR-loop cancel through terminatePrLoop (reason + successor/outcome), then update SANCTIONED_CANCEL_SITES",
76
+ );
77
+ });
78
+
79
+ function memData(rows: any[], tracking: any[]) {
80
+ const stores: Record<string, any[]> = { pull_requests: rows, pull_requests__tracking: tracking, user_tasks: [] };
81
+ const tbl = (name: string, pk: string) => {
82
+ const r = (stores[name] ??= []);
83
+ return {
84
+ all: async () => r.slice(),
85
+ get: async (id: any) => r.find((x) => x[pk] === id),
86
+ find: async (w: any = {}) => r.filter((x) => Object.entries(w).every(([k, v]) => x[k] === v)),
87
+ update: async (id: any, patch: any) => {
88
+ const x = r.find((y) => y[pk] === id);
89
+ if (x) Object.assign(x, patch);
90
+ },
91
+ };
92
+ };
93
+ const data = { table: (n: string, pk = "id") => tbl(n, pk) } as unknown as DataLayer;
94
+ return { data, stores };
95
+ }
96
+
97
+ test("#943 terminatePrLoop records the reason BEFORE the engine cancel", async () => {
98
+ const { data, stores } = memData([{ pr_key: "o/r#1", process_key: "k1", termination_reason: null }], []);
99
+ const seen: (string | null)[] = [];
100
+ const engine = {
101
+ cancelInstance: async () => {
102
+ seen.push(stores.pull_requests[0].termination_reason);
103
+ },
104
+ };
105
+ await terminatePrLoop(data, engine as never, "o/r#1", "k1", "handed back -> successor k2", false);
106
+ assertEquals(seen, ["handed back -> successor k2"]);
107
+ assertEquals(stores.pull_requests[0].termination_process_key, "k1", "the reason is bound to the cancelled instance");
108
+ });
109
+
110
+ test("#943/PR#944 terminatePrLoop never stamps a predecessor's reason onto a successor-owned row", async () => {
111
+ const { data, stores } = memData([{ pr_key: "o/r#1", process_key: "succ", termination_reason: null }], []);
112
+ const cancelled: string[] = [];
113
+ const engine = { cancelInstance: async (a: { processInstanceKey: string }) => void cancelled.push(a.processInstanceKey) };
114
+ await terminatePrLoop(data, engine as never, "o/r#1", "pred", "handed back -> successor succ", true);
115
+ assertEquals(cancelled, ["pred"], "the predecessor is still retired");
116
+ assertEquals(stores.pull_requests[0].termination_reason, null, "the successor's row carries no predecessor provenance");
117
+ });
118
+
119
+ test("#943/PR#944 terminatePrLoop still cancels when the provenance write fails", async () => {
120
+ const data = {
121
+ table: () => ({
122
+ get: async () => ({ pr_key: "o/r#1", process_key: "k1" }),
123
+ update: async () => {
124
+ throw new Error("db down");
125
+ },
126
+ }),
127
+ } as unknown as DataLayer;
128
+ const cancelled: string[] = [];
129
+ const engine = { cancelInstance: async (a: { processInstanceKey: string }) => void cancelled.push(a.processInstanceKey) };
130
+ await terminatePrLoop(data, engine as never, "o/r#1", "k1", "r", true);
131
+ assertEquals(cancelled, ["k1"]);
132
+ });
133
+
134
+ test("#943/PR#944 retireHandedOffMergeLoop retries the CAPTURED merge key and never touches the successor row's stage", async () => {
135
+ const { data, stores } = memData([{ pr_key: "o/r#1", process_key: "succ", status: "waiting_review" }], []);
136
+ let calls = 0;
137
+ const engine = {
138
+ cancelInstance: async (a: { processInstanceKey: string }) => {
139
+ calls++;
140
+ assertEquals(a.processInstanceKey, "merge-1");
141
+ if (calls === 1) throw new Error("transient");
142
+ },
143
+ };
144
+ assert(await retireHandedOffMergeLoop(data, engine as never, "o/r#1", "merge-1", "succ", "test"));
145
+ assertEquals(calls, 2);
146
+ assertEquals(stores.pull_requests[0].status, "waiting_review");
147
+ assertEquals(stores.pull_requests[0].process_key, "succ");
148
+ const failing = { cancelInstance: async () => Promise.reject(new Error("down")) };
149
+ assert(!(await retireHandedOffMergeLoop(data, failing as never, "o/r#1", "merge-1", "succ", "test")));
150
+ assertEquals(stores.pull_requests[0].status, "waiting_review", "a persistent retire failure never demotes the successor");
151
+ });
152
+
153
+ test("#943/PR#944 recordOperatorCancelProvenance binds an operator cancel to the owning instance", async () => {
154
+ const { data, stores } = memData([{ pr_key: "o/r#1", process_key: "k1" }, { pr_key: "o/r#2", process_key: "k2" }], []);
155
+ await recordOperatorCancelProvenance(data, "k1", "/actions/cancel-pr");
156
+ assertEquals(stores.pull_requests[0].termination_reason, "operator cancel (/actions/cancel-pr)");
157
+ assertEquals(stores.pull_requests[0].termination_process_key, "k1");
158
+ assertEquals(stores.pull_requests[1].termination_reason, undefined);
159
+ });
160
+
161
+ test("#943/PR#944 the page Cancel button routes through the provenance-recording door, not the built-in route", async () => {
162
+ const page = await readFile(join(ROOT, "pages/home.page.json"), "utf8");
163
+ assert(!page.includes('"/app/actions/cancel"'), "the built-in /app/actions/cancel bypasses termination provenance");
164
+ assert(page.includes('"/app/api/actions/cancel-pr"'));
165
+ });
166
+
167
+ test("#943 terminatePrLoop: strict rethrows a failed cancel; lenient only logs", async () => {
168
+ const { data } = memData([{ pr_key: "o/r#1" }], []);
169
+ const engine = {
170
+ cancelInstance: async () => {
171
+ throw new Error("boom");
172
+ },
173
+ };
174
+ let threw = false;
175
+ try {
176
+ await terminatePrLoop(data, engine as never, "o/r#1", "k1", "r", true);
177
+ } catch {
178
+ threw = true;
179
+ }
180
+ assert(threw, "strict rethrows");
181
+ await terminatePrLoop(data, engine as never, "o/r#1", "k1", "r", false);
182
+ });
183
+
184
+ const base = {
185
+ repo: "o/r",
186
+ url: "u",
187
+ title: null,
188
+ current_round: 1,
189
+ waiting_since: null,
190
+ last_review_id: null,
191
+ outcome: null,
192
+ created_at: "t",
193
+ updated_at: "t",
194
+ converged_at: "t",
195
+ merged_at: null,
196
+ active_worker: null,
197
+ lease_until: null,
198
+ incident_key: null,
199
+ incident_type: null,
200
+ incident_message: null,
201
+ incident_since: null,
202
+ auto_resolve_escalations: 0,
203
+ termination_reason: null,
204
+ };
205
+
206
+ test("#943 isOrphanedPr: loop ended in-app with the row still in flight and no reason → orphaned", () => {
207
+ assert(isOrphanedPr({ status: "waiting_review", derived_status: "abandoned", termination_reason: null, merged_at: null }));
208
+ assert(
209
+ !isOrphanedPr({ status: "waiting_review", derived_status: "abandoned", process_key: "k", termination_reason: "handed back", termination_process_key: "k", merged_at: null }),
210
+ "a reason recorded for the current owner is not an orphan",
211
+ );
212
+ assert(
213
+ isOrphanedPr({ status: "waiting_review", derived_status: "abandoned", process_key: "succ", termination_reason: "handed back", termination_process_key: "pred", merged_at: null }),
214
+ "a predecessor's reason never masks an orphaned successor (PR #944 review)",
215
+ );
216
+ assert(
217
+ isOrphanedPr({ status: "waiting_review", derived_status: "abandoned", process_key: "k", termination_reason: "legacy", merged_at: null }),
218
+ "an unbound reason explains nothing",
219
+ );
220
+ assert(!isOrphanedPr({ status: "abandoned", derived_status: "abandoned", termination_reason: null, merged_at: null }), "a settled row (human cancel/close) is not an orphan");
221
+ assert(!isOrphanedPr({ status: "waiting_merge", derived_status: "waiting_merge", termination_reason: null, merged_at: null }), "a live loop is not an orphan");
222
+ assert(!isOrphanedPr({ status: "merging", derived_status: "abandoned", termination_reason: null, merged_at: "t" }), "a merged PR is not an orphan");
223
+ });
224
+
225
+ test("#943 /status surfaces an orphaned PR instead of filtering it out", async () => {
226
+ const orphan = { ...base, pr_key: "o/r#1452", number: 1452, status: "waiting_review", process_key: "607414" };
227
+ const live = { ...base, pr_key: "o/r#2", number: 2, status: "waiting_merge", process_key: "9" };
228
+ const { data } = memData(
229
+ [orphan, live],
230
+ [
231
+ { ...orphan, derived_status: "abandoned" },
232
+ { ...live, derived_status: "waiting_merge" },
233
+ ],
234
+ );
235
+ const rows = await activePrs(data);
236
+ const o = rows.find((r) => r.prKey === "o/r#1452");
237
+ assert(o, "the orphaned PR is listed in /status");
238
+ assertEquals(o?.status, ORPHANED_PR_STATUS);
239
+ assertEquals(o?.terminationReason, null);
240
+ assertEquals(rows.find((r) => r.prKey === "o/r#2")?.status, "waiting_merge");
241
+ });
242
+
243
+ test("#943/PR#944 a persistently failed retirement is persisted and retried by later poll passes", async () => {
244
+ const { data, stores } = memData([{ pr_key: "o/r#1", process_key: "succ", status: "waiting_review" }], []);
245
+ const failing = { cancelInstance: async () => Promise.reject(new Error("down")) };
246
+ assert(!(await retireHandedOffMergeLoop(data, failing as never, "o/r#1", "merge-1", "succ", "test")));
247
+ assertEquals(stores.pull_requests[0].retire_pending_key, "merge-1", "the predecessor key is never dropped");
248
+ await retryPendingRetirements(data, failing as never);
249
+ assertEquals(stores.pull_requests[0].retire_pending_key, "merge-1");
250
+ const cancelled: string[] = [];
251
+ const ok = { cancelInstance: async (a: { processInstanceKey: string }) => void cancelled.push(a.processInstanceKey) };
252
+ await retryPendingRetirements(data, ok as never);
253
+ assertEquals(cancelled, ["merge-1"]);
254
+ assertEquals(stores.pull_requests[0].retire_pending_key, null);
255
+ assertEquals(stores.pull_requests[0].process_key, "succ");
256
+ assertEquals(stores.pull_requests[0].status, "waiting_review");
257
+ });
258
+
259
+ test("PR#944 a pending retirement whose key still OWNS the row (unpublished successor) is never retired", async () => {
260
+ const { data, stores } = memData([{ pr_key: "o/r#1", process_key: "merge-1", status: "waiting_review", retire_pending_key: "merge-1" }], []);
261
+ const cancelled: string[] = [];
262
+ const ok = { cancelInstance: async (a: { processInstanceKey: string }) => void cancelled.push(a.processInstanceKey) };
263
+ await retryPendingRetirements(data, ok as never);
264
+ assertEquals(cancelled, [], "a live owning merge loop is never retired into nothing");
265
+ assertEquals(stores.pull_requests[0].retire_pending_key, "merge-1");
266
+ });
267
+
268
+ test("PR#944 round 5: a KEYLESS owner row (unpublished handoff) is never retired into nothing — it stays recoverable", async () => {
269
+ // `submitPr` can publish a null `process_key` (phantom row, #704/#497); if the app stops before the
270
+ // handoff rollback lands (or the rollback write fails), the pending predecessor key survives with NO
271
+ // successor. Cancelling the still-live merge loop here would recreate the ownerless termination this
272
+ // PR prevents — the retirement must be deferred, leaving the NULL-key row for the dead-owner guard in
273
+ // `pollMerges` to re-drive the handoff.
274
+ const { data, stores } = memData([{ pr_key: "o/r#1", process_key: null, status: "waiting_review", retire_pending_key: "merge-1" }], []);
275
+ const cancelled: string[] = [];
276
+ const ok = { cancelInstance: async (a: { processInstanceKey: string }) => void cancelled.push(a.processInstanceKey) };
277
+ await retryPendingRetirements(data, ok as never);
278
+ assertEquals(cancelled, [], "no live successor → the predecessor is never retired");
279
+ assertEquals(stores.pull_requests[0].retire_pending_key, "merge-1", "the pending marker survives for recovery");
280
+ // Once a successor IS published, the same pass retires the predecessor against it.
281
+ stores.pull_requests[0].process_key = "succ";
282
+ await retryPendingRetirements(data, ok as never);
283
+ assertEquals(cancelled, ["merge-1"]);
284
+ assertEquals(stores.pull_requests[0].retire_pending_key, null);
285
+ });
286
+
287
+ // ── PR #944 round-6 review: restart-recovery of a pending handoff ───────────────────────────────
288
+ // A restart between the handoff's staging write and the successor publication strands the row in a
289
+ // shape NEITHER poll pass recovers: the `waiting_review`/`converging` stages sit outside the
290
+ // `waiting_merge` recovery scan, and the one-pending-retirement guard rejected a KEYLESS owner row
291
+ // outright, so even a keyless `waiting_merge` row could never re-drive its handoff. The recovery
292
+ // must be fenced to the LIVE merge stages (never demote a successor-owned `converging` row) and the
293
+ // pending-predecessor guard must admit a keyless owner row (there IS no live owner to protect).
294
+
295
+ /** In-memory `data` double with the exact tables `submitPr` touches on a phantom (null-key) publish. */
296
+ function handoffData(rows: any[], tracking: any[]) {
297
+ const stores: Record<string, any[]> = {
298
+ pull_requests: rows,
299
+ pull_requests__tracking: tracking,
300
+ escalations: [],
301
+ pr_dependencies: [],
302
+ worker_durable_resume: [],
303
+ world_checkpoints: [],
304
+ world_effects: [],
305
+ };
306
+ const tbl = (name: string, pk: string) => {
307
+ const r = (stores[name] ??= []);
308
+ return {
309
+ all: async () => r.slice(),
310
+ get: async (id: any) => r.find((x) => x[pk] === id),
311
+ find: async (w: any = {}) => r.filter((x) => Object.entries(w).every(([k, v]) => x[k] === v)),
312
+ findOne: async (w: any = {}) => r.find((x) => Object.entries(w).every(([k, v]) => x[k] === v)),
313
+ insert: async (row: any) => void r.push(row),
314
+ update: async (id: any, patch: any) => {
315
+ const x = r.find((y) => y[pk] === id);
316
+ if (x) Object.assign(x, patch);
317
+ },
318
+ delete: async (id: any) => {
319
+ const i = r.findIndex((y) => y[pk] === id);
320
+ if (i >= 0) r.splice(i, 1);
321
+ },
322
+ };
323
+ };
324
+ const data = {
325
+ table: (n: string, pk = "id") => tbl(n, pk),
326
+ open: () => ({ exec: async () => {} }),
327
+ } as unknown as DataLayer;
328
+ return { data, stores };
329
+ }
330
+
331
+ /** Run a handoff with the GitHub transport DISABLED (PR #944 round-7 review): forcing the `token`
332
+ * transport with no `GITHUB_TOKEN` makes `submitPr`'s best-effort `fetchPrMeta` return `null`
333
+ * WITHOUT any `gh` subprocess or network fetch — the test never depends on ambient credentials,
334
+ * the network, or an unroutable host failing fast. The engine double publishes a NULL key (the
335
+ * phantom-row path, #704/#497), so `submitPr` starts no successor and the handoff must roll back. */
336
+ async function runPhantomHandoff(row: any, tracking: any[]) {
337
+ const prevTransport = process.env["NANO_PR_GITHUB_TRANSPORT"];
338
+ const prevToken = process.env["GITHUB_TOKEN"];
339
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
340
+ delete process.env["GITHUB_TOKEN"];
341
+ try {
342
+ const { data, stores } = handoffData([row], tracking);
343
+ const engine = {
344
+ createInstance: async () => ({ processInstanceKey: null }),
345
+ cancelInstance: async () => {},
346
+ };
347
+ await handoffWaitingMergeToConvergence(data, engine as never, stores.pull_requests[0], "test");
348
+ return stores;
349
+ } finally {
350
+ if (prevTransport === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
351
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevTransport;
352
+ if (prevToken === undefined) delete process.env["GITHUB_TOKEN"];
353
+ else process.env["GITHUB_TOKEN"] = prevToken;
354
+ }
355
+ }
356
+
357
+ test("PR#944 round 6: a restart-stranded KEYLESS waiting_merge row re-drives its handoff past the pending-predecessor guard", async () => {
358
+ const stores = await runPhantomHandoff(
359
+ { pr_key: "o/r#1", repo: "o/r", number: 1, url: "u", process_key: null, status: "waiting_merge", retire_pending_key: "merge-1" },
360
+ [{ pr_key: "o/r#1", derived_status: "waiting_merge" }],
361
+ );
362
+ assertEquals(stores.pull_requests[0].status, "waiting_merge", "the failed handoff restores the live merge stage");
363
+ assertEquals(stores.pull_requests[0].process_key, null, "a keyless row stays keyless (the durable unfinished-handoff marker)");
364
+ assertEquals(stores.pull_requests[0].retire_pending_key, "merge-1", "the pending predecessor survives the retry for later passes");
365
+ });
366
+
367
+ test("PR#944 round 6: a restart-stranded waiting_review row still OWNED by its live merge loop is left for the retirement-retry pass, not demoted", async () => {
368
+ // The staging write already flipped the row to `waiting_review` and the owner is still the merge
369
+ // loop: this is a MID-HANDOFF shape, not a stranded one. The stage fence correctly skips it —
370
+ // re-staging would strand it outside `waiting_merge`'s scan. `retryPendingRetirements` recovers it
371
+ // once the successor publishes (the round-5 test covers that retirement); the fence must NOT
372
+ // "recover" it by demoting the stage back.
373
+ const stores = await runPhantomHandoff(
374
+ { pr_key: "o/r#1", repo: "o/r", number: 1, url: "u", process_key: "merge-1", status: "waiting_review", retire_pending_key: "merge-1" },
375
+ [{ pr_key: "o/r#1", derived_status: "waiting_merge" }],
376
+ );
377
+ assertEquals(stores.pull_requests[0].status, "waiting_review", "a mid-handoff row is never demoted back to waiting_merge");
378
+ assertEquals(stores.pull_requests[0].process_key, "merge-1", "the merge loop keeps owning the row until the successor publishes");
379
+ assertEquals(stores.pull_requests[0].retire_pending_key, "merge-1", "the pending marker survives for retryPendingRetirements");
380
+ });
381
+
382
+ test("PR#944 round 6: a restart-stranded KEYLESS converging row re-drives its handoff past the pending-predecessor guard", async () => {
383
+ // The reviewer's second stranded shape: the app stopped after the phantom (null-key) publish but
384
+ // before the rollback, leaving the row keyless at `converging` with the pending predecessor. The
385
+ // dead-owner guard in `pollMerges` re-drives the handoff from this shape; the pending-predecessor
386
+ // guard must admit it (there is no live owner to protect), and the rollback restores the live
387
+ // merge stage so the next pass retries cleanly.
388
+ const stores = await runPhantomHandoff(
389
+ { pr_key: "o/r#1", repo: "o/r", number: 1, url: "u", process_key: null, status: "converging", retire_pending_key: "merge-1" },
390
+ [{ pr_key: "o/r#1", derived_status: "converging" }],
391
+ );
392
+ assertEquals(stores.pull_requests[0].status, "waiting_merge", "the failed recovery handoff restores the live merge stage");
393
+ assertEquals(stores.pull_requests[0].process_key, null, "a keyless row stays keyless (the durable unfinished-handoff marker)");
394
+ assertEquals(stores.pull_requests[0].retire_pending_key, "merge-1", "the pending predecessor survives the retry for later passes");
395
+ });
396
+
397
+ test("PR#944 round 6: a successor-owned converging row is NEVER demoted by a redelivered handoff", async () => {
398
+ const { data, stores } = handoffData(
399
+ [{ pr_key: "o/r#1", repo: "o/r", number: 1, url: "u", process_key: "succ", status: "converging", retire_pending_key: "merge-1" }],
400
+ [{ pr_key: "o/r#1", derived_status: "converging" }],
401
+ );
402
+ const engine = {
403
+ createInstance: async () => {
404
+ throw new Error("must not be called — the successor owns the row");
405
+ },
406
+ cancelInstance: async () => {},
407
+ };
408
+ await handoffWaitingMergeToConvergence(data, engine as never, stores.pull_requests[0], "test");
409
+ assertEquals(stores.pull_requests[0].status, "converging");
410
+ assertEquals(stores.pull_requests[0].process_key, "succ");
411
+ assertEquals(stores.pull_requests[0].retire_pending_key, "merge-1", "the pending retirement is left for retryPendingRetirements");
412
+ });
@@ -34,7 +34,7 @@ function projectedOperations() {
34
34
  // dismiss) whose approval is a human click in the cockpit (ADR 0005 Decision 7), plus the
35
35
  // incident-retry remediation door (issue #931) — the human unstick action on an incident-parked
36
36
  // run. These — and ONLY these — must be excluded from the projected MCP tool surface.
37
- const EXPECTED_EXCLUDED = ["stageDeliveryGraph", "dispatchDeliveryGraph", "dismissProposal", "retryIncident"];
37
+ const EXPECTED_EXCLUDED = ["stageDeliveryGraph", "dispatchDeliveryGraph", "dismissProposal", "retryIncident", "cancelPrRun"];
38
38
 
39
39
  // A representative set of agent-facing operations that MUST stay exposed as tools — the drive doors
40
40
  // (submit PR / epic / delivery-graph set), the pure compile/preview doors, and the read/orientation
@@ -374,10 +374,13 @@ fi
374
374
  process.env["NANO_PR_GITHUB_TRANSPORT"] = "gh";
375
375
  globalThis.fetch = (() => Promise.resolve(new Response("no fetch in gh mode", { status: 500 }))) as typeof fetch;
376
376
  try {
377
- // Pass 1: the handoff fails (createInstance throws) AFTER the merge instance is retired.
377
+ // Pass 1: the handoff fails (createInstance throws). Issue #943: the successor is started BEFORE
378
+ // the merge instance is retired, so a failed start leaves the merge instance LIVE (never retired
379
+ // into nothing).
378
380
  await pollMerges(data, engine, "tok");
379
381
  const rowAfterFail = stores["pull_requests"][0];
380
- assertEquals(cancelled, ["merge-1"], "the live merge instance is still retired");
382
+ assertEquals(cancelled, [], "no successor -> the live merge instance is NOT retired");
383
+ assertEquals(rowAfterFail.process_key, "merge-1", "the live merge instance still owns the row");
381
384
  assertEquals(
382
385
  rowAfterFail.status,
383
386
  "waiting_merge",
@@ -403,6 +406,63 @@ fi
403
406
  assertEquals(instances[0].processDefinitionId, "convergence-loop", "the canonical convergence loop is re-entered on retry");
404
407
  assertEquals(row.status, "converging", "the retried handoff completes — the row presents the fresh convergence run");
405
408
  assertEquals(row.process_key, "conv-retry-1", "the row is re-pointed at the fresh convergence instance");
409
+ assertEquals(cancelled, ["merge-1"], "the merge instance is retired only once the successor exists");
410
+ });
411
+
412
+ test("#943 poller: the convergence handoff never retires the merge loop into NOTHING — the derived tracking edge folds the cancel ASYNCHRONOUSLY (nanobpm/nano-bpm#1452)", async () => {
413
+ // Production shape: `pull_requests__tracking.derived_status` folds an engine cancel in LATER (the
414
+ // reconciler edge), not synchronously inside `cancelInstance`. The old cancel-then-submit order
415
+ // therefore had `submitPr` read the just-cancelled merge key as a LIVE owner, return
416
+ // `alreadyRunning`, and start nothing — merge loop terminated, no successor, no escalation.
417
+ const { data, stores } = memData();
418
+ const { engine, cancelled } = recordingEngine();
419
+ const instances: any[] = [];
420
+ (engine as any).createInstance = async (args: any) => {
421
+ instances.push(args);
422
+ return { processInstanceKey: "conv-943" };
423
+ };
424
+ const mergeRow = { ...waitingMergeRow("o/r#1452", 1452), process_key: "merge-607414" };
425
+ stores["pull_requests"] = [mergeRow];
426
+ // The tracking view stays `waiting_merge` across the cancel (async fold), exactly as on merlin.
427
+ stores["pull_requests__tracking"] = [{ ...mergeRow, derived_status: "waiting_merge" }];
428
+ const bin = await mkdtemp(join(tmpdir(), "gh-stub-"));
429
+ const script = `#!/usr/bin/env bash
430
+ set -euo pipefail
431
+ if [[ "$*" == *"pr view"* ]]; then
432
+ echo '{"state":"OPEN","mergedAt":null,"mergeStateStatus":"BLOCKED","statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS"}],"isDraft":false,"headRefOid":"deadbeef","baseRefName":"main"}'
433
+ elif [[ "$*" == *"graphql"* ]]; then
434
+ echo '${graphqlThreads(1)}'
435
+ elif [[ "$*" == *"requested_reviewers"* ]]; then
436
+ exit 0
437
+ else
438
+ echo "unexpected gh args: $*" >&2
439
+ exit 1
440
+ fi
441
+ `;
442
+ await writeFile(join(bin, "gh"), script, { mode: 0o755 });
443
+ const prevPath = process.env.PATH;
444
+ const prevFetch = globalThis.fetch;
445
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
446
+ process.env.PATH = `${bin}:${prevPath ?? ""}`;
447
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "gh";
448
+ globalThis.fetch = (() => Promise.resolve(new Response("no fetch in gh mode", { status: 500 }))) as typeof fetch;
449
+ try {
450
+ await pollMerges(data, engine, "tok");
451
+ } finally {
452
+ globalThis.fetch = prevFetch;
453
+ if (prevPath === undefined) delete process.env.PATH;
454
+ else process.env.PATH = prevPath;
455
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
456
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
457
+ await rm(bin, { recursive: true, force: true });
458
+ }
459
+ const row = stores["pull_requests"][0];
460
+ assertEquals(instances.length, 1, "a successor convergence instance is started");
461
+ assertEquals(row.process_key, "conv-943", "the row is owned by the successor");
462
+ assertEquals(cancelled, ["merge-607414"], "the merge loop is retired only after the successor exists");
463
+ // PR #944 review: the predecessor's cancel reason is bound to the predecessor, never stamped onto
464
+ // the successor-owned row (it would mask a later orphaning of the successor).
465
+ assert(row.termination_reason == null, `the successor's row carries no predecessor provenance, got ${row.termination_reason}`);
406
466
  });
407
467
 
408
468
  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 () => {
@@ -53,16 +53,22 @@ function memData(stores: Stores) {
53
53
  } as any;
54
54
  }
55
55
 
56
- /** A fake engine whose `searchVariables` serves the given per-instance variables. */
56
+ /** A fake engine whose `searchVariables` serves the given per-instance variables. A name-less,
57
+ * key-less query (`{}` — the batched /status read, issue #936) returns EVERY variable across all
58
+ * instances, each row carrying its owning `processInstanceKey`, exactly as the real engine does. */
57
59
  function fakeEngine(varsByInstance: Record<string, Record<string, unknown>>) {
58
60
  return {
59
- searchVariables: (q: { processInstanceKey?: string; name?: string }) => {
60
- const vars = varsByInstance[String(q.processInstanceKey)] ?? {};
61
- return Promise.resolve(
62
- Object.entries(vars)
63
- .filter(([name]) => q.name == null || name === q.name)
64
- .map(([name, v]) => ({ name, value: JSON.stringify(v) })),
65
- );
61
+ searchVariables: (q: { processInstanceKey?: string; scopeKey?: string; name?: string }) => {
62
+ const rows: { name: string; value: string; processInstanceKey: string; scopeKey: string }[] = [];
63
+ for (const [piKey, vars] of Object.entries(varsByInstance)) {
64
+ if (q.processInstanceKey != null && piKey !== String(q.processInstanceKey)) continue;
65
+ if (q.scopeKey != null && piKey !== String(q.scopeKey)) continue;
66
+ for (const [name, v] of Object.entries(vars)) {
67
+ if (q.name != null && name !== q.name) continue;
68
+ rows.push({ name, value: JSON.stringify(v), processInstanceKey: piKey, scopeKey: piKey });
69
+ }
70
+ }
71
+ return Promise.resolve(rows);
66
72
  },
67
73
  } as any;
68
74
  }
@@ -167,3 +173,52 @@ test("merge policy: NANO_PR_AUTO_MERGE=0 blocks a requested merge as env, never
167
173
  else process.env["NANO_PR_AUTO_MERGE"] = prev;
168
174
  }
169
175
  });
176
+
177
+ function activeStores() {
178
+ return {
179
+ pull_requests: { rows: [prRow({})], key: "pr_key" },
180
+ user_tasks: { rows: [], key: "id" },
181
+ } satisfies Stores;
182
+ }
183
+
184
+ type Row = { name: string; value: string; processInstanceKey: string; scopeKey: string };
185
+ const row = (name: string, v: unknown): Row => ({ name, value: JSON.stringify(v), processInstanceKey: "PI-1", scopeKey: "PI-1" });
186
+
187
+ test("merge policy: an unusable autoMerge row still probes a page-cut convergeOnly (PR #937 review)", async () => {
188
+ // Batch: autoMerge present but null (unusable), convergeOnly cut from its page; the root probe finds it.
189
+ const engine = {
190
+ searchVariables: (q: { processInstanceKey?: string; name?: string }) => {
191
+ if (q.processInstanceKey == null) {
192
+ if (q.name === "autoMerge") return Promise.resolve([row("autoMerge", null)]);
193
+ if (q.name === "humanApproval") return Promise.resolve([row("humanApproval", false)]);
194
+ return Promise.resolve([]);
195
+ }
196
+ return Promise.resolve(q.name === "convergeOnly" ? [row("convergeOnly", true)] : []);
197
+ },
198
+ } as any;
199
+ const [pr] = await activePrs(memData(activeStores()), engine);
200
+ assertEquals(pr.merge.requested, false, "the legacy convergeOnly:true must be honoured when autoMerge is unusable");
201
+ });
202
+
203
+ test("merge policy: a delayed successful probe cannot resurrect a policy after a sibling probe failed (PR #937 review)", async () => {
204
+ // Batch cuts every name. PI-1: autoMerge probe rejects early, humanApproval:true resolves late.
205
+ // PI-2 (another PR) has a slower probe, so the projection is still pending when PI-1's late probe lands.
206
+ const stores = {
207
+ pull_requests: {
208
+ rows: [prRow({}), prRow({ pr_key: "owner/repo#2", number: 2, process_key: "PI-2" })],
209
+ key: "pr_key",
210
+ },
211
+ user_tasks: { rows: [], key: "id" },
212
+ } satisfies Stores;
213
+ const engine = {
214
+ searchVariables: (q: { processInstanceKey?: string; name?: string }) => {
215
+ if (q.processInstanceKey == null) return Promise.resolve([]);
216
+ if (q.processInstanceKey === "PI-2") return new Promise<Row[]>((r) => setTimeout(() => r([]), 60));
217
+ if (q.name === "autoMerge") return Promise.reject(new Error("boom"));
218
+ return new Promise<Row[]>((r) => setTimeout(() => r([row(q.name ?? "", true)]), 20));
219
+ },
220
+ } as any;
221
+ const prs = await activePrs(memData(stores), engine);
222
+ const pr = prs.find((p: { prKey?: string; pr_key?: string; number?: number }) => p.number === 1);
223
+ assertEquals(pr?.merge, { requested: true, enabled: true, effective: true, blockedBy: null }, "failed probe → default, not a half-read policy");
224
+ });
@@ -33,7 +33,7 @@ import {
33
33
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
34
34
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
35
35
 
36
- const READ_MODEL_MIGRATION = "128_pull_requests_read_model_incidents.sql";
36
+ const READ_MODEL_MIGRATION = "131_pull_requests_read_model_termination_reason.sql";
37
37
 
38
38
  // The real base `pull_requests` columns, in schema order — DERIVED from the migration chain (not a
39
39
  // hand-kept list that could silently omit one), used by both the drift guard and the e2e stand-in.
@@ -1358,13 +1358,15 @@ function captureRoot() {
1358
1358
  open: () => memOpen(stores),
1359
1359
  } as any;
1360
1360
  let captured: unknown;
1361
+ let vars: Record<string, unknown> | undefined;
1361
1362
  const engine = {
1362
1363
  createInstance: (req: { variables?: Record<string, unknown> }) => {
1363
1364
  captured = req.variables?.rootRequestKey;
1365
+ vars = req.variables;
1364
1366
  return Promise.resolve({ processInstanceKey: "PI-1" });
1365
1367
  },
1366
1368
  } as any;
1367
- return { data, engine, stores, get: () => captured };
1369
+ return { data, engine, stores, get: () => captured, vars: () => vars };
1368
1370
  }
1369
1371
 
1370
1372
  test("submitPr persists root_request_key and threads it onto the convergence instance", async () => {
@@ -1447,6 +1449,21 @@ test("startMerge reads root_request_key off the PR row onto the merge instance",
1447
1449
  });
1448
1450
  });
1449
1451
 
1452
+ test("startMerge seeds the merge-policy triple so /status needs no per-instance probes (PR #937)", async () => {
1453
+ await withGithubOff(async () => {
1454
+ const { data, engine, vars } = captureRoot();
1455
+ await startMerge(data, engine, {
1456
+ repo: "owner/repo",
1457
+ number: 9,
1458
+ url: "https://github.com/owner/repo/pull/9",
1459
+ prKey: "owner/repo#9",
1460
+ round: 1,
1461
+ });
1462
+ const seeded = vars() ?? {};
1463
+ assertEquals([seeded.autoMerge, seeded.convergeOnly, seeded.humanApproval], [true, false, false]);
1464
+ });
1465
+ });
1466
+
1450
1467
  // The repository envelope drives the c8ctl harness's isolated workspace provisioning: it is
1451
1468
  // emitted under the reserved `io.nanobpm.agentTask` namespace with the PR head branch as the
1452
1469
  // checkout ref, and omitted entirely when the head branch couldn't be resolved (so the harness