@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
|
@@ -55,10 +55,106 @@ function memTable(rows: any[], key: string) {
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function memData(
|
|
58
|
+
function memData(
|
|
59
|
+
stores: Record<string, { rows: any[]; key: string }>,
|
|
60
|
+
opts: { failExec?: (sql: string) => boolean; failUpdate?: (table: string) => boolean } = {},
|
|
61
|
+
) {
|
|
62
|
+
// Minimal `open().exec` emulating ONLY the tombstone `UPDATE "pr_adjudications" SET "invalidated_at"
|
|
63
|
+
// = ? WHERE "id" = ? AND "invalidated_at" IS NULL` that `revertAgentCompletion` issues via
|
|
64
|
+
// `invalidateAdjudication` (Copilot review of #806). A revert TOMBSTONES the source adjudication (it
|
|
65
|
+
// does NOT delete it) so a redelivered `record-answer` cannot re-insert the same fingerprint and
|
|
66
|
+
// resurrect the reverted decision. The SQL itself is validated against real SQLite in
|
|
67
|
+
// app/adjudications.test.ts; here it need only mutate the in-memory store so a revert-invalidation
|
|
68
|
+
// assertion can observe the row being tombstoned. `open().tx(fn)` runs `fn` against the same store and
|
|
69
|
+
// ROLLS BACK (restores a pre-tx snapshot) on throw, mirroring the real SQLite transaction the revert
|
|
70
|
+
// now commits atomically (issue #806 review — atomicity of tombstone + `reverted` flip). `opts` lets a
|
|
71
|
+
// test inject a transient write failure at a chosen point (exec or a table update) to exercise rollback.
|
|
72
|
+
const rawExec = async (sql: string, params: unknown[] = []) => {
|
|
73
|
+
const m = /UPDATE "pr_adjudications" SET "invalidated_at" = \? WHERE "id" = \? AND "invalidated_at" IS NULL/.exec(sql);
|
|
74
|
+
if (m) {
|
|
75
|
+
const store = stores.pr_adjudications;
|
|
76
|
+
if (store) {
|
|
77
|
+
const r = store.rows.find((r) => r[store.key] === params[1]);
|
|
78
|
+
if (r && (r.invalidated_at == null || String(r.invalidated_at).trim() === "")) {
|
|
79
|
+
r.invalidated_at = params[0];
|
|
80
|
+
return { changed: 1 };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { changed: 0 };
|
|
84
|
+
}
|
|
85
|
+
// The FIRST-HAND agent-revert tombstone keyed on `source_completion_id` (Copilot review of #806) —
|
|
86
|
+
// `invalidateAdjudicationByCompletion`. Tombstones EVERY live row this completion produced (a
|
|
87
|
+
// completion settles one question, so at most one) so reverting a first-hand agent answer (which has
|
|
88
|
+
// no `source_adjudication_id`) still stops the poller re-auto-applying it.
|
|
89
|
+
const c = /UPDATE "pr_adjudications" SET "invalidated_at" = \? WHERE "source_completion_id" = \? AND "invalidated_at" IS NULL/.exec(sql);
|
|
90
|
+
if (c) {
|
|
91
|
+
const store = stores.pr_adjudications;
|
|
92
|
+
let changed = 0;
|
|
93
|
+
if (store) {
|
|
94
|
+
for (const r of store.rows) {
|
|
95
|
+
if (r.source_completion_id === params[1] && (r.invalidated_at == null || String(r.invalidated_at).trim() === "")) {
|
|
96
|
+
r.invalidated_at = params[0];
|
|
97
|
+
changed++;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { changed };
|
|
102
|
+
}
|
|
103
|
+
// The conditional ledger flip the revert now issues (Copilot review of #806): the `reverted = 0`
|
|
104
|
+
// fence is what serialises two concurrent reverts of the SAME completion — the loser's guarded
|
|
105
|
+
// UPDATE changes ZERO rows, so the revert throws to roll the whole transaction (its tombstones
|
|
106
|
+
// included) back, leaving the winner's one-time audit metadata unclobbered. Emulate the fence so
|
|
107
|
+
// `res.changed` is honest.
|
|
108
|
+
const rev = /UPDATE "task_completions" SET "reverted" = 1, "reverted_by" = \?, "reverted_note" = \?, "reverted_at" = \? WHERE "id" = \? AND "reverted" = 0/.exec(sql);
|
|
109
|
+
if (rev) {
|
|
110
|
+
const store = stores.task_completions;
|
|
111
|
+
let changed = 0;
|
|
112
|
+
if (store) {
|
|
113
|
+
const r = store.rows.find((r) => r[store.key] === params[3]);
|
|
114
|
+
if (r && (r.reverted === 0 || r.reverted == null)) {
|
|
115
|
+
r.reverted = 1;
|
|
116
|
+
r.reverted_by = params[0];
|
|
117
|
+
r.reverted_note = params[1];
|
|
118
|
+
r.reverted_at = params[2];
|
|
119
|
+
changed = 1;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return { changed };
|
|
123
|
+
}
|
|
124
|
+
throw new Error(`unexpected exec sql: ${sql}`);
|
|
125
|
+
};
|
|
126
|
+
const exec = async (sql: string, params: unknown[] = []) => {
|
|
127
|
+
if (opts.failExec?.(sql)) throw new Error("transient write failure");
|
|
128
|
+
return rawExec(sql, params);
|
|
129
|
+
};
|
|
130
|
+
const table = (name: string, key: string) => {
|
|
131
|
+
const base = memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key);
|
|
132
|
+
if (opts.failUpdate?.(name)) {
|
|
133
|
+
return { ...base, update: () => Promise.reject(new Error("transient write failure")) };
|
|
134
|
+
}
|
|
135
|
+
return base;
|
|
136
|
+
};
|
|
137
|
+
const source: any = {
|
|
138
|
+
exec,
|
|
139
|
+
table,
|
|
140
|
+
tx: async (fn: (t: any) => Promise<unknown>) => {
|
|
141
|
+
const snap = Object.fromEntries(
|
|
142
|
+
Object.entries(stores).map(([n, s]) => [n, JSON.parse(JSON.stringify(s.rows))]),
|
|
143
|
+
);
|
|
144
|
+
try {
|
|
145
|
+
return await fn(source);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
for (const [n, s] of Object.entries(stores)) {
|
|
148
|
+
s.rows.length = 0;
|
|
149
|
+
s.rows.push(...snap[n]);
|
|
150
|
+
}
|
|
151
|
+
throw e;
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
59
155
|
return {
|
|
60
|
-
|
|
61
|
-
|
|
156
|
+
open: () => source,
|
|
157
|
+
table,
|
|
62
158
|
} as any;
|
|
63
159
|
}
|
|
64
160
|
|
|
@@ -98,7 +194,7 @@ test("agent completion resumes with the exact typed vars a human submits AND rec
|
|
|
98
194
|
// Same resume path a human drives: completeUserTask called with the identical typed variables.
|
|
99
195
|
assertEquals(completed.length, 1);
|
|
100
196
|
assertEquals(completed[0].userTaskKey, "ut-1");
|
|
101
|
-
assertEquals(completed[0].variables, { resolution: "answer", answer: "use v2" });
|
|
197
|
+
assertEquals(completed[0].variables, { resolution: "answer", answer: "use v2", completedUserTaskKey: "ut-1", completedCompletionId: 1 });
|
|
102
198
|
|
|
103
199
|
// Attribution recorded: an agent completion, its id, and the submitted variables.
|
|
104
200
|
const row = stores.task_completions.rows[0] as TaskCompletion;
|
|
@@ -191,7 +287,7 @@ test("a HUMAN operator completes a feature escalation via the SAME attributed re
|
|
|
191
287
|
|
|
192
288
|
// Identical resume path to the agent/task-inbox: completeUserTask with the exact typed variables.
|
|
193
289
|
assertEquals(completed.length, 1);
|
|
194
|
-
assertEquals(completed[0].variables, { resolution: "answer", answer: "use v2" });
|
|
290
|
+
assertEquals(completed[0].variables, { resolution: "answer", answer: "use v2", completedUserTaskKey: "ut-1", completedCompletionId: 1 });
|
|
195
291
|
|
|
196
292
|
// Attribution recorded as a HUMAN completion — the authority, so NOT reversible.
|
|
197
293
|
const row = stores.task_completions.rows[0] as TaskCompletion;
|
|
@@ -226,7 +322,7 @@ test("feature-blocked is HUMAN-completable but NOT agent-completable (issue #332
|
|
|
226
322
|
assertEquals(asHuman.ok, true, "the human completer retires feature-blocked");
|
|
227
323
|
assertEquals(asHuman.elementId, "feature-blocked");
|
|
228
324
|
assertEquals(completed.length, 1);
|
|
229
|
-
assertEquals(completed[0].variables, { note: "reassigned to a human" });
|
|
325
|
+
assertEquals(completed[0].variables, { note: "reassigned to a human", completedUserTaskKey: "ut-b", completedCompletionId: 1 });
|
|
230
326
|
});
|
|
231
327
|
|
|
232
328
|
test("conformance-escalation is HUMAN-completable but NOT agent-completable (issue #216)", async () => {
|
|
@@ -254,7 +350,7 @@ test("conformance-escalation is HUMAN-completable but NOT agent-completable (iss
|
|
|
254
350
|
assertEquals(asHuman.ok, true, "the human completer retires conformance-escalation");
|
|
255
351
|
assertEquals(asHuman.elementId, "conformance-escalation");
|
|
256
352
|
assertEquals(completed.length, 1);
|
|
257
|
-
assertEquals(completed[0].variables, { note: "filed follow-up" });
|
|
353
|
+
assertEquals(completed[0].variables, { note: "filed follow-up", completedUserTaskKey: "ut-c", completedCompletionId: 1 });
|
|
258
354
|
});
|
|
259
355
|
|
|
260
356
|
test("empty-plan-escalation is HUMAN-completable but NOT agent-completable (issues #623/#624)", async () => {
|
|
@@ -283,7 +379,7 @@ test("empty-plan-escalation is HUMAN-completable but NOT agent-completable (issu
|
|
|
283
379
|
assertEquals(asHuman.ok, true, "the human completer retires empty-plan-escalation");
|
|
284
380
|
assertEquals(asHuman.elementId, "empty-plan-escalation");
|
|
285
381
|
assertEquals(completed.length, 1);
|
|
286
|
-
assertEquals(completed[0].variables, { directive: "revise", notes: "look again" });
|
|
382
|
+
assertEquals(completed[0].variables, { directive: "revise", notes: "look again", completedUserTaskKey: "ut-e", completedCompletionId: 1 });
|
|
287
383
|
});
|
|
288
384
|
|
|
289
385
|
test("readiness-escalation(-pf) is HUMAN-completable but NOT agent-completable (issue #674)", async () => {
|
|
@@ -314,7 +410,7 @@ test("readiness-escalation(-pf) is HUMAN-completable but NOT agent-completable (
|
|
|
314
410
|
assertEquals(asHuman.ok, true, `the human completer retires ${elementId}`);
|
|
315
411
|
assertEquals(asHuman.elementId, elementId);
|
|
316
412
|
assertEquals(completed.length, 1);
|
|
317
|
-
assertEquals(completed[0].variables, { resolution: "abandon", answer: "upstream never published" });
|
|
413
|
+
assertEquals(completed[0].variables, { resolution: "abandon", answer: "upstream never published", completedUserTaskKey: "ut-r", completedCompletionId: 1 });
|
|
318
414
|
}
|
|
319
415
|
});
|
|
320
416
|
|
|
@@ -365,6 +461,182 @@ test("a human can revert/override an agent completion (recording who + when + co
|
|
|
365
461
|
assert(typeof row.reverted_at === "string" && row.reverted_at.length > 0, "reverted_at is stamped");
|
|
366
462
|
});
|
|
367
463
|
|
|
464
|
+
test("an auto-applied completion records the source adjudication id, and reverting it invalidates that adjudication (#806 review)", async () => {
|
|
465
|
+
// The convergence poller auto-resumes an already-answered wait-answer by replaying a
|
|
466
|
+
// `pr_adjudications` row. Marking that completion reverted alone would NOT stop the replay — the
|
|
467
|
+
// poller keeps matching the unchanged adjudication row. Reverting must TOMBSTONE the linked
|
|
468
|
+
// adjudication so the override actually sticks and the next round re-parks a human, while a
|
|
469
|
+
// redelivered `record-answer` cannot resurrect it (Copilot review of #806).
|
|
470
|
+
const stores = {
|
|
471
|
+
task_completions: { rows: [] as any[], key: "id" },
|
|
472
|
+
pr_adjudications: { rows: [{ id: 42, pr_key: "o/r#1", answer: "Cap at 5.", adjudicated_by: "alice", invalidated_at: null }] as any[], key: "id" },
|
|
473
|
+
};
|
|
474
|
+
const data = memData(stores);
|
|
475
|
+
const { engine } = fakeEngine([{ userTaskKey: "ut-1", elementId: "feature-escalation" }]);
|
|
476
|
+
|
|
477
|
+
const { completionId } = await completeUserTaskAttributed(
|
|
478
|
+
data,
|
|
479
|
+
engine,
|
|
480
|
+
{ userTaskKey: "ut-1", elementId: "feature-escalation", variables: { resolution: "answer", answer: "Cap at 5." } },
|
|
481
|
+
{ kind: "human", id: "alice" },
|
|
482
|
+
{ autoApplied: true, sourceAdjudicationId: 42 },
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const row = stores.task_completions.rows[0] as TaskCompletion;
|
|
486
|
+
assertEquals(row.auto_applied, 1, "an auto-apply is recorded auto_applied");
|
|
487
|
+
assertEquals(row.reversible, 1, "an auto-apply is always reversible, even when attributed to a human adjudicator");
|
|
488
|
+
assertEquals(row.source_adjudication_id, 42, "the replayed adjudication is linked");
|
|
489
|
+
|
|
490
|
+
assertEquals(stores.pr_adjudications.rows.length, 1, "the durable adjudication exists before the revert");
|
|
491
|
+
assertEquals(stores.pr_adjudications.rows[0].invalidated_at, null, "and is live (not tombstoned) before the revert");
|
|
492
|
+
const r = await revertAgentCompletion(data, completionId, { kind: "human", id: "bob" }, "override");
|
|
493
|
+
assertEquals(r.ok, true);
|
|
494
|
+
assertEquals(stores.pr_adjudications.rows.length, 1, "the source adjudication row is TOMBSTONED, not deleted, so a redelivered record-answer cannot re-insert its fingerprint");
|
|
495
|
+
assert(
|
|
496
|
+
typeof stores.pr_adjudications.rows[0].invalidated_at === "string" && stores.pr_adjudications.rows[0].invalidated_at.length > 0,
|
|
497
|
+
"reverting the auto-apply tombstoned its source adjudication so the poller cannot re-apply it",
|
|
498
|
+
);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("revert commits the tombstone and the ledger flip ATOMICALLY — a failed tombstone rolls back and is retryable (#806 review)", async () => {
|
|
502
|
+
// Atomicity: the revert tombstones the source adjudication(s) AND flips the ledger `reverted` flag in
|
|
503
|
+
// ONE transaction. If the tombstone throws, the whole transaction rolls back, so a retry sees an
|
|
504
|
+
// un-reverted, un-tombstoned row and completes cleanly — it never trips the `already reverted` guard
|
|
505
|
+
// with a still-live adjudication (an unrecoverable override, Finding 3).
|
|
506
|
+
const stores = {
|
|
507
|
+
task_completions: { rows: [] as any[], key: "id" },
|
|
508
|
+
pr_adjudications: { rows: [{ id: 42, pr_key: "o/r#1", answer: "Cap at 5.", invalidated_at: null }] as any[], key: "id" },
|
|
509
|
+
};
|
|
510
|
+
let failInvalidate = true;
|
|
511
|
+
// The FIRST tombstone attempt throws (a transient write failure); the transaction must roll back.
|
|
512
|
+
const data = memData(stores, { failExec: (sql) => failInvalidate && /pr_adjudications/.test(sql) });
|
|
513
|
+
const { engine } = fakeEngine([{ userTaskKey: "ut-1", elementId: "feature-escalation" }]);
|
|
514
|
+
|
|
515
|
+
const { completionId } = await completeUserTaskAttributed(
|
|
516
|
+
data,
|
|
517
|
+
engine,
|
|
518
|
+
{ userTaskKey: "ut-1", elementId: "feature-escalation", variables: { resolution: "answer", answer: "Cap at 5." } },
|
|
519
|
+
{ kind: "human", id: "alice" },
|
|
520
|
+
{ autoApplied: true, sourceAdjudicationId: 42 },
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
await assertRejects(() => revertAgentCompletion(data, completionId, { kind: "human", id: "bob" }, "override"));
|
|
524
|
+
assertEquals(
|
|
525
|
+
(stores.task_completions.rows[0] as TaskCompletion).reverted,
|
|
526
|
+
0,
|
|
527
|
+
"a failed tombstone must NOT have flipped the ledger reverted — otherwise the retry below is permanently rejected",
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
// The transient failure clears; a retry now completes and both tombstones + the ledger flip land.
|
|
531
|
+
failInvalidate = false;
|
|
532
|
+
const r = await revertAgentCompletion(data, completionId, { kind: "human", id: "bob" }, "override");
|
|
533
|
+
assertEquals(r.ok, true, "the retry after a transient failure succeeds (the revert is recoverable)");
|
|
534
|
+
assertEquals((stores.task_completions.rows[0] as TaskCompletion).reverted, 1, "the ledger is now reverted");
|
|
535
|
+
assert(
|
|
536
|
+
typeof stores.pr_adjudications.rows[0].invalidated_at === "string" && stores.pr_adjudications.rows[0].invalidated_at.length > 0,
|
|
537
|
+
"the source adjudication is tombstoned after the successful retry",
|
|
538
|
+
);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test("revert rolls the tombstone back when the LEDGER flip fails — no reverted=0-but-tombstoned window a redelivered record-answer could revive through (#806 review)", async () => {
|
|
542
|
+
// Finding B (Copilot review of #806): the tombstone and the `reverted` flip are two writes. Were they
|
|
543
|
+
// NOT atomic, a redelivered `record-answer` could interleave AFTER the tombstone but BEFORE `reverted`
|
|
544
|
+
// lands, observe the completion as still live (`reverted = 0`) with a tombstoned decision, and REVIVE
|
|
545
|
+
// it — resurrecting the operator's reverted override. Committing both in one transaction removes that
|
|
546
|
+
// intermediate state entirely: this test forces the SECOND write (the ledger flip) to throw and proves
|
|
547
|
+
// the FIRST (the tombstone) is rolled back, so no `reverted=0`-with-tombstone state is ever left behind.
|
|
548
|
+
const stores = {
|
|
549
|
+
task_completions: { rows: [] as any[], key: "id" },
|
|
550
|
+
pr_adjudications: { rows: [{ id: 42, pr_key: "o/r#1", answer: "Cap at 5.", invalidated_at: null }] as any[], key: "id" },
|
|
551
|
+
};
|
|
552
|
+
let failLedgerFlip = true;
|
|
553
|
+
const data = memData(stores, { failExec: (sql) => failLedgerFlip && /UPDATE "task_completions"/.test(sql) });
|
|
554
|
+
const { engine } = fakeEngine([{ userTaskKey: "ut-1", elementId: "feature-escalation" }]);
|
|
555
|
+
|
|
556
|
+
const { completionId } = await completeUserTaskAttributed(
|
|
557
|
+
data,
|
|
558
|
+
engine,
|
|
559
|
+
{ userTaskKey: "ut-1", elementId: "feature-escalation", variables: { resolution: "answer", answer: "Cap at 5." } },
|
|
560
|
+
{ kind: "human", id: "alice" },
|
|
561
|
+
{ autoApplied: true, sourceAdjudicationId: 42 },
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
await assertRejects(() => revertAgentCompletion(data, completionId, { kind: "human", id: "bob" }, "override"));
|
|
565
|
+
assertEquals(
|
|
566
|
+
stores.pr_adjudications.rows[0].invalidated_at,
|
|
567
|
+
null,
|
|
568
|
+
"the tombstone is rolled back when the ledger flip fails — the revert is all-or-nothing, so no revivable intermediate state exists",
|
|
569
|
+
);
|
|
570
|
+
assertEquals((stores.task_completions.rows[0] as TaskCompletion).reverted, 0, "the ledger stays un-reverted after the rollback");
|
|
571
|
+
|
|
572
|
+
// The transient failure clears; a retry now completes atomically.
|
|
573
|
+
failLedgerFlip = false;
|
|
574
|
+
const r = await revertAgentCompletion(data, completionId, { kind: "human", id: "bob" }, "override");
|
|
575
|
+
assertEquals(r.ok, true, "the retry after a transient failure succeeds");
|
|
576
|
+
assertEquals((stores.task_completions.rows[0] as TaskCompletion).reverted, 1, "the ledger is reverted after the retry");
|
|
577
|
+
assert(
|
|
578
|
+
typeof stores.pr_adjudications.rows[0].invalidated_at === "string" && stores.pr_adjudications.rows[0].invalidated_at.length > 0,
|
|
579
|
+
"and the source adjudication is tombstoned — both writes land together",
|
|
580
|
+
);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("reverting a first-hand completion leaves an UNLINKED adjudication untouched (#806 review)", async () => {
|
|
584
|
+
const stores = {
|
|
585
|
+
task_completions: { rows: [] as any[], key: "id" },
|
|
586
|
+
// An adjudication that this completion did NOT produce (source_completion_id ≠ our id) must not be
|
|
587
|
+
// tombstoned by reverting an unrelated completion.
|
|
588
|
+
pr_adjudications: { rows: [{ id: 7, pr_key: "o/r#1", answer: "keep", source_completion_id: 999, invalidated_at: null }] as any[], key: "id" },
|
|
589
|
+
};
|
|
590
|
+
const data = memData(stores);
|
|
591
|
+
const { engine } = fakeEngine([{ userTaskKey: "ut-1", elementId: "feature-escalation" }]);
|
|
592
|
+
|
|
593
|
+
const { completionId } = await completeUserTaskAttributed(
|
|
594
|
+
data,
|
|
595
|
+
engine,
|
|
596
|
+
{ userTaskKey: "ut-1", elementId: "feature-escalation", variables: { resolution: "answer", answer: "first-hand" } },
|
|
597
|
+
{ kind: "agent", id: "bot" },
|
|
598
|
+
);
|
|
599
|
+
|
|
600
|
+
const row = stores.task_completions.rows[0] as TaskCompletion;
|
|
601
|
+
assertEquals(row.auto_applied, 0);
|
|
602
|
+
assertEquals(row.source_adjudication_id, null, "a first-hand completion has no linked adjudication");
|
|
603
|
+
assertEquals((await revertAgentCompletion(data, completionId, { kind: "human", id: "alice" })).ok, true);
|
|
604
|
+
assertEquals(stores.pr_adjudications.rows.length, 1, "no adjudication is deleted for a first-hand revert");
|
|
605
|
+
assertEquals(stores.pr_adjudications.rows[0].invalidated_at, null, "an adjudication this completion did not produce is left live");
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("reverting a FIRST-HAND agent completion tombstones the adjudication it produced, via source_completion_id (#806 review)", async () => {
|
|
609
|
+
// A first-hand agent answer to a `wait-answer` records its OWN adjudication (auto_applied=0, no
|
|
610
|
+
// source_adjudication_id) linked back only by `source_completion_id`. Reverting that reversible agent
|
|
611
|
+
// completion must tombstone that decision, or the convergence poller re-auto-applies the overridden
|
|
612
|
+
// answer — the exact gap Finding 1 flagged. There is no `source_adjudication_id` to key on, so the
|
|
613
|
+
// revert finds the decision by the completion that created it.
|
|
614
|
+
const stores = {
|
|
615
|
+
task_completions: { rows: [] as any[], key: "id" },
|
|
616
|
+
pr_adjudications: { rows: [] as any[], key: "id" },
|
|
617
|
+
};
|
|
618
|
+
const data = memData(stores);
|
|
619
|
+
const { engine } = fakeEngine([{ userTaskKey: "ut-1", elementId: "feature-escalation" }]);
|
|
620
|
+
|
|
621
|
+
const { completionId } = await completeUserTaskAttributed(
|
|
622
|
+
data,
|
|
623
|
+
engine,
|
|
624
|
+
{ userTaskKey: "ut-1", elementId: "feature-escalation", variables: { resolution: "answer", answer: "first-hand" } },
|
|
625
|
+
{ kind: "agent", id: "bot" },
|
|
626
|
+
);
|
|
627
|
+
// The first-hand answer's own decision, linked to this completion (what `record-answer` records).
|
|
628
|
+
stores.pr_adjudications.rows.push({ id: 55, pr_key: "o/r#1", answer: "first-hand", source_completion_id: completionId, invalidated_at: null });
|
|
629
|
+
|
|
630
|
+
const row = stores.task_completions.rows[0] as TaskCompletion;
|
|
631
|
+
assertEquals(row.auto_applied, 0);
|
|
632
|
+
assertEquals(row.source_adjudication_id, null, "a first-hand completion has no source_adjudication_id — only source_completion_id links it");
|
|
633
|
+
assertEquals((await revertAgentCompletion(data, completionId, { kind: "human", id: "alice" })).ok, true);
|
|
634
|
+
assert(
|
|
635
|
+
typeof stores.pr_adjudications.rows[0].invalidated_at === "string" && stores.pr_adjudications.rows[0].invalidated_at.length > 0,
|
|
636
|
+
"reverting the first-hand agent completion tombstoned the adjudication it produced so the poller cannot re-apply it",
|
|
637
|
+
);
|
|
638
|
+
});
|
|
639
|
+
|
|
368
640
|
test("the ledger rolls back when the engine completion fails (never claims a completion that did not happen)", async () => {
|
|
369
641
|
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
370
642
|
const data = memData(stores);
|
|
@@ -591,7 +863,7 @@ test("completer accepts variables that satisfy the form contract (required prese
|
|
|
591
863
|
|
|
592
864
|
assertEquals(r.ok, true);
|
|
593
865
|
assertEquals(completed.length, 1, "a contract-valid completion resumes the process");
|
|
594
|
-
assertEquals(completed[0].variables, { directive: "revise", notes: "narrow scope" });
|
|
866
|
+
assertEquals(completed[0].variables, { directive: "revise", notes: "narrow scope", completedUserTaskKey: "ut-3", completedCompletionId: 1 });
|
|
595
867
|
});
|
|
596
868
|
|
|
597
869
|
test("validateEscalationVariables derives its contract from the canonical .form files", async () => {
|
package/app/agentCompletion.ts
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// an escalation user task" — the agent path is an extension of it, not a parallel copy.
|
|
22
22
|
|
|
23
23
|
import { readFileSync } from "node:fs";
|
|
24
|
-
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
24
|
+
import type { DataLayer, EngineClient, GatewayDataSource } from "@nanobpm/urban";
|
|
25
|
+
import { invalidateAdjudication, invalidateAdjudicationByCompletion } from "./adjudications.ts";
|
|
25
26
|
import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
|
|
26
27
|
import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
27
28
|
import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
|
|
@@ -51,6 +52,13 @@ export interface TaskCompletion {
|
|
|
51
52
|
variables_json: string;
|
|
52
53
|
/** 1 when a human may still override this completion (agent completions). */
|
|
53
54
|
reversible: number;
|
|
55
|
+
/** 1 when this completion is a machine AUTO-APPLY of a prior durable adjudication (issue #806), not
|
|
56
|
+
* a first-hand submission — recorded reversible so a human can always override the replayed answer. */
|
|
57
|
+
auto_applied: number;
|
|
58
|
+
/** The `pr_adjudications.id` this completion replayed, when it is an auto-apply (issue #806). NULL for
|
|
59
|
+
* a first-hand submission. `revertAgentCompletion` invalidates this exact adjudication on revert so
|
|
60
|
+
* the override is not silently re-applied by the next poller pass. */
|
|
61
|
+
source_adjudication_id: number | null;
|
|
54
62
|
/** 1 once a human has reverted/overridden it. */
|
|
55
63
|
reverted: number;
|
|
56
64
|
reverted_by: string | null;
|
|
@@ -269,14 +277,22 @@ export function validateEscalationVariables(
|
|
|
269
277
|
return null;
|
|
270
278
|
}
|
|
271
279
|
|
|
272
|
-
/** The canonical attributed completer. Records an attribution row in `task_completions`
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
* the
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
+
/** The canonical attributed completer. Records an attribution row in `task_completions` and THEN
|
|
281
|
+
* completes the user task with the exact typed `variables` — so the ledger row can never be lost by a
|
|
282
|
+
* resume that fires before the write. If the engine completion throws (a failed/rejected completion,
|
|
283
|
+
* or a lost race), the just-written row is rolled back so the ledger never claims a completion that
|
|
284
|
+
* did not happen, and the error is re-raised so the caller can retry. Returns the new completion id.
|
|
285
|
+
* This is the ONE host-side implementation of "complete an escalation user task"; the agent path, the
|
|
286
|
+
* human out-of-band answer path, AND the auto-apply replay all route through it.
|
|
287
|
+
*
|
|
288
|
+
* Reversibility of the recorded row: a FIRST-HAND completion is reversible IFF the actor is an agent —
|
|
289
|
+
* a human's first-hand answer is the authority and is NOT reversible. `opts.autoApplied` OVERRIDES
|
|
290
|
+
* that: an auto-applied REPLAY of a prior durable adjudication (issue #806) is ALWAYS recorded
|
|
291
|
+
* reversible (and flagged `auto_applied`) even when it preserves a prior HUMAN adjudicator's
|
|
292
|
+
* attribution, so a machine re-application is never an unchallengeable human authority and an operator
|
|
293
|
+
* may override it. `opts.sourceAdjudicationId` links such a replay back to the adjudication it
|
|
294
|
+
* re-applied (persisted only when `autoApplied`) so a later revert can invalidate it. Callers must
|
|
295
|
+
* therefore NOT infer irreversibility from a `human` attribution alone — check `auto_applied`. */
|
|
280
296
|
export async function completeUserTaskAttributed(
|
|
281
297
|
data: DataLayer,
|
|
282
298
|
engine: EngineClient,
|
|
@@ -287,6 +303,7 @@ export async function completeUserTaskAttributed(
|
|
|
287
303
|
variables: Record<string, unknown>;
|
|
288
304
|
},
|
|
289
305
|
actor: Actor,
|
|
306
|
+
opts?: { autoApplied?: boolean; sourceAdjudicationId?: number },
|
|
290
307
|
): Promise<{ completionId: number }> {
|
|
291
308
|
// Normalize + validate the attribution keys upfront so the ledger can never record a row with
|
|
292
309
|
// blank attribution or whitespace-mismatched keys.
|
|
@@ -295,7 +312,12 @@ export async function completeUserTaskAttributed(
|
|
|
295
312
|
const actorId = actor.id.trim();
|
|
296
313
|
if (!actorId) throw new Error("actor id is required");
|
|
297
314
|
|
|
298
|
-
|
|
315
|
+
// An AUTO-APPLIED replay of a prior durable adjudication (issue #806) is always human-overridable,
|
|
316
|
+
// regardless of the original actor kind — a machine re-application must never be an unchallengeable
|
|
317
|
+
// authority, so it is recorded `reversible` (and marked `auto_applied`) even when attributed to a
|
|
318
|
+
// prior HUMAN adjudicator.
|
|
319
|
+
const autoApplied = opts?.autoApplied === true;
|
|
320
|
+
const reversible = autoApplied || actor.kind === "agent";
|
|
299
321
|
const id = await taskCompletions(data).insert({
|
|
300
322
|
user_task_key: userTaskKey,
|
|
301
323
|
process_instance_key: target.processInstanceKey ?? null,
|
|
@@ -304,6 +326,10 @@ export async function completeUserTaskAttributed(
|
|
|
304
326
|
actor_id: actorId,
|
|
305
327
|
variables_json: JSON.stringify(target.variables ?? {}),
|
|
306
328
|
reversible: reversible ? 1 : 0,
|
|
329
|
+
auto_applied: autoApplied ? 1 : 0,
|
|
330
|
+
// Link an auto-apply back to the durable adjudication it replayed (issue #806) so a later revert can
|
|
331
|
+
// invalidate it. NULL for a first-hand submission; ignored (NULL) unless this is an auto-apply.
|
|
332
|
+
source_adjudication_id: autoApplied ? (opts?.sourceAdjudicationId ?? null) : null,
|
|
307
333
|
reverted: 0,
|
|
308
334
|
reverted_by: null,
|
|
309
335
|
reverted_note: null,
|
|
@@ -312,7 +338,23 @@ export async function completeUserTaskAttributed(
|
|
|
312
338
|
});
|
|
313
339
|
const completionId = Number(id);
|
|
314
340
|
try {
|
|
315
|
-
|
|
341
|
+
// Carry the completed user-task's identity forward on the resumed token (Copilot review of #806).
|
|
342
|
+
// `pr.answer-escalation` (record-answer) reconciles the durable adjudication AFTER this completion
|
|
343
|
+
// resumes the token; a convergence-loop instance is REUSED across rounds, so correlating the
|
|
344
|
+
// winning completion by process-instance + answer alone is ambiguous — an older round's completion,
|
|
345
|
+
// or a delayed higher-id same-answer row, can share both. Stamping the exact `userTaskKey` here lets
|
|
346
|
+
// that step require an EXACT ledger match against THIS wait-answer's completion (and fail open to a
|
|
347
|
+
// null adjudicator when the identity is absent) rather than attribute to an unrelated row. Reserved,
|
|
348
|
+
// additive keys — consumed only by record-answer; every other escalation flow simply never reads them.
|
|
349
|
+
// Injected only into the resumed token's variables, NOT the ledger row's `variables_json` above, so
|
|
350
|
+
// the recorded completion payload (and the answer correlation over it) is unchanged.
|
|
351
|
+
//
|
|
352
|
+
// Also stamp the EXACT ledger id of THIS completion (`completedCompletionId`). The engine resumes
|
|
353
|
+
// the token with exactly ONE completion's variables — the winner's — so its ledger id uniquely
|
|
354
|
+
// identifies the winning racer even when both racers submitted the IDENTICAL answer (answer
|
|
355
|
+
// correlation alone cannot separate two same-answer rows on the same `user_task_key`; the higher-id
|
|
356
|
+
// one may be the loser — Copilot review of #806). record-answer selects that exact row by id.
|
|
357
|
+
await engine.completeUserTask(userTaskKey, { ...target.variables, completedUserTaskKey: userTaskKey, completedCompletionId: completionId });
|
|
316
358
|
} catch (err) {
|
|
317
359
|
// The completion did not take — roll the attribution row back so the ledger reflects only
|
|
318
360
|
// completions that actually happened, and let the caller retry. The rollback is best-effort:
|
|
@@ -355,19 +397,28 @@ export interface AgentCompleteResult {
|
|
|
355
397
|
* completer passes the wider `HUMAN_COMPLETABLE_ELEMENTS` (which also admits `feature-blocked`).
|
|
356
398
|
* Queries `openUserTasks` (lifecycle-state `CREATED` only), NOT `searchUserTasks` (which returns
|
|
357
399
|
* tasks in ANY state) — a looping instance keeps COMPLETED/CANCELED tasks whose key could otherwise
|
|
358
|
-
* match and drive a doomed re-completion (a thrown 5xx) instead of the intended 404-style no-op.
|
|
400
|
+
* match and drive a doomed re-completion (a thrown 5xx) instead of the intended 404-style no-op.
|
|
401
|
+
*
|
|
402
|
+
* When the CALLER already knows the task's owning `processInstanceKey` (the convergence poller does —
|
|
403
|
+
* it just discovered the task in its sweep), pass it so the resolve scans that ONE instance instead of
|
|
404
|
+
* every open user task engine-wide. The auto-apply resume runs this once per already-adjudicated PR in
|
|
405
|
+
* a single poll pass, so an unfiltered global scan there is O(N²) work/REST load across the fleet
|
|
406
|
+
* (Copilot review of #806); a `processInstanceKey`-filtered scan makes it O(N). The filter never
|
|
407
|
+
* changes the outcome — the task lives in exactly that instance — and a miss still fails open (the
|
|
408
|
+
* one-off human/agent doors, which hold only a bare key, omit it and keep the engine-wide scan). */
|
|
359
409
|
async function resolveEscalationTask(
|
|
360
410
|
engine: EngineClient,
|
|
361
411
|
userTaskKey: string,
|
|
362
412
|
allowed: ReadonlySet<string> = ESCALATION_TASK_ELEMENTS,
|
|
363
|
-
|
|
364
|
-
|
|
413
|
+
processInstanceKey?: string,
|
|
414
|
+
): Promise<{ ok: true; elementId: string; processInstanceKey: string | null } | { ok: false; reason: string }> {
|
|
415
|
+
const open = await (processInstanceKey ? engine.openUserTasks({ processInstanceKey }) : engine.openUserTasks());
|
|
365
416
|
const match = open.find((t) => t.userTaskKey === userTaskKey);
|
|
366
417
|
if (!match) return { ok: false, reason: "no open completable task" };
|
|
367
418
|
if (!match.elementId || !isCompletableElement(match.elementId, allowed)) {
|
|
368
419
|
return { ok: false, reason: "not a completable task" };
|
|
369
420
|
}
|
|
370
|
-
return { ok: true, elementId: match.elementId };
|
|
421
|
+
return { ok: true, elementId: match.elementId, processInstanceKey: match.processInstanceKey ?? null };
|
|
371
422
|
}
|
|
372
423
|
|
|
373
424
|
/** Whether an open task's `elementId` is completable through the given `allowed` surface. Exact-set
|
|
@@ -406,7 +457,7 @@ export async function completeEscalationAsAgent(
|
|
|
406
457
|
const { completionId } = await completeUserTaskAttributed(
|
|
407
458
|
data,
|
|
408
459
|
engine,
|
|
409
|
-
{ userTaskKey, elementId: resolved.elementId, variables: input.variables },
|
|
460
|
+
{ userTaskKey, processInstanceKey: resolved.processInstanceKey, elementId: resolved.elementId, variables: input.variables },
|
|
410
461
|
{ kind: "agent", id: agentId },
|
|
411
462
|
);
|
|
412
463
|
return { ok: true, completionId, userTaskKey, elementId: resolved.elementId };
|
|
@@ -439,12 +490,49 @@ export async function completeEscalationAsHuman(
|
|
|
439
490
|
const { completionId } = await completeUserTaskAttributed(
|
|
440
491
|
data,
|
|
441
492
|
engine,
|
|
442
|
-
{ userTaskKey, elementId: resolved.elementId, variables: input.variables },
|
|
493
|
+
{ userTaskKey, processInstanceKey: resolved.processInstanceKey, elementId: resolved.elementId, variables: input.variables },
|
|
443
494
|
{ kind: "human", id: operatorId },
|
|
444
495
|
);
|
|
445
496
|
return { ok: true, completionId, userTaskKey, elementId: resolved.elementId };
|
|
446
497
|
}
|
|
447
498
|
|
|
499
|
+
/** AUTO-APPLY a prior durable adjudication to a re-derived escalation (issue #806). The convergence
|
|
500
|
+
* poller calls this to resume an already-answered `wait-answer` with the recorded answer instead of
|
|
501
|
+
* re-parking a human — through the SAME canonical `completeUserTaskAttributed` door, so there is no
|
|
502
|
+
* parallel completion path. Unlike `completeEscalationAsHuman` it records the completion `auto_applied`
|
|
503
|
+
* (a machine replay, distinguishable from a first-hand submission in the ledger) and PRESERVES the
|
|
504
|
+
* prior adjudicator's attribution kind (`human`/`agent`), so replaying an agent-settled decision can
|
|
505
|
+
* never launder it into an irreversible human authority (Copilot review of #806). Auto-applied
|
|
506
|
+
* completions are always recorded reversible, so a human may override the replayed answer. A key with
|
|
507
|
+
* no matching open escalation task is a 404-style no-op. Pass the poller-known `processInstanceKey` so
|
|
508
|
+
* the resolve scans that one instance instead of an engine-wide `openUserTasks()` — the resume runs
|
|
509
|
+
* once per already-adjudicated PR per poll pass, so a global scan there is O(N²) (Copilot review of #806). */
|
|
510
|
+
export async function completeEscalationAutoApplied(
|
|
511
|
+
data: DataLayer,
|
|
512
|
+
engine: EngineClient,
|
|
513
|
+
input: { userTaskKey: string; variables: Record<string, unknown>; actor: Actor; adjudicationId?: number; processInstanceKey?: string },
|
|
514
|
+
): Promise<AgentCompleteResult> {
|
|
515
|
+
const userTaskKey = input.userTaskKey.trim();
|
|
516
|
+
if (!userTaskKey) return { ok: false, reason: "userTaskKey is required" };
|
|
517
|
+
const actorId = input.actor.id.trim();
|
|
518
|
+
if (!actorId) return { ok: false, reason: "actor id is required" };
|
|
519
|
+
|
|
520
|
+
const resolved = await resolveEscalationTask(engine, userTaskKey, HUMAN_COMPLETABLE_ELEMENTS, input.processInstanceKey?.trim() || undefined);
|
|
521
|
+
if (!resolved.ok) return resolved;
|
|
522
|
+
|
|
523
|
+
const invalid = validateEscalationVariables(resolved.elementId, input.variables);
|
|
524
|
+
if (invalid) return { ok: false, reason: invalid };
|
|
525
|
+
|
|
526
|
+
const { completionId } = await completeUserTaskAttributed(
|
|
527
|
+
data,
|
|
528
|
+
engine,
|
|
529
|
+
{ userTaskKey, processInstanceKey: resolved.processInstanceKey, elementId: resolved.elementId, variables: input.variables },
|
|
530
|
+
{ kind: input.actor.kind, id: actorId },
|
|
531
|
+
{ autoApplied: true, sourceAdjudicationId: input.adjudicationId },
|
|
532
|
+
);
|
|
533
|
+
return { ok: true, completionId, userTaskKey, elementId: resolved.elementId };
|
|
534
|
+
}
|
|
535
|
+
|
|
448
536
|
export interface RevertResult {
|
|
449
537
|
ok: boolean;
|
|
450
538
|
reason?: string;
|
|
@@ -471,11 +559,63 @@ export async function revertAgentCompletion(
|
|
|
471
559
|
if (!reverterId) return { ok: false, reason: "reverter id is required" };
|
|
472
560
|
|
|
473
561
|
const correction = typeof note === "string" ? note.trim() : "";
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
562
|
+
// TOMBSTONE the durable adjudication(s) this completion produced AND flip the ledger `reverted` flag
|
|
563
|
+
// in ONE transaction (issue #806 review — atomicity). These are two writes; committing them
|
|
564
|
+
// atomically is what makes the revert safe against BOTH a crash mid-revert AND a concurrent revive:
|
|
565
|
+
// • Retry-safety: if either write throws, the whole transaction rolls back, so a retry sees an
|
|
566
|
+
// un-reverted, un-tombstoned row and re-runs cleanly — it never trips the `row.reverted` guard
|
|
567
|
+
// above with a still-live adjudication (an unrecoverable override).
|
|
568
|
+
// • Revive-race safety: `record-answer` is at-least-once, so a redelivered `record-answer` for this
|
|
569
|
+
// completion can run concurrently with this revert. Were the tombstone and the `reverted` flip
|
|
570
|
+
// SEPARATE writes, that redelivery could observe the intermediate state (`invalidated_at` set but
|
|
571
|
+
// `reverted` still 0), enter {@link reviveTombstonedDecision}, and clear the operator's tombstone —
|
|
572
|
+
// resurrecting the reverted decision. Inside a transaction the intermediate state is never visible
|
|
573
|
+
// to another connection: the concurrent revive observes EITHER neither write (adjudication live →
|
|
574
|
+
// not revivable) OR both (`reverted = 1` → its revive guard fails). Marking the ledger reverted
|
|
575
|
+
// alone would NOT stop the replay — the convergence poller matches the unchanged `pr_adjudications`
|
|
576
|
+
// row and re-applies the overridden answer on the next derived task, silently undoing this revert.
|
|
577
|
+
//
|
|
578
|
+
// Two disjoint links must be severed, and each no-ops when inapplicable:
|
|
579
|
+
// • auto-applied replay — this completion replayed an EXISTING decision (`auto_applied=1`,
|
|
580
|
+
// `source_adjudication_id` set); invalidate that decision by id.
|
|
581
|
+
// • first-hand agent answer — this completion is the agent's own answer to a `wait-answer`
|
|
582
|
+
// (`auto_applied=0`, no `source_adjudication_id`) that RECORDED a decision linked back by
|
|
583
|
+
// `source_completion_id`; invalidate by completion id. Without this the reverted first-hand
|
|
584
|
+
// answer stays live and the poller re-auto-applies it.
|
|
585
|
+
//
|
|
586
|
+
// The by-completion tombstone covers the record-THEN-revert ordering (the decision row already
|
|
587
|
+
// exists, so the tombstone finds and invalidates it). The MIRROR ordering — a revert that lands
|
|
588
|
+
// BEFORE the downstream `record-answer` job has inserted the row — is closed on the WRITE side:
|
|
589
|
+
// `recordAdjudication`/`healBlankProvenance` fence their INSERT/UPDATE on the source completion NOT
|
|
590
|
+
// being reverted ({@link notRevertedGuard}), so once we commit `reverted` below a late record-answer
|
|
591
|
+
// affects zero rows and cannot create a live decision linked to this reverted completion (issue #806
|
|
592
|
+
// review, Copilot). SQLite serialises the two writes, so whichever commits first the other observes.
|
|
593
|
+
// The `row.reverted` guard above is a READ from before this transaction, so it cannot serialise two
|
|
594
|
+
// concurrent reverts of the SAME completion: both snapshots observe `reverted = 0`, both pass the
|
|
595
|
+
// guard, and a blind `update(completionId, …)` would let the SECOND commit overwrite the FIRST's
|
|
596
|
+
// `reverted_by`/`reverted_note`/`reverted_at`, laundering the audit trail and violating the documented
|
|
597
|
+
// "a completion can only be reverted once" invariant (issue #806 review, Copilot). Fence the ledger
|
|
598
|
+
// flip on `reverted = 0` inside the transaction so exactly one revert wins: SQLite serialises the two
|
|
599
|
+
// transactions, so the loser's guarded UPDATE changes ZERO rows. On that zero-row loss we throw to roll
|
|
600
|
+
// back the WHOLE transaction — including this revert's tombstones — leaving the winner's revert and its
|
|
601
|
+
// tombstones as the sole durable state, and report the loss as the same idempotent `already reverted`.
|
|
602
|
+
const src = data.open();
|
|
603
|
+
const alreadyReverted = Symbol("already-reverted");
|
|
604
|
+
try {
|
|
605
|
+
await src.tx(async (t: GatewayDataSource) => {
|
|
606
|
+
if (row.auto_applied && row.source_adjudication_id != null) {
|
|
607
|
+
await invalidateAdjudication(data, row.source_adjudication_id, t);
|
|
608
|
+
}
|
|
609
|
+
await invalidateAdjudicationByCompletion(data, completionId, t);
|
|
610
|
+
const res = await t.exec(
|
|
611
|
+
`UPDATE "task_completions" SET "reverted" = 1, "reverted_by" = ?, "reverted_note" = ?, "reverted_at" = ? WHERE "id" = ? AND "reverted" = 0`,
|
|
612
|
+
[reverterId, correction || null, now(), completionId],
|
|
613
|
+
);
|
|
614
|
+
if (res.changed === 0) throw alreadyReverted;
|
|
615
|
+
});
|
|
616
|
+
} catch (err) {
|
|
617
|
+
if (err === alreadyReverted) return { ok: false, reason: "completion already reverted" };
|
|
618
|
+
throw err;
|
|
619
|
+
}
|
|
480
620
|
return { ok: true, completionId };
|
|
481
621
|
}
|
|
@@ -141,7 +141,7 @@ test("escalate REQUEST → Tasks-inbox row → operator ALLOW via the completion
|
|
|
141
141
|
assertEquals(result.completion.elementId, "acp-permission");
|
|
142
142
|
assertEquals(completed.length, 1);
|
|
143
143
|
assertEquals(completed[0].userTaskKey, "ut-perm-1");
|
|
144
|
-
assertEquals(completed[0].variables, { optionId: "allow", allowed: true });
|
|
144
|
+
assertEquals(completed[0].variables, { optionId: "allow", allowed: true, completedUserTaskKey: "ut-perm-1", completedCompletionId: 1 });
|
|
145
145
|
assertEquals(stores.task_completions.rows.length, 1);
|
|
146
146
|
assertEquals(stores.task_completions.rows[0].actor_kind, "human");
|
|
147
147
|
|
|
@@ -173,7 +173,7 @@ test("escalate REQUEST → operator DENY via the completion door → RESOLUTION
|
|
|
173
173
|
});
|
|
174
174
|
|
|
175
175
|
assertEquals(result.completion.ok, true);
|
|
176
|
-
assertEquals(completed[0].variables, { optionId: "deny", allowed: false });
|
|
176
|
+
assertEquals(completed[0].variables, { optionId: "deny", allowed: false, completedUserTaskKey: "ut-perm-2", completedCompletionId: 1 });
|
|
177
177
|
assertEquals(frames.length, 1);
|
|
178
178
|
const resolution = decodeResolution(frames[0]);
|
|
179
179
|
assertEquals(resolution.callId, "job-deny");
|