@nanobpm/nano-workforce 0.208.2 → 0.208.4
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/AGENTS.md +17 -0
- package/CHANGELOG.md +12 -0
- package/app/contracts.ts +1 -1
- package/app/feature.ts +5 -2
- package/app/implementBranchContract.test.ts +168 -0
- package/app/implementReconcile.ts +8 -2
- 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/repoEnvelope.test.ts +11 -75
- package/app/repoEnvelope.ts +122 -0
- 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/e2e/feature-preflight.e2e.ts +2 -2
- package/e2e/plan-fanout.e2e.ts +105 -9
- package/nano.app.json +4 -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/resources/processes/implement-cell.bpmn +80 -65
- package/workers/caps-prepare/worker.test.ts +11 -0
- package/workers/slice-branch/worker.test.ts +31 -0
- package/workers/slice-branch/worker.ts +39 -0
|
@@ -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/repoEnvelope.test.ts
CHANGED
|
@@ -12,82 +12,18 @@
|
|
|
12
12
|
// consumer together so they cannot drift again.
|
|
13
13
|
import { test } from "node:test";
|
|
14
14
|
import { assert, assertEquals } from "#test-assert";
|
|
15
|
-
import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
15
|
+
import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, recoverBranchCreate, repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
16
16
|
|
|
17
|
-
// ───
|
|
18
|
-
//
|
|
19
|
-
// (
|
|
20
|
-
// `io.nanobpm.agentTask` namespace out of BOTH a whole-object/JSON value AND flattened
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
29
|
-
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function deepMerge(base: unknown, over: unknown): unknown {
|
|
33
|
-
if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
|
|
34
|
-
const out: Record<string, unknown> = { ...base };
|
|
35
|
-
if (!isPlainObject(over)) return out;
|
|
36
|
-
for (const [k, v] of Object.entries(over)) {
|
|
37
|
-
if (v === undefined) continue;
|
|
38
|
-
out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
|
|
39
|
-
}
|
|
40
|
-
return out;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function setPath(obj: Record<string, unknown>, path: string[], value: unknown): void {
|
|
44
|
-
let cur = obj;
|
|
45
|
-
for (let i = 0; i < path.length - 1; i++) {
|
|
46
|
-
const k = path[i];
|
|
47
|
-
if (!isPlainObject(cur[k])) cur[k] = {};
|
|
48
|
-
cur = cur[k] as Record<string, unknown>;
|
|
49
|
-
}
|
|
50
|
-
cur[path[path.length - 1]] = value;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Mirror of the harness's `collectEnvelopeFrom`: reassemble the reserved namespace from a
|
|
54
|
-
* whole-object/JSON value AND flattened dotpath keys (element templates / task headers emit the
|
|
55
|
-
* latter). */
|
|
56
|
-
function collectEnvelopeFrom(source: Record<string, unknown>): Record<string, unknown> {
|
|
57
|
-
const out: Record<string, unknown> = {};
|
|
58
|
-
if (!isPlainObject(source)) return out;
|
|
59
|
-
const whole = source[AGENT_TASK_NS];
|
|
60
|
-
if (whole != null) {
|
|
61
|
-
let val: unknown = whole;
|
|
62
|
-
if (typeof whole === "string") {
|
|
63
|
-
try {
|
|
64
|
-
val = JSON.parse(whole);
|
|
65
|
-
} catch {
|
|
66
|
-
val = undefined;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
|
|
70
|
-
}
|
|
71
|
-
const prefix = `${AGENT_TASK_NS}.`;
|
|
72
|
-
for (const [key, value] of Object.entries(source)) {
|
|
73
|
-
if (!key.startsWith(prefix)) continue;
|
|
74
|
-
const rest = key.slice(prefix.length);
|
|
75
|
-
if (!rest) continue;
|
|
76
|
-
setPath(out, rest.split("."), value);
|
|
77
|
-
}
|
|
78
|
-
return out;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Mirror of the harness's `normalizeTaskEnvelope` branch slice: it reads `branch` from the TOP
|
|
82
|
-
* LEVEL of the merged envelope — `isPlainObject(raw.branch) ? raw.branch : {}` — never from
|
|
83
|
-
* `raw.repository.branch`. Returns the consumer's recovered `branch.create` (undefined when the
|
|
84
|
-
* producer put it somewhere the consumer doesn't look). */
|
|
85
|
-
function harnessReadsBranchCreate(customHeaders: Record<string, unknown>, variables: Record<string, unknown>): string | undefined {
|
|
86
|
-
const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables)) as Record<string, unknown>;
|
|
87
|
-
const branch = isPlainObject(raw.branch) ? raw.branch : {};
|
|
88
|
-
const create = branch.create;
|
|
89
|
-
return create == null ? undefined : String(create);
|
|
90
|
-
}
|
|
17
|
+
// ─── The consumer mirror ─────────────────────────────────────────────────────────────
|
|
18
|
+
// `recoverBranchCreate` (app/repoEnvelope.ts) is the ONE canonical mirror of the c8ctl harness's
|
|
19
|
+
// consumer shape (`collectEnvelopeFrom` + `normalizeTaskEnvelope`): it reassembles the reserved
|
|
20
|
+
// `io.nanobpm.agentTask` namespace out of BOTH a whole-object/JSON value AND flattened dotpath keys,
|
|
21
|
+
// `deepMerge`s the two sources (variables over headers), and reads `branch` from the TOP LEVEL of the
|
|
22
|
+
// merged envelope (`isPlainObject(raw.branch)`), NOT from `raw.repository.branch`. Keeping that mirror
|
|
23
|
+
// in lock-step with the harness is the point of this test: if the producer emits a shape the consumer
|
|
24
|
+
// doesn't read, the round-trip below drops `branch.create` and fails. Aliased here under the
|
|
25
|
+
// harness-centric name the assertions read naturally with.
|
|
26
|
+
const harnessReadsBranchCreate = recoverBranchCreate;
|
|
91
27
|
|
|
92
28
|
// ─── The round-trip contract ──────────────────────────────────────────────────────────
|
|
93
29
|
|