@nanobpm/nano-workforce 0.189.0 → 0.189.2
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 +12 -0
- package/SPEC.md +16 -0
- package/app/adjudications.test.ts +735 -0
- package/app/adjudications.ts +378 -0
- package/app/agentCompletion.test.ts +282 -10
- package/app/agentCompletion.ts +163 -23
- package/app/agentic/permission-bridge.test.ts +2 -2
- package/app/answer-escalation.test.ts +415 -2
- package/app/answerContextMapping.test.ts +83 -0
- package/app/convergenceAdjudicationResume.test.ts +274 -0
- package/app/github.test.ts +46 -1
- package/app/github.ts +10 -0
- package/app/service.test.ts +264 -2
- package/app/service.ts +104 -4
- package/app/terminalReaderBehaviour.test.ts +21 -0
- package/db/migrations/109_pr_adjudications.sql +61 -0
- package/db/migrations/110_task_completions_auto_applied.sql +34 -0
- package/operations/completeUserTask.test.ts +5 -5
- package/operations/listEscalations.test.ts +1 -1
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +9 -0
- package/resources/processes/merge-loop.bpmn +1 -0
- package/workers/answer-escalation/worker.ts +191 -11
package/app/service.test.ts
CHANGED
|
@@ -11,9 +11,10 @@ import { memDataFor } from "../test/worldDb.ts";
|
|
|
11
11
|
import { withTrackingViews } from "../test/trackingViews.ts";
|
|
12
12
|
import { DurableResumeRegistry } from "./durableResume.ts";
|
|
13
13
|
import { WorldStore } from "./world/index.ts";
|
|
14
|
-
import { abandonClosedPr, isPrSettled, MAX_ACK_RETRIES, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
14
|
+
import { abandonClosedPr, isPrSettled, MAX_ACK_RETRIES, parsePr, pollCapabilityGatesImpl, pollIncidentsImpl, pollReviews, pollWaveGatesImpl, repoEnvelopeVars, startMerge, submitPr, worldRestoreSha } from "./service.ts";
|
|
15
15
|
import { trackingTargetFor } from "./instanceTracking.ts";
|
|
16
16
|
import type { DataLayer } from "@nanobpm/urban";
|
|
17
|
+
import { READINESS_READY_MESSAGE } from "./readiness.ts";
|
|
17
18
|
|
|
18
19
|
function memTable(rows: any[], key: string) {
|
|
19
20
|
return {
|
|
@@ -39,6 +40,33 @@ function memTable(rows: any[], key: string) {
|
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
// Emulates the `data.open().exec` raw-SQL path submitPr uses to atomically reset a PR's adjudication
|
|
44
|
+
// memory (`resetAdjudications` → `DELETE FROM "pr_adjudications" WHERE "pr_key" = ?`, Copilot review of
|
|
45
|
+
// #806). The bulk-DELETE SQL itself is validated against real SQLite in app/adjudications.test.ts; here
|
|
46
|
+
// it need only mutate the in-memory `pr_adjudications` store so submitPr's reset is observable. Pushes
|
|
47
|
+
// an optional ordering token so the fence-ordering test can assert the reset runs AFTER `process_key`.
|
|
48
|
+
function memOpen(stores: Record<string, { rows: any[]; key: string }>, ops?: string[]) {
|
|
49
|
+
return {
|
|
50
|
+
exec: async (sql: string, params: any[] = []) => {
|
|
51
|
+
if (/DELETE FROM "pr_adjudications" WHERE "pr_key" = \?/.test(sql)) {
|
|
52
|
+
ops?.push("adjudication-delete");
|
|
53
|
+
const store = stores.pr_adjudications;
|
|
54
|
+
let changed = 0;
|
|
55
|
+
if (store) {
|
|
56
|
+
for (let i = store.rows.length - 1; i >= 0; i--) {
|
|
57
|
+
if (store.rows[i].pr_key === params[0]) {
|
|
58
|
+
store.rows.splice(i, 1);
|
|
59
|
+
changed++;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { changed };
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`unexpected exec sql: ${sql}`);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
42
70
|
function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
43
71
|
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
44
72
|
const prevTok = process.env["GITHUB_TOKEN"];
|
|
@@ -51,6 +79,90 @@ function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
|
51
79
|
});
|
|
52
80
|
}
|
|
53
81
|
|
|
82
|
+
function reviewPagesFetch(pages: Record<string, unknown>[][], requests: string[]) {
|
|
83
|
+
return (url: string | URL | Request): Promise<Response> => {
|
|
84
|
+
const u = new URL(String(url));
|
|
85
|
+
if (!u.pathname.endsWith("/reviews")) {
|
|
86
|
+
return Promise.resolve(
|
|
87
|
+
new Response(
|
|
88
|
+
JSON.stringify({ head: { ref: null, sha: "SHA_CURRENT" } }),
|
|
89
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
90
|
+
),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
requests.push(u.toString());
|
|
94
|
+
const page = Number(u.searchParams.get("page") ?? "1");
|
|
95
|
+
const headers = new Headers();
|
|
96
|
+
if (page < pages.length) {
|
|
97
|
+
headers.set(
|
|
98
|
+
"link",
|
|
99
|
+
`<https://api.github.com/repos/owner/repo/pulls/42/reviews?per_page=100&page=${page + 1}>; rel="next", ` +
|
|
100
|
+
`<https://api.github.com/repos/owner/repo/pulls/42/reviews?per_page=100&page=${pages.length}>; rel="last"`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return Promise.resolve(new Response(JSON.stringify(pages[page - 1] ?? []), { status: 200, headers }));
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
test("pollReviews publishes readiness-ready for a fresh review on the final page (#793)", async () => {
|
|
108
|
+
const oldReviews = Array.from({ length: 100 }, (_, i) => ({
|
|
109
|
+
id: i + 1,
|
|
110
|
+
state: "COMMENTED",
|
|
111
|
+
submitted_at: "2026-09-01T00:00:00Z",
|
|
112
|
+
commit_id: "SHA_CURRENT",
|
|
113
|
+
}));
|
|
114
|
+
const pages = [
|
|
115
|
+
oldReviews,
|
|
116
|
+
[{ id: 101, state: "APPROVED", submitted_at: "2026-09-15T12:00:00Z", commit_id: "SHA_CURRENT" }],
|
|
117
|
+
];
|
|
118
|
+
const requests: string[] = [];
|
|
119
|
+
const pr = {
|
|
120
|
+
pr_key: "owner/repo#42",
|
|
121
|
+
repo: "owner/repo",
|
|
122
|
+
number: 42,
|
|
123
|
+
status: "waiting_review",
|
|
124
|
+
waiting_since: "2026-09-10T00:00:00Z",
|
|
125
|
+
last_review_id: 100,
|
|
126
|
+
};
|
|
127
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
128
|
+
pull_requests: { rows: [pr], key: "pr_key" },
|
|
129
|
+
};
|
|
130
|
+
const data = {
|
|
131
|
+
table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], key),
|
|
132
|
+
} as any as DataLayer;
|
|
133
|
+
const messages: { name: string; correlationKey?: string; variables?: Record<string, unknown> }[] = [];
|
|
134
|
+
const engine = {
|
|
135
|
+
publishMessage: async (message: { name: string; correlationKey?: string; variables?: Record<string, unknown> }) => {
|
|
136
|
+
messages.push(message);
|
|
137
|
+
},
|
|
138
|
+
} as any;
|
|
139
|
+
|
|
140
|
+
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
141
|
+
const prevFetch = globalThis.fetch;
|
|
142
|
+
process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
|
|
143
|
+
globalThis.fetch = reviewPagesFetch(pages, requests) as typeof fetch;
|
|
144
|
+
try {
|
|
145
|
+
await pollReviews(data, engine, "tok");
|
|
146
|
+
} finally {
|
|
147
|
+
globalThis.fetch = prevFetch;
|
|
148
|
+
if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
149
|
+
else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
assertEquals(requests.length, 2, "the poller must read the final reviews page");
|
|
153
|
+
assertEquals(
|
|
154
|
+
messages,
|
|
155
|
+
[{
|
|
156
|
+
name: READINESS_READY_MESSAGE,
|
|
157
|
+
correlationKey: "owner/repo#42",
|
|
158
|
+
variables: { ready: true, detail: "review 101 (APPROVED)" },
|
|
159
|
+
}],
|
|
160
|
+
"fresh review must release the review wait",
|
|
161
|
+
);
|
|
162
|
+
assertEquals(pr.last_review_id, 101);
|
|
163
|
+
assertEquals(pr.status, "converging");
|
|
164
|
+
});
|
|
165
|
+
|
|
54
166
|
test("isPrSettled reads the derived tracking view — an out-of-band-abandoned PR (base row still converging) is settled", async () => {
|
|
55
167
|
// The base `pull_requests` row still reads `converging`, but the ADR-0065 derived tracking VIEW
|
|
56
168
|
// folds the reconciler's out-of-band terminal edge into `derived_status: "abandoned"`. Terminal-edge
|
|
@@ -104,6 +216,7 @@ test("re-submit of a cancelled PR marks stale open escalations", async () => {
|
|
|
104
216
|
};
|
|
105
217
|
const data = {
|
|
106
218
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
219
|
+
open: () => memOpen(stores),
|
|
107
220
|
} as any;
|
|
108
221
|
const engine = {
|
|
109
222
|
createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }),
|
|
@@ -140,7 +253,142 @@ test("re-submit of a cancelled PR marks stale open escalations", async () => {
|
|
|
140
253
|
});
|
|
141
254
|
});
|
|
142
255
|
|
|
143
|
-
// Red/green regression for
|
|
256
|
+
// Red/green regression for issue #806 (Copilot review): re-submitting a PR must ALSO invalidate its
|
|
257
|
+
// durable adjudication memory. The auto-resume replays a prior `(PR, question)` answer forever, so a
|
|
258
|
+
// re-opened PR whose question recurs would silently auto-apply the stale decision and an operator
|
|
259
|
+
// could never force a fresh one. `submitPr`'s reopen path clears `pr_adjudications` for the PR.
|
|
260
|
+
test("re-submit of a PR invalidates its durable adjudications (#806 review)", async () => {
|
|
261
|
+
await withGithubOff(async () => {
|
|
262
|
+
const PR_KEY = "owner/repo#42";
|
|
263
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
264
|
+
pull_requests: {
|
|
265
|
+
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", title: "t", status: "converged" }],
|
|
266
|
+
key: "pr_key",
|
|
267
|
+
},
|
|
268
|
+
escalations: { rows: [], key: "id" },
|
|
269
|
+
pr_adjudications: {
|
|
270
|
+
rows: [
|
|
271
|
+
{ id: 1, pr_key: PR_KEY, question_fingerprint: "fp-a", answer: "prior A", adjudicated_by: "alice", adjudicated_kind: "human", adjudicated_at: "t" },
|
|
272
|
+
{ id: 2, pr_key: "owner/repo#99", question_fingerprint: "fp-b", answer: "other PR", adjudicated_by: "bob", adjudicated_kind: "human", adjudicated_at: "t" },
|
|
273
|
+
],
|
|
274
|
+
key: "id",
|
|
275
|
+
},
|
|
276
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
277
|
+
};
|
|
278
|
+
const data = {
|
|
279
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
280
|
+
open: () => memOpen(stores),
|
|
281
|
+
} as any;
|
|
282
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }) } as any;
|
|
283
|
+
|
|
284
|
+
await submitPr(data, engine, { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", prKey: PR_KEY });
|
|
285
|
+
|
|
286
|
+
const remaining = stores.pr_adjudications.rows as Record<string, unknown>[];
|
|
287
|
+
assertEquals(remaining.length, 1, "this PR's adjudication is invalidated; another PR's is untouched");
|
|
288
|
+
assertEquals(remaining[0].pr_key, "owner/repo#99", "only the re-submitted PR's adjudications are cleared");
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Red/green regression for issue #806 (Copilot review): the durable adjudication RESET must happen
|
|
293
|
+
// AFTER `process_key` is advanced to the new instance — not before createInstance. Clearing the memory
|
|
294
|
+
// while `process_key` still names the OLD instance leaves a window where a delayed old-instance answer
|
|
295
|
+
// job passes the worker's staleness gate and reinserts its adjudication into the fresh run. Advancing
|
|
296
|
+
// the run identity FIRST fences that job, so the ordering is the fix. This asserts the observable
|
|
297
|
+
// invariant: the `pull_requests.process_key` write is issued BEFORE any `pr_adjudications.delete`.
|
|
298
|
+
test("re-submit advances process_key BEFORE resetting adjudications (fence ordering, #806 review)", async () => {
|
|
299
|
+
await withGithubOff(async () => {
|
|
300
|
+
const PR_KEY = "owner/repo#42";
|
|
301
|
+
const ops: string[] = [];
|
|
302
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
303
|
+
pull_requests: {
|
|
304
|
+
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", title: "t", status: "converged", process_key: "PI-OLD" }],
|
|
305
|
+
key: "pr_key",
|
|
306
|
+
},
|
|
307
|
+
escalations: { rows: [], key: "id" },
|
|
308
|
+
pr_adjudications: {
|
|
309
|
+
rows: [{ id: 1, pr_key: PR_KEY, question_fingerprint: "fp-a", answer: "prior A", adjudicated_by: "alice", adjudicated_kind: "human", adjudicated_at: "t" }],
|
|
310
|
+
key: "id",
|
|
311
|
+
},
|
|
312
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
313
|
+
};
|
|
314
|
+
const wrap = (name: string, key: string) => {
|
|
315
|
+
const t = memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key);
|
|
316
|
+
return {
|
|
317
|
+
...t,
|
|
318
|
+
update: (k: any, patch: any) => {
|
|
319
|
+
if (name === "pull_requests" && Object.prototype.hasOwnProperty.call(patch, "process_key")) ops.push("process_key");
|
|
320
|
+
return t.update(k, patch);
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
};
|
|
324
|
+
// The adjudication reset is now the atomic bulk `DELETE` via `data.open().exec` (Copilot review of
|
|
325
|
+
// #806), so `memOpen(stores, ops)` records the `adjudication-delete` ordering token — the table
|
|
326
|
+
// `delete` gateway is no longer on the reset path.
|
|
327
|
+
const data = { table: withTrackingViews(wrap), open: () => memOpen(stores, ops) } as any;
|
|
328
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-NEW" }) } as any;
|
|
329
|
+
|
|
330
|
+
await submitPr(data, engine, { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", prKey: PR_KEY });
|
|
331
|
+
|
|
332
|
+
assertEquals(stores.pr_adjudications.rows.length, 0, "the re-submitted PR's adjudication is invalidated");
|
|
333
|
+
const pkIdx = ops.indexOf("process_key");
|
|
334
|
+
const delIdx = ops.indexOf("adjudication-delete");
|
|
335
|
+
assertEquals(pkIdx >= 0, true, "process_key is advanced on reopen");
|
|
336
|
+
assertEquals(delIdx >= 0, true, "adjudications are reset on reopen");
|
|
337
|
+
assertEquals(pkIdx < delIdx, true, "process_key is advanced BEFORE the adjudication memory is reset (the fence ordering)");
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
// Red/green regression (Copilot review): the durable adjudication RESET runs AFTER the new instance is
|
|
342
|
+
// created and `process_key` is advanced. If the reset DELETE fails, the new convergence instance is
|
|
343
|
+
// already live while the OLD adjudications remain — and because the new instance is ACTIVE the
|
|
344
|
+
// `alreadyRunning` idempotency gate short-circuits every retry, so the reset is never re-run and the
|
|
345
|
+
// fresh run replays STALE decisions forever. `submitPr` must instead ROLL THE NEW RUN BACK on a reset
|
|
346
|
+
// failure: terminate the just-created instance (so nothing auto-applies stale memory) and rethrow, so
|
|
347
|
+
// the submission is not treated as started and a retry re-creates a clean run.
|
|
348
|
+
test("re-submit rolls back (cancels) the new instance when the adjudication reset fails (#806 review)", async () => {
|
|
349
|
+
await withGithubOff(async () => {
|
|
350
|
+
const PR_KEY = "owner/repo#42";
|
|
351
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
352
|
+
pull_requests: {
|
|
353
|
+
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", title: "t", status: "converged", process_key: "PI-OLD" }],
|
|
354
|
+
key: "pr_key",
|
|
355
|
+
},
|
|
356
|
+
escalations: { rows: [], key: "id" },
|
|
357
|
+
pr_adjudications: {
|
|
358
|
+
rows: [{ id: 1, pr_key: PR_KEY, question_fingerprint: "fp-a", answer: "prior A", adjudicated_by: "alice", adjudicated_kind: "human", adjudicated_at: "t" }],
|
|
359
|
+
key: "id",
|
|
360
|
+
},
|
|
361
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
362
|
+
};
|
|
363
|
+
// The reset DELETE throws (a transient DB failure), leaving the new instance live but the memory
|
|
364
|
+
// uncleared — the exact half-committed state the rollback guards against.
|
|
365
|
+
const failingOpen = () => ({
|
|
366
|
+
exec: async (sql: string) => {
|
|
367
|
+
if (/DELETE FROM "pr_adjudications" WHERE "pr_key" = \?/.test(sql)) throw new Error("boom: reset DELETE failed");
|
|
368
|
+
throw new Error(`unexpected exec sql: ${sql}`);
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
const data = { table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)), open: failingOpen } as any;
|
|
372
|
+
const cancelled: string[] = [];
|
|
373
|
+
const engine = {
|
|
374
|
+
createInstance: () => Promise.resolve({ processInstanceKey: "PI-NEW" }),
|
|
375
|
+
cancelInstance: (input: { processInstanceKey: string }) => {
|
|
376
|
+
cancelled.push(input.processInstanceKey);
|
|
377
|
+
return Promise.resolve();
|
|
378
|
+
},
|
|
379
|
+
} as any;
|
|
380
|
+
|
|
381
|
+
let threw = false;
|
|
382
|
+
try {
|
|
383
|
+
await submitPr(data, engine, { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", prKey: PR_KEY });
|
|
384
|
+
} catch {
|
|
385
|
+
threw = true;
|
|
386
|
+
}
|
|
387
|
+
assertEquals(threw, true, "a failed reset propagates so the caller can retry");
|
|
388
|
+
assertEquals(cancelled, ["PI-NEW"], "the just-created instance is terminated (rolled back), never left live with stale memory");
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
|
|
144
392
|
// can hit an engine incident that parks the token; until `pollIncidents` nothing on the PR row
|
|
145
393
|
// reflected it, so the grid kept showing "converging" while the run was dead in the water. This
|
|
146
394
|
// drives the pass's reconciliation core against a stubbed `/v2/incidents/search`:
|
|
@@ -180,6 +428,7 @@ test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears it,
|
|
|
180
428
|
};
|
|
181
429
|
const data = {
|
|
182
430
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
431
|
+
open: () => memOpen(stores),
|
|
183
432
|
} as any;
|
|
184
433
|
const headers = { "content-type": "application/json" };
|
|
185
434
|
|
|
@@ -233,6 +482,7 @@ test("pollIncidents never queries a PR with no live instance and clears any stal
|
|
|
233
482
|
};
|
|
234
483
|
const data = {
|
|
235
484
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
485
|
+
open: () => memOpen(stores),
|
|
236
486
|
} as any;
|
|
237
487
|
const headers = { "content-type": "application/json" };
|
|
238
488
|
|
|
@@ -266,6 +516,7 @@ test("pollIncidents picks the oldest incident by creationTime, sorting a missing
|
|
|
266
516
|
};
|
|
267
517
|
const data = {
|
|
268
518
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
519
|
+
open: () => memOpen(stores),
|
|
269
520
|
} as any;
|
|
270
521
|
const headers = { "content-type": "application/json" };
|
|
271
522
|
|
|
@@ -305,6 +556,7 @@ test("submitPr stringifies a numeric processInstanceKey (contract: string | null
|
|
|
305
556
|
};
|
|
306
557
|
const data = {
|
|
307
558
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
559
|
+
open: () => memOpen(stores),
|
|
308
560
|
} as any;
|
|
309
561
|
const engine = {
|
|
310
562
|
// A large key delivered as a JS number — the exact case that breaks dev response validation
|
|
@@ -339,6 +591,7 @@ function captureConvergeOnly() {
|
|
|
339
591
|
};
|
|
340
592
|
const data = {
|
|
341
593
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
594
|
+
open: () => memOpen(stores),
|
|
342
595
|
} as any;
|
|
343
596
|
let captured: unknown;
|
|
344
597
|
const engine = {
|
|
@@ -391,6 +644,7 @@ function captureVars() {
|
|
|
391
644
|
};
|
|
392
645
|
const data = {
|
|
393
646
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
647
|
+
open: () => memOpen(stores),
|
|
394
648
|
} as any;
|
|
395
649
|
let captured: Record<string, unknown> | undefined;
|
|
396
650
|
const engine = {
|
|
@@ -429,6 +683,7 @@ function captureRoot() {
|
|
|
429
683
|
};
|
|
430
684
|
const data = {
|
|
431
685
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
686
|
+
open: () => memOpen(stores),
|
|
432
687
|
} as any;
|
|
433
688
|
let captured: unknown;
|
|
434
689
|
const engine = {
|
|
@@ -795,6 +1050,7 @@ test("pollWaveGatesImpl is level-triggered: PRs merged before the token arrives
|
|
|
795
1050
|
};
|
|
796
1051
|
const data = {
|
|
797
1052
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1053
|
+
open: () => memOpen(stores),
|
|
798
1054
|
} as any;
|
|
799
1055
|
|
|
800
1056
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -884,6 +1140,7 @@ test("pollWaveGatesImpl never releases the barrier on an unverifiable subscripti
|
|
|
884
1140
|
};
|
|
885
1141
|
const data = {
|
|
886
1142
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1143
|
+
open: () => memOpen(stores),
|
|
887
1144
|
} as any;
|
|
888
1145
|
|
|
889
1146
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -988,6 +1245,7 @@ test("pollWaveGatesImpl releases the wave when a member PR is closed-unmerged an
|
|
|
988
1245
|
};
|
|
989
1246
|
const data = {
|
|
990
1247
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1248
|
+
open: () => memOpen(stores),
|
|
991
1249
|
} as any;
|
|
992
1250
|
|
|
993
1251
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -1044,6 +1302,7 @@ test("abandonClosedPr is idempotent — the terminal merges audit row is written
|
|
|
1044
1302
|
};
|
|
1045
1303
|
const data = {
|
|
1046
1304
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1305
|
+
open: () => memOpen(stores),
|
|
1047
1306
|
} as any;
|
|
1048
1307
|
|
|
1049
1308
|
await abandonClosedPr(data, "owner/repo#70", "closed without merging");
|
|
@@ -1072,6 +1331,7 @@ test("abandonClosedPr self-heals a missing pull_requests parent row before the F
|
|
|
1072
1331
|
};
|
|
1073
1332
|
const data = {
|
|
1074
1333
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1334
|
+
open: () => memOpen(stores),
|
|
1075
1335
|
} as any;
|
|
1076
1336
|
|
|
1077
1337
|
await abandonClosedPr(data, "owner/repo#71", "closed without merging");
|
|
@@ -1098,6 +1358,7 @@ test("abandonClosedPr rejects a malformed prKey with a clear error before any FK
|
|
|
1098
1358
|
};
|
|
1099
1359
|
const data = {
|
|
1100
1360
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1361
|
+
open: () => memOpen(stores),
|
|
1101
1362
|
} as any;
|
|
1102
1363
|
|
|
1103
1364
|
const err = await assertRejects(() => abandonClosedPr(data, "not-a-valid-pr-key", "closed without merging"));
|
|
@@ -1170,6 +1431,7 @@ function capsProbeExec(ready: boolean) {
|
|
|
1170
1431
|
function capsDataLayer(stores: Record<string, { rows: any[]; key: string }>) {
|
|
1171
1432
|
return {
|
|
1172
1433
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1434
|
+
open: () => memOpen(stores),
|
|
1173
1435
|
} as any;
|
|
1174
1436
|
}
|
|
1175
1437
|
|
package/app/service.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
|
|
10
10
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
11
11
|
import { ABANDONED_STATUS, abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { matchAdjudication, prAdjudications, resetAdjudications } from "./adjudications.ts";
|
|
13
|
+
import { completeEscalationAutoApplied, escalationFormId } from "./agentCompletion.ts";
|
|
13
14
|
import { agentSlaTimeout } from "./agentSla.ts";
|
|
14
15
|
import {
|
|
15
16
|
CAPS_RESOLVED_MESSAGE,
|
|
@@ -585,6 +586,14 @@ export async function submitPr(
|
|
|
585
586
|
for (const e of await escs(data).find({ pr_key: parsed.prKey, status: "open" })) {
|
|
586
587
|
await escs(data).update(e.id, { status: "stale" });
|
|
587
588
|
}
|
|
589
|
+
// A fresh convergence run must ALSO start with a clean durable adjudication memory (issue #806,
|
|
590
|
+
// Copilot review): the auto-resume replays a prior `(PR, question)` answer forever, so a re-opened
|
|
591
|
+
// PR whose question recurs would silently auto-apply the stale decision and an operator could never
|
|
592
|
+
// force a fresh one. This PR's adjudications are invalidated on reopen — but the reset is deferred
|
|
593
|
+
// to AFTER `process_key` is advanced to the new instance (see below), NOT here: clearing the memory
|
|
594
|
+
// while `process_key` still names the OLD instance leaves a window where a delayed old-instance
|
|
595
|
+
// answer job still passes the worker's staleness gate and reinserts its adjudication into the fresh
|
|
596
|
+
// run (Copilot review of #806). Advancing the run identity FIRST, then clearing, fences that job.
|
|
588
597
|
// Re-open a previously converged/abandoned/merged PR for a fresh convergence run.
|
|
589
598
|
await table.update(parsed.prKey, {
|
|
590
599
|
status: "converging",
|
|
@@ -682,8 +691,44 @@ export async function submitPr(
|
|
|
682
691
|
},
|
|
683
692
|
});
|
|
684
693
|
const processKey = processInstanceKey == null ? null : String(processInstanceKey);
|
|
685
|
-
|
|
686
|
-
|
|
694
|
+
// The `process_key` advance and the adjudication reset below are the two writes that MAKE the new
|
|
695
|
+
// run authoritative. If EITHER throws, the newly created instance is already live but the reopen is
|
|
696
|
+
// only half-committed — and a retry would short-circuit at the `alreadyRunning` idempotency gate
|
|
697
|
+
// (the new instance is ACTIVE, so `derived_status` is non-terminal), never re-running the reset. A
|
|
698
|
+
// failed reset would then leave the fresh run replaying STALE adjudication memory indefinitely
|
|
699
|
+
// (Copilot review). So roll the just-created run back on failure: terminate it and rethrow, so the
|
|
700
|
+
// submission is NOT treated as started. Terminating flips the PR's derived tracking status to a
|
|
701
|
+
// terminal edge (`abandoned`) via the `instanceTracking` reconciler, making the PR resubmittable so
|
|
702
|
+
// a retry re-creates a fresh instance and re-runs the reset cleanly — no orphaned run auto-applies
|
|
703
|
+
// stale decisions in the meantime.
|
|
704
|
+
try {
|
|
705
|
+
if (processKey != null) {
|
|
706
|
+
await table.update(parsed.prKey, { process_key: processKey });
|
|
707
|
+
}
|
|
708
|
+
// Invalidate this PR's durable adjudication memory for the fresh run (issue #806, Copilot review) —
|
|
709
|
+
// deferred to HERE, after `process_key` is advanced to the new instance above, so the reset happens
|
|
710
|
+
// UNDER the new run identity. On reopen (`existing`), any delayed old-instance answer job is now
|
|
711
|
+
// rejected by the worker's staleness gate (its `processInstanceKey` no longer matches the advanced
|
|
712
|
+
// `process_key`), so it cannot reinsert a stale adjudication after the reset; and the worker reads
|
|
713
|
+
// `process_key` as late as possible so it observes this advance. The insert-if-absent record then
|
|
714
|
+
// re-learns the operator's new answer for the new run. Runs unconditionally (even if `processKey` is
|
|
715
|
+
// null: the memory must still be clean for the fresh run). The wipe is a SINGLE atomic `DELETE`
|
|
716
|
+
// (`resetAdjudications`), never a row-by-row loop, so a crash mid-reset cannot leave a partially
|
|
717
|
+
// cleared memory (Copilot review of #806).
|
|
718
|
+
if (existing) {
|
|
719
|
+
await resetAdjudications(data, parsed.prKey);
|
|
720
|
+
}
|
|
721
|
+
} catch (err) {
|
|
722
|
+
if (processInstanceKey != null) {
|
|
723
|
+
try {
|
|
724
|
+
await engine.cancelInstance({ processInstanceKey: String(processInstanceKey) });
|
|
725
|
+
} catch (cancelErr) {
|
|
726
|
+
// Best-effort: a failed rollback-cancel leaves the instance for the abandon/reconcile poller
|
|
727
|
+
// to reap, but must not mask the original error that the caller needs to see and retry on.
|
|
728
|
+
console.warn(`[submit] ${parsed.prKey} rollback-cancel of ${processInstanceKey} failed: ${cancelErr}`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
throw err;
|
|
687
732
|
}
|
|
688
733
|
return { prKey: parsed.prKey, processKey };
|
|
689
734
|
}
|
|
@@ -2839,12 +2884,67 @@ export async function pollUserTasks(
|
|
|
2839
2884
|
// Desired set, deduped by completable key (a task is open at most once; guard a page overlap / a
|
|
2840
2885
|
// subject seen under two statuses mid-pass).
|
|
2841
2886
|
const desiredByKey = new Map<string, UserTaskRow>();
|
|
2887
|
+
// Keys auto-resumed from a durable adjudication this pass (issue #806). The reduced-capability scan
|
|
2888
|
+
// visits each instance twice (direct + callActivity hierarchy), and both queries snapshot the task
|
|
2889
|
+
// BEFORE the resume removes it, so the second visit would otherwise re-attempt a now-gone completion
|
|
2890
|
+
// and fall through to projecting the very row we just retired. Recording the key keeps the resume
|
|
2891
|
+
// one-shot and out of the inbox.
|
|
2892
|
+
const resumedByKey = new Set<string>();
|
|
2842
2893
|
const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string, rootProcessInstanceKey: string, formKey: string) => {
|
|
2843
2894
|
if (!elementId) return;
|
|
2844
2895
|
const rowKey = userTaskKey.trim();
|
|
2845
|
-
if (!rowKey || desiredByKey.has(rowKey)) return;
|
|
2896
|
+
if (!rowKey || desiredByKey.has(rowKey) || resumedByKey.has(rowKey)) return;
|
|
2846
2897
|
const ctx = await contextFor(elementId, userTaskKey, processInstanceKey, rootProcessInstanceKey, formKey);
|
|
2847
2898
|
if (!ctx) return;
|
|
2899
|
+
// Durable adjudication auto-resume (issue #806): before surfacing a NEW convergence `wait-answer`
|
|
2900
|
+
// to a human, check whether THIS PR already has a settled adjudication for the SAME question
|
|
2901
|
+
// (canonical `questionFingerprint`). If it does, resume the loop with the recorded answer through
|
|
2902
|
+
// the canonical `completeUserTaskAttributed` door — attributed to the prior adjudicator and marked
|
|
2903
|
+
// `auto_applied` (a machine replay, reversible so a human can still override) so it is never
|
|
2904
|
+
// laundered into a first-hand irreversible human authority (Copilot review of #806) — instead of
|
|
2905
|
+
// re-parking a human on an already-answered question (PR #800 / proc 46310: the same design
|
|
2906
|
+
// question escalated at round 2 and again at round 13). Scoped to the review loop's `wait-answer`
|
|
2907
|
+
// on a real PR key; on any resolution failure the task still projects, so an un-resumable question
|
|
2908
|
+
// always reaches a human (fail-open to the human).
|
|
2909
|
+
if (elementId === PR_WAIT_ANSWER_ELEMENT && ctx.subjectType === "pr" && ctx.question && parsePr(ctx.subjectKey)) {
|
|
2910
|
+
try {
|
|
2911
|
+
// The adjudication LOOKUP lives inside this fail-open `try` (not just the resume) so a transient
|
|
2912
|
+
// `pr_adjudications.find` error never rejects `project` and aborts `pollUserTasks` mid-pass — the
|
|
2913
|
+
// task still projects and the question always reaches a human (SPEC: adjudication-resolution
|
|
2914
|
+
// failures fail open to the human).
|
|
2915
|
+
const adjudication = matchAdjudication(await prAdjudications(data).find({ pr_key: ctx.subjectKey }), ctx.question);
|
|
2916
|
+
// Only auto-resume when the prior adjudicator's provenance is KNOWN. A settled row with a blank
|
|
2917
|
+
// `adjudicated_by` (completed out of band, so `latestAdjudicator` returned no actor) must NOT be
|
|
2918
|
+
// manufactured into a synthetic `human` actor — that would audit an unknown-provenance replay as
|
|
2919
|
+
// a first-hand human decision. Fail open to a fresh human task instead (Copilot review of #806).
|
|
2920
|
+
const adjudicatedBy = adjudication?.adjudicated_by?.trim();
|
|
2921
|
+
if (adjudication && adjudicatedBy) {
|
|
2922
|
+
const resumed = await completeEscalationAutoApplied(data, engine, {
|
|
2923
|
+
userTaskKey: rowKey,
|
|
2924
|
+
// The sweep already discovered this task's owning instance — hand it to the resolve so the
|
|
2925
|
+
// auto-apply scans that ONE instance, not every open user task engine-wide (issue #806
|
|
2926
|
+
// Copilot review: an unfiltered per-task scan makes a single poll pass O(N²) across N
|
|
2927
|
+
// already-adjudicated PRs). `contextFor`/the sweep report the task's direct instance, so the
|
|
2928
|
+
// filtered scan finds exactly this task; a miss still fails open to the human.
|
|
2929
|
+
processInstanceKey,
|
|
2930
|
+
variables: { answer: adjudication.answer },
|
|
2931
|
+
actor: {
|
|
2932
|
+
kind: adjudication.adjudicated_kind === "agent" ? "agent" : "human",
|
|
2933
|
+
id: adjudicatedBy,
|
|
2934
|
+
},
|
|
2935
|
+
// Link the auto-apply back to the replayed adjudication (issue #806) so a human revert of the
|
|
2936
|
+
// resulting completion invalidates this exact decision instead of it being silently re-applied.
|
|
2937
|
+
adjudicationId: adjudication.id,
|
|
2938
|
+
});
|
|
2939
|
+
if (resumed.ok) {
|
|
2940
|
+
resumedByKey.add(rowKey);
|
|
2941
|
+
return;
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
} catch (err) {
|
|
2945
|
+
console.error(`[poller] adjudication auto-resume (${ctx.subjectKey}): ${err}`);
|
|
2946
|
+
}
|
|
2947
|
+
}
|
|
2848
2948
|
const row = buildUserTaskRow(ctx, at);
|
|
2849
2949
|
if (row) desiredByKey.set(rowKey, row);
|
|
2850
2950
|
};
|
|
@@ -51,6 +51,27 @@ function memData(stores: Stores) {
|
|
|
51
51
|
return {
|
|
52
52
|
table: withTrackingViews((name: string, key: string) =>
|
|
53
53
|
memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
54
|
+
// Emulates the atomic bulk `DELETE FROM "pr_adjudications" WHERE "pr_key" = ?` submitPr issues via
|
|
55
|
+
// `data.open().exec` to reset a reopened PR's adjudication memory (Copilot review of #806). The SQL
|
|
56
|
+
// is validated against real SQLite in app/adjudications.test.ts; here it need only mutate the store.
|
|
57
|
+
open: () => ({
|
|
58
|
+
exec: async (sql: string, params: any[] = []) => {
|
|
59
|
+
if (/DELETE FROM "pr_adjudications" WHERE "pr_key" = \?/.test(sql)) {
|
|
60
|
+
const store = stores.pr_adjudications;
|
|
61
|
+
let changed = 0;
|
|
62
|
+
if (store) {
|
|
63
|
+
for (let i = store.rows.length - 1; i >= 0; i--) {
|
|
64
|
+
if (store.rows[i].pr_key === params[0]) {
|
|
65
|
+
store.rows.splice(i, 1);
|
|
66
|
+
changed++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { changed };
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`unexpected exec sql: ${sql}`);
|
|
73
|
+
},
|
|
74
|
+
}),
|
|
54
75
|
} as any;
|
|
55
76
|
}
|
|
56
77
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
-- 109_pr_adjudications.sql — issue #806: persist wait-answer human adjudications so an
|
|
2
|
+
-- already-answered convergence question does not re-escalate.
|
|
3
|
+
--
|
|
4
|
+
-- The convergence loop's `wait-answer` escalation (`record-answer` → resume) applies a human's
|
|
5
|
+
-- answer to the CURRENT round but keeps NO durable memory that "(this PR, this question) was already
|
|
6
|
+
-- adjudicated to X". A stateless later round that re-derives the identical escalation condition
|
|
7
|
+
-- re-parks a human from scratch (PR #800 / proc 46310: the same design question escalated at round 2
|
|
8
|
+
-- and again at round 13, both answered identically). A human adjudication is NOT GitHub-derivable —
|
|
9
|
+
-- it is a fact the app itself must remember — so it lives here, durably.
|
|
10
|
+
--
|
|
11
|
+
-- One row per (PR, question) a human has settled: `pr.answer-escalation` (record-answer) writes it on
|
|
12
|
+
-- answering, and the poller (`pollUserTasks`) reads it before surfacing a NEW `wait-answer` — when the
|
|
13
|
+
-- question's fingerprint matches an existing row it auto-resumes with the recorded answer (attributed
|
|
14
|
+
-- to the prior adjudicator) instead of re-escalating a human.
|
|
15
|
+
--
|
|
16
|
+
-- • question_fingerprint — the canonical `normalizeAdvisoryText` + `fingerprint` digest of the
|
|
17
|
+
-- escalation question (app/github.ts `questionFingerprint`), the SAME line-stable normalisation
|
|
18
|
+
-- advisory acks use; so only a byte/semantic-identical, already-answered question is suppressed
|
|
19
|
+
-- while a materially different question still escalates. No second fingerprint implementation.
|
|
20
|
+
-- • answer / adjudicated_by / adjudicated_kind / adjudicated_at — the settled answer, who settled it,
|
|
21
|
+
-- whether they were a `human` or an `agent` (ADR 0046), and when, so the auto-resume replays the
|
|
22
|
+
-- exact decision AND preserves the original attribution kind — a human-settled decision replays as
|
|
23
|
+
-- human, an agent-settled one as agent, so an auto-apply can never launder an agent decision into an
|
|
24
|
+
-- irreversible human authority (Copilot review of #806).
|
|
25
|
+
-- • invalidated_at — a TOMBSTONE set when a human REVERTS the auto-applied completion that replayed
|
|
26
|
+
-- this decision (`revertAgentCompletion` → `invalidateAdjudication`, Copilot review of #806). A plain
|
|
27
|
+
-- DELETE is NOT race-safe: the reverted completion's `record-answer` job can be redelivered
|
|
28
|
+
-- (at-least-once) AFTER the delete and re-insert the SAME `(pr_key, question_fingerprint)`, so the
|
|
29
|
+
-- next poller pass re-auto-applies and silently undoes the revert. Keeping the row as a tombstone lets
|
|
30
|
+
-- the `UNIQUE (pr_key, question_fingerprint)` fence make that redelivered re-insert a no-op, and
|
|
31
|
+
-- `matchAdjudication` skips a tombstoned row so it never auto-applies again. The tombstone is cleared
|
|
32
|
+
-- only by `resetAdjudications` on a fresh-run re-submit. NULL for a live, replayable decision.
|
|
33
|
+
-- • source_completion_id — the `task_completions.id` of the WINNING completion that produced this
|
|
34
|
+
-- decision (issue #806 review). A FIRST-HAND agent answer to a `wait-answer` records its own durable
|
|
35
|
+
-- adjudication (`adjudicated_kind="agent"`) but — unlike a machine auto-apply — its ledger row has
|
|
36
|
+
-- `auto_applied=0` and NO `source_adjudication_id`, so a human revert of that reversible agent
|
|
37
|
+
-- completion could not previously find and tombstone the decision it created, and the poller would
|
|
38
|
+
-- re-auto-apply the reverted answer. Linking every convergence adjudication to its winning completion
|
|
39
|
+
-- lets `revertAgentCompletion` invalidate the decision on ANY reversible agent revert, not only a
|
|
40
|
+
-- machine replay (`invalidateAdjudicationByCompletion`). INSERT-if-absent, so only the ORIGINAL
|
|
41
|
+
-- first-hand completion is recorded; a later auto-apply's re-record is a UNIQUE no-op that leaves the
|
|
42
|
+
-- link pointing at the first-hand winner. NULL for a legacy/uncorrelated answer.
|
|
43
|
+
--
|
|
44
|
+
-- `UNIQUE (pr_key, question_fingerprint)` keeps one settled answer per (PR, question); the surrogate
|
|
45
|
+
-- `id` PK gives the `Table<T>` gateway a single-column key. Forward-only, additive (expand). Numbered
|
|
46
|
+
-- after the current highest committed prefix (104) in the pre-assigned 109–110 block (#806); the
|
|
47
|
+
-- runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
|
|
48
|
+
CREATE TABLE IF NOT EXISTS pr_adjudications (
|
|
49
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
50
|
+
pr_key TEXT NOT NULL REFERENCES pull_requests(pr_key),
|
|
51
|
+
question_fingerprint TEXT NOT NULL,
|
|
52
|
+
answer TEXT,
|
|
53
|
+
adjudicated_by TEXT,
|
|
54
|
+
adjudicated_kind TEXT,
|
|
55
|
+
adjudicated_at TEXT NOT NULL,
|
|
56
|
+
invalidated_at TEXT,
|
|
57
|
+
source_completion_id INTEGER,
|
|
58
|
+
UNIQUE (pr_key, question_fingerprint)
|
|
59
|
+
);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_pradj_pr ON pr_adjudications(pr_key);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_pradj_srccompletion ON pr_adjudications(source_completion_id);
|