@nanobpm/nano-workforce 0.188.1 → 0.189.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +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/cockpit/mount.test.ts +50 -0
- package/app/agentic/cockpit/supply-render.test.ts +20 -0
- package/app/agentic/cockpit/supply-render.ts +15 -0
- package/app/agentic/cockpit/supply-view.ts +19 -2
- package/app/agentic/permission-bridge.test.ts +2 -2
- package/app/agentic/vocab/demand-report.test.ts +66 -1
- package/app/agentic/vocab/demand-report.ts +54 -6
- package/app/answer-escalation.test.ts +415 -2
- package/app/answerContextMapping.test.ts +83 -0
- package/app/contracts.ts +24 -0
- package/app/convergenceAdjudicationResume.test.ts +274 -0
- package/app/github.ts +10 -0
- package/app/harnessProtocol.test.ts +170 -0
- package/app/harnessProtocol.ts +312 -0
- package/app/mcpToolSurface.ts +7 -1
- package/app/service.test.ts +178 -1
- package/app/service.ts +104 -4
- package/app/terminalReaderBehaviour.test.ts +21 -0
- package/db/migrations/107_worker_harness_protocol.sql +30 -0
- package/db/migrations/109_pr_adjudications.sql +61 -0
- package/db/migrations/110_task_completions_auto_applied.sql +34 -0
- package/openapi.yaml +71 -1
- package/operations/completeUserTask.test.ts +5 -5
- package/operations/enrolAgenticWorker.test.ts +84 -0
- package/operations/enrolAgenticWorker.ts +67 -7
- package/operations/getAgenticRegistry.ts +1 -1
- package/operations/getAgenticSupply.test.ts +80 -0
- package/operations/getAgenticSupply.ts +15 -3
- package/operations/listEscalations.test.ts +1 -1
- package/package.json +1 -1
- package/pages/cockpit/mount.js +18 -0
- package/resources/processes/convergence-loop.bpmn +9 -0
- package/resources/processes/merge-loop.bpmn +1 -0
- package/test/worldDb.ts +6 -0
- package/workers/answer-escalation/worker.ts +191 -11
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
// Unit tests for the durable wait-answer adjudication store (issue #806).
|
|
2
|
+
//
|
|
3
|
+
// The convergence loop must remember a human's answer to a `wait-answer` question so a later
|
|
4
|
+
// stateless round that re-derives the IDENTICAL question auto-resumes with it instead of re-parking
|
|
5
|
+
// a human. These cover the pure fingerprint match + the INSERT-if-absent persistence, both keyed by
|
|
6
|
+
// the CANONICAL `questionFingerprint` (app/github.ts) — the same normaliser/fingerprint advisory acks
|
|
7
|
+
// use, so there is no second fingerprint implementation.
|
|
8
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import { assert, assertEquals } from "#test-assert";
|
|
13
|
+
import type { DataLayer } from "@nanobpm/urban";
|
|
14
|
+
import { bootTestApp } from "@nanobpm/urban-testkit";
|
|
15
|
+
import { invalidateAdjudication, invalidateAdjudicationByCompletion, matchAdjudication, prAdjudications, type PrAdjudicationRow, recordAdjudication, resetAdjudications } from "./adjudications.ts";
|
|
16
|
+
import { revertAgentCompletion, taskCompletions } from "./agentCompletion.ts";
|
|
17
|
+
import { questionFingerprint } from "./github.ts";
|
|
18
|
+
|
|
19
|
+
function row(over: Partial<PrAdjudicationRow>): PrAdjudicationRow {
|
|
20
|
+
return {
|
|
21
|
+
id: 1,
|
|
22
|
+
pr_key: "o/r#1",
|
|
23
|
+
question_fingerprint: questionFingerprint("Which retry cap?"),
|
|
24
|
+
answer: "Cap at 5.",
|
|
25
|
+
adjudicated_by: "alice",
|
|
26
|
+
adjudicated_kind: "human",
|
|
27
|
+
adjudicated_at: "2025-01-01T00:00:00.000Z",
|
|
28
|
+
invalidated_at: null,
|
|
29
|
+
source_completion_id: null,
|
|
30
|
+
...over,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("matchAdjudication: a TOMBSTONED (reverted) row is not replayable even with a valid answer", () => {
|
|
35
|
+
// A human revert of an auto-apply tombstones the source row (`invalidated_at`); the poller must
|
|
36
|
+
// NOT re-apply it, so the same question re-parks a human until a fresh submission (issue #806 review).
|
|
37
|
+
assertEquals(matchAdjudication([row({ invalidated_at: "2025-02-02T00:00:00.000Z" })], "Which retry cap?"), undefined);
|
|
38
|
+
assertEquals(matchAdjudication([row({ invalidated_at: " " })], "Which retry cap?")?.answer, "Cap at 5.", "a blank tombstone marker is treated as live");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("matchAdjudication: matches a byte/semantic-identical question via the canonical fingerprint", () => {
|
|
42
|
+
const rows = [row({})];
|
|
43
|
+
// Whitespace/case/leading-bullet differences normalise away, exactly as an advisory ack keys.
|
|
44
|
+
const hit = matchAdjudication(rows, " which Retry cap? ");
|
|
45
|
+
assertEquals(hit?.answer, "Cap at 5.");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("matchAdjudication: a materially different question does NOT match (still escalates)", () => {
|
|
49
|
+
const rows = [row({})];
|
|
50
|
+
assertEquals(matchAdjudication(rows, "Which timeout should we use?"), undefined);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("matchAdjudication: a settled row with a blank/absent answer is not replayable", () => {
|
|
54
|
+
assertEquals(matchAdjudication([row({ answer: null })], "Which retry cap?"), undefined);
|
|
55
|
+
assertEquals(matchAdjudication([row({ answer: " " })], "Which retry cap?"), undefined);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
// ── recordAdjudication I/O against the REAL provisioned SQLite data layer ───────────────────────────
|
|
60
|
+
// The write path uses raw guarded SQL (an atomic generation-fenced conditional INSERT and a
|
|
61
|
+
// compare-and-set blank→known UPDATE), so it is exercised against the actual data layer — not a table
|
|
62
|
+
// double — so the guards are validated, not modelled (mirrors deliveryGraphProposals.test.ts).
|
|
63
|
+
const APP_ROOT = resolve(import.meta.dirname, "..");
|
|
64
|
+
|
|
65
|
+
async function withData(
|
|
66
|
+
fn: (data: DataLayer, seedPr: (prKey: string, processKey?: string | null) => Promise<void>) => Promise<void>,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const dir = mkdtempSync(join(tmpdir(), "nwf-adj-"));
|
|
69
|
+
const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
|
|
70
|
+
try {
|
|
71
|
+
const seedPr = async (prKey: string, processKey: string | null = null) => {
|
|
72
|
+
const ts = new Date().toISOString();
|
|
73
|
+
// pr_adjudications.pr_key is an FK onto pull_requests; the generation guard also reads its
|
|
74
|
+
// process_key, so seed a minimal PR row carrying the run generation under test.
|
|
75
|
+
await app.db.table("pull_requests", "pr_key").insert({
|
|
76
|
+
pr_key: prKey,
|
|
77
|
+
repo: "o/r",
|
|
78
|
+
number: 1,
|
|
79
|
+
url: `https://github.com/o/r/pull/1#${prKey}`,
|
|
80
|
+
status: "converging",
|
|
81
|
+
current_round: 0,
|
|
82
|
+
process_key: processKey,
|
|
83
|
+
created_at: ts,
|
|
84
|
+
updated_at: ts,
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
await fn(app.db, seedPr);
|
|
88
|
+
} finally {
|
|
89
|
+
await app.stop?.();
|
|
90
|
+
rmSync(dir, { recursive: true, force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const findAdj = (data: DataLayer, prKey: string) => prAdjudications(data).find({ pr_key: prKey });
|
|
95
|
+
|
|
96
|
+
test("recordAdjudication: persists one settled row keyed by the canonical fingerprint", async () => {
|
|
97
|
+
await withData(async (data, seedPr) => {
|
|
98
|
+
await seedPr("o/r#1");
|
|
99
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
100
|
+
const rows = await findAdj(data, "o/r#1");
|
|
101
|
+
assertEquals(rows.length, 1);
|
|
102
|
+
assertEquals(rows[0].question_fingerprint, questionFingerprint("Which retry cap?"));
|
|
103
|
+
assertEquals(rows[0].answer, "Cap at 5.");
|
|
104
|
+
assertEquals(rows[0].adjudicated_by, "alice");
|
|
105
|
+
assertEquals(rows[0].adjudicated_kind, "human", "the adjudicator's kind is preserved for a faithful auto-resume attribution");
|
|
106
|
+
assertEquals(typeof rows[0].adjudicated_at, "string");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("recordAdjudication: INSERT-if-absent — a second answer to the identical question keeps the first", async () => {
|
|
111
|
+
await withData(async (data, seedPr) => {
|
|
112
|
+
await seedPr("o/r#1");
|
|
113
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
114
|
+
// A later auto-resume re-runs record-answer with the SAME fingerprint (whitespace-variant) — the
|
|
115
|
+
// original adjudicator/answer must survive rather than be overwritten by the auto-apply attribution.
|
|
116
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "which retry cap?", answer: "Cap at 9.", adjudicatedBy: "auto-applied", adjudicatedKind: "human" });
|
|
117
|
+
const rows = await findAdj(data, "o/r#1");
|
|
118
|
+
assertEquals(rows.length, 1, "no duplicate row for the same (pr, question)");
|
|
119
|
+
assertEquals(rows[0].answer, "Cap at 5.", "the ORIGINAL answer is preserved");
|
|
120
|
+
assertEquals(rows[0].adjudicated_by, "alice", "the ORIGINAL adjudicator is preserved");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("recordAdjudication: a blank answer is not a decision and is not recorded", async () => {
|
|
125
|
+
await withData(async (data, seedPr) => {
|
|
126
|
+
await seedPr("o/r#1");
|
|
127
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: " ", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
128
|
+
assertEquals((await findAdj(data, "o/r#1")).length, 0);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// --- issue #806 review: an UNKNOWN-provenance row (an answer that could not be correlated to an
|
|
133
|
+
// adjudicator, recorded with a blank `adjudicated_by`) is not auto-resumable — `pollUserTasks` refuses
|
|
134
|
+
// to launder an unattributed replay into a human authority — so it re-parks a human every round. A
|
|
135
|
+
// later KNOWN-adjudicator answer to the SAME question must HEAL the row to a replayable decision. ---
|
|
136
|
+
|
|
137
|
+
test("recordAdjudication: heals an unknown-provenance row when a known adjudicator later answers (#806 review)", async () => {
|
|
138
|
+
await withData(async (data, seedPr) => {
|
|
139
|
+
await seedPr("o/r#1");
|
|
140
|
+
// Round A: an uncorrelated answer records the decision with UNKNOWN provenance (blank adjudicator).
|
|
141
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 3.", adjudicatedBy: undefined, adjudicatedKind: undefined });
|
|
142
|
+
assertEquals((await findAdj(data, "o/r#1"))[0].adjudicated_by, null, "recorded with unknown provenance");
|
|
143
|
+
// Round B: the human answers, now with a KNOWN adjudicator — the row is promoted to that decision.
|
|
144
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
145
|
+
const rows = await findAdj(data, "o/r#1");
|
|
146
|
+
assertEquals(rows.length, 1, "still one row for the same (pr, question)");
|
|
147
|
+
assertEquals(rows[0].adjudicated_by, "alice", "provenance is healed to the known adjudicator");
|
|
148
|
+
assertEquals(rows[0].adjudicated_kind, "human", "the healed row carries the known adjudicator kind");
|
|
149
|
+
assertEquals(rows[0].answer, "Cap at 5.", "the healed row replays the human's answer, not the earlier uncorrelated one");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("recordAdjudication: a KNOWN-provenance row is immutable — a later answer never overwrites it (#806 review)", async () => {
|
|
154
|
+
await withData(async (data, seedPr) => {
|
|
155
|
+
await seedPr("o/r#1");
|
|
156
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
157
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 9.", adjudicatedBy: "bob", adjudicatedKind: "human" });
|
|
158
|
+
const rows = await findAdj(data, "o/r#1");
|
|
159
|
+
assertEquals(rows.length, 1);
|
|
160
|
+
assertEquals(rows[0].adjudicated_by, "alice", "the ORIGINAL known adjudicator is preserved");
|
|
161
|
+
assertEquals(rows[0].answer, "Cap at 5.", "the ORIGINAL answer is preserved");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("recordAdjudication: an unknown-provenance row stays unknown when a later answer is also uncorrelated (#806 review)", async () => {
|
|
166
|
+
await withData(async (data, seedPr) => {
|
|
167
|
+
await seedPr("o/r#1");
|
|
168
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 3.", adjudicatedBy: undefined, adjudicatedKind: undefined });
|
|
169
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 7.", adjudicatedBy: " ", adjudicatedKind: undefined });
|
|
170
|
+
const rows = await findAdj(data, "o/r#1");
|
|
171
|
+
assertEquals(rows.length, 1);
|
|
172
|
+
assertEquals(rows[0].adjudicated_by, null, "no known adjudicator to heal with, so it stays unknown");
|
|
173
|
+
assertEquals(rows[0].answer, "Cap at 3.", "the row is untouched when there is nothing to heal to");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// --- issue #806 review (round 8): the blank→known promotion is a compare-and-set, so two concurrent
|
|
178
|
+
// known healers cannot clobber each other — only the FIRST promotion of a blank row wins, keeping the
|
|
179
|
+
// documented first-known decision immutable. (A read-then-unconditional-update let the later writer
|
|
180
|
+
// overwrite the earlier known answer/adjudicator.) ---
|
|
181
|
+
|
|
182
|
+
test("recordAdjudication: two known healers race a blank row — only the first promotion wins (CAS) (#806 review)", async () => {
|
|
183
|
+
await withData(async (data, seedPr) => {
|
|
184
|
+
await seedPr("o/r#1");
|
|
185
|
+
// A blank-provenance row exists (an uncorrelated answer landed first).
|
|
186
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 3.", adjudicatedBy: undefined, adjudicatedKind: undefined });
|
|
187
|
+
// First known healer promotes it to alice.
|
|
188
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
189
|
+
// Second known healer read the SAME blank row but writes AFTER alice's promotion: the CAS blank guard
|
|
190
|
+
// is now false, so its update affects zero rows and alice's decision stands (no clobber).
|
|
191
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 9.", adjudicatedBy: "bob", adjudicatedKind: "human" });
|
|
192
|
+
const rows = await findAdj(data, "o/r#1");
|
|
193
|
+
assertEquals(rows.length, 1);
|
|
194
|
+
assertEquals(rows[0].adjudicated_by, "alice", "the FIRST known healer's attribution is immutable");
|
|
195
|
+
assertEquals(rows[0].answer, "Cap at 5.", "the FIRST known healer's answer is not clobbered by the later one");
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// --- issue #806 review (round 8): every write is fenced on the run generation (the PR's current
|
|
200
|
+
// process_key) so a pre-reset straggler — one whose run was superseded by a re-submit that advanced
|
|
201
|
+
// process_key and cleared the memory — cannot resurrect a stale adjudication for the fresh run. ---
|
|
202
|
+
|
|
203
|
+
test("recordAdjudication: a stale-generation straggler's INSERT is fenced out; the current generation writes (#806 review)", async () => {
|
|
204
|
+
await withData(async (data, seedPr) => {
|
|
205
|
+
// The PR has been re-submitted: its current run generation is P2.
|
|
206
|
+
await seedPr("o/r#1", "P2");
|
|
207
|
+
// A straggler from the OLD run (P1) tries to record after the reset — the generation fence no-ops it.
|
|
208
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Stale.", adjudicatedBy: "ghost", adjudicatedKind: "human", expectedProcessKey: "P1" });
|
|
209
|
+
assertEquals((await findAdj(data, "o/r#1")).length, 0, "the pre-reset straggler cannot insert for the fresh run");
|
|
210
|
+
// The current run (P2) records normally.
|
|
211
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human", expectedProcessKey: "P2" });
|
|
212
|
+
const rows = await findAdj(data, "o/r#1");
|
|
213
|
+
assertEquals(rows.length, 1);
|
|
214
|
+
assertEquals(rows[0].adjudicated_by, "alice");
|
|
215
|
+
assertEquals(rows[0].answer, "Cap at 5.");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("recordAdjudication: a stale-generation straggler's blank→known HEAL is fenced out (#806 review)", async () => {
|
|
220
|
+
await withData(async (data, seedPr) => {
|
|
221
|
+
await seedPr("o/r#1", "P2");
|
|
222
|
+
// The current run recorded an unknown-provenance row (blank adjudicator).
|
|
223
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 3.", adjudicatedBy: undefined, adjudicatedKind: undefined, expectedProcessKey: "P2" });
|
|
224
|
+
// A stale straggler (P1) with a known adjudicator must NOT heal it — the generation fence blocks it,
|
|
225
|
+
// so it cannot attribute the fresh run's decision to an old run's actor.
|
|
226
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Ghost.", adjudicatedBy: "ghost", adjudicatedKind: "human", expectedProcessKey: "P1" });
|
|
227
|
+
assertEquals((await findAdj(data, "o/r#1"))[0].adjudicated_by, null, "the stale straggler cannot heal after the reset");
|
|
228
|
+
// The current run (P2) heals it correctly.
|
|
229
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human", expectedProcessKey: "P2" });
|
|
230
|
+
const rows = await findAdj(data, "o/r#1");
|
|
231
|
+
assertEquals(rows[0].adjudicated_by, "alice", "the current-generation known adjudicator heals it");
|
|
232
|
+
assertEquals(rows[0].answer, "Cap at 5.");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("recordAdjudication: an absent expectedProcessKey fails open (unclassifiable job still records) (#806 review)", async () => {
|
|
237
|
+
await withData(async (data, seedPr) => {
|
|
238
|
+
// Even though the PR carries a process_key, a job with NO expected key cannot be classified as
|
|
239
|
+
// stale — it must still record (mirrors the worker's staleness gate fail-open).
|
|
240
|
+
await seedPr("o/r#1", "P2");
|
|
241
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human", expectedProcessKey: undefined });
|
|
242
|
+
const rows = await findAdj(data, "o/r#1");
|
|
243
|
+
assertEquals(rows.length, 1, "an unclassifiable job is not silently dropped");
|
|
244
|
+
assertEquals(rows[0].adjudicated_by, "alice");
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// --- fence classification & error propagation: exercised via a data double that injects the raw exec
|
|
249
|
+
// outcome, so we assert the `isUniqueConstraintFence` triage without racing a real concurrent writer. ---
|
|
250
|
+
|
|
251
|
+
test("recordAdjudication: a NON-fence insert error still propagates (#806 review)", async () => {
|
|
252
|
+
// The conditional INSERT's raw exec fails with a non-UNIQUE error — it must NOT be swallowed.
|
|
253
|
+
const data = {
|
|
254
|
+
open() {
|
|
255
|
+
return {
|
|
256
|
+
exec() {
|
|
257
|
+
throw new Error("disk full");
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
},
|
|
261
|
+
table() {
|
|
262
|
+
throw new Error("re-read must not be reached when the insert error propagates");
|
|
263
|
+
},
|
|
264
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal data double for error injection
|
|
265
|
+
} as any;
|
|
266
|
+
let threw = false;
|
|
267
|
+
try {
|
|
268
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
269
|
+
} catch {
|
|
270
|
+
threw = true;
|
|
271
|
+
}
|
|
272
|
+
assertEquals(threw, true, "a non-fence error is not swallowed");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("recordAdjudication: heals the winner on a UNIQUE-fence collision when the winner is unknown-provenance and we are known (#806 review)", async () => {
|
|
276
|
+
await withData(async (data, seedPr) => {
|
|
277
|
+
await seedPr("o/r#1");
|
|
278
|
+
// A concurrent uncorrelated (unknown-provenance) answer already inserted the row; our conditional
|
|
279
|
+
// insert then trips the UNIQUE fence. Because WE carry a known adjudicator, the catch re-reads the
|
|
280
|
+
// winner and applies the blank→known promotion — so the row does not stay non-replayable forever.
|
|
281
|
+
await prAdjudications(data).insert({
|
|
282
|
+
pr_key: "o/r#1",
|
|
283
|
+
question_fingerprint: questionFingerprint("Which retry cap?"),
|
|
284
|
+
answer: "Cap at 3.",
|
|
285
|
+
adjudicated_by: null,
|
|
286
|
+
adjudicated_kind: null,
|
|
287
|
+
adjudicated_at: "2025-01-01T00:00:00.000Z",
|
|
288
|
+
});
|
|
289
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
290
|
+
const rows = await findAdj(data, "o/r#1");
|
|
291
|
+
assertEquals(rows.length, 1);
|
|
292
|
+
assertEquals(rows[0].adjudicated_by, "alice", "the fenced-out known writer heals the unknown-provenance winner");
|
|
293
|
+
assertEquals(rows[0].adjudicated_kind, "human");
|
|
294
|
+
assertEquals(rows[0].answer, "Cap at 5.", "the healed row replays the human's answer, not the racer's uncorrelated one");
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("recordAdjudication: a UNIQUE-fence collision against an already-known winner is a pure no-op (#806 review)", async () => {
|
|
299
|
+
await withData(async (data, seedPr) => {
|
|
300
|
+
await seedPr("o/r#1");
|
|
301
|
+
await prAdjudications(data).insert({
|
|
302
|
+
pr_key: "o/r#1",
|
|
303
|
+
question_fingerprint: questionFingerprint("Which retry cap?"),
|
|
304
|
+
answer: "Cap at 3.",
|
|
305
|
+
adjudicated_by: "bob",
|
|
306
|
+
adjudicated_kind: "human",
|
|
307
|
+
adjudicated_at: "2025-01-01T00:00:00.000Z",
|
|
308
|
+
});
|
|
309
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
310
|
+
const rows = await findAdj(data, "o/r#1");
|
|
311
|
+
assertEquals(rows.length, 1);
|
|
312
|
+
assertEquals(rows[0].adjudicated_by, "bob", "an already-attributed fence winner is immutable");
|
|
313
|
+
assertEquals(rows[0].answer, "Cap at 3.", "the ORIGINAL answer stands");
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ── resetAdjudications / invalidateAdjudication: the fresh-run and revert boundary invalidations ────
|
|
318
|
+
// (Copilot review of #806). `submitPr` wipes a PR's whole adjudication memory on reopen in ONE atomic
|
|
319
|
+
// statement (never a partial row-by-row loop), and a human revert of an auto-applied completion
|
|
320
|
+
// invalidates the exact adjudication it replayed so the poller cannot silently re-apply it.
|
|
321
|
+
|
|
322
|
+
test("resetAdjudications: atomically clears EVERY adjudication for the PR (fresh-run boundary)", async () => {
|
|
323
|
+
await withData(async (data, seedPr) => {
|
|
324
|
+
await seedPr("o/r#1");
|
|
325
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
326
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which timeout?", answer: "30s", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
327
|
+
assertEquals((await findAdj(data, "o/r#1")).length, 2, "two distinct questions were remembered");
|
|
328
|
+
await resetAdjudications(data, "o/r#1");
|
|
329
|
+
assertEquals((await findAdj(data, "o/r#1")).length, 0, "the whole PR's memory is wiped in one statement");
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test("resetAdjudications: only touches the target PR, not a sibling's memory", async () => {
|
|
334
|
+
await withData(async (data, seedPr) => {
|
|
335
|
+
await seedPr("o/r#1");
|
|
336
|
+
await seedPr("o/r#2");
|
|
337
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
338
|
+
await recordAdjudication(data, { prKey: "o/r#2", question: "Which retry cap?", answer: "Cap at 9.", adjudicatedBy: "bob", adjudicatedKind: "human" });
|
|
339
|
+
await resetAdjudications(data, "o/r#1");
|
|
340
|
+
assertEquals((await findAdj(data, "o/r#1")).length, 0);
|
|
341
|
+
assertEquals((await findAdj(data, "o/r#2")).length, 1, "a sibling PR's memory is untouched");
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("invalidateAdjudication: TOMBSTONES exactly the replayed row so the poller cannot re-apply it", async () => {
|
|
346
|
+
await withData(async (data, seedPr) => {
|
|
347
|
+
await seedPr("o/r#1");
|
|
348
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
349
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which timeout?", answer: "30s", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
350
|
+
const rows = await findAdj(data, "o/r#1");
|
|
351
|
+
const target = rows.find((r) => r.answer === "Cap at 5.");
|
|
352
|
+
await invalidateAdjudication(data, target?.id as number);
|
|
353
|
+
const after = await findAdj(data, "o/r#1");
|
|
354
|
+
// The row is NOT deleted — a DELETE would let a redelivered record-answer re-insert the same
|
|
355
|
+
// fingerprint and resurrect the reverted decision. It is tombstoned so UNIQUE still fences.
|
|
356
|
+
assertEquals(after.length, 2, "the reverted row is tombstoned, not deleted (both rows survive)");
|
|
357
|
+
assertEquals(matchAdjudication(after, "Which retry cap?"), undefined, "the tombstoned decision is no longer replayable");
|
|
358
|
+
assertEquals(matchAdjudication(after, "Which timeout?")?.answer, "30s", "the unrelated adjudication still replays");
|
|
359
|
+
// Idempotent — a second call (or a prior reset) is a harmless no-op that does not re-stamp.
|
|
360
|
+
const firstStamp = (await findAdj(data, "o/r#1")).find((r) => r.answer === "Cap at 5.")?.invalidated_at;
|
|
361
|
+
await invalidateAdjudication(data, target?.id as number);
|
|
362
|
+
const secondStamp = (await findAdj(data, "o/r#1")).find((r) => r.answer === "Cap at 5.")?.invalidated_at;
|
|
363
|
+
assertEquals(secondStamp, firstStamp, "a second invalidate leaves the original tombstone stamp untouched");
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("invalidateAdjudication: a redelivered record-answer after a revert cannot resurrect the tombstoned decision (#806 review)", async () => {
|
|
368
|
+
// Finding A: a plain DELETE was NOT race-safe — an at-least-once `record-answer` redelivery (same run
|
|
369
|
+
// generation, so the generation guard passes) would re-INSERT the same (pr_key, fingerprint) after the
|
|
370
|
+
// human's revert-delete, and the poller would re-auto-apply the overridden answer. The tombstone keeps
|
|
371
|
+
// the row so UNIQUE fences the re-insert as a no-op and matchAdjudication keeps skipping it.
|
|
372
|
+
await withData(async (data, seedPr) => {
|
|
373
|
+
await seedPr("o/r#1");
|
|
374
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
375
|
+
const target = (await findAdj(data, "o/r#1")).find((r) => r.answer === "Cap at 5.");
|
|
376
|
+
await invalidateAdjudication(data, target?.id as number);
|
|
377
|
+
// The record-answer job is redelivered with the SAME question+answer (at-least-once semantics).
|
|
378
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
379
|
+
const after = await findAdj(data, "o/r#1");
|
|
380
|
+
assertEquals(after.length, 1, "the redelivery is a UNIQUE-fenced no-op — no second row is inserted");
|
|
381
|
+
assert(typeof after[0].invalidated_at === "string" && (after[0].invalidated_at as string).length > 0, "the row stays tombstoned across the redelivery");
|
|
382
|
+
assertEquals(matchAdjudication(after, "Which retry cap?"), undefined, "the reverted decision stays un-replayable — the revert is durable");
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("recordAdjudication: stamps source_completion_id linking the decision to its winning completion (#806 review)", async () => {
|
|
387
|
+
// A first-hand agent answer records its own decision (auto_applied=0, no source_adjudication_id); the
|
|
388
|
+
// ONLY link back to the completion that produced it is source_completion_id, so a later revert of that
|
|
389
|
+
// completion can tombstone the decision (invalidateAdjudicationByCompletion). Absent → NULL.
|
|
390
|
+
await withData(async (data, seedPr) => {
|
|
391
|
+
await seedPr("o/r#1");
|
|
392
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "bot", adjudicatedKind: "agent", sourceCompletionId: 77 });
|
|
393
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which timeout?", answer: "30s", adjudicatedBy: "bot", adjudicatedKind: "agent" });
|
|
394
|
+
const rows = await findAdj(data, "o/r#1");
|
|
395
|
+
assertEquals(rows.find((r) => r.answer === "Cap at 5.")?.source_completion_id, 77, "the winning completion id is stamped");
|
|
396
|
+
assertEquals(rows.find((r) => r.answer === "30s")?.source_completion_id, null, "an absent completion id records NULL");
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("recordAdjudication: source_completion_id pins to the ORIGINAL first-hand winner across a later auto-apply re-record (#806 review)", async () => {
|
|
401
|
+
// The first-hand answer records with its completion id; a later auto-apply replays the SAME question
|
|
402
|
+
// and re-records — a UNIQUE no-op that must NOT overwrite the link to the first-hand completion, so a
|
|
403
|
+
// revert of the first-hand completion still finds and tombstones the decision.
|
|
404
|
+
await withData(async (data, seedPr) => {
|
|
405
|
+
await seedPr("o/r#1");
|
|
406
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "bot", adjudicatedKind: "agent", sourceCompletionId: 10 });
|
|
407
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "bot", adjudicatedKind: "agent", sourceCompletionId: 20 });
|
|
408
|
+
const rows = await findAdj(data, "o/r#1");
|
|
409
|
+
assertEquals(rows.length, 1, "the re-record is a UNIQUE no-op");
|
|
410
|
+
assertEquals(rows[0].source_completion_id, 10, "the link stays pinned to the original first-hand winner");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("invalidateAdjudicationByCompletion: tombstones the decision produced by a specific completion (#806 review)", async () => {
|
|
415
|
+
// The first-hand-agent-revert counterpart to invalidateAdjudication: keyed on source_completion_id
|
|
416
|
+
// (there is no source_adjudication_id for a first-hand answer). Tombstones (not deletes), idempotent,
|
|
417
|
+
// and only touches the row that completion produced.
|
|
418
|
+
await withData(async (data, seedPr) => {
|
|
419
|
+
await seedPr("o/r#1");
|
|
420
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "bot", adjudicatedKind: "agent", sourceCompletionId: 88 });
|
|
421
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which timeout?", answer: "30s", adjudicatedBy: "bot", adjudicatedKind: "agent", sourceCompletionId: 99 });
|
|
422
|
+
await invalidateAdjudicationByCompletion(data, 88);
|
|
423
|
+
const after = await findAdj(data, "o/r#1");
|
|
424
|
+
assertEquals(after.length, 2, "the decision is tombstoned, not deleted");
|
|
425
|
+
assertEquals(matchAdjudication(after, "Which retry cap?"), undefined, "the completion's decision is no longer replayable");
|
|
426
|
+
assertEquals(matchAdjudication(after, "Which timeout?")?.answer, "30s", "a decision from a different completion is untouched");
|
|
427
|
+
// Idempotent — a retry after a partial revert re-tombstones as a no-op.
|
|
428
|
+
const firstStamp = (await findAdj(data, "o/r#1")).find((r) => r.answer === "Cap at 5.")?.invalidated_at;
|
|
429
|
+
await invalidateAdjudicationByCompletion(data, 88);
|
|
430
|
+
const secondStamp = (await findAdj(data, "o/r#1")).find((r) => r.answer === "Cap at 5.")?.invalidated_at;
|
|
431
|
+
assertEquals(secondStamp, firstStamp, "a second call leaves the original tombstone stamp untouched");
|
|
432
|
+
});
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test("invalidateAdjudicationByCompletion: a completion with no linked decision is a no-op (#806 review)", async () => {
|
|
436
|
+
await withData(async (data, seedPr) => {
|
|
437
|
+
await seedPr("o/r#1");
|
|
438
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "bot", adjudicatedKind: "agent", sourceCompletionId: 5 });
|
|
439
|
+
await invalidateAdjudicationByCompletion(data, 404);
|
|
440
|
+
const after = await findAdj(data, "o/r#1");
|
|
441
|
+
assertEquals(matchAdjudication(after, "Which retry cap?")?.answer, "Cap at 5.", "an unrelated completion id tombstones nothing");
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// --- issue #806 review (round 13, Copilot): the revert-BEFORE-record ordering. A human reverts a
|
|
446
|
+
// first-hand agent completion, but the downstream `record-answer` job has not yet inserted the decision
|
|
447
|
+
// row — so the revert-time `invalidateAdjudicationByCompletion` finds nothing to tombstone. The insert
|
|
448
|
+
// (and the blank→known heal) must then be FENCED on the completion NOT being reverted, else it creates a
|
|
449
|
+
// LIVE decision linked to an already-reverted completion that the poller re-auto-applies, silently
|
|
450
|
+
// undoing the revert (the mirror of the record-then-revert ordering the by-completion tombstone covers). --
|
|
451
|
+
|
|
452
|
+
/** Seed a `task_completions` row and return its id, so the notRevertedGuard has a real row to read. */
|
|
453
|
+
async function seedCompletion(data: DataLayer, over: { reverted: number; auto_applied?: number }): Promise<number> {
|
|
454
|
+
return await taskCompletions(data).insert({
|
|
455
|
+
user_task_key: "ut-1",
|
|
456
|
+
process_instance_key: null,
|
|
457
|
+
element_id: "wait-answer",
|
|
458
|
+
actor_kind: "agent",
|
|
459
|
+
actor_id: "bot",
|
|
460
|
+
variables_json: "{}",
|
|
461
|
+
reversible: 1,
|
|
462
|
+
auto_applied: over.auto_applied ?? 0,
|
|
463
|
+
source_adjudication_id: null,
|
|
464
|
+
reverted: over.reverted,
|
|
465
|
+
reverted_by: over.reverted ? "alice" : null,
|
|
466
|
+
reverted_note: null,
|
|
467
|
+
reverted_at: over.reverted ? new Date().toISOString() : null,
|
|
468
|
+
created_at: new Date().toISOString(),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Flip an existing completion to REVERTED in place — the state a human revert leaves behind, without
|
|
473
|
+
* re-inserting a new completion row. Used to reproduce the realistic ordering: a decision is recorded
|
|
474
|
+
* against a LIVE completion, THEN that completion is reverted (which tombstones the decision). */
|
|
475
|
+
async function revertCompletion(data: DataLayer, id: number): Promise<void> {
|
|
476
|
+
await data
|
|
477
|
+
.open()
|
|
478
|
+
.exec(`UPDATE "task_completions" SET "reverted" = 1, "reverted_by" = 'alice', "reverted_at" = ? WHERE "id" = ?`, [
|
|
479
|
+
new Date().toISOString(),
|
|
480
|
+
id,
|
|
481
|
+
]);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
test("revertAgentCompletion: a concurrent revert whose pre-check read a stale reverted=0 loses at the DB fence — it does NOT overwrite the winner's audit trail and rolls its tombstone back (#806 review, Copilot)", async () => {
|
|
485
|
+
await withData(async (data) => {
|
|
486
|
+
const id = await seedCompletion(data, { reverted: 0 });
|
|
487
|
+
// The FIRST reverter (bob) wins and durably stamps the one-time audit metadata.
|
|
488
|
+
const winner = await revertAgentCompletion(data, id, { kind: "human", id: "bob" }, "bob-note");
|
|
489
|
+
assertEquals(winner.ok, true);
|
|
490
|
+
|
|
491
|
+
// The SECOND reverter (alice) is a genuine concurrent racer: it read `reverted = 0` BEFORE bob
|
|
492
|
+
// committed, so its outside-transaction pre-check passes. A data double reproduces exactly that
|
|
493
|
+
// stale snapshot for the pre-check read, while every write (the guarded UPDATE + tombstones) still
|
|
494
|
+
// hits the REAL db where bob already committed `reverted = 1`.
|
|
495
|
+
const live = await taskCompletions(data).get(id);
|
|
496
|
+
assert(live);
|
|
497
|
+
const stale = { ...live, reverted: 0, reverted_by: null, reverted_note: null, reverted_at: null };
|
|
498
|
+
// biome-ignore lint/suspicious/noExplicitAny: data double that forces one stale pre-check read
|
|
499
|
+
const doubled = {
|
|
500
|
+
open: () => data.open(),
|
|
501
|
+
table: (name: string, key: string) => {
|
|
502
|
+
const base = data.table(name, key);
|
|
503
|
+
if (name !== "task_completions") return base;
|
|
504
|
+
return new Proxy(base, {
|
|
505
|
+
get(t, p, r) {
|
|
506
|
+
if (p === "get") return (gid: number) => (gid === id ? Promise.resolve(stale) : t.get(gid));
|
|
507
|
+
const v = Reflect.get(t, p, r);
|
|
508
|
+
return typeof v === "function" ? v.bind(t) : v;
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
},
|
|
512
|
+
} as any;
|
|
513
|
+
|
|
514
|
+
const loser = await revertAgentCompletion(doubled, id, { kind: "human", id: "alice" }, "alice-note");
|
|
515
|
+
assertEquals(loser.ok, false, "the racer that lost the `reverted = 0` fence reports no revert");
|
|
516
|
+
assertEquals(loser.reason, "completion already reverted");
|
|
517
|
+
|
|
518
|
+
const row = await taskCompletions(data).get(id);
|
|
519
|
+
assert(row);
|
|
520
|
+
assertEquals(row.reverted, 1);
|
|
521
|
+
assertEquals(row.reverted_by, "bob", "the winner's audit identity is intact — the loser did not clobber it");
|
|
522
|
+
assertEquals(row.reverted_note, "bob-note", "the winner's corrective note is intact");
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test("recordAdjudication: does NOT create a live decision for an already-reverted source completion (revert-before-record, #806 review)", async () => {
|
|
527
|
+
await withData(async (data, seedPr) => {
|
|
528
|
+
await seedPr("o/r#1");
|
|
529
|
+
const reverted = await seedCompletion(data, { reverted: 1 });
|
|
530
|
+
// The revert already ran (completion reverted, but no decision existed to tombstone); a late
|
|
531
|
+
// record-answer now tries to insert. The fence must make it a zero-row no-op.
|
|
532
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "bot", adjudicatedKind: "agent", sourceCompletionId: reverted });
|
|
533
|
+
assertEquals((await findAdj(data, "o/r#1")).length, 0, "no live decision is linked to a reverted completion");
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test("recordAdjudication: a NON-reverted source completion still records normally (#806 review)", async () => {
|
|
538
|
+
await withData(async (data, seedPr) => {
|
|
539
|
+
await seedPr("o/r#1");
|
|
540
|
+
const live = await seedCompletion(data, { reverted: 0 });
|
|
541
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "bot", adjudicatedKind: "agent", sourceCompletionId: live });
|
|
542
|
+
const rows = await findAdj(data, "o/r#1");
|
|
543
|
+
assertEquals(rows.length, 1, "a live completion's decision records");
|
|
544
|
+
assertEquals(rows[0].source_completion_id, live, "linked to its completion");
|
|
545
|
+
assertEquals(matchAdjudication(rows, "Which retry cap?")?.answer, "Cap at 5.");
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// --- issue #806 review (round 14, Copilot): a TOMBSTONED decision must be REVIVABLE by the fresh human
|
|
550
|
+
// answer that follows a revert. After a revert tombstones the decision, the recurring question re-parks a
|
|
551
|
+
// human; when that human answers, the fresh answer's record-answer INSERT trips the UNIQUE fence and — with
|
|
552
|
+
// only the blank→known heal — no-ops, so the override is never remembered and the question re-parks
|
|
553
|
+
// forever. `reviveTombstonedDecision` clears the tombstone for a NEW, non-reverted completion while still
|
|
554
|
+
// rejecting a redelivery of the reverted completion (and any uncorrelated answer). ---
|
|
555
|
+
|
|
556
|
+
test("recordAdjudication: a fresh, non-reverted answer REVIVES a tombstoned decision so the human's override is remembered (#806 review)", async () => {
|
|
557
|
+
await withData(async (data, seedPr) => {
|
|
558
|
+
await seedPr("o/r#1");
|
|
559
|
+
// A first-hand agent answer records a live decision linked to its (not-yet-reverted) completion,
|
|
560
|
+
// which a human then reverts — flipping the completion to reverted AND tombstoning the decision.
|
|
561
|
+
const original = await seedCompletion(data, { reverted: 0 });
|
|
562
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "bot", adjudicatedKind: "agent", sourceCompletionId: original });
|
|
563
|
+
await revertCompletion(data, original);
|
|
564
|
+
await invalidateAdjudicationByCompletion(data, original);
|
|
565
|
+
assertEquals(matchAdjudication(await findAdj(data, "o/r#1"), "Which retry cap?"), undefined, "the decision is tombstoned after the revert");
|
|
566
|
+
// The human now answers the re-parked question fresh — a NEW, non-reverted completion.
|
|
567
|
+
const fresh = await seedCompletion(data, { reverted: 0 });
|
|
568
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "which retry CAP?", answer: "Cap at 3.", adjudicatedBy: "alice", adjudicatedKind: "human", sourceCompletionId: fresh });
|
|
569
|
+
const rows = await findAdj(data, "o/r#1");
|
|
570
|
+
assertEquals(rows.length, 1, "the tombstone is revived in place, not duplicated");
|
|
571
|
+
assertEquals(matchAdjudication(rows, "Which retry cap?")?.answer, "Cap at 3.", "the fresh override is now remembered and replayable");
|
|
572
|
+
assertEquals(rows[0].adjudicated_by, "alice", "the revived decision carries the fresh adjudicator");
|
|
573
|
+
assertEquals(rows[0].adjudicated_kind, "human");
|
|
574
|
+
assertEquals(rows[0].source_completion_id, fresh, "the revived decision links to the fresh completion so a later revert can tombstone it again");
|
|
575
|
+
assertEquals(rows[0].invalidated_at, null, "the tombstone is cleared");
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test("recordAdjudication: a redelivery of the REVERTED completion cannot revive its own tombstone (#806 review)", async () => {
|
|
580
|
+
await withData(async (data, seedPr) => {
|
|
581
|
+
await seedPr("o/r#1");
|
|
582
|
+
const original = await seedCompletion(data, { reverted: 0 });
|
|
583
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "bot", adjudicatedKind: "agent", sourceCompletionId: original });
|
|
584
|
+
await revertCompletion(data, original);
|
|
585
|
+
await invalidateAdjudicationByCompletion(data, original);
|
|
586
|
+
// The reverted completion's record-answer is redelivered (at-least-once, SAME completion id) — the
|
|
587
|
+
// notRevertedGuard must make it a zero-row no-op, so the revert stays durable.
|
|
588
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "bot", adjudicatedKind: "agent", sourceCompletionId: original });
|
|
589
|
+
const rows = await findAdj(data, "o/r#1");
|
|
590
|
+
assertEquals(rows.length, 1, "no duplicate row");
|
|
591
|
+
assert(typeof rows[0].invalidated_at === "string" && (rows[0].invalidated_at as string).length > 0, "the tombstone survives the redelivery");
|
|
592
|
+
assertEquals(matchAdjudication(rows, "Which retry cap?"), undefined, "the reverted decision stays un-replayable");
|
|
593
|
+
});
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("recordAdjudication: a machine auto-apply REPLAY (auto_applied=1) cannot revive a tombstoned decision (#806 review)", async () => {
|
|
597
|
+
await withData(async (data, seedPr) => {
|
|
598
|
+
await seedPr("o/r#1");
|
|
599
|
+
// A first-hand human answer records a live decision; a human then reverts it (tombstone + reverted).
|
|
600
|
+
const original = await seedCompletion(data, { reverted: 0 });
|
|
601
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human", sourceCompletionId: original });
|
|
602
|
+
await revertCompletion(data, original);
|
|
603
|
+
await invalidateAdjudicationByCompletion(data, original);
|
|
604
|
+
// The convergence poller's auto-apply REPLAY of the (now-reverted) decision produces a MACHINE
|
|
605
|
+
// completion (auto_applied=1) whose own `record-answer` also runs. That replay completion is itself
|
|
606
|
+
// not-yet-reverted, so a bare not-reverted fence would let it clear the operator's tombstone and
|
|
607
|
+
// resurrect the reverted decision — the revive must additionally require a FIRST-HAND source.
|
|
608
|
+
const replay = await seedCompletion(data, { reverted: 0, auto_applied: 1 });
|
|
609
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human", sourceCompletionId: replay });
|
|
610
|
+
const rows = await findAdj(data, "o/r#1");
|
|
611
|
+
assertEquals(rows.length, 1, "no duplicate row");
|
|
612
|
+
assert(typeof rows[0].invalidated_at === "string" && (rows[0].invalidated_at as string).length > 0, "a machine replay leaves the operator's tombstone intact");
|
|
613
|
+
assertEquals(matchAdjudication(rows, "Which retry cap?"), undefined, "the reverted decision is not resurrected by a machine auto-apply replay");
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("recordAdjudication: an UNCORRELATED answer (no completion id) does NOT revive a tombstone (#806 review)", async () => {
|
|
618
|
+
await withData(async (data, seedPr) => {
|
|
619
|
+
await seedPr("o/r#1");
|
|
620
|
+
const original = await seedCompletion(data, { reverted: 0 });
|
|
621
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human", sourceCompletionId: original });
|
|
622
|
+
await revertCompletion(data, original);
|
|
623
|
+
await invalidateAdjudicationByCompletion(data, original);
|
|
624
|
+
// A legacy/out-of-band answer carrying NO completion id cannot be told apart from a redelivery of the
|
|
625
|
+
// reverted completion, so it must not revive — the revert stays durable.
|
|
626
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 3.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
627
|
+
const rows = await findAdj(data, "o/r#1");
|
|
628
|
+
assertEquals(rows.length, 1, "no duplicate row");
|
|
629
|
+
assert(typeof rows[0].invalidated_at === "string" && (rows[0].invalidated_at as string).length > 0, "the tombstone survives an uncorrelated answer");
|
|
630
|
+
assertEquals(matchAdjudication(rows, "Which retry cap?"), undefined, "the tombstone is not revived without a correlated completion");
|
|
631
|
+
});
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("recordAdjudication: an unattributed (blank adjudicator) answer does NOT revive a tombstone (#806 review)", async () => {
|
|
635
|
+
await withData(async (data, seedPr) => {
|
|
636
|
+
await seedPr("o/r#1");
|
|
637
|
+
const original = await seedCompletion(data, { reverted: 0 });
|
|
638
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human", sourceCompletionId: original });
|
|
639
|
+
await revertCompletion(data, original);
|
|
640
|
+
await invalidateAdjudicationByCompletion(data, original);
|
|
641
|
+
// A fresh, non-reverted completion but with UNKNOWN provenance must not launder a replayable authority.
|
|
642
|
+
const fresh = await seedCompletion(data, { reverted: 0 });
|
|
643
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 3.", adjudicatedBy: undefined, adjudicatedKind: undefined, sourceCompletionId: fresh });
|
|
644
|
+
const rows = await findAdj(data, "o/r#1");
|
|
645
|
+
assert(typeof rows[0].invalidated_at === "string" && (rows[0].invalidated_at as string).length > 0, "an unattributed answer leaves the tombstone intact");
|
|
646
|
+
assertEquals(matchAdjudication(rows, "Which retry cap?"), undefined, "not revived without a known adjudicator");
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("recordAdjudication: a stale-generation straggler cannot REVIVE a tombstone after a re-submit reset (#806 review)", async () => {
|
|
651
|
+
await withData(async (data, seedPr) => {
|
|
652
|
+
await seedPr("o/r#1", "gen-2"); // the PR has advanced to a fresh run generation
|
|
653
|
+
const reverted = await seedCompletion(data, { reverted: 1 });
|
|
654
|
+
// Seed a tombstoned row directly (as the prior generation left it).
|
|
655
|
+
await prAdjudications(data).insert({
|
|
656
|
+
pr_key: "o/r#1",
|
|
657
|
+
question_fingerprint: questionFingerprint("Which retry cap?"),
|
|
658
|
+
answer: "Cap at 5.",
|
|
659
|
+
adjudicated_by: "bot",
|
|
660
|
+
adjudicated_kind: "agent",
|
|
661
|
+
adjudicated_at: new Date().toISOString(),
|
|
662
|
+
invalidated_at: new Date().toISOString(),
|
|
663
|
+
source_completion_id: 500,
|
|
664
|
+
} as PrAdjudicationRow);
|
|
665
|
+
const fresh = await seedCompletion(data, { reverted: 0 });
|
|
666
|
+
// A straggler from the OLD generation (`gen-1`) tries to revive — its generation guard is false.
|
|
667
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 3.", adjudicatedBy: "alice", adjudicatedKind: "human", expectedProcessKey: "gen-1", sourceCompletionId: fresh });
|
|
668
|
+
const rows = await findAdj(data, "o/r#1");
|
|
669
|
+
assert(typeof rows[0].invalidated_at === "string" && (rows[0].invalidated_at as string).length > 0, "the stale straggler cannot revive across the generation advance");
|
|
670
|
+
assertEquals(matchAdjudication(rows, "Which retry cap?"), undefined, "the tombstone stays under the fresh generation");
|
|
671
|
+
});
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("healBlankProvenance: does NOT relink a live decision to an already-reverted healing completion (#806 review)", async () => {
|
|
675
|
+
await withData(async (data, seedPr) => {
|
|
676
|
+
await seedPr("o/r#1");
|
|
677
|
+
// A prior uncorrelated answer left a blank-provenance row (no adjudicator, no completion link).
|
|
678
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 3.", adjudicatedBy: undefined, adjudicatedKind: undefined });
|
|
679
|
+
// A known adjudicator now answers — but its completion was already reverted, so the heal must not
|
|
680
|
+
// promote/relink the row to the reverted completion.
|
|
681
|
+
const reverted = await seedCompletion(data, { reverted: 1 });
|
|
682
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human", sourceCompletionId: reverted });
|
|
683
|
+
const rows = await findAdj(data, "o/r#1");
|
|
684
|
+
assertEquals(rows.length, 1, "no duplicate row");
|
|
685
|
+
assertEquals(rows[0].adjudicated_by, null, "the blank row is NOT healed by a reverted completion");
|
|
686
|
+
assertEquals(rows[0].source_completion_id, null, "no link to the reverted completion");
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
// --- issue #806 review (round 12): a blank→known HEAL must also stamp `source_completion_id`, else a
|
|
691
|
+
// healed decision keeps a NULL link and a revert of the healing first-hand agent completion cannot find
|
|
692
|
+
// it via `invalidateAdjudicationByCompletion` — the overridden answer stays replayable and the poller
|
|
693
|
+
// re-auto-applies it, silently undoing the human's revert. ---
|
|
694
|
+
|
|
695
|
+
test("healBlankProvenance: a blank→known heal stamps source_completion_id so the healing completion's revert tombstones it (#806 review)", async () => {
|
|
696
|
+
await withData(async (data, seedPr) => {
|
|
697
|
+
await seedPr("o/r#1");
|
|
698
|
+
// An uncorrelated (blank-provenance, no completion link) answer lands first.
|
|
699
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 3.", adjudicatedBy: undefined, adjudicatedKind: undefined });
|
|
700
|
+
assertEquals((await findAdj(data, "o/r#1"))[0].source_completion_id, null, "the blank row starts with no completion link");
|
|
701
|
+
// A KNOWN first-hand agent answer (carrying its winning completion id) heals it.
|
|
702
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "bot", adjudicatedKind: "agent", sourceCompletionId: 42 });
|
|
703
|
+
const healed = await findAdj(data, "o/r#1");
|
|
704
|
+
assertEquals(healed.length, 1);
|
|
705
|
+
assertEquals(healed[0].adjudicated_by, "bot", "the blank row is promoted to the known adjudicator");
|
|
706
|
+
assertEquals(healed[0].answer, "Cap at 5.");
|
|
707
|
+
assertEquals(healed[0].source_completion_id, 42, "the healing completion is linked so a revert can find the decision");
|
|
708
|
+
// Reverting that completion now tombstones the promoted decision (before the fix, the NULL link made
|
|
709
|
+
// this a no-op and the overridden answer stayed replayable).
|
|
710
|
+
await invalidateAdjudicationByCompletion(data, 42);
|
|
711
|
+
assertEquals(matchAdjudication(await findAdj(data, "o/r#1"), "Which retry cap?"), undefined, "the reverted decision is no longer replayable");
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
test("healBlankProvenance: an uncorrelated heal (no completion id) preserves an existing link (#806 review)", async () => {
|
|
716
|
+
await withData(async (data, seedPr) => {
|
|
717
|
+
await seedPr("o/r#1");
|
|
718
|
+
// A blank-provenance row that DID carry a completion link (a first-hand answer whose adjudicator was
|
|
719
|
+
// not correlated but whose winning completion was known).
|
|
720
|
+
await prAdjudications(data).insert({
|
|
721
|
+
pr_key: "o/r#1",
|
|
722
|
+
question_fingerprint: questionFingerprint("Which retry cap?"),
|
|
723
|
+
answer: "Cap at 3.",
|
|
724
|
+
adjudicated_by: null,
|
|
725
|
+
adjudicated_kind: null,
|
|
726
|
+
adjudicated_at: "2025-01-01T00:00:00.000Z",
|
|
727
|
+
source_completion_id: 7,
|
|
728
|
+
});
|
|
729
|
+
// A known heal WITHOUT its own completion id must not NULL the existing link (COALESCE preserves it).
|
|
730
|
+
await recordAdjudication(data, { prKey: "o/r#1", question: "Which retry cap?", answer: "Cap at 5.", adjudicatedBy: "alice", adjudicatedKind: "human" });
|
|
731
|
+
const healed = await findAdj(data, "o/r#1");
|
|
732
|
+
assertEquals(healed[0].adjudicated_by, "alice", "the row is promoted");
|
|
733
|
+
assertEquals(healed[0].source_completion_id, 7, "an uncorrelated heal preserves the original completion link");
|
|
734
|
+
});
|
|
735
|
+
});
|