@nanobpm/nano-workforce 0.189.0 → 0.189.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/SPEC.md +16 -0
- package/app/adjudications.test.ts +735 -0
- package/app/adjudications.ts +378 -0
- package/app/agentCompletion.test.ts +282 -10
- package/app/agentCompletion.ts +163 -23
- package/app/agentic/permission-bridge.test.ts +2 -2
- package/app/answer-escalation.test.ts +415 -2
- package/app/answerContextMapping.test.ts +83 -0
- package/app/convergenceAdjudicationResume.test.ts +274 -0
- package/app/github.test.ts +46 -1
- package/app/github.ts +10 -0
- package/app/service.test.ts +264 -2
- package/app/service.ts +104 -4
- package/app/terminalReaderBehaviour.test.ts +21 -0
- package/db/migrations/109_pr_adjudications.sql +61 -0
- package/db/migrations/110_task_completions_auto_applied.sql +34 -0
- package/operations/completeUserTask.test.ts +5 -5
- package/operations/listEscalations.test.ts +1 -1
- package/package.json +1 -1
- package/resources/processes/convergence-loop.bpmn +9 -0
- package/resources/processes/merge-loop.bpmn +1 -0
- package/workers/answer-escalation/worker.ts +191 -11
|
@@ -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
|
+
}
|