@nanobpm/nano-workforce 0.189.1 → 0.189.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.
@@ -7,9 +7,32 @@ import { test } from "node:test";
7
7
  import { assertEquals } from "#test-assert";
8
8
  import type { DataLayer } from "@nanobpm/urban";
9
9
  import { withTrackingViews } from "../test/trackingViews.ts";
10
- import { deriveFeatureDelivery } from "./feature.ts";
10
+ import { deriveFeatureCompletion, deriveFeatureDelivery, foldCompletedFeatureRun } from "./feature.ts";
11
11
  import { pollFeatureDelivery } from "./service.ts";
12
12
 
13
+ // Pin the GitHub transport to `token` with an EMPTY token so the edge (1) re-enroll tests — which now
14
+ // reach the real `submitPr` for a converging run whose PR row is missing (PR #809 review) — never shell
15
+ // out to `gh`/`fetch`: `fetchPrMeta` short-circuits to `null` (best-effort), keeping the tests hermetic.
16
+ const PRIOR_TRANSPORT = process.env["NANO_PR_GITHUB_TRANSPORT"];
17
+ const PRIOR_TOKEN = process.env["GITHUB_TOKEN"];
18
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
19
+ process.env["GITHUB_TOKEN"] = "";
20
+ process.on("exit", () => {
21
+ if (PRIOR_TRANSPORT === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
22
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = PRIOR_TRANSPORT;
23
+ if (PRIOR_TOKEN === undefined) delete process.env["GITHUB_TOKEN"];
24
+ else process.env["GITHUB_TOKEN"] = PRIOR_TOKEN;
25
+ });
26
+
27
+ // An engine stub for the CONVERGING-reconcile tests (edge 1), which seed no `running` runs — the
28
+ // COMPLETED→terminal fold (edge 2, issue #808) therefore reads no instance. The completion-fold tests
29
+ // below pass their own state-returning stub. `createInstance` records the re-enroll (edge 1, PR #809)
30
+ // and returns a fresh instance key so `submitPr` completes; `searchProcessInstances` stays empty.
31
+ const STUB_ENGINE = {
32
+ searchProcessInstances: async () => [] as any[],
33
+ createInstance: async () => ({ processInstanceKey: "pi-reenroll" }),
34
+ } as any;
35
+
13
36
  function memData(): { data: DataLayer; stores: Record<string, any[]> } {
14
37
  const stores: Record<string, any[]> = {};
15
38
  function tbl(name: string, pk = "id") {
@@ -25,6 +48,9 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
25
48
  async find(where: any = {}) {
26
49
  return rows.filter((r) => match(r, where));
27
50
  },
51
+ async findOne(where: any = {}) {
52
+ return rows.find((r) => match(r, where));
53
+ },
28
54
  async insert(row: any) {
29
55
  rows.push({ ...row });
30
56
  return row[pk];
@@ -33,9 +59,49 @@ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
33
59
  const r = rows.find((row) => row[pk] === id);
34
60
  if (r) Object.assign(r, patch);
35
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
+ },
36
65
  };
37
66
  }
38
67
  const data = { table: withTrackingViews((n: string, pk?: string) => tbl(n, pk)) } as any as DataLayer;
68
+ // Minimal `open().exec()` for the guarded CAS folds (foldCompletedFeatureRun — the COMPLETED
69
+ // running→terminal fold AND the mid-handoff opened→abandoned reconcile). It mutates the in-memory
70
+ // `feature_runs` store exactly as the SQLite guard would, so the race/handoff tests exercise the real
71
+ // CAS predicate (same feature_key + process_key + status = the observed transient, now a bound param).
72
+ (data as any).open = () => ({
73
+ async exec(sql: string, params: any[]) {
74
+ // The re-enrol edge (1) heals a partially-enrolled PR via `submitPr`, which — on an existing row —
75
+ // atomically wipes that PR's durable adjudication memory (`resetAdjudications` →
76
+ // `DELETE FROM "pr_adjudications" WHERE "pr_key" = ?`, issue #806). Model it against the in-memory
77
+ // `pr_adjudications` store so the re-enrol path completes exactly as it does on SQLite.
78
+ if (/DELETE FROM "pr_adjudications" WHERE "pr_key" = \?/.test(sql)) {
79
+ const [pr_key] = params;
80
+ const rows = stores.pr_adjudications ?? [];
81
+ let changed = 0;
82
+ for (let i = rows.length - 1; i >= 0; i--) {
83
+ if (rows[i].pr_key === pr_key) {
84
+ rows.splice(i, 1);
85
+ changed++;
86
+ }
87
+ }
88
+ return { changed };
89
+ }
90
+ if (!/UPDATE "feature_runs" SET .* WHERE "feature_key" = \? AND "process_key" = \? AND "status" = \?/.test(sql)) {
91
+ throw new Error(`memData mock: unhandled sql: ${sql}`);
92
+ }
93
+ const [status, label, updated_at, feature_key, process_key, expect_status] = params;
94
+ const rows = stores.feature_runs ?? [];
95
+ let changed = 0;
96
+ for (const r of rows) {
97
+ if (r.feature_key === feature_key && r.process_key === process_key && r.status === expect_status) {
98
+ Object.assign(r, { status, delivery_label: label, updated_at });
99
+ changed++;
100
+ }
101
+ }
102
+ return { changed };
103
+ },
104
+ });
39
105
  return { data, stores };
40
106
  }
41
107
 
@@ -68,7 +134,7 @@ test("pollFeatureDelivery: a converging run whose PR merged is reconciled to mer
68
134
  ];
69
135
  stores.pull_requests = [{ pr_key: "o/r#5", status: "merged" }];
70
136
 
71
- await pollFeatureDelivery(data);
137
+ await pollFeatureDelivery(data, STUB_ENGINE);
72
138
 
73
139
  assertEquals(stores.feature_runs[0].status, "merged");
74
140
  assertEquals(stores.feature_runs[0].delivery_label, "merged");
@@ -81,7 +147,7 @@ test("pollFeatureDelivery: a run with a still-in-flight PR stays converging with
81
147
  ];
82
148
  stores.pull_requests = [{ pr_key: "o/r#6", status: "waiting_review" }];
83
149
 
84
- await pollFeatureDelivery(data);
150
+ await pollFeatureDelivery(data, STUB_ENGINE);
85
151
 
86
152
  assertEquals(stores.feature_runs[0].status, "converging");
87
153
  assertEquals(stores.feature_runs[0].delivery_label, "waiting_review");
@@ -96,7 +162,7 @@ test("pollFeatureDelivery: only touches converging runs with a pr_key", async ()
96
162
  ];
97
163
  stores.pull_requests = [{ pr_key: "o/r#9", status: "merged" }];
98
164
 
99
- await pollFeatureDelivery(data);
165
+ await pollFeatureDelivery(data, STUB_ENGINE);
100
166
 
101
167
  assertEquals(stores.feature_runs[0].status, "opened");
102
168
  assertEquals(stores.feature_runs[1].status, "converging");
@@ -104,15 +170,408 @@ test("pollFeatureDelivery: only touches converging runs with a pr_key", async ()
104
170
  assertEquals(stores.feature_runs[2].status, "blocked");
105
171
  });
106
172
 
107
- test("pollFeatureDelivery: a dangling pr_key (missing PR row) stays converging, never a false terminal", async () => {
173
+ test("pollFeatureDelivery: a converging run whose PR row is MISSING is re-enrolled (idempotent) and stays converging, never a false terminal (PR #809)", async () => {
108
174
  const { data, stores } = memData();
109
175
  stores.feature_runs = [
110
- { feature_key: "o/r#6", status: "converging", pr_key: "o/r#404", delivery_label: null },
176
+ { feature_key: "o/r#6", status: "converging", pr_key: "o/r#404", auto_merge: 0, delivery_label: null },
111
177
  ];
112
178
  stores.pull_requests = [];
179
+ let created = 0;
180
+ const engine = {
181
+ searchProcessInstances: async () => [],
182
+ createInstance: async () => {
183
+ created++;
184
+ return { processInstanceKey: "pi-404" };
185
+ },
186
+ } as any;
113
187
 
114
- await pollFeatureDelivery(data);
188
+ await pollFeatureDelivery(data, engine);
115
189
 
190
+ // `converge-feature` now writes `converging` BEFORE `submitPr`, so a crash in that gap leaves a
191
+ // converging row with no enrolled PR. Edge (1) heals it by RE-ENROLLING via `submitPr` (which starts
192
+ // a fresh convergence instance) rather than wedging it forever at "PR record missing".
193
+ assertEquals(created, 1, "the missing-PR converging run is re-enrolled via submitPr");
194
+ assertEquals(stores.pull_requests.length, 1, "submitPr registered the pull_requests row");
195
+ assertEquals(stores.pull_requests[0].pr_key, "o/r#404");
116
196
  assertEquals(stores.feature_runs[0].status, "converging");
117
197
  assertEquals(stores.feature_runs[0].delivery_label, "PR record missing");
118
198
  });
199
+
200
+ test("pollFeatureDelivery: a converging run whose PR row is PARTIALLY enrolled (present, non-terminal, process_key NULL) is re-enrolled — a create-instance crash after the row insert does not wedge it (PR #809)", async () => {
201
+ const { data, stores } = memData();
202
+ stores.feature_runs = [
203
+ { feature_key: "o/r#40", status: "converging", pr_key: "o/r#405", auto_merge: 0, delivery_label: null },
204
+ ];
205
+ // `submitPr` inserts the PR row (`status='converging'`) BEFORE `engine.createInstance`, then writes
206
+ // `process_key` only after it returns. A create-instance crash in that window leaves this shape:
207
+ // a NON-terminal PR row with NO `process_key` and NO live instance (#704/#497 phantom). `prStatus`
208
+ // is non-null ("converging"), so the missing-row check alone would skip it and it would wedge forever.
209
+ stores.pull_requests = [{ pr_key: "o/r#405", status: "converging", process_key: null }];
210
+ let created = 0;
211
+ const engine = {
212
+ searchProcessInstances: async () => [],
213
+ createInstance: async () => {
214
+ created++;
215
+ return { processInstanceKey: "pi-405" };
216
+ },
217
+ } as any;
218
+
219
+ await pollFeatureDelivery(data, engine);
220
+
221
+ // Edge (1) recognises the partial enrollment and RE-ENROLLS via the idempotent `submitPr`, which now
222
+ // treats a keyless non-terminal row as resubmittable — re-creating the instance and installing the key.
223
+ assertEquals(created, 1, "the partially-enrolled converging run is re-enrolled via submitPr");
224
+ assertEquals(stores.pull_requests.length, 1, "no duplicate pull_requests row is inserted");
225
+ assertEquals(stores.pull_requests[0].process_key, "pi-405", "submitPr installed the missing process_key");
226
+ assertEquals(stores.feature_runs[0].status, "converging");
227
+ });
228
+
229
+ test("pollFeatureDelivery: a converging run whose PR is FULLY enrolled (process_key present) is NOT re-enrolled — no redundant createInstance (PR #809)", async () => {
230
+ const { data, stores } = memData();
231
+ stores.feature_runs = [
232
+ { feature_key: "o/r#41", status: "converging", pr_key: "o/r#406", auto_merge: 0, delivery_label: null },
233
+ ];
234
+ stores.pull_requests = [{ pr_key: "o/r#406", status: "waiting_review", process_key: "pi-406" }];
235
+ let created = 0;
236
+ const engine = {
237
+ searchProcessInstances: async () => [],
238
+ createInstance: async () => {
239
+ created++;
240
+ return { processInstanceKey: "pi-x" };
241
+ },
242
+ } as any;
243
+
244
+ await pollFeatureDelivery(data, engine);
245
+
246
+ // A live PR (non-null `process_key`) is enrolled — edge (1) must project its status, never re-enroll.
247
+ assertEquals(created, 0, "a fully-enrolled PR is never re-enrolled");
248
+ assertEquals(stores.pull_requests[0].process_key, "pi-406");
249
+ assertEquals(stores.feature_runs[0].status, "converging");
250
+ assertEquals(stores.feature_runs[0].delivery_label, "waiting_review");
251
+ });
252
+
253
+ // ── Edge 2: COMPLETED → terminal fold for a normally-completing `running` run (issue #808) ─────────
254
+
255
+ test("deriveFeatureCompletion: a run that raised a PR folds to opened", () => {
256
+ assertEquals(deriveFeatureCompletion({ pr_key: "o/r#5" }), { status: "opened", label: "PR raised" });
257
+ });
258
+
259
+ test("deriveFeatureCompletion: a run that raised no PR folds to skipped", () => {
260
+ assertEquals(deriveFeatureCompletion({ pr_key: null }), { status: "skipped", label: "nothing to do" });
261
+ });
262
+
263
+ test("pollFeatureDelivery: a raise-only running run whose engine instance is COMPLETED folds to opened (issue #808)", async () => {
264
+ const { data, stores } = memData();
265
+ stores.feature_runs = [
266
+ { feature_key: "o/r#7", status: "running", process_key: "pi-7", pr_key: "o/r#5", converge: 0, delivery_label: null },
267
+ ];
268
+ stores.pull_requests = [];
269
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: "pi-7", state: "COMPLETED" }] } as any;
270
+
271
+ await pollFeatureDelivery(data, engine);
272
+
273
+ // Red before the fix: it stays wedged at `running` in the Active bucket.
274
+ assertEquals(stores.feature_runs[0].status, "opened");
275
+ assertEquals(stores.feature_runs[0].delivery_label, "PR raised");
276
+ });
277
+
278
+ test("pollFeatureDelivery: a nothing-to-do running run whose instance is COMPLETED folds to skipped (issue #808)", async () => {
279
+ const { data, stores } = memData();
280
+ stores.feature_runs = [
281
+ { feature_key: "o/r#8", status: "running", process_key: "pi-8", pr_key: null, converge: 0, delivery_label: null },
282
+ ];
283
+ stores.pull_requests = [];
284
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: "pi-8", state: "COMPLETED" }] } as any;
285
+
286
+ await pollFeatureDelivery(data, engine);
287
+
288
+ assertEquals(stores.feature_runs[0].status, "skipped");
289
+ assertEquals(stores.feature_runs[0].delivery_label, "nothing to do");
290
+ });
291
+
292
+ test("pollFeatureDelivery: a running run whose instance is STILL ACTIVE is left untouched (idempotent)", async () => {
293
+ const { data, stores } = memData();
294
+ stores.feature_runs = [
295
+ { feature_key: "o/r#9", status: "running", process_key: "pi-9", pr_key: "o/r#5", converge: 0, delivery_label: null },
296
+ ];
297
+ stores.pull_requests = [];
298
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: "pi-9", state: "ACTIVE" }] } as any;
299
+
300
+ await pollFeatureDelivery(data, engine);
301
+
302
+ assertEquals(stores.feature_runs[0].status, "running");
303
+ assertEquals(stores.feature_runs[0].delivery_label, null);
304
+ });
305
+
306
+ test("pollFeatureDelivery: a running run TERMINATED out of band (derived abandoned) is left to onTerminated, never re-queried (issue #808)", async () => {
307
+ const { data, stores } = memData();
308
+ stores.feature_runs = [
309
+ { feature_key: "o/r#10", status: "running", process_key: "pi-10", pr_key: "o/r#5", converge: 0, delivery_label: null, derived_status: "abandoned" },
310
+ ];
311
+ stores.pull_requests = [];
312
+ let queried = false;
313
+ const engine = { searchProcessInstances: async () => { queried = true; return []; } } as any;
314
+
315
+ await pollFeatureDelivery(data, engine);
316
+
317
+ assertEquals(queried, false);
318
+ assertEquals(stores.feature_runs[0].status, "running");
319
+ });
320
+
321
+ test("pollFeatureDelivery: never touches a CONVERGING run in the COMPLETED fold — that edge stays owned by the converging reconcile (issue #808)", async () => {
322
+ const { data, stores } = memData();
323
+ stores.feature_runs = [
324
+ { feature_key: "o/r#11", status: "converging", process_key: "pi-11", pr_key: "o/r#5", converge: 1, delivery_label: null },
325
+ ];
326
+ stores.pull_requests = [{ pr_key: "o/r#5", status: "waiting_review", process_key: "pi-pr5" }];
327
+ let queried = false;
328
+ const engine = { searchProcessInstances: async () => { queried = true; return [{ processInstanceKey: "pi-11", state: "COMPLETED" }]; } } as any;
329
+
330
+ await pollFeatureDelivery(data, engine);
331
+
332
+ // The COMPLETED fold is scoped to `running`, so it never reads the engine for a converging run;
333
+ // the converging reconcile above keeps it converging while its PR is in flight.
334
+ assertEquals(queried, false);
335
+ assertEquals(stores.feature_runs[0].status, "converging");
336
+ assertEquals(stores.feature_runs[0].delivery_label, "waiting_review");
337
+ });
338
+
339
+ test("pollFeatureDelivery: a re-seed race (new process_key) between the engine read and the write is a no-op — the guard never clobbers the fresh incarnation (issue #808)", async () => {
340
+ const { data, stores } = memData();
341
+ stores.feature_runs = [
342
+ { feature_key: "o/r#12", status: "running", process_key: "pi-12-old", pr_key: "o/r#5", converge: 0, delivery_label: null },
343
+ ];
344
+ stores.pull_requests = [];
345
+ // The engine read reports the OLD instance COMPLETED, but `startFeature` re-seeds the SAME row to a
346
+ // fresh `running` incarnation (new process_key) in the window before the write — modelled by mutating
347
+ // the store from inside the awaited stub. A blind update-by-key would fold this live run to opened.
348
+ const engine = {
349
+ searchProcessInstances: async () => {
350
+ const row = stores.feature_runs[0];
351
+ row.process_key = "pi-12-new";
352
+ row.pr_key = null;
353
+ return [{ processInstanceKey: "pi-12-old", state: "COMPLETED" }];
354
+ },
355
+ } as any;
356
+
357
+ await pollFeatureDelivery(data, engine);
358
+
359
+ // The guarded CAS matched zero rows (process_key drifted), so the fresh incarnation is untouched.
360
+ assertEquals(stores.feature_runs[0].status, "running");
361
+ assertEquals(stores.feature_runs[0].process_key, "pi-12-new");
362
+ assertEquals(stores.feature_runs[0].delivery_label, null);
363
+ });
364
+
365
+ test("foldCompletedFeatureRun: guarded CAS flips a matching running row and is a no-op on a process_key mismatch (issue #808)", async () => {
366
+ const { data, stores } = memData();
367
+ stores.feature_runs = [
368
+ { feature_key: "o/r#13", status: "running", process_key: "pi-13", pr_key: null, delivery_label: null },
369
+ ];
370
+
371
+ // Matching (feature_key + process_key + status='running') → flips.
372
+ assertEquals(await foldCompletedFeatureRun(data, "o/r#13", "pi-13", "opened", "PR raised"), true);
373
+ assertEquals(stores.feature_runs[0].status, "opened");
374
+ assertEquals(stores.feature_runs[0].delivery_label, "PR raised");
375
+
376
+ // A stale process_key (a re-seed already flipped the row back to running under a new key) → no-op.
377
+ stores.feature_runs[0].status = "running";
378
+ stores.feature_runs[0].process_key = "pi-13-new";
379
+ assertEquals(await foldCompletedFeatureRun(data, "o/r#13", "pi-13", "skipped", "nothing to do"), false);
380
+ assertEquals(stores.feature_runs[0].status, "running");
381
+ });
382
+
383
+ // ── Edge 3: MID-HANDOFF `opened` → abandoned when the instance died before convergence (PR #809) ────
384
+ //
385
+ // `record-feature` writes base `status="opened"` for a converge-REQUESTED run too, BEFORE `gw-converge`
386
+ // hands it to `converge-feature` (which flips it to `converging`). That transient `opened` + `converge=1`
387
+ // + `pr_key` window is NOT dismissable (featureReadModel excludes it), and no other edge / instanceTracking
388
+ // activeStatus scans it — so an instance TERMINATED mid-handoff would strand the row in Active forever.
389
+ // This edge owns that liveness.
390
+
391
+ test("pollFeatureDelivery: a mid-handoff opened run whose instance TERMINATED (gone) folds to abandoned (PR #809)", async () => {
392
+ const { data, stores } = memData();
393
+ stores.feature_runs = [
394
+ { feature_key: "o/r#20", status: "opened", process_key: "pi-20", pr_key: "o/r#5", converge: 1, delivery_label: null },
395
+ ];
396
+ stores.pull_requests = [];
397
+ // The instance is gone (terminated + reaped) → searchProcessInstances returns no snapshot.
398
+ const engine = { searchProcessInstances: async () => [] } as any;
399
+
400
+ await pollFeatureDelivery(data, engine);
401
+
402
+ // Red before the fix: it stays wedged at `opened` (non-dismissable, unowned) in the Active bucket.
403
+ assertEquals(stores.feature_runs[0].status, "abandoned");
404
+ assertEquals(stores.feature_runs[0].delivery_label, "handoff interrupted");
405
+ });
406
+
407
+ test("pollFeatureDelivery: a mid-handoff opened run whose instance read TERMINATED folds to abandoned (PR #809)", async () => {
408
+ const { data, stores } = memData();
409
+ stores.feature_runs = [
410
+ { feature_key: "o/r#21", status: "opened", process_key: "pi-21", pr_key: "o/r#5", converge: 1, delivery_label: null },
411
+ ];
412
+ stores.pull_requests = [];
413
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: "pi-21", state: "TERMINATED" }] } as any;
414
+
415
+ await pollFeatureDelivery(data, engine);
416
+
417
+ assertEquals(stores.feature_runs[0].status, "abandoned");
418
+ assertEquals(stores.feature_runs[0].delivery_label, "handoff interrupted");
419
+ });
420
+
421
+ test("pollFeatureDelivery: a mid-handoff opened run whose instance is STILL ACTIVE is left for converge-feature (PR #809)", async () => {
422
+ const { data, stores } = memData();
423
+ stores.feature_runs = [
424
+ { feature_key: "o/r#22", status: "opened", process_key: "pi-22", pr_key: "o/r#5", converge: 1, delivery_label: null },
425
+ ];
426
+ stores.pull_requests = [];
427
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: "pi-22", state: "ACTIVE" }] } as any;
428
+
429
+ await pollFeatureDelivery(data, engine);
430
+
431
+ assertEquals(stores.feature_runs[0].status, "opened");
432
+ assertEquals(stores.feature_runs[0].delivery_label, null);
433
+ });
434
+
435
+ test("pollFeatureDelivery: a FINISHED raise-only opened run (converge=0) is never queried by the handoff edge (PR #809)", async () => {
436
+ const { data, stores } = memData();
437
+ stores.feature_runs = [
438
+ { feature_key: "o/r#23", status: "opened", process_key: "pi-23", pr_key: "o/r#5", converge: 0, delivery_label: null },
439
+ ];
440
+ stores.pull_requests = [];
441
+ let queried = false;
442
+ const engine = { searchProcessInstances: async () => { queried = true; return []; } } as any;
443
+
444
+ await pollFeatureDelivery(data, engine);
445
+
446
+ // A raise-only opened is genuinely terminal + dismissable — the handoff edge must not touch it.
447
+ assertEquals(queried, false);
448
+ assertEquals(stores.feature_runs[0].status, "opened");
449
+ });
450
+
451
+ test("pollFeatureDelivery: a keyless opened run (pr_key null) is never queried by the handoff edge (PR #809)", async () => {
452
+ const { data, stores } = memData();
453
+ stores.feature_runs = [
454
+ { feature_key: "o/r#24", status: "opened", process_key: "pi-24", pr_key: null, converge: 1, delivery_label: null },
455
+ ];
456
+ stores.pull_requests = [];
457
+ let queried = false;
458
+ const engine = { searchProcessInstances: async () => { queried = true; return []; } } as any;
459
+
460
+ await pollFeatureDelivery(data, engine);
461
+
462
+ // A keyless opened never satisfied the gateway's prKey!=null and fell through to End — terminal.
463
+ assertEquals(queried, false);
464
+ assertEquals(stores.feature_runs[0].status, "opened");
465
+ });
466
+
467
+ test("pollFeatureDelivery: a mid-handoff opened run already folded abandoned out of band is not re-queried (PR #809)", async () => {
468
+ const { data, stores } = memData();
469
+ stores.feature_runs = [
470
+ { feature_key: "o/r#25", status: "opened", process_key: "pi-25", pr_key: "o/r#5", converge: 1, delivery_label: null, derived_status: "abandoned" },
471
+ ];
472
+ stores.pull_requests = [];
473
+ let queried = false;
474
+ const engine = { searchProcessInstances: async () => { queried = true; return []; } } as any;
475
+
476
+ await pollFeatureDelivery(data, engine);
477
+
478
+ assertEquals(queried, false);
479
+ assertEquals(stores.feature_runs[0].status, "opened");
480
+ });
481
+
482
+ test("pollFeatureDelivery: a mid-handoff opened run whose instance read a KNOWN terminal (COMPLETED) folds to abandoned (PR #809 review, thread 3)", async () => {
483
+ const { data, stores } = memData();
484
+ stores.feature_runs = [
485
+ { feature_key: "o/r#26", status: "opened", process_key: "pi-26", pr_key: "o/r#5", converge: 1, delivery_label: null },
486
+ ];
487
+ stores.pull_requests = [];
488
+ // COMPLETED is a KNOWN terminal (ENGINE_TERMINAL_STATES) — a positive "the instance is gone", so a
489
+ // still-`opened` mid-handoff row is a genuinely interrupted handoff and folds to abandoned.
490
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: "pi-26", state: "COMPLETED" }] } as any;
491
+
492
+ await pollFeatureDelivery(data, engine);
493
+
494
+ assertEquals(stores.feature_runs[0].status, "abandoned");
495
+ assertEquals(stores.feature_runs[0].delivery_label, "handoff interrupted");
496
+ });
497
+
498
+ test("RED/GREEN pollFeatureDelivery: a mid-handoff opened run whose PR row EXISTS (enrolled) folds to converging, NEVER abandoned, even when the instance TERMINATED (PR #809 review / escalation 162)", async () => {
499
+ const { data, stores } = memData();
500
+ stores.feature_runs = [
501
+ { feature_key: "o/r#30", status: "opened", process_key: "pi-30", pr_key: "o/r#5", converge: 1, delivery_label: null },
502
+ ];
503
+ // `converge-feature` already enrolled the PR (a `pull_requests` row EXISTS), then the FEATURE instance
504
+ // terminated mid-handoff before flipping the row to `converging`. The PR is a live/settled SEPARATE
505
+ // process — the handoff was NOT interrupted.
506
+ stores.pull_requests = [{ pr_key: "o/r#5", status: "converging" }];
507
+ // Instance gone AND read as a KNOWN terminal — either signal would drive the OLD instance-only edge to
508
+ // `abandoned`. The PR-first rule must override both.
509
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: "pi-30", state: "TERMINATED" }] } as any;
510
+
511
+ await pollFeatureDelivery(data, engine);
512
+
513
+ // Red before the fix (instance-only edge): folds an ENROLLED, live PR to `abandoned`, losing its outcome.
514
+ assertEquals(stores.feature_runs[0].status, "converging", "an enrolled PR is projected, never abandoned");
515
+ assertEquals(stores.feature_runs[0].delivery_label, "converging");
516
+ });
517
+
518
+ test("pollFeatureDelivery: a mid-handoff opened run whose enrolled PR already MERGED projects the terminal outcome (merged), not abandoned (PR #809 review / escalation 162)", async () => {
519
+ const { data, stores } = memData();
520
+ stores.feature_runs = [
521
+ { feature_key: "o/r#31", status: "opened", process_key: "pi-31", pr_key: "o/r#5", converge: 1, delivery_label: null },
522
+ ];
523
+ stores.pull_requests = [{ pr_key: "o/r#5", status: "merged" }];
524
+ let queried = false;
525
+ // The PR row settles the liveness question — the handoff edge must not even probe the engine.
526
+ const engine = { searchProcessInstances: async () => { queried = true; return []; } } as any;
527
+
528
+ await pollFeatureDelivery(data, engine);
529
+
530
+ assertEquals(queried, false, "an existing PR row is authoritative — no engine probe needed");
531
+ assertEquals(stores.feature_runs[0].status, "merged");
532
+ assertEquals(stores.feature_runs[0].delivery_label, "merged");
533
+ });
534
+
535
+ test("RED/GREEN pollFeatureDelivery: a mid-handoff opened run whose instance snapshot carries an EMPTY/UNKNOWN state is SPARED, never folded (PR #809 review, thread 3)", async () => {
536
+ const { data, stores } = memData();
537
+ stores.feature_runs = [
538
+ { feature_key: "o/r#28", status: "opened", process_key: "pi-28", pr_key: "o/r#5", converge: 1, delivery_label: null },
539
+ { feature_key: "o/r#29", status: "opened", process_key: "pi-29", pr_key: "o/r#5", converge: 1, delivery_label: null },
540
+ ];
541
+ stores.pull_requests = [];
542
+ // The engine ANSWERED (the instance is present) but with an empty / newly-introduced state we cannot
543
+ // interpret — a partial read, NOT a confirmed death. The reconcile-probe tri-state spares such a row;
544
+ // folding it to `abandoned` would kill a possibly still-live handoff off a wire shape we misread.
545
+ const engine = {
546
+ searchProcessInstances: async ({ processInstanceKeys }: any) => {
547
+ const key = String(processInstanceKeys[0]);
548
+ return [{ processInstanceKey: key, state: key === "pi-28" ? "" : "SUSPENDED_NOVEL_STATE" }];
549
+ },
550
+ } as any;
551
+
552
+ await pollFeatureDelivery(data, engine);
553
+
554
+ // Red before the fix (`state !== "ACTIVE"` folded everything non-ACTIVE): both rows would go abandoned.
555
+ assertEquals(stores.feature_runs[0].status, "opened", "an empty engine state spares the row");
556
+ assertEquals(stores.feature_runs[0].delivery_label, null);
557
+ assertEquals(stores.feature_runs[1].status, "opened", "an unrecognised engine state spares the row");
558
+ assertEquals(stores.feature_runs[1].delivery_label, null);
559
+ });
560
+
561
+ test("RED/GREEN pollFeatureDelivery: a running run edge (2) folds to `opened` in a pass is NOT re-folded to abandoned by the handoff edge in the SAME pass (PR #809 review, thread 1)", async () => {
562
+ const { data, stores } = memData();
563
+ // A `running` run carrying converge=1 + pr_key whose instance COMPLETED: edge (2) folds it running→
564
+ // `opened` (PR raised). Edge (3) then re-reads the DB, sees that just-folded `opened`+converge=1+pr_key
565
+ // row with a non-ACTIVE (COMPLETED) instance, and — without the same-pass exclusion — would clobber
566
+ // edge (2)'s legitimate terminal to `abandoned`.
567
+ stores.feature_runs = [
568
+ { feature_key: "o/r#27", status: "running", process_key: "pi-27", pr_key: "o/r#5", converge: 1, delivery_label: null },
569
+ ];
570
+ stores.pull_requests = [];
571
+ const engine = { searchProcessInstances: async () => [{ processInstanceKey: "pi-27", state: "COMPLETED" }] } as any;
572
+
573
+ await pollFeatureDelivery(data, engine);
574
+
575
+ assertEquals(stores.feature_runs[0].status, "opened", "edge (2)'s terminal outcome stands — the same-pass handoff edge leaves it alone");
576
+ assertEquals(stores.feature_runs[0].delivery_label, "PR raised");
577
+ });
@@ -31,7 +31,7 @@ import { deriveListBucket, deriveStage } from "./stage.ts";
31
31
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
32
32
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
33
33
 
34
- const MIGRATION_LATEST = "099_feature_read_model_ack_open.sql";
34
+ const MIGRATION_LATEST = "112_feature_read_model_opened_midhandoff.sql";
35
35
 
36
36
  // The base `feature_runs` shape the VIEW reads, plus the `user_tasks` inbox (034) the `attention`
37
37
  // derivation `EXISTS`-reads, plus a stand-in for the managed `feature_runs__tracking` derived VIEW
@@ -253,6 +253,85 @@ test("the migration 080 VIEW derives list_bucket EXACTLY like deriveListBucket (
253
253
  }
254
254
  });
255
255
 
256
+ test("RED/GREEN #808: a raise-only `opened` run is DISMISSABLE into History (ack_open=1, list_bucket active→history on ack) though the pipeline STILL renders `PR open`", () => {
257
+ // A raise-only feature run (converge not requested) ends at status `opened`: a PR was raised and the
258
+ // run is FINISHED. #808 folds a COMPLETED such run's frozen `running` status to `opened` so it stops
259
+ // wedging in Active. But before the ack/History terminal set was decoupled from the stage-`Done` set,
260
+ // `opened` (deliberately a LIVE `PR open` STAGE, excluded from STAGE_DONE_STATUSES) had `ack_open=0`
261
+ // and `list_bucket='active'` forever — so `acknowledgeDone` 409'd and the run stayed wedged, the exact
262
+ // bug #808 aims to fix. The wider FEATURE_ACK_TERMINAL_STATUSES makes `opened` dismissable while the
263
+ // pipeline STAGE still classifies on STAGE_DONE_STATUSES (so it renders `PR open`, never `Done`).
264
+ const db = viewDb();
265
+
266
+ // Unacknowledged raise-only run: dismissable and still in Active (stays until the operator ticks off).
267
+ addRun(db, "o/r#opened", { status: "opened", pr_key: "o/r#pr1" });
268
+ const open = projection(db, "o/r#opened");
269
+ assertEquals(open.stage, "PR open", "a raise-only `opened` run STILL renders the LIVE `PR open` stage, not Done");
270
+ assertEquals(open.stage_state, null, "its stage is in-progress (no terminal render state) — it is not `Done`");
271
+ assertEquals(open.ack_open, 1, "the finished raise-only run is dismissable (ack_open=1) — acknowledgeDone no longer 409s");
272
+ assertEquals(open.list_bucket, "active", "an un-dismissed `opened` run sits in Active until the operator ticks it off");
273
+ assertEquals(open.list_bucket, deriveListBucket("opened", null), "matches the TS oracle");
274
+
275
+ // Acknowledged raise-only run: dismissed to History (ack_open closes), pipeline still `PR open`.
276
+ addRun(db, "o/r#openedAck", { status: "opened", pr_key: "o/r#pr2", acknowledged_at: "2026-02-02T00:00:00Z" });
277
+ const acked = projection(db, "o/r#openedAck");
278
+ assertEquals(acked.list_bucket, "history", "a dismissed `opened` run drops to History (no longer wedged in Active)");
279
+ assertEquals(acked.list_bucket, deriveListBucket("opened", "2026-02-02T00:00:00Z"), "matches the TS oracle");
280
+ assertEquals(acked.ack_open, 0, "an already-dismissed run offers no Dismiss affordance");
281
+ assertEquals(acked.stage, "PR open", "dismissal does NOT reclassify the pipeline STAGE to Done — it stays `PR open`");
282
+ });
283
+
284
+ test("#808: a `converging` run stays LIVE (NOT dismissable) — the ack set excludes it (still in the convergence loop)", () => {
285
+ // `converging` is terminal-for-redispatch but the PR is still in flight — `pollFeatureDelivery` keeps
286
+ // reconciling it to merged/converged/abandoned — so it must NOT be dismissable (that is why the ack
287
+ // set is NARROWER than FEATURE_TERMINAL_STATUSES: it adds `opened` but excludes `converging`).
288
+ const db = viewDb();
289
+ addRun(db, "o/r#conv", { status: "converging", pr_key: "o/r#pr3" });
290
+ const row = projection(db, "o/r#conv");
291
+ assertEquals(row.stage, "Converging", "a handed-off run renders the LIVE Converging stage");
292
+ assertEquals(row.ack_open, 0, "a still-converging run is NOT dismissable");
293
+ assertEquals(row.list_bucket, "active", "it stays in Active (live), never History");
294
+ });
295
+
296
+ test("RED/GREEN #808 follow-up: a MID-HANDOFF `opened` run (converge=1 AND pr_key set) is NOT dismissable, but a finished `opened` (raise-only, or keyless converge=1) IS", () => {
297
+ // `record-feature` writes `status='opened'` for a converge-REQUESTED run too, BEFORE `gw-converge`
298
+ // routes it into `converge-feature` (which flips it to `converging`). In that window the row reads
299
+ // `opened` while the engine instance is still ACTIVE, so offering Dismiss would let a premature ack
300
+ // later drag a still-converging run to History. The row distinguishes the transient (`converge=1 AND
301
+ // pr_key IS NOT NULL`) from a genuinely-finished `opened`. Same mid-flight guard the epic model uses.
302
+ const db = viewDb();
303
+
304
+ // Mid-handoff: converge requested AND a keyed PR — about to enter the convergence loop. NOT dismissable.
305
+ addRun(db, "o/r#mid", { status: "opened", converge: 1, pr_key: "o/r#pr1" });
306
+ const mid = projection(db, "o/r#mid");
307
+ assertEquals(mid.stage, "PR open", "a mid-handoff `opened` still renders the LIVE `PR open` stage");
308
+ assertEquals(mid.ack_open, 0, "a mid-handoff `opened` run is NOT dismissable (it is about to hand off to converging)");
309
+ assertEquals(mid.list_bucket, "active", "it stays in Active — a premature ack must never drag a converging run to History");
310
+ // SQL/TS oracle parity (PR #809 review): the `deriveListBucket` adapter MUST receive `converge`/`pr_key`
311
+ // — the mid-handoff carve-out reads them — or it diverges from the VIEW for this state.
312
+ assertEquals(mid.list_bucket, deriveListBucket("opened", null, { converge: 1, pr_key: "o/r#pr1" }), "TS oracle matches SQL for a mid-handoff `opened`");
313
+
314
+ // A stray/premature ack on a mid-handoff row must STILL not drop it to History.
315
+ addRun(db, "o/r#midAck", { status: "opened", converge: 1, pr_key: "o/r#pr2", acknowledged_at: "2026-02-02T00:00:00Z" });
316
+ assertEquals(projection(db, "o/r#midAck").list_bucket, "active", "a stray ack on a mid-handoff `opened` does NOT move it to History");
317
+ // RED without the `converge`/`pr_key` args: a converge/pr_key-blind oracle would wrongly return `history`
318
+ // for this ACKED mid-handoff row (the parity gap the review flagged); with them it agrees with the VIEW.
319
+ assertEquals(
320
+ projection(db, "o/r#midAck").list_bucket,
321
+ deriveListBucket("opened", "2026-02-02T00:00:00Z", { converge: 1, pr_key: "o/r#pr2" }),
322
+ "TS oracle matches SQL for an ACKED mid-handoff `opened` (stays active, not history)",
323
+ );
324
+
325
+ // Raise-only (converge=0) with a PR: a FINISHED run — dismissable (the #808 case).
326
+ addRun(db, "o/r#raise", { status: "opened", converge: 0, pr_key: "o/r#pr3" });
327
+ assertEquals(projection(db, "o/r#raise").ack_open, 1, "a raise-only `opened` run (converge=0) is dismissable");
328
+
329
+ // Keyless `opened` with converge requested: never satisfied the gateway's `prKey != null`, fell through
330
+ // to End — a FINISHED run, so dismissable (not a mid-handoff, since it never handed off).
331
+ addRun(db, "o/r#keyless", { status: "opened", converge: 1, pr_key: null });
332
+ assertEquals(projection(db, "o/r#keyless").ack_open, 1, "a keyless `opened` (converge=1, pr_key NULL) is finished and dismissable");
333
+ });
334
+
256
335
  test("the migration 080 VIEW IGNORES any stale STORED projection columns — it reads only from status et al.", () => {
257
336
  const db = viewDb();
258
337
  // A merged run whose STORED columns lie (frozen from when it was `running`). The VIEW must re-derive.