@nanobpm/nano-workforce 0.189.0 → 0.189.1
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/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.ts +10 -0
- package/app/service.test.ts +178 -1
- 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
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Red/green regression for issue #806 — the convergence loop must not re-escalate an already-answered
|
|
2
|
+
// `wait-answer` question. When a durable adjudication exists for (this PR, this question fingerprint),
|
|
3
|
+
// the poller (`pollUserTasks`) auto-resumes the parked `wait-answer` with the recorded answer through
|
|
4
|
+
// the SAME `completeEscalationAsHuman` door a human uses — attributed to the prior adjudicator —
|
|
5
|
+
// instead of re-parking a human (PR #800 / proc 46310: the same design question escalated at round 2
|
|
6
|
+
// and again at round 13, both answered identically).
|
|
7
|
+
//
|
|
8
|
+
// A materially different question (no matching adjudication) still escalates normally.
|
|
9
|
+
import { test } from "node:test";
|
|
10
|
+
import { assertEquals } from "#test-assert";
|
|
11
|
+
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
12
|
+
import { questionFingerprint } from "./github.ts";
|
|
13
|
+
import { pollUserTasks } from "./service.ts";
|
|
14
|
+
|
|
15
|
+
// biome-ignore lint/suspicious/noExplicitAny: in-memory table double, mirrors pollUserTasks.test.ts
|
|
16
|
+
function memData(seed: Record<string, any[]> = {}): { data: DataLayer; stores: Record<string, any[]> } {
|
|
17
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
18
|
+
const stores: Record<string, any[]> = {};
|
|
19
|
+
for (const [k, v] of Object.entries(seed)) stores[k] = v.map((r) => ({ ...r }));
|
|
20
|
+
function tbl(name: string, pk = "id") {
|
|
21
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
22
|
+
const rows = (stores[name] ??= [] as any[]);
|
|
23
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
24
|
+
const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
|
|
25
|
+
return {
|
|
26
|
+
async all() {
|
|
27
|
+
return rows.slice();
|
|
28
|
+
},
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
30
|
+
async get(id: any) {
|
|
31
|
+
return rows.find((r) => r[pk] === id);
|
|
32
|
+
},
|
|
33
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
34
|
+
async find(where: any = {}) {
|
|
35
|
+
return rows.filter((r) => match(r, where));
|
|
36
|
+
},
|
|
37
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
38
|
+
async insert(r: any) {
|
|
39
|
+
rows.push({ ...r });
|
|
40
|
+
return r[pk];
|
|
41
|
+
},
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
43
|
+
async update(id: any, patch: any) {
|
|
44
|
+
const r = rows.find((row) => row[pk] === id);
|
|
45
|
+
if (r) Object.assign(r, patch);
|
|
46
|
+
},
|
|
47
|
+
// biome-ignore lint/suspicious/noExplicitAny: see above
|
|
48
|
+
async delete(id: any) {
|
|
49
|
+
const i = rows.findIndex((r) => r[pk] === id);
|
|
50
|
+
if (i >= 0) rows.splice(i, 1);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const data = { table: (n: string, pk?: string) => tbl(n, pk) } as unknown as DataLayer;
|
|
55
|
+
return { data, stores };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type FakeTask = { userTaskKey: string; elementId: string; processInstanceKey: string };
|
|
59
|
+
|
|
60
|
+
/** A fake engine backing BOTH the poller's per-instance `openUserTasks({processInstanceKey})` scan AND
|
|
61
|
+
* the `completeEscalationAsHuman` door's unfiltered `openUserTasks()` resolve. `completeUserTask`
|
|
62
|
+
* removes the task (a resumed task is no longer open) and records the completion for assertions. */
|
|
63
|
+
function fakeEngine(tasks: FakeTask[]) {
|
|
64
|
+
const open = tasks.slice();
|
|
65
|
+
const completions: { userTaskKey: string; variables: Record<string, unknown> }[] = [];
|
|
66
|
+
// Every `openUserTasks` filter seen this run, so a test can assert the auto-apply resolve scans a
|
|
67
|
+
// single instance (`{processInstanceKey}`) rather than an engine-wide unfiltered scan (issue #806).
|
|
68
|
+
const scans: (undefined | { processInstanceKey?: string; rootProcessInstanceKey?: string })[] = [];
|
|
69
|
+
const engine = {
|
|
70
|
+
openUserTasks: (filter?: { processInstanceKey?: string; rootProcessInstanceKey?: string }) => {
|
|
71
|
+
scans.push(filter);
|
|
72
|
+
return Promise.resolve(
|
|
73
|
+
open.filter((t) => {
|
|
74
|
+
if (filter?.processInstanceKey) return t.processInstanceKey === filter.processInstanceKey;
|
|
75
|
+
if (filter?.rootProcessInstanceKey) return t.processInstanceKey === filter.rootProcessInstanceKey;
|
|
76
|
+
return true;
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
completeUserTask: (userTaskKey: string, variables: Record<string, unknown>) => {
|
|
81
|
+
const i = open.findIndex((t) => t.userTaskKey === userTaskKey);
|
|
82
|
+
if (i < 0) return Promise.reject(new Error("no such open task"));
|
|
83
|
+
open.splice(i, 1);
|
|
84
|
+
completions.push({ userTaskKey, variables });
|
|
85
|
+
return Promise.resolve();
|
|
86
|
+
},
|
|
87
|
+
} as unknown as EngineClient;
|
|
88
|
+
return { engine, completions, scans };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
test("pollUserTasks: auto-resumes an already-answered wait-answer instead of re-parking a human (#806)", async () => {
|
|
92
|
+
const question = "Should the timeout be a boundary event or a poller sentinel?";
|
|
93
|
+
const { data, stores } = memData({
|
|
94
|
+
pull_requests: [{ pr_key: "o/r#800", status: "escalated", process_key: "rp-800", url: "https://github.com/o/r/pull/800", title: "Converge" }],
|
|
95
|
+
escalations: [{ id: 1, pr_key: "o/r#800", status: "open", question }],
|
|
96
|
+
pr_adjudications: [
|
|
97
|
+
{
|
|
98
|
+
id: 1,
|
|
99
|
+
pr_key: "o/r#800",
|
|
100
|
+
// Whitespace/case variant — the canonical fingerprint normalises it to the same key.
|
|
101
|
+
question_fingerprint: questionFingerprint(` ${question.toUpperCase()} `),
|
|
102
|
+
answer: "Option A: a bounded boundary event.",
|
|
103
|
+
adjudicated_by: "alice",
|
|
104
|
+
adjudicated_at: "2025-01-01T00:00:00.000Z",
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
});
|
|
108
|
+
const { engine, completions } = fakeEngine([{ userTaskKey: "ut-800", elementId: "wait-answer", processInstanceKey: "rp-800" }]);
|
|
109
|
+
|
|
110
|
+
await pollUserTasks(data, engine);
|
|
111
|
+
|
|
112
|
+
assertEquals(completions.length, 1, "the parked wait-answer is auto-resumed");
|
|
113
|
+
assertEquals(completions[0].userTaskKey, "ut-800");
|
|
114
|
+
assertEquals(completions[0].variables.answer, "Option A: a bounded boundary event.", "resumed with the recorded answer");
|
|
115
|
+
assertEquals((stores.user_tasks ?? []).length, 0, "no wait-answer row is projected — the human is NOT re-parked");
|
|
116
|
+
const ledger = stores.task_completions ?? [];
|
|
117
|
+
assertEquals(ledger.length, 1, "the auto-resume is recorded in the completion ledger");
|
|
118
|
+
assertEquals(ledger[0].actor_id, "alice", "attributed to the prior adjudicator");
|
|
119
|
+
assertEquals(ledger[0].actor_kind, "human", "the prior adjudicator's kind is preserved (a human-settled decision)");
|
|
120
|
+
assertEquals(ledger[0].auto_applied, 1, "the replay is marked auto_applied — distinguishable from a first-hand submission");
|
|
121
|
+
assertEquals(ledger[0].reversible, 1, "an auto-applied replay is human-overridable");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("pollUserTasks: auto-resume PRESERVES an agent adjudicator's kind and stays reversible (#806 review)", async () => {
|
|
125
|
+
// A prior AGENT-settled adjudication (ADR 0046) must replay as an agent completion, never laundered
|
|
126
|
+
// into an irreversible human authority — the whole point of recording `adjudicated_kind`.
|
|
127
|
+
const question = "Which retry cap should the husk loop use?";
|
|
128
|
+
const { data, stores } = memData({
|
|
129
|
+
pull_requests: [{ pr_key: "o/r#801", status: "escalated", process_key: "rp-801", url: "https://github.com/o/r/pull/801", title: "Converge" }],
|
|
130
|
+
escalations: [{ id: 1, pr_key: "o/r#801", status: "open", question }],
|
|
131
|
+
pr_adjudications: [
|
|
132
|
+
{
|
|
133
|
+
id: 1,
|
|
134
|
+
pr_key: "o/r#801",
|
|
135
|
+
question_fingerprint: questionFingerprint(question),
|
|
136
|
+
answer: "Cap at 3.",
|
|
137
|
+
adjudicated_by: "senior-agent",
|
|
138
|
+
adjudicated_kind: "agent",
|
|
139
|
+
adjudicated_at: "2025-01-01T00:00:00.000Z",
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
});
|
|
143
|
+
const { engine, completions } = fakeEngine([{ userTaskKey: "ut-801", elementId: "wait-answer", processInstanceKey: "rp-801" }]);
|
|
144
|
+
|
|
145
|
+
await pollUserTasks(data, engine);
|
|
146
|
+
|
|
147
|
+
assertEquals(completions.length, 1, "the parked wait-answer is auto-resumed");
|
|
148
|
+
const ledger = stores.task_completions ?? [];
|
|
149
|
+
assertEquals(ledger[0].actor_kind, "agent", "the agent adjudicator's kind is preserved — not laundered into a human");
|
|
150
|
+
assertEquals(ledger[0].actor_id, "senior-agent");
|
|
151
|
+
assertEquals(ledger[0].auto_applied, 1, "still marked auto_applied");
|
|
152
|
+
assertEquals(ledger[0].reversible, 1, "still reversible — a human may override the replayed agent answer");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("pollUserTasks: a DIFFERENT question with no adjudication still escalates to a human (#806)", async () => {
|
|
156
|
+
const { data, stores } = memData({
|
|
157
|
+
pull_requests: [{ pr_key: "o/r#800", status: "escalated", process_key: "rp-800", url: "https://github.com/o/r/pull/800", title: "Converge" }],
|
|
158
|
+
escalations: [{ id: 1, pr_key: "o/r#800", status: "open", question: "A brand-new question nobody has answered." }],
|
|
159
|
+
pr_adjudications: [
|
|
160
|
+
{
|
|
161
|
+
id: 1,
|
|
162
|
+
pr_key: "o/r#800",
|
|
163
|
+
question_fingerprint: questionFingerprint("Some other, already-settled question."),
|
|
164
|
+
answer: "Prior answer.",
|
|
165
|
+
adjudicated_by: "alice",
|
|
166
|
+
adjudicated_at: "2025-01-01T00:00:00.000Z",
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
const { engine, completions } = fakeEngine([{ userTaskKey: "ut-800", elementId: "wait-answer", processInstanceKey: "rp-800" }]);
|
|
171
|
+
|
|
172
|
+
await pollUserTasks(data, engine);
|
|
173
|
+
|
|
174
|
+
assertEquals(completions.length, 0, "no auto-resume — the question is materially different");
|
|
175
|
+
const rows = stores.user_tasks ?? [];
|
|
176
|
+
assertEquals(rows.length, 1, "the new question is projected for a human to answer");
|
|
177
|
+
assertEquals(rows[0].user_task_key, "ut-800");
|
|
178
|
+
assertEquals(rows[0].question, "A brand-new question nobody has answered.");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("pollUserTasks: an adjudication with UNKNOWN provenance fails open to a human, not a synthetic actor (#806 review)", async () => {
|
|
182
|
+
// A settled row whose `adjudicated_by` is blank (completed out of band, so `latestAdjudicator`
|
|
183
|
+
// returned no actor) must NOT be auto-replayed as a manufactured `human` actor — that would audit an
|
|
184
|
+
// unknown-provenance replay as a first-hand human decision. It fails open to a fresh human task.
|
|
185
|
+
const question = "Should the cache be write-through or write-back?";
|
|
186
|
+
const { data, stores } = memData({
|
|
187
|
+
pull_requests: [{ pr_key: "o/r#802", status: "escalated", process_key: "rp-802", url: "https://github.com/o/r/pull/802", title: "Converge" }],
|
|
188
|
+
escalations: [{ id: 1, pr_key: "o/r#802", status: "open", question }],
|
|
189
|
+
pr_adjudications: [
|
|
190
|
+
{
|
|
191
|
+
id: 1,
|
|
192
|
+
pr_key: "o/r#802",
|
|
193
|
+
question_fingerprint: questionFingerprint(question),
|
|
194
|
+
answer: "Write-through.",
|
|
195
|
+
adjudicated_by: null,
|
|
196
|
+
adjudicated_kind: null,
|
|
197
|
+
adjudicated_at: "2025-01-01T00:00:00.000Z",
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
});
|
|
201
|
+
const { engine, completions } = fakeEngine([{ userTaskKey: "ut-802", elementId: "wait-answer", processInstanceKey: "rp-802" }]);
|
|
202
|
+
|
|
203
|
+
await pollUserTasks(data, engine);
|
|
204
|
+
|
|
205
|
+
assertEquals(completions.length, 0, "no auto-resume — a synthetic human actor is never manufactured");
|
|
206
|
+
const rows = stores.user_tasks ?? [];
|
|
207
|
+
assertEquals(rows.length, 1, "the question projects for a human to answer (fail-open)");
|
|
208
|
+
assertEquals(rows[0].user_task_key, "ut-802");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("pollUserTasks: a transient adjudication-lookup error fails open and never aborts the pass (#806 review)", async () => {
|
|
212
|
+
// The adjudication LOOKUP is inside the fail-open try, so a transient `pr_adjudications.find` error
|
|
213
|
+
// must NOT reject `project`/abort `pollUserTasks` — the task still projects and reaches a human.
|
|
214
|
+
const question = "Should retries be capped?";
|
|
215
|
+
const { data, stores } = memData({
|
|
216
|
+
pull_requests: [{ pr_key: "o/r#803", status: "escalated", process_key: "rp-803", url: "https://github.com/o/r/pull/803", title: "Converge" }],
|
|
217
|
+
escalations: [{ id: 1, pr_key: "o/r#803", status: "open", question }],
|
|
218
|
+
});
|
|
219
|
+
const base = data.table.bind(data);
|
|
220
|
+
const failing = {
|
|
221
|
+
table(name: string, pk?: string) {
|
|
222
|
+
const t = base(name, pk);
|
|
223
|
+
if (name === "pr_adjudications") {
|
|
224
|
+
return { ...t, find: () => Promise.reject(new Error("transient db error")) };
|
|
225
|
+
}
|
|
226
|
+
return t;
|
|
227
|
+
},
|
|
228
|
+
} as unknown as DataLayer;
|
|
229
|
+
const { engine, completions } = fakeEngine([{ userTaskKey: "ut-803", elementId: "wait-answer", processInstanceKey: "rp-803" }]);
|
|
230
|
+
|
|
231
|
+
await pollUserTasks(failing, engine);
|
|
232
|
+
|
|
233
|
+
assertEquals(completions.length, 0, "no auto-resume on a lookup error");
|
|
234
|
+
const rows = stores.user_tasks ?? [];
|
|
235
|
+
assertEquals(rows.length, 1, "the task still projects — the poller did not abort (fail-open)");
|
|
236
|
+
assertEquals(rows[0].user_task_key, "ut-803");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("pollUserTasks: auto-resume resolves the task per-instance, never an engine-wide scan (#806 review)", async () => {
|
|
240
|
+
// Copilot review of #806: `completeEscalationAutoApplied` -> `resolveEscalationTask` used an
|
|
241
|
+
// UNFILTERED `openUserTasks()` scan, run once per already-adjudicated PR in a single poll pass — so
|
|
242
|
+
// N parked-and-answered PRs cost N full engine scans (O(N²) work/REST). The poller already knows each
|
|
243
|
+
// task's owning instance, so the resolve must scan THAT instance (`{processInstanceKey}`) only. Seed
|
|
244
|
+
// several answered PRs and assert the pass issues ZERO unfiltered scans.
|
|
245
|
+
const q = "Boundary event or poller sentinel?";
|
|
246
|
+
const prKeys = ["o/r#810", "o/r#811", "o/r#812"];
|
|
247
|
+
const { data } = memData({
|
|
248
|
+
pull_requests: prKeys.map((pr_key, i) => ({
|
|
249
|
+
pr_key,
|
|
250
|
+
status: "escalated",
|
|
251
|
+
process_key: `rp-81${i}`,
|
|
252
|
+
url: `https://github.com/o/r/pull/81${i}`,
|
|
253
|
+
title: "Converge",
|
|
254
|
+
})),
|
|
255
|
+
escalations: prKeys.map((pr_key, i) => ({ id: i + 1, pr_key, status: "open", question: q })),
|
|
256
|
+
pr_adjudications: prKeys.map((pr_key, i) => ({
|
|
257
|
+
id: i + 1,
|
|
258
|
+
pr_key,
|
|
259
|
+
question_fingerprint: questionFingerprint(q),
|
|
260
|
+
answer: "Option A: a bounded boundary event.",
|
|
261
|
+
adjudicated_by: "alice",
|
|
262
|
+
adjudicated_at: "2025-01-01T00:00:00.000Z",
|
|
263
|
+
})),
|
|
264
|
+
});
|
|
265
|
+
const { engine, completions, scans } = fakeEngine(
|
|
266
|
+
prKeys.map((_, i) => ({ userTaskKey: `ut-81${i}`, elementId: "wait-answer", processInstanceKey: `rp-81${i}` })),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await pollUserTasks(data, engine);
|
|
270
|
+
|
|
271
|
+
assertEquals(completions.length, 3, "all three answered wait-answers auto-resume");
|
|
272
|
+
const unfiltered = scans.filter((f) => !f?.processInstanceKey && !f?.rootProcessInstanceKey);
|
|
273
|
+
assertEquals(unfiltered.length, 0, "no engine-wide (unfiltered) openUserTasks scan — the resolve is per-instance");
|
|
274
|
+
});
|
package/app/github.ts
CHANGED
|
@@ -240,6 +240,16 @@ export function advisoryStableKey(path: string, text: string): string {
|
|
|
240
240
|
return `${path.trim()}#${fingerprint(normalizeAdvisoryText(text))}`;
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
+
/** The line-stable fingerprint of a convergence escalation QUESTION (issue #806): the SAME canonical
|
|
244
|
+
* `normalizeAdvisoryText` + `fingerprint` digest advisory acks key on, applied to the escalation's
|
|
245
|
+
* question text. Reuses the ONE normaliser/fingerprint pair (no second implementation) so a durable
|
|
246
|
+
* wait-answer adjudication keyed by `(prKey, questionFingerprint)` is byte/semantic-stable the exact
|
|
247
|
+
* disciplined way an advisory ack is — only a semantically-identical, already-answered question is
|
|
248
|
+
* suppressed; a materially different question keys differently and still escalates. */
|
|
249
|
+
export function questionFingerprint(text: string): string {
|
|
250
|
+
return fingerprint(normalizeAdvisoryText(text));
|
|
251
|
+
}
|
|
252
|
+
|
|
243
253
|
/** Parse Copilot's suppressed / low-confidence advisories out of a review body. Copilot renders them
|
|
244
254
|
* under a `<summary>Suppressed comments (N)</summary>` block, each as a bold `**path:line**` header
|
|
245
255
|
* followed by the advisory prose. Returns de-duplicated advisories (empty when there is no block). */
|
package/app/service.test.ts
CHANGED
|
@@ -39,6 +39,33 @@ function memTable(rows: any[], key: string) {
|
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// Emulates the `data.open().exec` raw-SQL path submitPr uses to atomically reset a PR's adjudication
|
|
43
|
+
// memory (`resetAdjudications` → `DELETE FROM "pr_adjudications" WHERE "pr_key" = ?`, Copilot review of
|
|
44
|
+
// #806). The bulk-DELETE SQL itself is validated against real SQLite in app/adjudications.test.ts; here
|
|
45
|
+
// it need only mutate the in-memory `pr_adjudications` store so submitPr's reset is observable. Pushes
|
|
46
|
+
// an optional ordering token so the fence-ordering test can assert the reset runs AFTER `process_key`.
|
|
47
|
+
function memOpen(stores: Record<string, { rows: any[]; key: string }>, ops?: string[]) {
|
|
48
|
+
return {
|
|
49
|
+
exec: async (sql: string, params: any[] = []) => {
|
|
50
|
+
if (/DELETE FROM "pr_adjudications" WHERE "pr_key" = \?/.test(sql)) {
|
|
51
|
+
ops?.push("adjudication-delete");
|
|
52
|
+
const store = stores.pr_adjudications;
|
|
53
|
+
let changed = 0;
|
|
54
|
+
if (store) {
|
|
55
|
+
for (let i = store.rows.length - 1; i >= 0; i--) {
|
|
56
|
+
if (store.rows[i].pr_key === params[0]) {
|
|
57
|
+
store.rows.splice(i, 1);
|
|
58
|
+
changed++;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { changed };
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`unexpected exec sql: ${sql}`);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
42
69
|
function withGithubOff(run: () => Promise<void>): Promise<void> {
|
|
43
70
|
const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
|
|
44
71
|
const prevTok = process.env["GITHUB_TOKEN"];
|
|
@@ -104,6 +131,7 @@ test("re-submit of a cancelled PR marks stale open escalations", async () => {
|
|
|
104
131
|
};
|
|
105
132
|
const data = {
|
|
106
133
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
134
|
+
open: () => memOpen(stores),
|
|
107
135
|
} as any;
|
|
108
136
|
const engine = {
|
|
109
137
|
createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }),
|
|
@@ -140,7 +168,142 @@ test("re-submit of a cancelled PR marks stale open escalations", async () => {
|
|
|
140
168
|
});
|
|
141
169
|
});
|
|
142
170
|
|
|
143
|
-
// Red/green regression for
|
|
171
|
+
// Red/green regression for issue #806 (Copilot review): re-submitting a PR must ALSO invalidate its
|
|
172
|
+
// durable adjudication memory. The auto-resume replays a prior `(PR, question)` answer forever, so a
|
|
173
|
+
// re-opened PR whose question recurs would silently auto-apply the stale decision and an operator
|
|
174
|
+
// could never force a fresh one. `submitPr`'s reopen path clears `pr_adjudications` for the PR.
|
|
175
|
+
test("re-submit of a PR invalidates its durable adjudications (#806 review)", async () => {
|
|
176
|
+
await withGithubOff(async () => {
|
|
177
|
+
const PR_KEY = "owner/repo#42";
|
|
178
|
+
const stores: Record<string, { rows: unknown[]; key: string }> = {
|
|
179
|
+
pull_requests: {
|
|
180
|
+
rows: [{ pr_key: PR_KEY, repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", title: "t", status: "converged" }],
|
|
181
|
+
key: "pr_key",
|
|
182
|
+
},
|
|
183
|
+
escalations: { rows: [], key: "id" },
|
|
184
|
+
pr_adjudications: {
|
|
185
|
+
rows: [
|
|
186
|
+
{ id: 1, pr_key: PR_KEY, question_fingerprint: "fp-a", answer: "prior A", adjudicated_by: "alice", adjudicated_kind: "human", adjudicated_at: "t" },
|
|
187
|
+
{ id: 2, pr_key: "owner/repo#99", question_fingerprint: "fp-b", answer: "other PR", adjudicated_by: "bob", adjudicated_kind: "human", adjudicated_at: "t" },
|
|
188
|
+
],
|
|
189
|
+
key: "id",
|
|
190
|
+
},
|
|
191
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
192
|
+
};
|
|
193
|
+
const data = {
|
|
194
|
+
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
195
|
+
open: () => memOpen(stores),
|
|
196
|
+
} as any;
|
|
197
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }) } as any;
|
|
198
|
+
|
|
199
|
+
await submitPr(data, engine, { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", prKey: PR_KEY });
|
|
200
|
+
|
|
201
|
+
const remaining = stores.pr_adjudications.rows as Record<string, unknown>[];
|
|
202
|
+
assertEquals(remaining.length, 1, "this PR's adjudication is invalidated; another PR's is untouched");
|
|
203
|
+
assertEquals(remaining[0].pr_key, "owner/repo#99", "only the re-submitted PR's adjudications are cleared");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Red/green regression for issue #806 (Copilot review): the durable adjudication RESET must happen
|
|
208
|
+
// AFTER `process_key` is advanced to the new instance — not before createInstance. Clearing the memory
|
|
209
|
+
// while `process_key` still names the OLD instance leaves a window where a delayed old-instance answer
|
|
210
|
+
// job passes the worker's staleness gate and reinserts its adjudication into the fresh run. Advancing
|
|
211
|
+
// the run identity FIRST fences that job, so the ordering is the fix. This asserts the observable
|
|
212
|
+
// invariant: the `pull_requests.process_key` write is issued BEFORE any `pr_adjudications.delete`.
|
|
213
|
+
test("re-submit advances process_key BEFORE resetting adjudications (fence ordering, #806 review)", async () => {
|
|
214
|
+
await withGithubOff(async () => {
|
|
215
|
+
const PR_KEY = "owner/repo#42";
|
|
216
|
+
const ops: string[] = [];
|
|
217
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
218
|
+
pull_requests: {
|
|
219
|
+
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" }],
|
|
220
|
+
key: "pr_key",
|
|
221
|
+
},
|
|
222
|
+
escalations: { rows: [], key: "id" },
|
|
223
|
+
pr_adjudications: {
|
|
224
|
+
rows: [{ id: 1, pr_key: PR_KEY, question_fingerprint: "fp-a", answer: "prior A", adjudicated_by: "alice", adjudicated_kind: "human", adjudicated_at: "t" }],
|
|
225
|
+
key: "id",
|
|
226
|
+
},
|
|
227
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
228
|
+
};
|
|
229
|
+
const wrap = (name: string, key: string) => {
|
|
230
|
+
const t = memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key);
|
|
231
|
+
return {
|
|
232
|
+
...t,
|
|
233
|
+
update: (k: any, patch: any) => {
|
|
234
|
+
if (name === "pull_requests" && Object.prototype.hasOwnProperty.call(patch, "process_key")) ops.push("process_key");
|
|
235
|
+
return t.update(k, patch);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
};
|
|
239
|
+
// The adjudication reset is now the atomic bulk `DELETE` via `data.open().exec` (Copilot review of
|
|
240
|
+
// #806), so `memOpen(stores, ops)` records the `adjudication-delete` ordering token — the table
|
|
241
|
+
// `delete` gateway is no longer on the reset path.
|
|
242
|
+
const data = { table: withTrackingViews(wrap), open: () => memOpen(stores, ops) } as any;
|
|
243
|
+
const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-NEW" }) } as any;
|
|
244
|
+
|
|
245
|
+
await submitPr(data, engine, { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", prKey: PR_KEY });
|
|
246
|
+
|
|
247
|
+
assertEquals(stores.pr_adjudications.rows.length, 0, "the re-submitted PR's adjudication is invalidated");
|
|
248
|
+
const pkIdx = ops.indexOf("process_key");
|
|
249
|
+
const delIdx = ops.indexOf("adjudication-delete");
|
|
250
|
+
assertEquals(pkIdx >= 0, true, "process_key is advanced on reopen");
|
|
251
|
+
assertEquals(delIdx >= 0, true, "adjudications are reset on reopen");
|
|
252
|
+
assertEquals(pkIdx < delIdx, true, "process_key is advanced BEFORE the adjudication memory is reset (the fence ordering)");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Red/green regression (Copilot review): the durable adjudication RESET runs AFTER the new instance is
|
|
257
|
+
// created and `process_key` is advanced. If the reset DELETE fails, the new convergence instance is
|
|
258
|
+
// already live while the OLD adjudications remain — and because the new instance is ACTIVE the
|
|
259
|
+
// `alreadyRunning` idempotency gate short-circuits every retry, so the reset is never re-run and the
|
|
260
|
+
// fresh run replays STALE decisions forever. `submitPr` must instead ROLL THE NEW RUN BACK on a reset
|
|
261
|
+
// failure: terminate the just-created instance (so nothing auto-applies stale memory) and rethrow, so
|
|
262
|
+
// the submission is not treated as started and a retry re-creates a clean run.
|
|
263
|
+
test("re-submit rolls back (cancels) the new instance when the adjudication reset fails (#806 review)", async () => {
|
|
264
|
+
await withGithubOff(async () => {
|
|
265
|
+
const PR_KEY = "owner/repo#42";
|
|
266
|
+
const stores: Record<string, { rows: any[]; key: string }> = {
|
|
267
|
+
pull_requests: {
|
|
268
|
+
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" }],
|
|
269
|
+
key: "pr_key",
|
|
270
|
+
},
|
|
271
|
+
escalations: { rows: [], key: "id" },
|
|
272
|
+
pr_adjudications: {
|
|
273
|
+
rows: [{ id: 1, pr_key: PR_KEY, question_fingerprint: "fp-a", answer: "prior A", adjudicated_by: "alice", adjudicated_kind: "human", adjudicated_at: "t" }],
|
|
274
|
+
key: "id",
|
|
275
|
+
},
|
|
276
|
+
pr_dependencies: { rows: [], key: "pr_key" },
|
|
277
|
+
};
|
|
278
|
+
// The reset DELETE throws (a transient DB failure), leaving the new instance live but the memory
|
|
279
|
+
// uncleared — the exact half-committed state the rollback guards against.
|
|
280
|
+
const failingOpen = () => ({
|
|
281
|
+
exec: async (sql: string) => {
|
|
282
|
+
if (/DELETE FROM "pr_adjudications" WHERE "pr_key" = \?/.test(sql)) throw new Error("boom: reset DELETE failed");
|
|
283
|
+
throw new Error(`unexpected exec sql: ${sql}`);
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
const data = { table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)), open: failingOpen } as any;
|
|
287
|
+
const cancelled: string[] = [];
|
|
288
|
+
const engine = {
|
|
289
|
+
createInstance: () => Promise.resolve({ processInstanceKey: "PI-NEW" }),
|
|
290
|
+
cancelInstance: (input: { processInstanceKey: string }) => {
|
|
291
|
+
cancelled.push(input.processInstanceKey);
|
|
292
|
+
return Promise.resolve();
|
|
293
|
+
},
|
|
294
|
+
} as any;
|
|
295
|
+
|
|
296
|
+
let threw = false;
|
|
297
|
+
try {
|
|
298
|
+
await submitPr(data, engine, { repo: "owner/repo", number: 42, url: "https://github.com/owner/repo/pull/42", prKey: PR_KEY });
|
|
299
|
+
} catch {
|
|
300
|
+
threw = true;
|
|
301
|
+
}
|
|
302
|
+
assertEquals(threw, true, "a failed reset propagates so the caller can retry");
|
|
303
|
+
assertEquals(cancelled, ["PI-NEW"], "the just-created instance is terminated (rolled back), never left live with stale memory");
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
144
307
|
// can hit an engine incident that parks the token; until `pollIncidents` nothing on the PR row
|
|
145
308
|
// reflected it, so the grid kept showing "converging" while the run was dead in the water. This
|
|
146
309
|
// drives the pass's reconciliation core against a stubbed `/v2/incidents/search`:
|
|
@@ -180,6 +343,7 @@ test("pollIncidents mirrors an ACTIVE incident onto the PR row, then clears it,
|
|
|
180
343
|
};
|
|
181
344
|
const data = {
|
|
182
345
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
346
|
+
open: () => memOpen(stores),
|
|
183
347
|
} as any;
|
|
184
348
|
const headers = { "content-type": "application/json" };
|
|
185
349
|
|
|
@@ -233,6 +397,7 @@ test("pollIncidents never queries a PR with no live instance and clears any stal
|
|
|
233
397
|
};
|
|
234
398
|
const data = {
|
|
235
399
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
400
|
+
open: () => memOpen(stores),
|
|
236
401
|
} as any;
|
|
237
402
|
const headers = { "content-type": "application/json" };
|
|
238
403
|
|
|
@@ -266,6 +431,7 @@ test("pollIncidents picks the oldest incident by creationTime, sorting a missing
|
|
|
266
431
|
};
|
|
267
432
|
const data = {
|
|
268
433
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
434
|
+
open: () => memOpen(stores),
|
|
269
435
|
} as any;
|
|
270
436
|
const headers = { "content-type": "application/json" };
|
|
271
437
|
|
|
@@ -305,6 +471,7 @@ test("submitPr stringifies a numeric processInstanceKey (contract: string | null
|
|
|
305
471
|
};
|
|
306
472
|
const data = {
|
|
307
473
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
474
|
+
open: () => memOpen(stores),
|
|
308
475
|
} as any;
|
|
309
476
|
const engine = {
|
|
310
477
|
// A large key delivered as a JS number — the exact case that breaks dev response validation
|
|
@@ -339,6 +506,7 @@ function captureConvergeOnly() {
|
|
|
339
506
|
};
|
|
340
507
|
const data = {
|
|
341
508
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
509
|
+
open: () => memOpen(stores),
|
|
342
510
|
} as any;
|
|
343
511
|
let captured: unknown;
|
|
344
512
|
const engine = {
|
|
@@ -391,6 +559,7 @@ function captureVars() {
|
|
|
391
559
|
};
|
|
392
560
|
const data = {
|
|
393
561
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
562
|
+
open: () => memOpen(stores),
|
|
394
563
|
} as any;
|
|
395
564
|
let captured: Record<string, unknown> | undefined;
|
|
396
565
|
const engine = {
|
|
@@ -429,6 +598,7 @@ function captureRoot() {
|
|
|
429
598
|
};
|
|
430
599
|
const data = {
|
|
431
600
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
601
|
+
open: () => memOpen(stores),
|
|
432
602
|
} as any;
|
|
433
603
|
let captured: unknown;
|
|
434
604
|
const engine = {
|
|
@@ -795,6 +965,7 @@ test("pollWaveGatesImpl is level-triggered: PRs merged before the token arrives
|
|
|
795
965
|
};
|
|
796
966
|
const data = {
|
|
797
967
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
968
|
+
open: () => memOpen(stores),
|
|
798
969
|
} as any;
|
|
799
970
|
|
|
800
971
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -884,6 +1055,7 @@ test("pollWaveGatesImpl never releases the barrier on an unverifiable subscripti
|
|
|
884
1055
|
};
|
|
885
1056
|
const data = {
|
|
886
1057
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1058
|
+
open: () => memOpen(stores),
|
|
887
1059
|
} as any;
|
|
888
1060
|
|
|
889
1061
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -988,6 +1160,7 @@ test("pollWaveGatesImpl releases the wave when a member PR is closed-unmerged an
|
|
|
988
1160
|
};
|
|
989
1161
|
const data = {
|
|
990
1162
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1163
|
+
open: () => memOpen(stores),
|
|
991
1164
|
} as any;
|
|
992
1165
|
|
|
993
1166
|
const published: { name: string; correlationKey?: string }[] = [];
|
|
@@ -1044,6 +1217,7 @@ test("abandonClosedPr is idempotent — the terminal merges audit row is written
|
|
|
1044
1217
|
};
|
|
1045
1218
|
const data = {
|
|
1046
1219
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1220
|
+
open: () => memOpen(stores),
|
|
1047
1221
|
} as any;
|
|
1048
1222
|
|
|
1049
1223
|
await abandonClosedPr(data, "owner/repo#70", "closed without merging");
|
|
@@ -1072,6 +1246,7 @@ test("abandonClosedPr self-heals a missing pull_requests parent row before the F
|
|
|
1072
1246
|
};
|
|
1073
1247
|
const data = {
|
|
1074
1248
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1249
|
+
open: () => memOpen(stores),
|
|
1075
1250
|
} as any;
|
|
1076
1251
|
|
|
1077
1252
|
await abandonClosedPr(data, "owner/repo#71", "closed without merging");
|
|
@@ -1098,6 +1273,7 @@ test("abandonClosedPr rejects a malformed prKey with a clear error before any FK
|
|
|
1098
1273
|
};
|
|
1099
1274
|
const data = {
|
|
1100
1275
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1276
|
+
open: () => memOpen(stores),
|
|
1101
1277
|
} as any;
|
|
1102
1278
|
|
|
1103
1279
|
const err = await assertRejects(() => abandonClosedPr(data, "not-a-valid-pr-key", "closed without merging"));
|
|
@@ -1170,6 +1346,7 @@ function capsProbeExec(ready: boolean) {
|
|
|
1170
1346
|
function capsDataLayer(stores: Record<string, { rows: any[]; key: string }>) {
|
|
1171
1347
|
return {
|
|
1172
1348
|
table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
|
|
1349
|
+
open: () => memOpen(stores),
|
|
1173
1350
|
} as any;
|
|
1174
1351
|
}
|
|
1175
1352
|
|