@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
|
@@ -13,21 +13,106 @@
|
|
|
13
13
|
// `/status` window and a divergence from the merge loop the two paths are meant to share.
|
|
14
14
|
import { test } from "node:test";
|
|
15
15
|
import { assertEquals } from "#test-assert";
|
|
16
|
+
import { questionFingerprint } from "../app/github.ts";
|
|
16
17
|
import handler from "../workers/answer-escalation/worker.ts";
|
|
17
18
|
|
|
18
|
-
function fakeApp(escalationRows: Record<string, unknown>[]) {
|
|
19
|
+
function fakeApp(escalationRows: Record<string, unknown>[], prRows: Record<string, unknown>[] = []) {
|
|
19
20
|
const updates: { key: unknown; patch: Record<string, unknown> }[] = [];
|
|
20
21
|
const prUpdates: { key: unknown; patch: Record<string, unknown> }[] = [];
|
|
22
|
+
const adjudications: Record<string, unknown>[] = [];
|
|
23
|
+
const completions: Record<string, unknown>[] = [];
|
|
24
|
+
// Emulates ONLY the two guarded statements `recordAdjudication` issues via `data.open().exec` — the
|
|
25
|
+
// generation-fenced conditional INSERT and the blank→known compare-and-set UPDATE — over the same
|
|
26
|
+
// in-memory `adjudications`/`prRows` arrays the `table()` double reads. (The guard SQL itself is
|
|
27
|
+
// validated end-to-end against real SQLite in app/adjudications.test.ts; here it need only persist so
|
|
28
|
+
// these worker tests can assert the attribution `latestAdjudicator` computes.)
|
|
29
|
+
const generationAllows = (prKey: unknown, expectedKey: unknown): boolean =>
|
|
30
|
+
!prRows.some((r) => r.pr_key === prKey && r.process_key != null && r.process_key !== expectedKey);
|
|
31
|
+
const exec = async (sql: string, params: unknown[] = []) => {
|
|
32
|
+
const fenced = sql.includes("NOT EXISTS");
|
|
33
|
+
if (/^\s*INSERT INTO "pr_adjudications"/.test(sql)) {
|
|
34
|
+
const [pr_key, question_fingerprint, answer, adjudicated_by, adjudicated_kind, adjudicated_at, source_completion_id] = params;
|
|
35
|
+
if (fenced && !generationAllows(params[7], params[8])) return { changed: 0 };
|
|
36
|
+
if (adjudications.some((r) => r.pr_key === pr_key && r.question_fingerprint === question_fingerprint)) {
|
|
37
|
+
throw new Error("UNIQUE constraint failed: pr_adjudications.pr_key, pr_adjudications.question_fingerprint");
|
|
38
|
+
}
|
|
39
|
+
adjudications.push({ id: adjudications.length + 1, pr_key, question_fingerprint, answer, adjudicated_by, adjudicated_kind, adjudicated_at, source_completion_id });
|
|
40
|
+
return { changed: 1 };
|
|
41
|
+
}
|
|
42
|
+
if (/UPDATE "pr_adjudications"/.test(sql)) {
|
|
43
|
+
const [answer, adjudicated_by, adjudicated_kind, adjudicated_at, source_completion_id, id] = params;
|
|
44
|
+
if (fenced && !generationAllows(params[6], params[7])) return { changed: 0 };
|
|
45
|
+
const row = adjudications.find((r) => r.id === id);
|
|
46
|
+
const priorBy = typeof row?.adjudicated_by === "string" ? row.adjudicated_by.trim() : row?.adjudicated_by;
|
|
47
|
+
if (!row || (priorBy !== null && priorBy !== undefined && priorBy !== "")) return { changed: 0 };
|
|
48
|
+
// COALESCE(?, "source_completion_id"): stamp the healer's completion when present, else preserve.
|
|
49
|
+
Object.assign(row, { answer, adjudicated_by, adjudicated_kind, adjudicated_at, source_completion_id: source_completion_id ?? row.source_completion_id });
|
|
50
|
+
return { changed: 1 };
|
|
51
|
+
}
|
|
52
|
+
if (/UPDATE "escalations" SET "answer"/.test(sql)) {
|
|
53
|
+
// [answer, answered_at, id, ...guard]
|
|
54
|
+
const [answer, answered_at, id] = params;
|
|
55
|
+
if (fenced && !generationAllows(params[params.length - 2], params[params.length - 1])) return { changed: 0 };
|
|
56
|
+
updates.push({ key: id, patch: { answer, status: "answered", answered_at } });
|
|
57
|
+
return { changed: 1 };
|
|
58
|
+
}
|
|
59
|
+
if (/UPDATE "escalations" SET "status" = 'stale'/.test(sql)) {
|
|
60
|
+
// [id, ...guard]
|
|
61
|
+
const [id] = params;
|
|
62
|
+
if (fenced && !generationAllows(params[params.length - 2], params[params.length - 1])) return { changed: 0 };
|
|
63
|
+
updates.push({ key: id, patch: { status: "stale" } });
|
|
64
|
+
return { changed: 1 };
|
|
65
|
+
}
|
|
66
|
+
if (/UPDATE "pull_requests"/.test(sql)) {
|
|
67
|
+
// [updated_at, pr_key, ...guard]
|
|
68
|
+
const [updated_at, pr_key] = params;
|
|
69
|
+
if (fenced && !generationAllows(params[params.length - 2], params[params.length - 1])) return { changed: 0 };
|
|
70
|
+
prUpdates.push({ key: pr_key, patch: { status: "converging", updated_at } });
|
|
71
|
+
return { changed: 1 };
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`unexpected exec sql: ${sql}`);
|
|
74
|
+
};
|
|
21
75
|
const app = {
|
|
22
76
|
data: {
|
|
77
|
+
open() {
|
|
78
|
+
return { exec };
|
|
79
|
+
},
|
|
23
80
|
table(name: string, _key: string) {
|
|
24
81
|
if (name === "pull_requests") {
|
|
25
82
|
return {
|
|
83
|
+
async find(where: Record<string, unknown>) {
|
|
84
|
+
return prRows.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
85
|
+
},
|
|
26
86
|
async update(key: unknown, patch: Record<string, unknown>) {
|
|
27
87
|
prUpdates.push({ key, patch });
|
|
28
88
|
},
|
|
29
89
|
};
|
|
30
90
|
}
|
|
91
|
+
if (name === "pr_adjudications") {
|
|
92
|
+
return {
|
|
93
|
+
async find(where: Record<string, unknown>) {
|
|
94
|
+
return adjudications.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
95
|
+
},
|
|
96
|
+
async insert(r: Record<string, unknown>) {
|
|
97
|
+
adjudications.push({ id: adjudications.length + 1, ...r });
|
|
98
|
+
return adjudications.length;
|
|
99
|
+
},
|
|
100
|
+
async update(id: number, patch: Record<string, unknown>) {
|
|
101
|
+
const row = adjudications.find((r) => r.id === id);
|
|
102
|
+
if (row) Object.assign(row, patch);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (name === "task_completions") {
|
|
107
|
+
return {
|
|
108
|
+
async find(where: Record<string, unknown>) {
|
|
109
|
+
return completions.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
|
|
110
|
+
},
|
|
111
|
+
async get(id: unknown) {
|
|
112
|
+
return completions.find((r) => r.id === id) ?? undefined;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
31
116
|
if (name !== "escalations") throw new Error(`unexpected table ${name}`);
|
|
32
117
|
return {
|
|
33
118
|
async find(where: Record<string, unknown>) {
|
|
@@ -42,7 +127,7 @@ function fakeApp(escalationRows: Record<string, unknown>[]) {
|
|
|
42
127
|
},
|
|
43
128
|
},
|
|
44
129
|
};
|
|
45
|
-
return { app, updates, prUpdates };
|
|
130
|
+
return { app, updates, prUpdates, adjudications, completions };
|
|
46
131
|
}
|
|
47
132
|
|
|
48
133
|
test("retires the latest open escalation to answered with the submitted answer", async () => {
|
|
@@ -87,6 +172,36 @@ test("retires ALL open rows: newest answered, any duplicate open rows marked sta
|
|
|
87
172
|
assertEquals(prUpdates[0].patch.status, "converging");
|
|
88
173
|
});
|
|
89
174
|
|
|
175
|
+
test("answers the escalation identified by escalationId, not merely the newest open row (#806 review)", async () => {
|
|
176
|
+
// The winning completion carries the exact `escalationId` it answers (EscalationOut → process var →
|
|
177
|
+
// dataEnvelope.in). We answer THAT row and retire the others as stale — even if it is not the newest.
|
|
178
|
+
const rows = [
|
|
179
|
+
{ id: 3, pr_key: "o/r#1", status: "open", question: "Which retry cap?" },
|
|
180
|
+
{ id: 7, pr_key: "o/r#1", status: "open", question: "Which timeout?" },
|
|
181
|
+
];
|
|
182
|
+
const { app, updates } = fakeApp(rows);
|
|
183
|
+
const job = { variables: { prKey: "o/r#1", answer: "Cap at 5.", escalationId: 3 } };
|
|
184
|
+
await handler(job as any, app as any);
|
|
185
|
+
const answered = updates.find((u) => u.patch.status === "answered");
|
|
186
|
+
const stale = updates.find((u) => u.patch.status === "stale");
|
|
187
|
+
assertEquals(answered?.key, 3, "the escalation named by escalationId is the one answered");
|
|
188
|
+
assertEquals(answered?.patch.answer, "Cap at 5.");
|
|
189
|
+
assertEquals(stale?.key, 7, "the other open row is retired stale, not answered");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("a redelivered answer whose escalationId matches no open row is a no-op — it does not misfile under a newer question (#806 review)", async () => {
|
|
193
|
+
// Finding 2: a redelivered Q1 `record-answer` running after the process opened Q2 must NOT store Q1's
|
|
194
|
+
// answer under Q2's fingerprint and retire Q2. Q1's job carries escalationId=1 (a snapshot); Q1 is
|
|
195
|
+
// already retired and only Q2 (id 2) is open, so the id no longer matches any open row → no-op.
|
|
196
|
+
const rows = [{ id: 2, pr_key: "o/r#1", status: "open", question: "Which timeout?" }];
|
|
197
|
+
const { app, updates, prUpdates, adjudications } = fakeApp(rows);
|
|
198
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence", escalationId: 1 } };
|
|
199
|
+
await handler(job as any, app as any);
|
|
200
|
+
assertEquals(updates.length, 0, "Q2 is untouched — no open escalation is answered or staled");
|
|
201
|
+
assertEquals(prUpdates.length, 0, "the PR row is not moved off escalated by a stale redelivery");
|
|
202
|
+
assertEquals(adjudications.length, 0, "no adjudication is recorded under the wrong question");
|
|
203
|
+
});
|
|
204
|
+
|
|
90
205
|
test("no open row is a no-op (idempotent re-completion)", async () => {
|
|
91
206
|
const rows = [{ id: 7, pr_key: "o/r#1", status: "answered", question: "Which retry cap?" }];
|
|
92
207
|
const { app, updates, prUpdates } = fakeApp(rows);
|
|
@@ -104,3 +219,301 @@ test("a blank answer is recorded as NULL, not an empty string", async () => {
|
|
|
104
219
|
assertEquals(updates[0].patch.answer, null);
|
|
105
220
|
assertEquals(updates[0].patch.status, "answered");
|
|
106
221
|
});
|
|
222
|
+
|
|
223
|
+
// --- issue #806: persist the wait-answer adjudication so a re-derived identical question does not
|
|
224
|
+
// re-escalate. record-answer writes a durable (prKey, questionFingerprint) -> {answer, adjudicatedBy}
|
|
225
|
+
// row keyed by the canonical fingerprint, attributed to whoever just completed the task (the newest
|
|
226
|
+
// task_completions row this resume stamped for the process instance). ---
|
|
227
|
+
|
|
228
|
+
test("persists a durable adjudication of the answered question, attributed to the completer (#806)", async () => {
|
|
229
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
230
|
+
const { app, adjudications, completions } = fakeApp(rows);
|
|
231
|
+
// The resume's completion stamped a ledger row for this process instance (alice, a human, answered).
|
|
232
|
+
completions.push({ id: 1, process_instance_key: "pi-1", user_task_key: "ut-1", element_id: "wait-answer", actor_id: "alice", actor_kind: "human", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
233
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence", completedUserTaskKey: "ut-1" } };
|
|
234
|
+
await handler(job as any, app as any);
|
|
235
|
+
assertEquals(adjudications.length, 1, "one durable adjudication row is written");
|
|
236
|
+
assertEquals(adjudications[0].pr_key, "o/r#1");
|
|
237
|
+
assertEquals(adjudications[0].question_fingerprint, questionFingerprint("Which retry cap?"), "keyed by the canonical fingerprint");
|
|
238
|
+
assertEquals(adjudications[0].answer, "Cap at 5.");
|
|
239
|
+
assertEquals(adjudications[0].adjudicated_by, "alice", "attributed to the completer from the ledger");
|
|
240
|
+
assertEquals(adjudications[0].adjudicated_kind, "human", "the completer's kind is preserved from the ledger");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// --- Copilot review of #806: the attribution lookup must correlate with the completion that actually
|
|
244
|
+
// WON the user-task race, not the newest ledger row. Both canonical completers insert their row BEFORE
|
|
245
|
+
// calling completeUserTask, and the loser only removes its row AFTER the engine rejects it — so a
|
|
246
|
+
// higher-id LOSER row can be transiently present when record-answer runs. Choosing "newest" would
|
|
247
|
+
// persist/replay the loser's actor_kind against the winner's answer. Correlate on the winning answer. ---
|
|
248
|
+
|
|
249
|
+
test("attributes to the WINNING completion, not a transient higher-id loser row (#806 review)", async () => {
|
|
250
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
251
|
+
const { app, adjudications, completions } = fakeApp(rows);
|
|
252
|
+
// The human (alice) WON — the engine resumed this token with her answer "Cap at 5.". A losing racer
|
|
253
|
+
// (a poller auto-resume of a stale adjudication, attributed to an AGENT) inserted a HIGHER-id row
|
|
254
|
+
// carrying a DIFFERENT answer, and has not yet rolled it back. "Newest" would wrongly pick the agent.
|
|
255
|
+
completions.push({ id: 1, process_instance_key: "pi-1", user_task_key: "ut-1", actor_id: "alice", actor_kind: "human", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
256
|
+
completions.push({ id: 2, process_instance_key: "pi-1", user_task_key: "ut-1", actor_id: "senior-agent", actor_kind: "agent", variables_json: JSON.stringify({ answer: "Cap at 3." }) });
|
|
257
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence", completedUserTaskKey: "ut-1" } };
|
|
258
|
+
await handler(job as any, app as any);
|
|
259
|
+
assertEquals(adjudications.length, 1);
|
|
260
|
+
assertEquals(adjudications[0].adjudicated_by, "alice", "attributed to the winner (matching answer), not the higher-id loser");
|
|
261
|
+
assertEquals(adjudications[0].adjudicated_kind, "human", "the winner's kind is recorded, never the loser's");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("no ledger row matches the winning answer → null adjudicator (fails open), never a wrong one (#806 review)", async () => {
|
|
265
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
266
|
+
const { app, adjudications, completions } = fakeApp(rows);
|
|
267
|
+
// Only a stale loser row (a DIFFERENT answer) is present — the winning completion did not route
|
|
268
|
+
// through the ledger. Attributing to the unrelated row would be wrong; record a null adjudicator so
|
|
269
|
+
// the auto-resume gate fails open to a fresh human task instead.
|
|
270
|
+
completions.push({ id: 1, process_instance_key: "pi-1", user_task_key: "ut-1", actor_id: "senior-agent", actor_kind: "agent", variables_json: JSON.stringify({ answer: "Cap at 3." }) });
|
|
271
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence", completedUserTaskKey: "ut-1" } };
|
|
272
|
+
await handler(job as any, app as any);
|
|
273
|
+
assertEquals(adjudications.length, 1, "the adjudication is still recorded (a real convergence answer)");
|
|
274
|
+
assertEquals(adjudications[0].adjudicated_by, null, "an uncorrelated winner records a null adjudicator");
|
|
275
|
+
assertEquals(adjudications[0].adjudicated_kind, null, "no wrong kind is laundered in");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// --- Copilot review of #806: the legacy/out-of-band fallback (no `completedCompletionId`) selected the
|
|
279
|
+
// NEWEST candidate when several completions shared this task key AND answer. Without a completion id
|
|
280
|
+
// there is no evidence which row won, so the newest could be the LOSER. Attribute ONLY when exactly one
|
|
281
|
+
// candidate remains; otherwise fail open to a human. ---
|
|
282
|
+
|
|
283
|
+
test("an ambiguous same-answer fallback (>1 candidate, no completion id) fails open, never picks newest (#806 review)", async () => {
|
|
284
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
285
|
+
const { app, adjudications, completions } = fakeApp(rows);
|
|
286
|
+
// Two completions on the SAME user_task_key with the IDENTICAL answer and NO carried completion id.
|
|
287
|
+
// The higher-id row (an agent auto-resume loser not yet rolled back) is NOT provably the winner, so a
|
|
288
|
+
// "newest" pick could attribute to the loser. With no disambiguating id the only safe answer is none.
|
|
289
|
+
completions.push({ id: 50, process_instance_key: "pi-1", user_task_key: "ut-1", actor_id: "alice", actor_kind: "human", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
290
|
+
completions.push({ id: 51, process_instance_key: "pi-1", user_task_key: "ut-1", actor_id: "senior-agent", actor_kind: "agent", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
291
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence", completedUserTaskKey: "ut-1" } };
|
|
292
|
+
await handler(job as any, app as any);
|
|
293
|
+
assertEquals(adjudications.length, 1, "the adjudication is still recorded (a real convergence answer)");
|
|
294
|
+
assertEquals(adjudications[0].adjudicated_by, null, "an ambiguous same-answer set records a null adjudicator, never the newest guess");
|
|
295
|
+
assertEquals(adjudications[0].adjudicated_kind, null, "no wrong kind is laundered in");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// --- Copilot review of #806: the exact identity of the completion that resumed THIS wait-answer is
|
|
299
|
+
|
|
300
|
+
test("excludes an older round's same-answer completion; attributes by exact user-task identity (#806 review)", async () => {
|
|
301
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
302
|
+
const { app, adjudications, completions } = fakeApp(rows);
|
|
303
|
+
// The CURRENT round's winner (alice) has a LOWER id than a delayed higher-id completion of a PRIOR
|
|
304
|
+
// round's wait-answer (a different `user_task_key`) that recorded the SAME recurring answer. A
|
|
305
|
+
// "newest matching answer" correlation would wrongly pick the higher-id intruder (the agent); the
|
|
306
|
+
// exact `completedUserTaskKey` identity keeps attribution on this round's completion.
|
|
307
|
+
completions.push({ id: 20, process_instance_key: "pi-1", user_task_key: "ut-current", actor_id: "alice", actor_kind: "human", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
308
|
+
completions.push({ id: 25, process_instance_key: "pi-1", user_task_key: "ut-prior", actor_id: "stale-agent", actor_kind: "agent", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
309
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence", completedUserTaskKey: "ut-current" } };
|
|
310
|
+
await handler(job as any, app as any);
|
|
311
|
+
assertEquals(adjudications.length, 1);
|
|
312
|
+
assertEquals(adjudications[0].adjudicated_by, "alice", "attributed by exact user-task identity, not the higher-id same-answer prior-round row");
|
|
313
|
+
assertEquals(adjudications[0].adjudicated_kind, "human", "the current round's completer kind is recorded");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// --- Copilot review of #806: when both racers submit the IDENTICAL answer on the SAME wait-answer,
|
|
317
|
+
// answer correlation cannot separate them and the higher-id row may be the LOSER. The resumed token
|
|
318
|
+
// carries `completedCompletionId` — the exact ledger id of the winning completion — so record-answer
|
|
319
|
+
// selects that exact row regardless of a same-answer loser's id. ---
|
|
320
|
+
|
|
321
|
+
test("attributes by exact completion id even when a same-answer loser has a higher id (#806 review)", async () => {
|
|
322
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
323
|
+
const { app, adjudications, completions } = fakeApp(rows);
|
|
324
|
+
// Both racers submitted the SAME answer on the SAME user_task_key. The engine accepted the HUMAN
|
|
325
|
+
// (ledger id 30); an agent auto-resume loser inserted a HIGHER-id row (31) with the identical answer
|
|
326
|
+
// and has not yet rolled back. Answer + user-task correlation alone would pick the higher-id agent;
|
|
327
|
+
// the carried `completedCompletionId` (30) pins attribution to the exact winning row.
|
|
328
|
+
completions.push({ id: 30, process_instance_key: "pi-1", user_task_key: "ut-1", actor_id: "alice", actor_kind: "human", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
329
|
+
completions.push({ id: 31, process_instance_key: "pi-1", user_task_key: "ut-1", actor_id: "senior-agent", actor_kind: "agent", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
330
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence", completedUserTaskKey: "ut-1", completedCompletionId: 30 } };
|
|
331
|
+
await handler(job as any, app as any);
|
|
332
|
+
assertEquals(adjudications.length, 1);
|
|
333
|
+
assertEquals(adjudications[0].adjudicated_by, "alice", "the exact winning completion id wins over a same-answer higher-id loser");
|
|
334
|
+
assertEquals(adjudications[0].adjudicated_kind, "human");
|
|
335
|
+
assertEquals(adjudications[0].source_completion_id, 30, "the decision is linked to its winning completion so a later revert can tombstone it (#806 review)");
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("a carried completion id that matches no ledger row → null adjudicator (fails open) (#806 review)", async () => {
|
|
339
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
340
|
+
const { app, adjudications, completions } = fakeApp(rows);
|
|
341
|
+
completions.push({ id: 30, process_instance_key: "pi-1", user_task_key: "ut-1", actor_id: "alice", actor_kind: "human", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
342
|
+
// The carried id (99) matches nothing — never fall back to guessing another row.
|
|
343
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence", completedUserTaskKey: "ut-1", completedCompletionId: 99 } };
|
|
344
|
+
await handler(job as any, app as any);
|
|
345
|
+
assertEquals(adjudications.length, 1, "the adjudication is still recorded (a real convergence answer)");
|
|
346
|
+
assertEquals(adjudications[0].adjudicated_by, null, "an unmatched completion id records a null adjudicator, never a wrong one");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("attributes by completion id even when the ledger row has a NULL process_instance_key (Copilot review)", async () => {
|
|
350
|
+
// Production reality: the canonical completers (`completeUserTaskAttributed`) resolve the task
|
|
351
|
+
// through the typed `openUserTasks` seam, which omits `processInstanceKey` (app/service.ts), so EVERY
|
|
352
|
+
// real ledger row is stored with `process_instance_key = null`. The winner is identified by the
|
|
353
|
+
// globally-unique ledger id carried on the resumed token — so attribution must look it up by PRIMARY
|
|
354
|
+
// KEY, never by a `process_instance_key`-scoped scan (which would never match the null-keyed row and
|
|
355
|
+
// would fail open, recording a null adjudicator and re-parking an already-answered question).
|
|
356
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
357
|
+
const { app, adjudications, completions } = fakeApp(rows);
|
|
358
|
+
completions.push({ id: 42, process_instance_key: null, user_task_key: "ut-1", actor_id: "alice", actor_kind: "human", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
359
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence", completedUserTaskKey: "ut-1", completedCompletionId: 42 } };
|
|
360
|
+
await handler(job as any, app as any);
|
|
361
|
+
assertEquals(adjudications.length, 1);
|
|
362
|
+
assertEquals(adjudications[0].adjudicated_by, "alice", "attributed by primary-key completion-id lookup despite the null process_instance_key");
|
|
363
|
+
assertEquals(adjudications[0].adjudicated_kind, "human", "the winning completer kind is recorded");
|
|
364
|
+
assertEquals(adjudications[0].source_completion_id, 42, "the decision is linked to its winning completion");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("carries no user-task identity → null adjudicator (fails open), never a guess (#806 review)", async () => {
|
|
368
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
369
|
+
const { app, adjudications, completions } = fakeApp(rows);
|
|
370
|
+
// An out-of-band resume that did not stamp `completedUserTaskKey`. Even with a same-answer ledger row
|
|
371
|
+
// present, we cannot EXACTLY identify this wait-answer's completion, so we fail open rather than guess.
|
|
372
|
+
completions.push({ id: 1, process_instance_key: "pi-1", user_task_key: "ut-x", actor_id: "alice", actor_kind: "human", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
373
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence" } };
|
|
374
|
+
await handler(job as any, app as any);
|
|
375
|
+
assertEquals(adjudications.length, 1, "the adjudication is still recorded (a real convergence answer)");
|
|
376
|
+
assertEquals(adjudications[0].adjudicated_by, null, "no carried identity → null adjudicator, never a guess");
|
|
377
|
+
assertEquals(adjudications[0].adjudicated_kind, null);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// --- Copilot review of #806: this worker must reject a delayed/redelivered job from a SUPERSEDED
|
|
381
|
+
// process instance. `pull_requests.process_key` tracks the CURRENT loop instance; a re-submit (or the
|
|
382
|
+
// merge hand-off) reassigns it. A stale old-process job must NOT reinsert its adjudication after the
|
|
383
|
+
// fresh run's reset, nor answer the new run's open escalation. ---
|
|
384
|
+
|
|
385
|
+
test("no-ops a redelivered job from a superseded (non-current) process instance (#806 review)", async () => {
|
|
386
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
387
|
+
const { app, updates, prUpdates, adjudications, completions } = fakeApp(rows, [
|
|
388
|
+
{ pr_key: "o/r#1", status: "escalated", process_key: "pi-current" },
|
|
389
|
+
]);
|
|
390
|
+
// A stale completion from the OLD instance carries a matching identity + answer, but the PR's current
|
|
391
|
+
// process is `pi-current`. The job is from `pi-stale`, so it must touch nothing.
|
|
392
|
+
completions.push({ id: 1, process_instance_key: "pi-stale", user_task_key: "ut-old", actor_id: "ghost", actor_kind: "agent", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
393
|
+
const job = { processInstanceKey: "pi-stale", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence", completedUserTaskKey: "ut-old" } };
|
|
394
|
+
await handler(job as any, app as any);
|
|
395
|
+
assertEquals(adjudications.length, 0, "a superseded-process job never reinserts an adjudication after the reset");
|
|
396
|
+
assertEquals(updates.length, 0, "it never answers the new run's open escalation");
|
|
397
|
+
assertEquals(prUpdates.length, 0, "it never moves the PR row");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("accepts a job whose process instance IS the PR's current process_key (#806 review)", async () => {
|
|
401
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
402
|
+
const { app, updates, adjudications, completions } = fakeApp(rows, [
|
|
403
|
+
{ pr_key: "o/r#1", status: "escalated", process_key: "pi-1" },
|
|
404
|
+
]);
|
|
405
|
+
completions.push({ id: 1, process_instance_key: "pi-1", user_task_key: "ut-1", actor_id: "alice", actor_kind: "human", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
406
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence", completedUserTaskKey: "ut-1" } };
|
|
407
|
+
await handler(job as any, app as any);
|
|
408
|
+
assertEquals(updates.length, 1, "the current-process job is honoured");
|
|
409
|
+
assertEquals(adjudications.length, 1, "and its adjudication is recorded");
|
|
410
|
+
assertEquals(adjudications[0].adjudicated_by, "alice");
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("a blank answer records NO adjudication (not a replayable decision) (#806)", async () => {
|
|
414
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
415
|
+
const { app, adjudications } = fakeApp(rows);
|
|
416
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: " ", answerContext: "convergence" } };
|
|
417
|
+
await handler(job as any, app as any);
|
|
418
|
+
assertEquals(adjudications.length, 0);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// --- Copilot review of #806: this ONE worker services both loops (`record-answer` in the convergence
|
|
422
|
+
// loop AND `record-merge-answer` in the merge loop, #256). Only a CONVERGENCE answer may feed the
|
|
423
|
+
// convergence adjudication memory — a merge decision recorded here could be replayed for a later
|
|
424
|
+
// convergence `wait-answer` whose text happens to match, replaying a merge-context answer that was
|
|
425
|
+
// never a convergence adjudication. The originating step stamps `answerContext`. ---
|
|
426
|
+
|
|
427
|
+
test("a MERGE-loop answer is NOT recorded as a convergence adjudication (#806 review)", async () => {
|
|
428
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Rebase or merge-commit?" }];
|
|
429
|
+
const { app, adjudications, updates, prUpdates } = fakeApp(rows);
|
|
430
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Rebase.", answerContext: "merge" } };
|
|
431
|
+
await handler(job as any, app as any);
|
|
432
|
+
assertEquals(adjudications.length, 0, "a merge-context answer never contaminates the convergence adjudication memory");
|
|
433
|
+
assertEquals(updates[0].patch.status, "answered", "the escalation row is still reconciled");
|
|
434
|
+
assertEquals(prUpdates[0].patch.status, "converging", "the PR row is still moved off `escalated`");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("an absent answerContext (legacy) is NOT recorded as a convergence adjudication (#806 review)", async () => {
|
|
438
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
439
|
+
const { app, adjudications } = fakeApp(rows);
|
|
440
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5." } };
|
|
441
|
+
await handler(job as any, app as any);
|
|
442
|
+
assertEquals(adjudications.length, 0, "only an explicit convergence context feeds the adjudication memory");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("records the adjudication BEFORE the escalation row transitions off `open` (crash safety, #806 review)", async () => {
|
|
446
|
+
// The suppressed advisory: if the adjudication insert happened AFTER the rows flip off `open`, a
|
|
447
|
+
// crash in that window would lose the adjudication (a retry finds no open row and returns), so the
|
|
448
|
+
// answered question could re-escalate after restart. Recording it FIRST + INSERT-if-absent makes a
|
|
449
|
+
// retry re-record a no-op. Assert the ordering by capturing when each write ran.
|
|
450
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
451
|
+
const order: string[] = [];
|
|
452
|
+
const adjudications: Record<string, unknown>[] = [];
|
|
453
|
+
const completions = [{ id: 1, process_instance_key: "pi-1", user_task_key: "ut-1", actor_id: "alice", actor_kind: "human", variables_json: JSON.stringify({ answer: "Cap at 5." }) }];
|
|
454
|
+
const app = {
|
|
455
|
+
data: {
|
|
456
|
+
open() {
|
|
457
|
+
return {
|
|
458
|
+
async exec(sql: string, params: unknown[] = []) {
|
|
459
|
+
if (/^\s*INSERT INTO "pr_adjudications"/.test(sql)) {
|
|
460
|
+
order.push("adjudication");
|
|
461
|
+
adjudications.push({ pr_key: params[0], question_fingerprint: params[1], answer: params[2], adjudicated_by: params[3], adjudicated_kind: params[4] });
|
|
462
|
+
return { changed: 1 };
|
|
463
|
+
}
|
|
464
|
+
if (/UPDATE "escalations"/.test(sql)) {
|
|
465
|
+
order.push("escalation");
|
|
466
|
+
return { changed: 1 };
|
|
467
|
+
}
|
|
468
|
+
if (/UPDATE "pull_requests"/.test(sql)) {
|
|
469
|
+
order.push("pr");
|
|
470
|
+
return { changed: 1 };
|
|
471
|
+
}
|
|
472
|
+
return { changed: 0 };
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
},
|
|
476
|
+
table(name: string) {
|
|
477
|
+
if (name === "pull_requests") return { async find() { return []; } };
|
|
478
|
+
if (name === "pr_adjudications") {
|
|
479
|
+
return { async find() { return []; } };
|
|
480
|
+
}
|
|
481
|
+
if (name === "task_completions") {
|
|
482
|
+
return { async find(where: Record<string, unknown>) { return completions.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v)); } };
|
|
483
|
+
}
|
|
484
|
+
if (name !== "escalations") throw new Error(`unexpected table ${name}`);
|
|
485
|
+
return {
|
|
486
|
+
async find() { return rows; },
|
|
487
|
+
};
|
|
488
|
+
},
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
const job = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence", completedUserTaskKey: "ut-1" } };
|
|
492
|
+
await handler(job as any, app as any);
|
|
493
|
+
assertEquals(adjudications.length, 1, "the adjudication is recorded");
|
|
494
|
+
assertEquals(order[0], "adjudication", "the adjudication is persisted BEFORE any row transitions off `open`");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// --- issue #806 review: a null-provenance adjudication (an uncorrelated answer) makes the poller
|
|
498
|
+
// fail open and re-park a human every round. A LATER known-adjudicator answer to the same question,
|
|
499
|
+
// arriving through this worker, must HEAL the durable row to a replayable decision instead of being
|
|
500
|
+
// dropped by INSERT-if-absent — otherwise the question re-parks forever. ---
|
|
501
|
+
|
|
502
|
+
test("a later known-adjudicator answer heals an earlier uncorrelated (null-provenance) adjudication (#806 review)", async () => {
|
|
503
|
+
const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
|
|
504
|
+
const { app, adjudications, completions } = fakeApp(rows);
|
|
505
|
+
// Round A: an out-of-band answer that carried no completion identity → recorded with null provenance.
|
|
506
|
+
const jobA = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 3.", answerContext: "convergence" } };
|
|
507
|
+
await handler(jobA as any, app as any);
|
|
508
|
+
assertEquals(adjudications.length, 1);
|
|
509
|
+
assertEquals(adjudications[0].adjudicated_by, null, "round A recorded unknown provenance");
|
|
510
|
+
// Round B: the question re-parked a human (unknown provenance fails open); the human answers via the
|
|
511
|
+
// canonical completer, stamping the exact winning completion id. The durable row must be healed.
|
|
512
|
+
rows.push({ id: 8, pr_key: "o/r#1", status: "open", question: "Which retry cap?" });
|
|
513
|
+
completions.push({ id: 40, process_instance_key: "pi-1", user_task_key: "ut-b", actor_id: "alice", actor_kind: "human", variables_json: JSON.stringify({ answer: "Cap at 5." }) });
|
|
514
|
+
const jobB = { processInstanceKey: "pi-1", variables: { prKey: "o/r#1", answer: "Cap at 5.", answerContext: "convergence", completedUserTaskKey: "ut-b", completedCompletionId: 40 } };
|
|
515
|
+
await handler(jobB as any, app as any);
|
|
516
|
+
assertEquals(adjudications.length, 1, "still one durable row for the (pr, question)");
|
|
517
|
+
assertEquals(adjudications[0].adjudicated_by, "alice", "provenance healed to the known adjudicator");
|
|
518
|
+
assertEquals(adjudications[0].answer, "Cap at 5.", "the healed row replays the human's answer, not the earlier uncorrelated one");
|
|
519
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Model-drift guard for the `answerContext` boundary between the two answer loops (Copilot review of
|
|
2
|
+
// #806, suppressed advisories on convergence-loop.bpmn:312 and merge-loop.bpmn:332).
|
|
3
|
+
//
|
|
4
|
+
// `pr.answer-escalation` (the ONE worker servicing both `record-answer` in the convergence loop and
|
|
5
|
+
// `record-merge-answer` in the merge loop) feeds the durable convergence adjudication memory ONLY when
|
|
6
|
+
// `answerContext == "convergence"`. That classification rests entirely on a literal `<zeebe:input>`
|
|
7
|
+
// ioMapping on each service task. The worker-level tests inject `answerContext` DIRECTLY, so they would
|
|
8
|
+
// stay green even if a mapping were deleted or changed — a silent regression that would classify real
|
|
9
|
+
// convergence answers as legacy (disabling durable adjudication) or, with the wrong value, contaminate
|
|
10
|
+
// convergence adjudications with a merge decision.
|
|
11
|
+
//
|
|
12
|
+
// These pure text assertions over the committed BPMN pin the integration point the worker tests cannot
|
|
13
|
+
// see, matching the repo's lightweight model-guard style (convergenceEscalationGuard.test.ts,
|
|
14
|
+
// mergeEscalationUserTask.test.ts). The stored source encodes the FEEL string literal's quotes as the
|
|
15
|
+
// `"` XML entity, so the expected sources below are the raw on-disk form.
|
|
16
|
+
import { test } from "node:test";
|
|
17
|
+
import { assert } from "#test-assert";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
|
|
20
|
+
/** The `<bpmn:serviceTask id="…">…</bpmn:serviceTask>` block for `id`, whitespace-collapsed so
|
|
21
|
+
* attribute-order / line-wrapping churn does not make the assertions brittle. */
|
|
22
|
+
function serviceTask(file: string, id: string): string {
|
|
23
|
+
const bpmn = readFileSync(`resources/processes/${file}`, "utf8").replace(/\s+/g, " ");
|
|
24
|
+
const m = bpmn.match(new RegExp(`<bpmn:serviceTask\\b[^>]*\\bid="${id}"[\\s\\S]*?<\\/bpmn:serviceTask>`));
|
|
25
|
+
assert(m, `${file} must define serviceTask ${id}`);
|
|
26
|
+
return (m as RegExpMatchArray)[0];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const CASES: ReadonlyArray<{ file: string; task: string; context: string }> = [
|
|
30
|
+
{ file: "convergence-loop.bpmn", task: "record-answer", context: "convergence" },
|
|
31
|
+
{ file: "merge-loop.bpmn", task: "record-merge-answer", context: "merge" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
for (const { file, task, context } of CASES) {
|
|
35
|
+
test(`${task} (${file}) is bound to pr.answer-escalation and stamps answerContext="${context}"`, () => {
|
|
36
|
+
const block = serviceTask(file, task);
|
|
37
|
+
assert(
|
|
38
|
+
block.includes(`<zeebe:taskDefinition type="pr.answer-escalation"`),
|
|
39
|
+
`${task} must invoke the pr.answer-escalation worker`,
|
|
40
|
+
);
|
|
41
|
+
// The exact literal ioMapping the worker's `isConvergence` test keys off. `"` is the on-disk
|
|
42
|
+
// encoding of the quotes around the FEEL string literal.
|
|
43
|
+
assert(
|
|
44
|
+
block.includes(`<zeebe:input source="="${context}"" target="answerContext" />`),
|
|
45
|
+
`${task} must stamp a literal answerContext="${context}" so the reconcile step classifies it correctly`,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Cross-check the two contexts never collapse to the same value — a copy-paste that stamped
|
|
51
|
+
// "convergence" on the merge task (or vice versa) would let one loop's decision feed the other's memory.
|
|
52
|
+
test("the two answer loops stamp DISTINCT answerContext values", () => {
|
|
53
|
+
const conv = serviceTask("convergence-loop.bpmn", "record-answer");
|
|
54
|
+
const merge = serviceTask("merge-loop.bpmn", "record-merge-answer");
|
|
55
|
+
assert(conv.includes(`"convergence"`), "convergence loop stamps convergence");
|
|
56
|
+
assert(!conv.includes(`"merge"`), "convergence loop must NOT stamp merge");
|
|
57
|
+
assert(merge.includes(`"merge"`), "merge loop stamps merge");
|
|
58
|
+
assert(!merge.includes(`"convergence"`), "merge loop must NOT stamp convergence");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Model-drift guard for the one-shot completion-identity handoff (Copilot review of #806).
|
|
62
|
+
// `completeUserTaskAttributed` stamps `completedUserTaskKey`/`completedCompletionId` onto the resumed
|
|
63
|
+
// convergence instance's variables, and record-answer reads them to pin adjudication attribution to the
|
|
64
|
+
// exact winning completion. But the convergence instance is REUSED across rounds, so if record-answer
|
|
65
|
+
// did not CLEAR them after consuming them, a later metadata-less (direct/legacy) completion would
|
|
66
|
+
// inherit the PREVIOUS round's `completedCompletionId` and `latestAdjudicator` would take the exact-id
|
|
67
|
+
// branch, attributing the new answer to the OLD completion instead of failing open. The `=null` outputs
|
|
68
|
+
// make the handoff one-shot; the worker tests inject these vars directly and so cannot see the clear.
|
|
69
|
+
test("record-answer CLEARS the one-shot completion-identity handoff vars after consuming them (#806 review)", () => {
|
|
70
|
+
const block = serviceTask("convergence-loop.bpmn", "record-answer");
|
|
71
|
+
assert(
|
|
72
|
+
block.includes(`<zeebe:input source="=completedCompletionId" target="completedCompletionId" />`),
|
|
73
|
+
"record-answer must READ completedCompletionId to pin exact-winner attribution",
|
|
74
|
+
);
|
|
75
|
+
assert(
|
|
76
|
+
block.includes(`<zeebe:output source="=null" target="completedUserTaskKey" />`),
|
|
77
|
+
"record-answer must CLEAR completedUserTaskKey so a later round cannot inherit a stale handoff",
|
|
78
|
+
);
|
|
79
|
+
assert(
|
|
80
|
+
block.includes(`<zeebe:output source="=null" target="completedCompletionId" />`),
|
|
81
|
+
"record-answer must CLEAR completedCompletionId so a later metadata-less completion fails open, not mis-attributes to the old completion",
|
|
82
|
+
);
|
|
83
|
+
});
|