@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.
@@ -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 technical-incident surfacing (issue #94). A convergence/merge instance
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 { escalationFormId } from "./agentCompletion.ts";
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
- if (processKey != null) {
686
- await table.update(parsed.prKey, { process_key: processKey });
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);