@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.
@@ -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). */
@@ -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 technical-incident surfacing (issue #94). A convergence/merge instance
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