@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.
@@ -0,0 +1,378 @@
1
+ // Durable wait-answer adjudication memory (issue #806) — the app's record that "(this PR, this
2
+ // question) was already adjudicated to X by a human at T", so a stateless convergence round that
3
+ // re-derives the identical escalation condition auto-resumes with the recorded answer instead of
4
+ // re-parking a human from scratch (PR #800 / proc 46310: the same design question escalated at
5
+ // round 2 and again at round 13).
6
+ //
7
+ // A human adjudication is NOT GitHub-derivable — the convergence gate is intentionally stateless
8
+ // w.r.t. GitHub, but a human's decision is a fact only the app can remember — so it is persisted
9
+ // durably in `pr_adjudications` (migration 109), keyed by the PR and the canonical QUESTION
10
+ // FINGERPRINT (the SAME `normalizeAdvisoryText` + `fingerprint` normalisation advisory acks key on,
11
+ // exported from app/github.ts — no second fingerprint implementation).
12
+ //
13
+ // Two touch points on the CANONICAL escalation/answer path own this store (no parallel adjudication
14
+ // store, no second suppression heuristic — derivation over duplication):
15
+ // • `pr.answer-escalation` (record-answer) calls `recordAdjudication` on answering a `wait-answer`,
16
+ // persisting the settled answer + adjudicator.
17
+ // • the poller (`pollUserTasks`) calls `matchAdjudication` before surfacing a NEW `wait-answer`;
18
+ // on a fingerprint match it auto-resumes through `completeEscalationAutoApplied` — which shares the
19
+ // canonical `completeUserTaskAttributed` door a human's `completeEscalationAsHuman` uses, but records
20
+ // the completion `auto_applied`/`reversible` (never laundering the replay into a first-class human
21
+ // decision) — attributed to the prior adjudicator.
22
+ import type { DataLayer, GatewayDataSource } from "@nanobpm/urban";
23
+ import { isUniqueConstraintFence } from "./dbFence.ts";
24
+ import { questionFingerprint } from "./github.ts";
25
+
26
+ /** One durable human adjudication of a convergence question, keyed by `(pr_key, question_fingerprint)`
27
+ * (a `UNIQUE` constraint; `id` is the surrogate single-column PK for the `Table<T>` gateway). */
28
+ export interface PrAdjudicationRow {
29
+ id: number;
30
+ pr_key: string;
31
+ /** The canonical `questionFingerprint` (app/github.ts) of the settled escalation question. */
32
+ question_fingerprint: string;
33
+ /** The human's settled answer, replayed verbatim on auto-resume. */
34
+ answer: string | null;
35
+ /** Who settled it (the prior adjudicator), attributed on auto-resume. */
36
+ adjudicated_by: string | null;
37
+ /** Whether the prior adjudicator was a `human` or an `agent` (ADR 0046), preserved so an auto-resume
38
+ * replays with the ORIGINAL attribution kind — never laundering an agent decision into a human one. */
39
+ adjudicated_kind: string | null;
40
+ adjudicated_at: string;
41
+ /** A TOMBSTONE timestamp set when a human reverts the auto-applied completion that replayed this
42
+ * decision (issue #806 review). While set, the row is no longer replayable — {@link matchAdjudication}
43
+ * skips it — but it stays present so the `UNIQUE (pr_key, question_fingerprint)` fence makes a
44
+ * redelivered `record-answer` for the SAME reverted completion a no-op (a plain DELETE would let that
45
+ * redelivery recreate the row and undo the revert). A FRESH, non-reverted answer to the re-parked
46
+ * question REVIVES it ({@link reviveTombstonedDecision}) — clearing the tombstone so the human's new
47
+ * override is remembered — while the reverted completion's own redelivery stays rejected. NULL = live. */
48
+ invalidated_at: string | null;
49
+ /** The `task_completions.id` of the WINNING completion that produced this decision (issue #806 review).
50
+ * A FIRST-HAND agent answer records its own adjudication with `auto_applied=0` and no
51
+ * `source_adjudication_id`; this link lets {@link invalidateAdjudicationByCompletion} tombstone the
52
+ * decision when a human reverts that reversible agent completion — not only a machine auto-apply.
53
+ * INSERT-if-absent, so it stays pinned to the ORIGINAL first-hand winner. NULL for a legacy/
54
+ * uncorrelated answer. */
55
+ source_completion_id: number | null;
56
+ }
57
+
58
+ export const prAdjudications = (data: DataLayer) => data.table<PrAdjudicationRow>("pr_adjudications", "id");
59
+
60
+ /** Pure: the durable adjudication whose fingerprint matches `question` (via the canonical
61
+ * `questionFingerprint`), or `undefined`. Only a row carrying a non-blank `answer` is returned — a
62
+ * blank/absent answer is not a replayable decision (the `pr-escalation.form` requires a non-blank
63
+ * answer, so auto-resuming with a blank one would fail validation), so it never suppresses a fresh
64
+ * escalation. A TOMBSTONED row (`invalidated_at` set — a human reverted the auto-apply that replayed
65
+ * it, issue #806 review) is likewise never returned, so a reverted decision re-parks a human instead of
66
+ * auto-applying again. */
67
+ export function matchAdjudication(
68
+ rows: readonly PrAdjudicationRow[],
69
+ question: string,
70
+ ): PrAdjudicationRow | undefined {
71
+ const fp = questionFingerprint(question);
72
+ return rows.find(
73
+ (r) =>
74
+ r.question_fingerprint === fp &&
75
+ typeof r.answer === "string" &&
76
+ r.answer.trim() !== "" &&
77
+ (r.invalidated_at == null || r.invalidated_at.trim() === ""),
78
+ );
79
+ }
80
+
81
+ /** Input to {@link recordAdjudication}. `expectedProcessKey` is the run generation this answer job
82
+ * belongs to (the `pr.answer-escalation` job's own process-instance key); every write is fenced on it
83
+ * (see {@link generationGuard}) so a pre-reset straggler cannot resurrect a stale adjudication for a
84
+ * fresh run. `undefined` (a job carrying no instance key) fails open, matching the worker's staleness
85
+ * gate — an unclassifiable job proceeds rather than dropping a legitimate operator answer. */
86
+ interface RecordAdjudicationInput {
87
+ prKey: string;
88
+ question: string;
89
+ answer: string | undefined;
90
+ adjudicatedBy: string | undefined;
91
+ adjudicatedKind: string | undefined;
92
+ expectedProcessKey?: string | undefined;
93
+ /** The `task_completions.id` of the winning completion this answer settled (issue #806 review), stored
94
+ * as `source_completion_id` so a later revert of that completion can tombstone the decision it created.
95
+ * Undefined for a legacy/uncorrelated answer (recorded NULL). */
96
+ sourceCompletionId?: number | undefined;
97
+ }
98
+
99
+ /** A SQL predicate (+ bind params) that is TRUE only while this write still belongs to the CURRENT run
100
+ * generation: no `pull_requests` row for `prKey` carries a `process_key` that is BOTH set AND different
101
+ * from the writer's `expectedProcessKey`. Woven into the adjudication INSERT/UPDATE so the ownership
102
+ * check and the write are ONE atomic statement — the fix `submitPr` advances `process_key` BEFORE it
103
+ * clears the memory relies on (Copilot review of #806): an `answer-escalation` straggler that read the
104
+ * old key, then paused across a re-submit that advanced the key and deleted the rows, finds this guard
105
+ * false at write time and no-ops, so it cannot insert its stale adjudication after the reset for the
106
+ * fresh run to auto-apply. When `expectedProcessKey` is absent the guard is a constant TRUE (fail open,
107
+ * mirroring the worker gate — an unclassifiable job must not be silently dropped). Exported so the
108
+ * `pr.answer-escalation` worker fences its escalation/PR transitions on the SAME generation predicate
109
+ * as this store's adjudication writes (one guard, no second implementation — Copilot review of #806). */
110
+ export function generationGuard(prKey: string, expectedProcessKey: string | undefined): { sql: string; params: unknown[] } {
111
+ if (expectedProcessKey == null || expectedProcessKey === "") return { sql: "1 = 1", params: [] };
112
+ return {
113
+ sql: `NOT EXISTS (SELECT 1 FROM "pull_requests" WHERE "pr_key" = ? AND "process_key" IS NOT NULL AND "process_key" <> ?)`,
114
+ params: [prKey, expectedProcessKey],
115
+ };
116
+ }
117
+
118
+ /** A SQL predicate (+ bind params) that is TRUE only while the WINNING completion `sourceCompletionId`
119
+ * has NOT been reverted. Woven into the adjudication INSERT/UPDATE so recording a first-hand agent
120
+ * answer's decision and the completion's reverted state are checked in ONE atomic statement — closing
121
+ * the revert-before-record race (issue #806 review, Copilot): a human revert of a first-hand agent
122
+ * completion runs {@link invalidateAdjudicationByCompletion}, but if the downstream `record-answer` job
123
+ * has not yet inserted the `pr_adjudications` row, that tombstone changes zero rows and the later insert
124
+ * would otherwise create a LIVE decision linked to an already-reverted completion — which the poller
125
+ * then re-auto-applies, silently undoing the revert. Fencing the insert on `reverted = 0` makes the
126
+ * revert-then-record ordering a no-op (the insert affects zero rows), the mirror of the record-then-revert
127
+ * ordering the by-completion tombstone already covers; SQLite serialises the two writes, so whichever
128
+ * commits first, the other observes it. When `sourceCompletionId` is absent (a legacy/uncorrelated
129
+ * answer with no completion to check) the guard is a constant TRUE (fail open, mirroring
130
+ * {@link generationGuard}) — an unlinkable answer must not be silently dropped. */
131
+ export function notRevertedGuard(sourceCompletionId: number | undefined): { sql: string; params: unknown[] } {
132
+ if (sourceCompletionId == null) return { sql: "1 = 1", params: [] };
133
+ return {
134
+ sql: `NOT EXISTS (SELECT 1 FROM "task_completions" WHERE "id" = ? AND "reverted" = 1)`,
135
+ params: [sourceCompletionId],
136
+ };
137
+ }
138
+
139
+ /** A stricter revive fence than {@link notRevertedGuard}: the source completion must be a FIRST-HAND
140
+ * human answer (`auto_applied = 0`) AND not reverted. `record-answer` runs not only for a human's
141
+ * first-hand answer to the re-parked `wait-answer` but ALSO for `completeEscalationAutoApplied`
142
+ * REPLAYS (`auto_applied = 1`, a machine re-application of an already-recorded decision). If such a
143
+ * replay's `record-answer` is in flight while a human reverts the FIRST-HAND source completion, the
144
+ * replay completion is itself still `reverted = 0`, so a bare {@link notRevertedGuard} would let the
145
+ * machine replay clear the operator's tombstone and RESURRECT the reverted decision (Copilot review of
146
+ * #806). Only a genuine human answer (`auto_applied = 0`) may revive; a machine replay is a no-op.
147
+ * A missing completion also fails the EXISTS (safe — no revive), so an uncorrelated/legacy id cannot
148
+ * launder a revive. */
149
+ export function firstHandRevivableGuard(sourceCompletionId: number): { sql: string; params: unknown[] } {
150
+ return {
151
+ sql: `EXISTS (SELECT 1 FROM "task_completions" WHERE "id" = ? AND "auto_applied" = 0 AND "reverted" = 0)`,
152
+ params: [sourceCompletionId],
153
+ };
154
+ }
155
+
156
+ /** Persist a human adjudication of `question` for `prKey`, INSERT-if-absent so the ORIGINAL
157
+ * adjudicator/answer is preserved across later auto-resumes (which re-run record-answer with the
158
+ * same fingerprint). A blank answer is not a decision and is not recorded. Idempotent: a second
159
+ * answer to the identical question keeps the first settled row — UNLESS that first row was recorded
160
+ * with UNKNOWN provenance (a blank `adjudicated_by`, from an answer that could not be correlated to
161
+ * an adjudicator). `pollUserTasks` refuses to auto-resume an unknown-provenance decision (it never
162
+ * launders an unattributed replay into a human authority), so such a row keeps re-parking a human
163
+ * every round; when a KNOWN adjudicator later answers the same question, promote the row to a
164
+ * replayable decision — its answer AND attribution — so future rounds auto-resume instead of
165
+ * re-parking forever (issue #806 review). A row that ALREADY carries known provenance stays immutable.
166
+ * A TOMBSTONED row (a human reverted the auto-applied replay) is REVIVED by a fresh, non-reverted answer
167
+ * to the re-parked question ({@link reviveTombstonedDecision}) — otherwise the override is never
168
+ * remembered and the question re-parks forever (Copilot review of #806) — while a redelivery of the
169
+ * reverted completion itself is still rejected.
170
+ *
171
+ * Every write is fenced on the run generation ({@link generationGuard}) so a pre-reset straggler cannot
172
+ * write after `submitPr` advances `process_key`, AND the blank→known promotion is a conditional
173
+ * compare-and-set (see {@link healBlankProvenance}) so two concurrent known healers cannot clobber each
174
+ * other's answer/adjudicator — only the first promotion of a blank row wins, keeping the documented
175
+ * first-known decision immutable (Copilot review of #806). */
176
+ export async function recordAdjudication(data: DataLayer, input: RecordAdjudicationInput): Promise<void> {
177
+ const answer = typeof input.answer === "string" ? input.answer.trim() : "";
178
+ if (answer === "") return;
179
+ const question = input.question.trim();
180
+ if (question === "") return;
181
+ const fp = questionFingerprint(question);
182
+ const db = data.open();
183
+ const guard = generationGuard(input.prKey, input.expectedProcessKey);
184
+ const revertGuard = notRevertedGuard(input.sourceCompletionId);
185
+ try {
186
+ // Conditional INSERT-if-absent: the `... SELECT ? … WHERE <generationGuard>` is atomic, so the
187
+ // ownership check and the insert are ONE statement — a stale straggler's guard is false and the
188
+ // insert affects zero rows (no separate read-then-write TOCTOU window). A concurrent/redelivered
189
+ // answer that already inserted the SAME fingerprint trips the `UNIQUE(pr_key, question_fingerprint)`
190
+ // fence, which we tolerate below exactly as the sequential no-op the winner's durable row yields.
191
+ // The insert is ALSO fenced on the source completion NOT being reverted ({@link notRevertedGuard}):
192
+ // if a human reverted this first-hand agent completion before `record-answer` inserted its row, the
193
+ // revert-time tombstone found nothing to invalidate, so without this fence the insert would create a
194
+ // LIVE decision linked to an already-reverted completion that the poller re-auto-applies (issue #806
195
+ // review, Copilot — the revert-before-record ordering). Fenced, the insert affects zero rows.
196
+ const res = await db.exec(
197
+ `INSERT INTO "pr_adjudications" ("pr_key","question_fingerprint","answer","adjudicated_by","adjudicated_kind","adjudicated_at","source_completion_id")
198
+ SELECT ?, ?, ?, ?, ?, ?, ? WHERE ${guard.sql} AND ${revertGuard.sql}`,
199
+ [input.prKey, fp, answer, input.adjudicatedBy?.trim() || null, input.adjudicatedKind?.trim() || null, new Date().toISOString(), input.sourceCompletionId ?? null, ...guard.params, ...revertGuard.params],
200
+ );
201
+ if (res.changed > 0) return; // fresh insert won under the current generation
202
+ } catch (err) {
203
+ // Tolerate ONLY the UNIQUE fence as the idempotent no-op the sequential path yields (the winner's
204
+ // ORIGINAL row is already durable) — never surface it as a spurious `pr.answer-escalation` incident.
205
+ // Any other error still propagates. This is the ONE canonical fence classifier (`app/dbFence.ts`),
206
+ // the same pattern as `deliveryConnector`'s claim insert and `WorldStore`'s checkpoint insert
207
+ // (derivation over duplication).
208
+ if (!isUniqueConstraintFence(err)) throw err;
209
+ }
210
+ // We reach here when the insert affected no row: either a row already exists (UNIQUE fence, or another
211
+ // writer's row already present) OR the generation guard was false (a stale straggler — leave it a
212
+ // no-op). Re-read the ACTUAL current row and apply the blank→known promotion the winner would have; a
213
+ // stale straggler's heal is likewise fenced to a no-op, and an already-attributed row is immutable.
214
+ const winner = await prAdjudications(data).find({ pr_key: input.prKey, question_fingerprint: fp });
215
+ if (winner.length === 0) return;
216
+ // A TOMBSTONED winner (a human reverted the auto-applied replay) is REVIVED by a fresh, non-reverted
217
+ // answer to the re-parked question (issue #806 review) — otherwise the human's override is never
218
+ // remembered and the question re-parks forever. If the revive fires, we are done; only a LIVE
219
+ // blank-provenance winner falls through to the blank→known heal (the two preconditions are disjoint,
220
+ // but returning early keeps the healer off a row the revive just settled).
221
+ if (await reviveTombstonedDecision(data, winner[0], answer, input)) return;
222
+ await healBlankProvenance(data, winner[0], answer, input);
223
+ }
224
+
225
+ /** REVIVE a TOMBSTONED adjudication with a FRESH, non-reverted answer (issue #806 review). After a human
226
+ * reverts an auto-applied replay, {@link invalidateAdjudicationByCompletion} tombstones the decision
227
+ * (`invalidated_at` set) so {@link matchAdjudication} skips it and the recurring question re-parks a
228
+ * human every round. When that human then answers the re-parked `wait-answer`, the fresh answer's
229
+ * `record-answer` INSERT trips the `UNIQUE(pr_key, question_fingerprint)` fence, and with ONLY the
230
+ * blank→known heal below it no-ops (the tombstoned row already carries known provenance and `invalidated_at`)
231
+ * — so the tombstone survives and the override is NEVER remembered (Copilot review of #806: the recurring
232
+ * question re-parks a human forever instead of remembering the new decision).
233
+ *
234
+ * This CONDITIONAL compare-and-set clears the tombstone and installs the fresh answer/adjudicator, but
235
+ * ONLY for a NEW, non-reverted, FIRST-HAND completion. The `UPDATE … WHERE "invalidated_at" IS NOT NULL`
236
+ * re-checks the tombstone precondition INSIDE the write (so it fires on a tombstoned row and no-ops on a
237
+ * live one), and the {@link firstHandRevivableGuard} on the INCOMING completion makes a REDELIVERY of the
238
+ * very completion that was reverted (its `sourceCompletionId` is `reverted = 1`) AND a machine
239
+ * auto-apply REPLAY (`auto_applied = 1`, whose own `record-answer` also runs) affect zero rows — so
240
+ * neither the reverted answer nor an in-flight replay can resurrect the decision it was reverted from,
241
+ * while a genuinely new FIRST-HAND human answer IS remembered (Copilot review of #806). Run-generation fenced ({@link generationGuard}) like every other
242
+ * write, so a pre-reset straggler cannot revive after a re-submit reset. A revive INSTALLS a first-hand
243
+ * replayable decision, so it requires a KNOWN adjudicator AND a KNOWN (correlated) completion — an
244
+ * unattributed answer cannot launder into a replayable authority (mirrors the auto-resume provenance
245
+ * gate), and an UNCORRELATED answer (no `sourceCompletionId`, e.g. a legacy out-of-band resume) cannot
246
+ * be told apart from a redelivery of the reverted completion, so it is a no-op that leaves the tombstone
247
+ * intact. Returns whether a row was revived. */
248
+ async function reviveTombstonedDecision(
249
+ data: DataLayer,
250
+ prior: PrAdjudicationRow,
251
+ answer: string,
252
+ input: { prKey: string; adjudicatedBy: string | undefined; adjudicatedKind: string | undefined; expectedProcessKey?: string | undefined; sourceCompletionId?: number | undefined },
253
+ ): Promise<boolean> {
254
+ // Only a tombstoned row is revivable; a live row is owned by the INSERT-if-absent / blank→known heal.
255
+ if (prior.invalidated_at == null || prior.invalidated_at.trim() === "") return false;
256
+ const nowBy = input.adjudicatedBy?.trim();
257
+ if (!nowBy) return false;
258
+ // A revive requires a KNOWN, correlated completion. Without a `sourceCompletionId` we cannot tell a
259
+ // genuinely NEW answer from an at-least-once REDELIVERY of the reverted completion's own record-answer
260
+ // (a legacy/uncorrelated answer carries none) — so an uncorrelated answer never revives, keeping the
261
+ // revert durable (Copilot review of #806). A correlated completion is additionally fenced on NOT being
262
+ // reverted below, so a redelivery of the reverted completion itself still no-ops.
263
+ if (input.sourceCompletionId == null) return false;
264
+ const db = data.open();
265
+ const guard = generationGuard(input.prKey, input.expectedProcessKey);
266
+ const reviveGuard = firstHandRevivableGuard(input.sourceCompletionId);
267
+ const res = await db.exec(
268
+ `UPDATE "pr_adjudications" SET "answer" = ?, "adjudicated_by" = ?, "adjudicated_kind" = ?, "adjudicated_at" = ?, "source_completion_id" = ?, "invalidated_at" = NULL
269
+ WHERE "id" = ? AND "invalidated_at" IS NOT NULL AND ${guard.sql} AND ${reviveGuard.sql}`,
270
+ [answer, nowBy, input.adjudicatedKind?.trim() || null, new Date().toISOString(), input.sourceCompletionId ?? null, prior.id, ...guard.params, ...reviveGuard.params],
271
+ );
272
+ return res.changed > 0;
273
+ }
274
+
275
+ /** Promote an UNKNOWN-provenance adjudication row to a replayable decision (issue #806 review). The
276
+ * prior answer could not be attributed, so auto-resume fails open and the question re-parks a human
277
+ * every round; a now-known adjudicator's answer heals the row — its answer AND attribution together, so
278
+ * the replayed decision is the human's, not the earlier uncorrelated one. Only heal blank→known: a row
279
+ * that ALREADY carries known provenance is immutable (INSERT-if-absent preserves the ORIGINAL). A no-op
280
+ * when the prior row is already attributed or the incoming answer is still unattributed.
281
+ *
282
+ * The promotion is a CONDITIONAL compare-and-set — the `UPDATE … WHERE "adjudicated_by" IS NULL OR
283
+ * TRIM("adjudicated_by") = ''` re-checks the blank precondition INSIDE the write, so of two concurrent
284
+ * known healers that both read the same blank row only the FIRST promotes it; the second's guard is
285
+ * already false and its update affects zero rows, leaving the first healer's answer/adjudicator intact
286
+ * (Copilot review of #806 — a read-then-unconditional-update would let the later writer clobber the
287
+ * earlier known decision). The write is ALSO fenced on the run generation so a pre-reset straggler
288
+ * cannot heal after a re-submit reset. The promotion also carries the healing answer's winning
289
+ * completion into `source_completion_id` (issue #806 review) so a later revert of that completion can
290
+ * tombstone the promoted decision — see the compare-and-set below. */
291
+ async function healBlankProvenance(
292
+ data: DataLayer,
293
+ prior: PrAdjudicationRow,
294
+ answer: string,
295
+ input: { prKey: string; adjudicatedBy: string | undefined; adjudicatedKind: string | undefined; expectedProcessKey?: string | undefined; sourceCompletionId?: number | undefined },
296
+ ): Promise<void> {
297
+ const priorBy = prior.adjudicated_by?.trim();
298
+ const nowBy = input.adjudicatedBy?.trim();
299
+ if (priorBy || !nowBy) return;
300
+ // Never resurrect a TOMBSTONED row (a human reverted the auto-apply that replayed it, issue #806
301
+ // review) — a redelivered `record-answer` must not heal a reverted decision back into a replayable
302
+ // one. The write is also fenced on `invalidated_at IS NULL` below so the check is atomic with it.
303
+ if (prior.invalidated_at != null && prior.invalidated_at.trim() !== "") return;
304
+ const db = data.open();
305
+ const guard = generationGuard(input.prKey, input.expectedProcessKey);
306
+ const revertGuard = notRevertedGuard(input.sourceCompletionId);
307
+ // Carry the healing answer's WINNING completion into `source_completion_id` in the SAME compare-and-set
308
+ // (issue #806 review): the promoted decision is now the known adjudicator's, so if that answer came from
309
+ // a reversible first-hand agent completion, `revertAgentCompletion` must be able to tombstone it via
310
+ // `invalidateAdjudicationByCompletion`. Without this, a healed row keeps a NULL link and a revert of the
311
+ // healing completion leaves the overridden answer replayable — the poller re-auto-applies it, silently
312
+ // undoing the human's revert (the exact failure mode the completion link exists to prevent). `COALESCE`
313
+ // stamps the healer's completion when present and otherwise preserves any existing link (an uncorrelated
314
+ // heal never NULLs a link the original first-hand winner recorded). The UPDATE is ALSO fenced on the
315
+ // healing completion NOT being reverted ({@link notRevertedGuard}) so a promotion cannot relink a live
316
+ // row to an already-reverted completion (issue #806 review, Copilot — the revert-before-record ordering
317
+ // applied to the blank→known heal, the mirror of the INSERT fence above).
318
+ await db.exec(
319
+ `UPDATE "pr_adjudications" SET "answer" = ?, "adjudicated_by" = ?, "adjudicated_kind" = ?, "adjudicated_at" = ?, "source_completion_id" = COALESCE(?, "source_completion_id")
320
+ WHERE "id" = ? AND ("adjudicated_by" IS NULL OR TRIM("adjudicated_by") = '') AND "invalidated_at" IS NULL AND ${guard.sql} AND ${revertGuard.sql}`,
321
+ [answer, nowBy, input.adjudicatedKind?.trim() || null, new Date().toISOString(), input.sourceCompletionId ?? null, prior.id, ...guard.params, ...revertGuard.params],
322
+ );
323
+ }
324
+
325
+ /** Atomically clear ALL durable adjudications for `prKey` — the fresh-run boundary invalidation
326
+ * `submitPr` performs on reopen (issue #806, Copilot review). A SINGLE `DELETE … WHERE pr_key = ?`
327
+ * rather than a row-by-row `Table.delete` loop, so a crash mid-reset can never leave a PARTIALLY
328
+ * cleared memory (some questions still replayable, others gone) — the whole PR's memory is wiped in
329
+ * one statement or not at all. The caller advances `pull_requests.process_key` to the new run BEFORE
330
+ * calling this, so any straggler answer job from the retired run is already fenced (its
331
+ * `expectedProcessKey` no longer matches) and cannot re-insert between the advance and this wipe. */
332
+ export async function resetAdjudications(data: DataLayer, prKey: string): Promise<void> {
333
+ await data.open().exec(`DELETE FROM "pr_adjudications" WHERE "pr_key" = ?`, [prKey]);
334
+ }
335
+
336
+ /** Invalidate a single durable adjudication by surrogate `id` — the decision is no longer replayable,
337
+ * so the next convergence round that re-derives the question re-parks a human (issue #806, Copilot
338
+ * review). Called from `revertAgentCompletion` when a human reverts the AUTO-APPLIED completion that
339
+ * replayed this adjudication: marking the `task_completions` row reverted alone would NOT stop the
340
+ * poller — it matches the unchanged `pr_adjudications` row and replays the same overridden answer on
341
+ * the next derived task, silently undoing the human's revert.
342
+ *
343
+ * Sets a TOMBSTONE (`invalidated_at`) rather than DELETING the row. A DELETE is NOT race-safe: the
344
+ * reverted completion's `record-answer` job can be redelivered (at-least-once) AFTER the delete and
345
+ * re-insert the SAME `(pr_key, question_fingerprint)` — its `generationGuard` still passes (a revert
346
+ * does not advance the run generation), so the row comes back and the next poller pass re-auto-applies,
347
+ * undoing the revert (Copilot review of #806). Keeping the row as a tombstone means that redelivered
348
+ * insert trips the `UNIQUE (pr_key, question_fingerprint)` fence (a no-op) and `recordAdjudication` will
349
+ * not resurrect it for the SAME reverted completion (its `notRevertedGuard` is false), while
350
+ * `matchAdjudication` skips it — so the revert is a durable override. (A genuinely NEW, non-reverted
351
+ * answer to the re-parked question does revive the tombstone via {@link reviveTombstonedDecision}, so
352
+ * the human's follow-up decision is remembered.) The original decision's audit survives on the reverted
353
+ * completion ledger row and on this tombstoned row.
354
+ * Conditional on `invalidated_at IS NULL` so it is idempotent (a second revert/reset is a no-op) and
355
+ * never overwrites the first invalidation time. The tombstone is cleared only by `resetAdjudications`
356
+ * on a fresh-run re-submit. */
357
+ export async function invalidateAdjudication(data: DataLayer, id: number, on?: GatewayDataSource): Promise<void> {
358
+ await (on ?? data.open()).exec(
359
+ `UPDATE "pr_adjudications" SET "invalidated_at" = ? WHERE "id" = ? AND "invalidated_at" IS NULL`,
360
+ [new Date().toISOString(), id],
361
+ );
362
+ }
363
+
364
+ /** TOMBSTONE the adjudication produced by a specific WINNING completion (issue #806 review), keyed on
365
+ * `source_completion_id`. This is the FIRST-HAND agent-answer counterpart to {@link invalidateAdjudication}:
366
+ * a first-hand agent completion of a `wait-answer` records its own adjudication with `auto_applied=0` and
367
+ * NO `source_adjudication_id`, so reverting it cannot find the decision by adjudication id — it must be
368
+ * found by the completion that created it. Without this the reverted answer stays live and the poller
369
+ * re-auto-applies it, silently undoing the human's revert. Tombstones (does not DELETE) for the same
370
+ * race-safety reason as {@link invalidateAdjudication}, and is conditional on `invalidated_at IS NULL`
371
+ * so it is idempotent — a retry after a partial revert is a safe no-op. A completion settles at most one
372
+ * question, so at most one row matches; a completion with no linked adjudication matches none (no-op). */
373
+ export async function invalidateAdjudicationByCompletion(data: DataLayer, completionId: number, on?: GatewayDataSource): Promise<void> {
374
+ await (on ?? data.open()).exec(
375
+ `UPDATE "pr_adjudications" SET "invalidated_at" = ? WHERE "source_completion_id" = ? AND "invalidated_at" IS NULL`,
376
+ [new Date().toISOString(), completionId],
377
+ );
378
+ }