@nanobpm/nano-workforce 0.208.2 → 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 +6 -0
- package/app/loopTermination.test.ts +412 -0
- package/app/mcpExclusions.test.ts +1 -1
- package/app/mergeBlockedThreads.test.ts +62 -2
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/service.ts +300 -30
- package/app/terminalReaderBehaviour.test.ts +15 -4
- package/build-info.json +4 -4
- package/db/migrations/130_pull_requests_termination_reason.sql +17 -0
- package/db/migrations/131_pull_requests_read_model_termination_reason.sql +48 -0
- package/openapi.yaml +53 -0
- package/operations/cancelInstance.test.ts +9 -0
- package/operations/cancelInstance.ts +28 -2
- package/operations/cancelPrRun.ts +16 -0
- package/package.json +1 -1
- package/pages/home.page.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
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
|
+
|
|
1
7
|
## [0.208.2](https://github.com/nanobpm/nano-workforce/compare/v0.208.1...v0.208.2) (2026-11-10)
|
|
2
8
|
|
|
3
9
|
### 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)
|
|
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, [
|
|
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 () => {
|
|
@@ -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 = "
|
|
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.
|
package/app/service.ts
CHANGED
|
@@ -416,6 +416,21 @@ export interface PullRequest {
|
|
|
416
416
|
// Opt-in escalation steward (120_escalation_auto_resolutions.sql, issue #889): 1 ⇒ this PR's open
|
|
417
417
|
// escalations are eligible for the steward to auto-resolve (recorded reviewably). Default 0.
|
|
418
418
|
auto_resolve_escalations: number;
|
|
419
|
+
// Loop-termination provenance (130_pull_requests_termination_reason.sql, issue #943): the reason the
|
|
420
|
+
// app last cancelled this PR's convergence/merge loop, stamped by {@link terminatePrLoop}. NULL when
|
|
421
|
+
// the app never cancelled the current run.
|
|
422
|
+
termination_reason: string | null;
|
|
423
|
+
// The engine instance `termination_reason` describes (issue #943, PR #944 review): provenance is
|
|
424
|
+
// bound to the CANCELLED instance, so a predecessor's reason can never explain away the orphaning of
|
|
425
|
+
// a successor that later owns the row. Honoured only while it equals `process_key`.
|
|
426
|
+
termination_process_key: string | null;
|
|
427
|
+
// A handed-off predecessor merge loop whose retirement (engine cancel) has not yet succeeded (PR #944
|
|
428
|
+
// review). Durable so {@link retryPendingRetirements} re-attempts it on every poll pass instead of
|
|
429
|
+
// dropping the key after a bounded in-memory retry. NULL when nothing is pending.
|
|
430
|
+
retire_pending_key?: string | null;
|
|
431
|
+
// Operator Dismiss stamp (app/acknowledge.ts). Run-scoped: cleared on reopen so a dismissal of a
|
|
432
|
+
// PRIOR run never hides an orphaning of the current one.
|
|
433
|
+
acknowledged_at?: string | null;
|
|
419
434
|
}
|
|
420
435
|
|
|
421
436
|
interface PrDependency {
|
|
@@ -963,6 +978,13 @@ async function submitPrCritical(
|
|
|
963
978
|
// watermark could drop the new run's early rounds from the churn window (or, after the new
|
|
964
979
|
// run passes that number, silently expire). Reset it so the new run's churn clock starts clean.
|
|
965
980
|
churn_escalated_round: null,
|
|
981
|
+
// Run-scoped termination provenance (issue #943): a prior run's cancel reason must not mask a
|
|
982
|
+
// later orphaning of THIS run in `/status`.
|
|
983
|
+
termination_reason: null,
|
|
984
|
+
termination_process_key: null,
|
|
985
|
+
// The operator Dismiss is run-scoped too (PR #944 review): a dismissal of the PRIOR run must not
|
|
986
|
+
// count as acknowledging a later unexplained termination of THIS run.
|
|
987
|
+
acknowledged_at: null,
|
|
966
988
|
outcome: null,
|
|
967
989
|
converged_at: null,
|
|
968
990
|
merged_at: null,
|
|
@@ -1191,6 +1213,9 @@ export interface ActivePr {
|
|
|
1191
1213
|
/** The ACTIVE engine incident parking this PR's instance (issue #931), mirrored by the poller's
|
|
1192
1214
|
* `pollIncidents` pass; null when none. While set, `status` reads `incident`. */
|
|
1193
1215
|
incident: StatusIncident | null;
|
|
1216
|
+
/** Why the app last cancelled this PR's loop (issue #943), naming the successor/outcome; null when
|
|
1217
|
+
* never. An `orphaned` status means the loop ended with no reason, successor, or escalation. */
|
|
1218
|
+
terminationReason: string | null;
|
|
1194
1219
|
}
|
|
1195
1220
|
|
|
1196
1221
|
/** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
|
|
@@ -1218,7 +1243,7 @@ export async function activePrs(
|
|
|
1218
1243
|
// `status`/worker-owned transient columns are still surfaced on each row below (the view re-exports
|
|
1219
1244
|
// `base.*`), and `pull_requests` has no `onWaitingHuman` edge, so for a still-active PR
|
|
1220
1245
|
// `derived_status === status`.
|
|
1221
|
-
.filter((p) => !TERMINAL_STATUSES.includes(p.derived_status))
|
|
1246
|
+
.filter((p) => !TERMINAL_STATUSES.includes(p.derived_status) || isOrphanedPr(p))
|
|
1222
1247
|
.sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
|
|
1223
1248
|
// Derive the STRUCTURED open-escalation pointer from the ONE `user_tasks` read model (issue #666) —
|
|
1224
1249
|
// the same user-task surface `listEscalations` and the Convergence/Tasks page consume — so `/status`
|
|
@@ -1256,13 +1281,14 @@ export async function activePrs(
|
|
|
1256
1281
|
// kept reading `converging` for hours while nothing ran. Overlay the derived `incident` status
|
|
1257
1282
|
// (the base `status` column is never touched) and carry the mirrored incident block.
|
|
1258
1283
|
const incident = statusIncidentFor(p);
|
|
1284
|
+
const orphaned = isOrphanedPr(p);
|
|
1259
1285
|
return {
|
|
1260
1286
|
prKey: p.pr_key,
|
|
1261
1287
|
repo: p.repo,
|
|
1262
1288
|
number: p.number,
|
|
1263
1289
|
url: p.url,
|
|
1264
1290
|
title: p.title ?? null,
|
|
1265
|
-
status: incident ? INCIDENT_STATUS : p.status,
|
|
1291
|
+
status: orphaned ? ORPHANED_PR_STATUS : incident ? INCIDENT_STATUS : p.status,
|
|
1266
1292
|
round: p.current_round,
|
|
1267
1293
|
processKey: p.process_key ?? null,
|
|
1268
1294
|
waitingSince: p.waiting_since ?? null,
|
|
@@ -1273,10 +1299,57 @@ export async function activePrs(
|
|
|
1273
1299
|
autoResolveEscalations: p.auto_resolve_escalations === 1,
|
|
1274
1300
|
merge: mergeByPr.get(p.pr_key) ?? mergePolicyDefault(),
|
|
1275
1301
|
incident,
|
|
1302
|
+
terminationReason: p.termination_reason ?? null,
|
|
1276
1303
|
};
|
|
1277
1304
|
});
|
|
1278
1305
|
}
|
|
1279
1306
|
|
|
1307
|
+
/** `/status` overlay for an ORPHANED PR (issue #943): its loop instance ended (derived tracking edge
|
|
1308
|
+
* folded to `abandoned`) while the base row still sits at an in-flight stage — no worker or human
|
|
1309
|
+
* settled it — and no termination reason was recorded (every app-initiated cancel records one via
|
|
1310
|
+
* {@link terminatePrLoop}, and the operator cancel door records one too), no merge landed, and no
|
|
1311
|
+
* successor took over. Cleared by an operator Dismiss (`acknowledged_at`). That is exactly the
|
|
1312
|
+
* silently-dropped nanobpm/nano-bpm#1452 shape; it must be visible, not filtered out. */
|
|
1313
|
+
export const ORPHANED_PR_STATUS = "orphaned";
|
|
1314
|
+
|
|
1315
|
+
/** Whether a tracked PR row is orphaned (see {@link ORPHANED_PR_STATUS}). Exported for tests. */
|
|
1316
|
+
export function isOrphanedPr(p: {
|
|
1317
|
+
status: string;
|
|
1318
|
+
derived_status: string;
|
|
1319
|
+
process_key?: string | null;
|
|
1320
|
+
termination_reason?: string | null;
|
|
1321
|
+
termination_process_key?: string | null;
|
|
1322
|
+
merged_at: string | null;
|
|
1323
|
+
acknowledged_at?: string | null;
|
|
1324
|
+
}): boolean {
|
|
1325
|
+
return (
|
|
1326
|
+
TERMINAL_STATUSES.includes(p.derived_status) &&
|
|
1327
|
+
// An operator Dismiss (`acknowledged_at`) clears the overlay once a human has seen it.
|
|
1328
|
+
!p.acknowledged_at &&
|
|
1329
|
+
// The BASE row is still at an in-flight stage of the loop (the manifest's active set): nothing
|
|
1330
|
+
// settled it. This base-vs-derived divergence IS the orphan signal.
|
|
1331
|
+
activeStatusesFor("pull_requests").includes(p.status) &&
|
|
1332
|
+
// Provenance only explains the CURRENT owner's termination: a reason bound to a different
|
|
1333
|
+
// (predecessor) instance never masks an orphaned successor.
|
|
1334
|
+
!hasOwnTerminationReason(p) &&
|
|
1335
|
+
p.merged_at == null
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
/** Whether the row carries a termination reason recorded for the instance that currently owns it. */
|
|
1340
|
+
function hasOwnTerminationReason(p: {
|
|
1341
|
+
process_key?: string | null;
|
|
1342
|
+
termination_reason?: string | null;
|
|
1343
|
+
termination_process_key?: string | null;
|
|
1344
|
+
}): boolean {
|
|
1345
|
+
return (
|
|
1346
|
+
!!p.termination_reason &&
|
|
1347
|
+
p.termination_process_key != null &&
|
|
1348
|
+
p.process_key != null &&
|
|
1349
|
+
String(p.termination_process_key) === String(p.process_key)
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1280
1353
|
/** The default merge policy when no instance variables are readable (mid-handoff row, engine
|
|
1281
1354
|
* unreachable, or a legacy loop carrying neither variable): the pre-#916 behaviour merged by
|
|
1282
1355
|
* default, so the projection must NOT read such a PR as review-only. */
|
|
@@ -1592,7 +1665,7 @@ export async function retireTerminalReviewWait(
|
|
|
1592
1665
|
if (pr.process_key != null) {
|
|
1593
1666
|
const key = String(pr.process_key);
|
|
1594
1667
|
try {
|
|
1595
|
-
await engine.
|
|
1668
|
+
await terminatePrLoop(data, engine, pr.pr_key, key, `review wait retired: PR ${liveness} out-of-band`, true);
|
|
1596
1669
|
} catch (err) {
|
|
1597
1670
|
// Only finalize the row once the instance is confirmed gone/terminal: a transiently-failed
|
|
1598
1671
|
// cancel would otherwise drop the row from `waiting_review` scans while the live instance's
|
|
@@ -2016,36 +2089,75 @@ export async function maybeEnsureFreshHeadRun(
|
|
|
2016
2089
|
* defaults `autoResolveEscalations` to false and overwrites the row's flag on reopen, so an opted-in PR
|
|
2017
2090
|
* would silently lose automated escalation resolution merely because late review threads appeared.
|
|
2018
2091
|
*
|
|
2019
|
-
* RETRYABLE on failure (PR #
|
|
2020
|
-
* row is restored to `waiting_merge`
|
|
2021
|
-
*
|
|
2022
|
-
*
|
|
2023
|
-
|
|
2024
|
-
* unfinished handoff is re-detected next pass by the dead-owner guard in `pollMerges` (which keys off
|
|
2025
|
-
* the terminal `derived_status`, independently of the GitHub verdict). Best-effort throughout: a failed
|
|
2026
|
-
* cancel or restore only logs, leaving the row for the next pass. */
|
|
2027
|
-
async function handoffWaitingMergeToConvergence(
|
|
2092
|
+
* RETRYABLE on failure, with creation and retirement recovered SEPARATELY (PR #944 review): if no
|
|
2093
|
+
* successor is created, the row is restored to `waiting_merge` naming the still-LIVE merge owner, so
|
|
2094
|
+
* the next pass simply retries. Once a successor is published the row is never demoted; retiring the
|
|
2095
|
+
* captured merge key is retried by {@link retireHandedOffMergeLoop}. Exported for tests. */
|
|
2096
|
+
export async function handoffWaitingMergeToConvergence(
|
|
2028
2097
|
data: DataLayer,
|
|
2029
2098
|
engine: EngineClient,
|
|
2030
2099
|
pr: PullRequest,
|
|
2031
2100
|
logContext: string,
|
|
2032
2101
|
) {
|
|
2033
2102
|
const prKey = pr.pr_key;
|
|
2103
|
+
const mergeKey = pr.process_key != null ? String(pr.process_key) : null;
|
|
2034
2104
|
try {
|
|
2035
2105
|
// `waiting_since` bounds the review the fresh loop collects: only a review submitted at or after
|
|
2036
2106
|
// the handoff counts, so a pre-merge review (submitted before this flip) can never satisfy the new
|
|
2037
2107
|
// run's wait.
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2108
|
+
// FENCE the staging write on the captured owner (PR #944 review): if a cancel/resubmit replaced the
|
|
2109
|
+
// owner while this pass awaited GitHub, the replacement owns the row (possibly parked on a stage such
|
|
2110
|
+
// as `waiting_deps`) — re-staging it to `waiting_review` would strand it outside its stage's scan.
|
|
2111
|
+
// The STAGE side of the fence is an allowlist (PR #944 round-6 review), not "unchanged since this
|
|
2112
|
+
// pass read the row": a restart mid-handoff can strand the row at a handoff-transient stage
|
|
2113
|
+
// (`waiting_review`, or keyless at `converging` after a phantom null-key publish) outside the
|
|
2114
|
+
// `waiting_merge` recovery scan, and an unchanged-stage fence would skip it forever. The fence
|
|
2115
|
+
// admits (a) the LIVE merge stages the merge poller scans (`waiting_merge`, `waiting_lane`) and
|
|
2116
|
+
// (b) a KEYLESS row at a handoff-transient stage — there is no live owner to protect, and the
|
|
2117
|
+
// rollback below restores the live merge stage so the next pass re-drives the handoff. A
|
|
2118
|
+
// successor-owned row (`converging` naming the fresh convergence instance) still fails the OWNER
|
|
2119
|
+
// check and is never demoted; a predecessor-owned `waiting_review` row is mid-handoff and is left
|
|
2120
|
+
// for `retryPendingRetirements`, not re-staged.
|
|
2121
|
+
const fresh = await prs(data).get(prKey);
|
|
2122
|
+
const freshOwner = fresh?.process_key != null ? String(fresh.process_key) : null;
|
|
2123
|
+
const HANDOFFABLE_STAGES = new Set(["waiting_merge", "waiting_lane"]);
|
|
2124
|
+
const HANDOFF_TRANSIENT_STAGES = new Set(["waiting_review", "converging"]);
|
|
2125
|
+
const stageOk =
|
|
2126
|
+
HANDOFFABLE_STAGES.has(String(fresh?.status)) ||
|
|
2127
|
+
(freshOwner == null && HANDOFF_TRANSIENT_STAGES.has(String(fresh?.status)));
|
|
2128
|
+
if (!fresh || freshOwner !== mergeKey || !stageOk) {
|
|
2129
|
+
console.log(`[poller] ${logContext} handoff ${prKey}: owner/stage changed (${freshOwner}/${fresh?.status}); skipping`);
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
// ONE pending predecessor retirement at a time (PR #944 review): while an earlier predecessor's
|
|
2133
|
+
// retirement is unconfirmed, never start another handoff — a second key would overwrite the first
|
|
2134
|
+
// and leave that live loop untracked. `retryPendingRetirements` clears it first. A KEYLESS owner
|
|
2135
|
+
// row is exempt (PR #944 round-6 review): there is no live owner to protect — the pending key names
|
|
2136
|
+
// the predecessor the ORIGINAL handoff stranded, and this recovery handoff re-stages against THAT
|
|
2137
|
+
// SAME key, so nothing is overwritten.
|
|
2138
|
+
const pendingKey = fresh.retire_pending_key != null ? String(fresh.retire_pending_key) : "";
|
|
2139
|
+
if (pendingKey !== "" && mergeKey != null && pendingKey !== mergeKey) {
|
|
2140
|
+
console.log(`[poller] ${logContext} handoff ${prKey}: predecessor ${pendingKey} retirement pending; deferring`);
|
|
2141
|
+
return;
|
|
2047
2142
|
}
|
|
2048
|
-
|
|
2143
|
+
const handoffTs = now();
|
|
2144
|
+
// Persist the retirement INTENT in the same write that stages the handoff (PR #944 review), so a
|
|
2145
|
+
// restart after successor publication can never lose the predecessor's key. The retry pass only
|
|
2146
|
+
// acts once the row's owner is no longer this key (i.e. the successor is published); a rollback
|
|
2147
|
+
// clears it.
|
|
2148
|
+
await prs(data).update(prKey, {
|
|
2149
|
+
status: "waiting_review",
|
|
2150
|
+
waiting_since: handoffTs,
|
|
2151
|
+
updated_at: handoffTs,
|
|
2152
|
+
...(mergeKey != null ? { retire_pending_key: mergeKey } : {}),
|
|
2153
|
+
});
|
|
2154
|
+
// SUCCESSOR FIRST, then retire (issue #943). The old order — cancel the merge instance, THEN
|
|
2155
|
+
// `submitPr` — orphaned nanobpm/nano-bpm#1452: the derived tracking status folds the cancel in
|
|
2156
|
+
// asynchronously, so `submitPr` still read the row's `process_key` as a LIVE owner, returned
|
|
2157
|
+
// `alreadyRunning`, and started NOTHING — the merge loop was gone, no convergence loop took over,
|
|
2158
|
+
// no human was told. Now the live merge instance hands back as the exact owner (`handoffFrom`, the
|
|
2159
|
+
// #939 channel), so the successor exists before the predecessor is retired.
|
|
2160
|
+
const res = await submitPr(
|
|
2049
2161
|
data,
|
|
2050
2162
|
engine,
|
|
2051
2163
|
{ repo: pr.repo, number: pr.number, url: pr.url, prKey },
|
|
@@ -2055,25 +2167,183 @@ async function handoffWaitingMergeToConvergence(
|
|
|
2055
2167
|
null,
|
|
2056
2168
|
false,
|
|
2057
2169
|
pr.auto_resolve_escalations === 1,
|
|
2170
|
+
undefined,
|
|
2171
|
+
mergeKey,
|
|
2058
2172
|
);
|
|
2059
|
-
|
|
2173
|
+
const successor = "processKey" in res ? res.processKey : null;
|
|
2174
|
+
if (successor == null) {
|
|
2175
|
+
// Deduped against a REPLACEMENT owner (a cancel/resubmit landed while this pass awaited GitHub):
|
|
2176
|
+
// the live replacement owns the row now — never overwrite its key with the stale merge key.
|
|
2177
|
+
const current = await prs(data).get(prKey);
|
|
2178
|
+
if (current?.process_key != null && String(current.process_key) !== mergeKey) {
|
|
2179
|
+
console.log(`[poller] ${logContext} handoff ${prKey}: replaced by ${current.process_key}; leaving it as owner`);
|
|
2180
|
+
return;
|
|
2181
|
+
}
|
|
2182
|
+
// No successor was started — never retire the merge loop into nothing.
|
|
2183
|
+
throw new Error("convergence hand-off started no successor instance");
|
|
2184
|
+
}
|
|
2185
|
+
// The successor is PUBLISHED: from here on the row is convergence-owned and must never be demoted
|
|
2186
|
+
// back to `waiting_merge` (PR #944 review) — a retirement failure is recovered separately, against
|
|
2187
|
+
// the CAPTURED merge key, without touching the successor's row.
|
|
2188
|
+
if (mergeKey != null) await retireHandedOffMergeLoop(data, engine, prKey, mergeKey, successor, logContext);
|
|
2189
|
+
console.log(`[poller] ${logContext} -> handed back into convergence (${successor}) -> ${prKey}`);
|
|
2060
2190
|
} catch (err) {
|
|
2191
|
+
// CREATION failed (submitPr threw or started nothing): roll back to the prior owner. The merge
|
|
2192
|
+
// instance was NOT retired, so it stays LIVE and keeps owning the PR — restore BOTH the stage and
|
|
2193
|
+
// its `process_key` (a null-successor reopen may already have overwritten the key with NULL), so
|
|
2194
|
+
// the next pass re-attempts the handoff against the right instance. A row that had no owner key
|
|
2195
|
+
// (an earlier unfinished handoff) stays keyless: the NULL key is the durable "unfinished handoff"
|
|
2196
|
+
// marker the dead-owner guard in `pollMerges` keys on. Best-effort: a failed restore is only logged.
|
|
2061
2197
|
console.error(`[poller] ${logContext} handoff ${prKey}: ${err}`);
|
|
2062
|
-
// Restore the row to `waiting_merge` so the next pass re-attempts the handoff, and NULL the
|
|
2063
|
-
// `process_key`: the merge instance was retired above (or never lived), so the row genuinely has
|
|
2064
|
-
// NO live owner. The null key is the durable "unfinished handoff" marker the dead-owner guard in
|
|
2065
|
-
// `pollMerges` keys on — and it is what distinguishes this retryable shape from a HUMAN cancel of a
|
|
2066
|
-
// parked merge wait (which leaves `process_key` naming the terminated instance and must NOT be
|
|
2067
|
-
// resurrected). Best-effort: a failed restore still leaves the original error logged.
|
|
2068
2198
|
try {
|
|
2069
|
-
|
|
2199
|
+
// FENCE the rollback on the captured owner (PR #944 review): if a replacement took the row while
|
|
2200
|
+
// we awaited, it owns the PR — restoring the stale merge key would point tracking at a retired loop.
|
|
2201
|
+
const current = await prs(data).get(prKey);
|
|
2202
|
+
if (current?.process_key != null && String(current.process_key) !== mergeKey) {
|
|
2203
|
+
console.log(`[poller] ${logContext} handoff ${prKey}: owner changed to ${current.process_key}; no rollback`);
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
await prs(data).update(prKey, {
|
|
2207
|
+
status: "waiting_merge",
|
|
2208
|
+
...(mergeKey != null ? { process_key: mergeKey, retire_pending_key: null } : {}),
|
|
2209
|
+
updated_at: now(),
|
|
2210
|
+
});
|
|
2070
2211
|
} catch (restoreErr) {
|
|
2071
2212
|
console.error(`[poller] ${logContext} handoff ${prKey}: restore waiting_merge: ${restoreErr}`);
|
|
2072
2213
|
}
|
|
2073
2214
|
}
|
|
2074
2215
|
}
|
|
2075
2216
|
|
|
2217
|
+
/** Retire the predecessor merge loop after its convergence successor is published (issue #943, PR #944
|
|
2218
|
+
* review). Bounded retry against the CAPTURED merge key; a persistent failure is logged loudly but never
|
|
2219
|
+
* rolls the successor-owned row back. Exported for tests. */
|
|
2220
|
+
export async function retireHandedOffMergeLoop(
|
|
2221
|
+
data: DataLayer,
|
|
2222
|
+
engine: Pick<EngineClient, "cancelInstance">,
|
|
2223
|
+
prKey: string,
|
|
2224
|
+
mergeKey: string,
|
|
2225
|
+
successor: string,
|
|
2226
|
+
logContext: string,
|
|
2227
|
+
attempts = 2,
|
|
2228
|
+
): Promise<boolean> {
|
|
2229
|
+
const reason = `merge loop handed back to convergence (${logContext}) -> successor ${successor}`;
|
|
2230
|
+
for (let i = 1; i <= attempts; i++) {
|
|
2231
|
+
try {
|
|
2232
|
+
await terminatePrLoop(data, engine, prKey, mergeKey, reason, true);
|
|
2233
|
+
await setRetirePending(data, prKey, mergeKey, false);
|
|
2234
|
+
return true;
|
|
2235
|
+
} catch (err) {
|
|
2236
|
+
console.error(`[poller] ${logContext} handoff ${prKey}: retire merge ${mergeKey} attempt ${i}/${attempts}: ${err}`);
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
// Persist the pending retirement so later passes keep retrying it (never drop the predecessor key —
|
|
2240
|
+
// a live merge loop could fire its timeout and overwrite the successor-owned row).
|
|
2241
|
+
await setRetirePending(data, prKey, mergeKey, true);
|
|
2242
|
+
return false;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
/** Durably mark/clear a pending predecessor retirement. Clearing is fenced on the key so a newer
|
|
2246
|
+
* pending retirement is never wiped by an older one's success. Best-effort (logged). */
|
|
2247
|
+
async function setRetirePending(data: DataLayer, prKey: string, mergeKey: string, pending: boolean): Promise<void> {
|
|
2248
|
+
try {
|
|
2249
|
+
const row = await prs(data).get(prKey);
|
|
2250
|
+
if (!row) return;
|
|
2251
|
+
if (pending) await prs(data).update(prKey, { retire_pending_key: mergeKey, updated_at: now() });
|
|
2252
|
+
else if (row.retire_pending_key != null && String(row.retire_pending_key) === mergeKey)
|
|
2253
|
+
await prs(data).update(prKey, { retire_pending_key: null, updated_at: now() });
|
|
2254
|
+
} catch (err) {
|
|
2255
|
+
console.error(`[poller] ${prKey}: persist retire-pending ${mergeKey}: ${err}`);
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
/** Poll pass (PR #944 review): re-attempt every persisted pending predecessor retirement. Never touches
|
|
2260
|
+
* the row's stage or owner — only the predecessor instance and the pending marker. Exported for tests. */
|
|
2261
|
+
export async function retryPendingRetirements(data: DataLayer, engine: Pick<EngineClient, "cancelInstance">) {
|
|
2262
|
+
for (const pr of await prs(data).all()) {
|
|
2263
|
+
const key = pr.retire_pending_key;
|
|
2264
|
+
if (key == null || key === "") continue;
|
|
2265
|
+
// A pending key that is still the row's OWNER means the handoff never published a successor (e.g. a
|
|
2266
|
+
// restart mid-handoff): the merge loop is live and owning — never retire it into nothing.
|
|
2267
|
+
if (pr.process_key != null && String(pr.process_key) === String(key)) continue;
|
|
2268
|
+
// A KEYLESS owner row is the same unpublished-handoff shape (PR #944 round-5 review): `submitPr`
|
|
2269
|
+
// can publish a null `process_key`, and if the app stops before the rollback lands (or the rollback
|
|
2270
|
+
// write fails) the pending key survives with NO successor. Retiring here would cancel the still-live
|
|
2271
|
+
// merge loop with successor "none" — the ownerless termination this PR exists to prevent. Leave the
|
|
2272
|
+
// pending marker in place: the dead-owner guard in `pollMerges` re-drives the handoff from the
|
|
2273
|
+
// NULL-key row, and the next pass after a successor is published retires the predecessor against it.
|
|
2274
|
+
if (pr.process_key == null) {
|
|
2275
|
+
console.log(`[poller] retire-retry ${pr.pr_key}: no successor published (process_key null); deferring retirement of ${key}`);
|
|
2276
|
+
continue;
|
|
2277
|
+
}
|
|
2278
|
+
await retireHandedOffMergeLoop(data, engine, pr.pr_key, String(key), String(pr.process_key), "retire-retry", 1);
|
|
2279
|
+
}
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
/** Record WHY a PR loop instance was terminated (issue #943), bound to THAT instance. The ONE writer of
|
|
2283
|
+
* termination provenance, shared by {@link terminatePrLoop} (app-initiated) and every operator cancel
|
|
2284
|
+
* door (`/actions/cancel`, `/actions/cancel-pr`). The reason is stamped only when `processKey` is the
|
|
2285
|
+
* row's CURRENT owner: once a successor owns the row (the convergence hand-off publishes it before
|
|
2286
|
+
* retiring the predecessor), the predecessor's reason has nothing to explain on that row and must not
|
|
2287
|
+
* mask a later orphaning of the successor (PR #944 review). Exported for the cancel doors + tests. */
|
|
2288
|
+
export async function recordTerminationProvenance(
|
|
2289
|
+
data: DataLayer,
|
|
2290
|
+
prKey: string,
|
|
2291
|
+
processKey: string,
|
|
2292
|
+
reason: string,
|
|
2293
|
+
): Promise<boolean> {
|
|
2294
|
+
const row = await prs(data).get(prKey);
|
|
2295
|
+
if (!row || row.process_key == null || String(row.process_key) !== String(processKey)) return false;
|
|
2296
|
+
await prs(data).update(prKey, {
|
|
2297
|
+
termination_reason: reason,
|
|
2298
|
+
termination_process_key: String(processKey),
|
|
2299
|
+
updated_at: now(),
|
|
2300
|
+
});
|
|
2301
|
+
return true;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
/** Record operator-cancel provenance on every PR row a cancelled instance owns (issue #943). */
|
|
2305
|
+
export async function recordOperatorCancelProvenance(data: DataLayer, processKey: string, door: string): Promise<void> {
|
|
2306
|
+
for (const row of await prs(data).find({ process_key: processKey })) {
|
|
2307
|
+
await recordTerminationProvenance(data, row.pr_key, processKey, `operator cancel (${door})`);
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
/** THE canonical door for an app-initiated cancel of a PR's convergence/merge loop (issue #943).
|
|
2312
|
+
* Every in-app termination must say WHY and what took over: the reason (which names the successor
|
|
2313
|
+
* instance or the terminal GitHub outcome) is stamped on the row's `termination_reason` BEFORE the
|
|
2314
|
+
* engine cancel, so even a crash between the two leaves provenance. `strict` rethrows a failed cancel
|
|
2315
|
+
* (callers that must confirm the instance is gone); otherwise a failed cancel is logged. The
|
|
2316
|
+
* `cancel-sites` guard test (app/loopTermination.test.ts) fails if any new `cancelInstance` call site
|
|
2317
|
+
* targeting a PR loop bypasses this door. Exported for tests. */
|
|
2318
|
+
export async function terminatePrLoop(
|
|
2319
|
+
data: DataLayer,
|
|
2320
|
+
engine: Pick<EngineClient, "cancelInstance">,
|
|
2321
|
+
prKey: string,
|
|
2322
|
+
processKey: string,
|
|
2323
|
+
reason: string,
|
|
2324
|
+
strict: boolean,
|
|
2325
|
+
): Promise<void> {
|
|
2326
|
+
try {
|
|
2327
|
+
await recordTerminationProvenance(data, prKey, processKey, reason);
|
|
2328
|
+
} catch (err) {
|
|
2329
|
+
// Provenance is diagnostic: a failed write must never keep a loop alive that must be retired.
|
|
2330
|
+
console.error(`[loop] ${prKey}: record termination reason for ${processKey} failed: ${err}`);
|
|
2331
|
+
}
|
|
2332
|
+
try {
|
|
2333
|
+
await engine.cancelInstance({ processInstanceKey: processKey });
|
|
2334
|
+
} catch (err) {
|
|
2335
|
+
if (strict) throw err;
|
|
2336
|
+
console.error(`[loop] ${prKey}: cancel ${processKey} (${reason}) failed: ${err}`);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2076
2340
|
export async function pollMerges(data: DataLayer, engine: EngineClient, token: string) {
|
|
2341
|
+
// 0) Pending predecessor retirements from earlier hand-offs (PR #944 review).
|
|
2342
|
+
try {
|
|
2343
|
+
await retryPendingRetirements(data, engine);
|
|
2344
|
+
} catch (err) {
|
|
2345
|
+
console.error(`[poller] retire-retry: ${err}`);
|
|
2346
|
+
}
|
|
2077
2347
|
// 1) Dependencies merged?
|
|
2078
2348
|
for (const pr of await prs(data).find({ status: "waiting_deps" })) {
|
|
2079
2349
|
const prKey = pr.pr_key;
|
|
@@ -104,6 +104,10 @@ test("submitPr re-opens a derive-only-terminated PR (base 'converging', derived
|
|
|
104
104
|
status: "converging", // base transient FROZEN — reconciler no longer writes the terminal
|
|
105
105
|
derived_status: "abandoned", // ADR-0065 derive-only terminal
|
|
106
106
|
current_round: 3,
|
|
107
|
+
// A PRIOR run's operator Dismiss + provenance must not survive the reopen (PR #944 review).
|
|
108
|
+
acknowledged_at: "2024-01-05",
|
|
109
|
+
termination_reason: "operator cancel",
|
|
110
|
+
termination_process_key: "PI-1",
|
|
107
111
|
}],
|
|
108
112
|
key: "pr_key",
|
|
109
113
|
},
|
|
@@ -126,24 +130,31 @@ test("submitPr re-opens a derive-only-terminated PR (base 'converging', derived
|
|
|
126
130
|
assertEquals(pr.status, "converging");
|
|
127
131
|
assertEquals(pr.current_round, 1); // re-opened for a fresh run
|
|
128
132
|
assertEquals(pr.process_key, "PI-9");
|
|
133
|
+
assertEquals(pr.acknowledged_at, null, "a prior run's Dismiss never acknowledges this run's orphaning");
|
|
134
|
+
assertEquals(pr.termination_reason, null);
|
|
135
|
+
assertEquals(pr.termination_process_key, null);
|
|
129
136
|
});
|
|
130
137
|
});
|
|
131
138
|
|
|
132
|
-
// #503 / #497 phantom: `activePrs`
|
|
133
|
-
//
|
|
134
|
-
|
|
139
|
+
// #503 / #497 phantom: `activePrs` classifies on `derived_status`, so a derive-only-terminated PR never
|
|
140
|
+
// reads as its frozen in-flight base status. Issue #943: if NO reason was recorded for the termination
|
|
141
|
+
// (an orphan) it is surfaced as `orphaned` rather than silently dropped; a recorded reason or an
|
|
142
|
+
// operator Dismiss (`acknowledged_at`) excludes it.
|
|
143
|
+
test("activePrs never reports a derive-only-terminated PR as active — orphaned if unexplained, excluded if explained/dismissed", async () => {
|
|
135
144
|
const stores: Stores = {
|
|
136
145
|
pull_requests: {
|
|
137
146
|
rows: [
|
|
138
147
|
{ pr_key: "o/r#1", repo: "o/r", number: 1, url: "u1", status: "converging", derived_status: "abandoned", current_round: 2, updated_at: "2024-01-02" },
|
|
139
148
|
{ pr_key: "o/r#2", repo: "o/r", number: 2, url: "u2", status: "converging", derived_status: "converging", current_round: 1, updated_at: "2024-01-01" },
|
|
149
|
+
{ pr_key: "o/r#3", repo: "o/r", number: 3, url: "u3", status: "converging", derived_status: "abandoned", process_key: "PI-3", termination_reason: "operator cancel", termination_process_key: "PI-3", current_round: 1, updated_at: "2024-01-03" },
|
|
150
|
+
{ pr_key: "o/r#4", repo: "o/r", number: 4, url: "u4", status: "converging", derived_status: "abandoned", acknowledged_at: "2024-01-05", current_round: 1, updated_at: "2024-01-04" },
|
|
140
151
|
],
|
|
141
152
|
key: "pr_key",
|
|
142
153
|
},
|
|
143
154
|
escalations: { rows: [], key: "id" },
|
|
144
155
|
};
|
|
145
156
|
const active = await activePrs(memData(stores));
|
|
146
|
-
assertEquals(active.map((p) => p.prKey), ["o/r#2"]);
|
|
157
|
+
assertEquals(active.map((p) => [p.prKey, p.status]), [["o/r#1", "orphaned"], ["o/r#2", "converging"]]);
|
|
147
158
|
});
|
|
148
159
|
|
|
149
160
|
// #503: `pollIncidentsImpl` classifies a dead instance on `derived_status`, so a derive-only-terminated
|
package/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.208.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitTag": "v0.208.
|
|
5
|
-
"builtAt": "2026-11-
|
|
2
|
+
"version": "0.208.3",
|
|
3
|
+
"gitSha": "ff42e2a6fa77fd780949822adf3d4f2b9caefc38",
|
|
4
|
+
"gitTag": "v0.208.3",
|
|
5
|
+
"builtAt": "2026-11-11T02:35:58.462Z"
|
|
6
6
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- Loop-termination provenance (issue #943). A merge loop was terminated in-app (the waiting-merge →
|
|
2
|
+
-- convergence hand-off) with no successor and no escalation, and nothing on the row said why — the PR
|
|
3
|
+
-- silently dropped off `/status`. Every APP-INITIATED cancel of a PR's convergence/merge loop now
|
|
4
|
+
-- stamps a one-line reason here (via `terminatePrLoop` in app/service.ts), naming the outcome it
|
|
5
|
+
-- led to (a successor convergence instance, or the PR's terminal GitHub state). NULL when the current
|
|
6
|
+
-- run was never cancelled by the app. `/status` surfaces it, and an `orphaned` overlay flags a PR
|
|
7
|
+
-- whose loop ended with NO recorded reason, no successor and no escalation.
|
|
8
|
+
ALTER TABLE pull_requests ADD COLUMN termination_reason TEXT;
|
|
9
|
+
|
|
10
|
+
-- The instance the reason describes (PR #944 review): provenance is bound to the CANCELLED instance and
|
|
11
|
+
-- only explains a termination while it equals the row's `process_key`, so a predecessor's reason can
|
|
12
|
+
-- never mask the later orphaning of a successor.
|
|
13
|
+
ALTER TABLE pull_requests ADD COLUMN termination_process_key TEXT;
|
|
14
|
+
|
|
15
|
+
-- A handed-off predecessor merge loop whose retirement has not yet succeeded (PR #944 review): durable
|
|
16
|
+
-- so the poller retries it every pass instead of dropping the key. NULL when nothing is pending.
|
|
17
|
+
ALTER TABLE pull_requests ADD COLUMN retire_pending_key TEXT;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
-- Re-create the `pull_requests_read_model` VIEW so it re-exports the new base column (the VIEW must pass
|
|
2
|
+
-- through EVERY base `pull_requests` column — app/pullRequestReadModel.test.ts DRIFT GUARD). A mechanical
|
|
3
|
+
-- re-emission of 128 with `termination_reason` added; the derived columns are emitted VERBATIM from
|
|
4
|
+
-- app/pullRequestReadModel.ts and are unchanged. No BEGIN/COMMIT (the runner wraps each file). Numbered after 130.
|
|
5
|
+
|
|
6
|
+
DROP VIEW IF EXISTS pull_requests_read_model;
|
|
7
|
+
|
|
8
|
+
CREATE VIEW pull_requests_read_model AS
|
|
9
|
+
SELECT
|
|
10
|
+
pr.pr_key AS pr_key,
|
|
11
|
+
pr.repo AS repo,
|
|
12
|
+
pr.number AS number,
|
|
13
|
+
pr.url AS url,
|
|
14
|
+
pr.title AS title,
|
|
15
|
+
COALESCE(pr.derived_status, pr.status) AS status,
|
|
16
|
+
pr.current_round AS current_round,
|
|
17
|
+
pr.process_key AS process_key,
|
|
18
|
+
pr.waiting_since AS waiting_since,
|
|
19
|
+
pr.last_review_id AS last_review_id,
|
|
20
|
+
pr.outcome AS outcome,
|
|
21
|
+
pr.created_at AS created_at,
|
|
22
|
+
pr.updated_at AS updated_at,
|
|
23
|
+
pr.converged_at AS converged_at,
|
|
24
|
+
pr.merged_at AS merged_at,
|
|
25
|
+
pr.active_worker AS active_worker,
|
|
26
|
+
pr.lease_until AS lease_until,
|
|
27
|
+
pr.last_nudge_at AS last_nudge_at,
|
|
28
|
+
pr.fresh_head_run_head AS fresh_head_run_head,
|
|
29
|
+
pr.abandon_token AS abandon_token,
|
|
30
|
+
pr.incident_key AS incident_key,
|
|
31
|
+
pr.incident_type AS incident_type,
|
|
32
|
+
pr.incident_since AS incident_since,
|
|
33
|
+
pr.incident_message AS incident_message,
|
|
34
|
+
pr.last_round_head AS last_round_head,
|
|
35
|
+
pr.last_progress_job_key AS last_progress_job_key,
|
|
36
|
+
pr.last_progress_result AS last_progress_result,
|
|
37
|
+
pr.last_progress_agent_watermark AS last_progress_agent_watermark,
|
|
38
|
+
pr.churn_escalated_round AS churn_escalated_round,
|
|
39
|
+
pr.root_request_key AS root_request_key,
|
|
40
|
+
pr.epic_phase_label AS epic_phase_label,
|
|
41
|
+
pr.acknowledged_at AS acknowledged_at,
|
|
42
|
+
pr.auto_resolve_escalations AS auto_resolve_escalations,
|
|
43
|
+
pr.termination_reason AS termination_reason,
|
|
44
|
+
pr.termination_process_key AS termination_process_key,
|
|
45
|
+
pr.retire_pending_key AS retire_pending_key,
|
|
46
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0)), 0) THEN 'history' ELSE 'active' END AS list_bucket,
|
|
47
|
+
CASE WHEN COALESCE((COALESCE((COALESCE(("pr"."derived_status" = 'merged'), 0) OR COALESCE(("pr"."derived_status" = 'converged'), 0) OR COALESCE(("pr"."derived_status" = 'abandoned'), 0) OR COALESCE(("pr"."derived_status" = 'closed'), 0) OR COALESCE(("pr"."derived_status" = 'failed'), 0)), 0) AND (NOT COALESCE(COALESCE(("pr"."acknowledged_at" = "pr"."acknowledged_at"), 0), 0))), 0) THEN 1 ELSE 0 END AS ack_open
|
|
48
|
+
FROM pull_requests__tracking pr;
|
package/openapi.yaml
CHANGED
|
@@ -94,7 +94,15 @@ components:
|
|
|
94
94
|
- autoResolveEscalations
|
|
95
95
|
- merge
|
|
96
96
|
- incident
|
|
97
|
+
- terminationReason
|
|
97
98
|
properties:
|
|
99
|
+
terminationReason:
|
|
100
|
+
type: string
|
|
101
|
+
nullable: true
|
|
102
|
+
description: >-
|
|
103
|
+
Why the app (or an operator) last cancelled this PR's current loop instance (issue #943),
|
|
104
|
+
naming the successor/outcome; null when never. An `orphaned` status means the loop ended
|
|
105
|
+
with no recorded reason, successor, or escalation.
|
|
98
106
|
prKey:
|
|
99
107
|
type: string
|
|
100
108
|
repo:
|
|
@@ -4357,6 +4365,51 @@ paths:
|
|
|
4357
4365
|
application/json:
|
|
4358
4366
|
schema:
|
|
4359
4367
|
$ref: "#/components/schemas/ErrorBody"
|
|
4368
|
+
/actions/cancel-pr:
|
|
4369
|
+
post:
|
|
4370
|
+
operationId: cancelPrRun
|
|
4371
|
+
# x-mcp exclusion: the page-only twin of `cancelInstance` (the per-row Cancel button). Agents use
|
|
4372
|
+
# the guarded `/actions/cancel` door.
|
|
4373
|
+
x-mcp:
|
|
4374
|
+
exclude: true
|
|
4375
|
+
summary: "Page-facing record-consistent cancel (issue #943): identical to `cancelInstance` (shared implementation, records termination provenance) but unguarded, because the declarative page action cannot attach x-hook-secret (see operations/cancelPrRun.ts)."
|
|
4376
|
+
requestBody:
|
|
4377
|
+
required: true
|
|
4378
|
+
content:
|
|
4379
|
+
application/json:
|
|
4380
|
+
schema:
|
|
4381
|
+
$ref: "#/components/schemas/CancelInstanceIntent"
|
|
4382
|
+
responses:
|
|
4383
|
+
"200":
|
|
4384
|
+
description: The instance was terminated; its record derives/flips to `abandoned` and drops out of `listActivePrs`.
|
|
4385
|
+
content:
|
|
4386
|
+
application/json:
|
|
4387
|
+
schema:
|
|
4388
|
+
$ref: "#/components/schemas/CancelInstanceResult"
|
|
4389
|
+
"400":
|
|
4390
|
+
description: Missing/invalid `processInstanceKey`.
|
|
4391
|
+
content:
|
|
4392
|
+
application/json:
|
|
4393
|
+
schema:
|
|
4394
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
4395
|
+
"404":
|
|
4396
|
+
description: "No tracked record owns the key (a clean no-op — the engine is never touched). Body is a CancelInstanceResult with `ok:false`, `state:\"gone\"`, `reconciled:0`."
|
|
4397
|
+
content:
|
|
4398
|
+
application/json:
|
|
4399
|
+
schema:
|
|
4400
|
+
$ref: "#/components/schemas/CancelInstanceResult"
|
|
4401
|
+
"502":
|
|
4402
|
+
description: "The cancel did not fully take: EITHER the engine did NOT stop the instance (the cancel was not committed; the run may still be live), OR the engine stopped it but the tracked record did not settle to a terminal edge (`ok:false`, `reconciled:0`) — a wedged record the caller must not read as done."
|
|
4403
|
+
content:
|
|
4404
|
+
application/json:
|
|
4405
|
+
schema:
|
|
4406
|
+
$ref: "#/components/schemas/CancelInstanceResult"
|
|
4407
|
+
"503":
|
|
4408
|
+
description: The app has no data source configured, so the cancel cannot be reconciled into the record.
|
|
4409
|
+
content:
|
|
4410
|
+
application/json:
|
|
4411
|
+
schema:
|
|
4412
|
+
$ref: "#/components/schemas/ErrorBody"
|
|
4360
4413
|
/actions/incident/retry:
|
|
4361
4414
|
post:
|
|
4362
4415
|
operationId: retryIncident
|
|
@@ -293,3 +293,12 @@ test("record routing also covers PR/plan aggregates (a resolved pull_requests ke
|
|
|
293
293
|
assertEquals(res.body.ok, true);
|
|
294
294
|
assertEquals(res.body.reconciled, 1);
|
|
295
295
|
});
|
|
296
|
+
|
|
297
|
+
test("PR#944: operator-cancel provenance is recorded even when the record fails to reconcile (502)", async () => {
|
|
298
|
+
const rows: any[] = [{ pr_key: "o/r#4", process_key: "400", status: "converging" }];
|
|
299
|
+
const { app } = makeRecordApp({ pull_requests: { rows, key: "pr_key" } }, { readBackState: "TERMINATED" });
|
|
300
|
+
const res = await call(app, { processInstanceKey: "400" });
|
|
301
|
+
assertEquals(res.status, 502, "the unreconciled record still surfaces as 502");
|
|
302
|
+
assertEquals(rows[0].termination_process_key, "400", "provenance is stamped once the engine confirms termination");
|
|
303
|
+
assert(typeof rows[0].termination_reason === "string" && rows[0].termination_reason.length > 0);
|
|
304
|
+
});
|
|
@@ -37,9 +37,12 @@
|
|
|
37
37
|
// door, when NANO_PR_WEBHOOK_SECRET is set callers must present it via the x-hook-secret header —
|
|
38
38
|
// mirroring `reconcileEngineState`/`agentCompleteEscalation`.
|
|
39
39
|
|
|
40
|
+
import type { AppApi } from "@nanobpm/urban";
|
|
40
41
|
import { cancelInstanceReconciling } from "@nanobpm/urban";
|
|
41
42
|
import { engineBackedBindings, resolveTrackedInstance } from "../app/instanceTracking.ts";
|
|
43
|
+
import { recordOperatorCancelProvenance } from "../app/service.ts";
|
|
42
44
|
import { envVar } from "../app/version.ts";
|
|
45
|
+
import type { CancelInstanceResponse } from "../nano-generated/api-io.d.ts";
|
|
43
46
|
import { defineOperation } from "../nano-generated/operations.ts";
|
|
44
47
|
|
|
45
48
|
const SECRET = envVar("NANO_PR_WEBHOOK_SECRET") ?? "";
|
|
@@ -49,6 +52,17 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
|
|
|
49
52
|
app.log.warn("cancelInstance rejected: missing/invalid shared secret");
|
|
50
53
|
return { status: 401, body: { error: "unauthorized" } };
|
|
51
54
|
}
|
|
55
|
+
return cancelTrackedRun(app, body, "/actions/cancel");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** The ONE record-consistent cancel implementation shared by every cancel door (this guarded API/MCP
|
|
59
|
+
* door and the unguarded page door `/actions/cancel-pr`, operations/cancelPrRun.ts), so both stamp
|
|
60
|
+
* termination provenance (issue #943, PR #944 review) and neither can drift. Auth stays per door. */
|
|
61
|
+
export async function cancelTrackedRun(
|
|
62
|
+
app: AppApi,
|
|
63
|
+
body: unknown,
|
|
64
|
+
door: string,
|
|
65
|
+
): Promise<{ status: number; body: CancelInstanceResponse }> {
|
|
52
66
|
if (!app.data) {
|
|
53
67
|
app.log.warn("cancelInstance: no data source configured — cannot reconcile the record");
|
|
54
68
|
return { status: 503, body: { error: "no data source configured" } };
|
|
@@ -58,7 +72,7 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
|
|
|
58
72
|
// so a numeric JSON value would already have lost precision before it reached us — accepting it
|
|
59
73
|
// (and coercing it back to a string) would silently cancel the wrong instance and also contradicts
|
|
60
74
|
// the OpenAPI `string` contract. Reject a non-string with a 400 rather than coerce.
|
|
61
|
-
const raw = body && typeof body === "object" ? body.processInstanceKey : undefined;
|
|
75
|
+
const raw = body && typeof body === "object" && "processInstanceKey" in body ? body.processInstanceKey : undefined;
|
|
62
76
|
const processInstanceKey = typeof raw === "string" ? raw.trim() : "";
|
|
63
77
|
if (!processInstanceKey) {
|
|
64
78
|
return { status: 400, body: { error: "processInstanceKey is required and must be a string" } };
|
|
@@ -124,6 +138,17 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
|
|
|
124
138
|
// delta lies. Re-read the resolved record and count it reconciled only when its ADR-0065
|
|
125
139
|
// `derived_status` has LEFT its `activeStatuses` (become terminal → resubmittable). When we cannot
|
|
126
140
|
// read the record (no default source), trust the primitive's own count.
|
|
141
|
+
// Termination provenance (issue #943): a deliberate operator cancel of a PR loop is NOT an orphan —
|
|
142
|
+
// stamp the reason (as soon as the ENGINE confirms termination, before
|
|
143
|
+
// the record re-read — a 502 on an unreconciled record must not lose it; PR #944 review) on the owning PR row so `/status` does not flag it `orphaned`. Best-effort.
|
|
144
|
+
if (canResolveRecord) {
|
|
145
|
+
try {
|
|
146
|
+
await recordOperatorCancelProvenance(app.data, processInstanceKey, door);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
app.log.warn("cancelInstance: could not record termination reason", { processInstanceKey, error: String(err) });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
127
152
|
let reconciled = result.reconciled;
|
|
128
153
|
let recordReconciled = true;
|
|
129
154
|
if (canResolveRecord) {
|
|
@@ -163,4 +188,5 @@ export default defineOperation("cancelInstance", async ({ req, body }, app) => {
|
|
|
163
188
|
reconciled,
|
|
164
189
|
});
|
|
165
190
|
return { status: 200, body: responseBody };
|
|
166
|
-
}
|
|
191
|
+
}
|
|
192
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// POST /app/api/actions/cancel-pr → operationId `cancelPrRun` (issue #943, PR #944 review).
|
|
2
|
+
//
|
|
3
|
+
// The page-facing twin of `cancelInstance` for the per-row Cancel button. The runtime's built-in
|
|
4
|
+
// `/app/actions/cancel` page action bypassed the app's termination-provenance write, so a deliberate
|
|
5
|
+
// UI cancel left an in-flight base status with no reason and `/status` reported it `orphaned`. Both
|
|
6
|
+
// doors now route through ONE implementation (`cancelTrackedRun`), which stamps provenance.
|
|
7
|
+
//
|
|
8
|
+
// Auth posture is preserved: like the built-in page action it replaces (and `retryIncident`), this
|
|
9
|
+
// door is INTENTIONALLY UNGUARDED by NANO_PR_WEBHOOK_SECRET — the `@nanobpm/urban` page runtime cannot
|
|
10
|
+
// attach x-hook-secret, so a guard would 401 every button press on a secured deployment. The guarded
|
|
11
|
+
// `/actions/cancel` stays the API/MCP door; this one is excluded from MCP (`x-mcp`).
|
|
12
|
+
|
|
13
|
+
import { defineOperation } from "../nano-generated/operations.ts";
|
|
14
|
+
import { cancelTrackedRun } from "./cancelInstance.ts";
|
|
15
|
+
|
|
16
|
+
export default defineOperation("cancelPrRun", async ({ body }, app) => cancelTrackedRun(app, body, "/actions/cancel-pr"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.208.
|
|
3
|
+
"version": "0.208.3",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
package/pages/home.page.json
CHANGED