@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.
@@ -55,10 +55,106 @@ function memTable(rows: any[], key: string) {
55
55
  };
56
56
  }
57
57
 
58
- function memData(stores: Record<string, { rows: any[]; key: string }>) {
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
- table: (name: string, key: string) =>
61
- memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
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 () => {
@@ -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` (reversible
273
- * iff the actor is an agent) and THEN completes the user task with the exact typed `variables` — so
274
- * the ledger row can never be lost by a resume that fires before the write. If the engine
275
- * completion throws (a failed/rejected completion, or a lost race), the just-written row is rolled
276
- * back so the ledger never claims a completion that did not happen, and the error is re-raised so
277
- * the caller can retry. Returns the new completion id. This is the ONE host-side implementation of
278
- * "complete an escalation user task"; both the agent path and the human out-of-band answer paths
279
- * route through it. */
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
- const reversible = actor.kind === "agent";
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
- await engine.completeUserTask(userTaskKey, target.variables);
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
- ): Promise<{ ok: true; elementId: string } | { ok: false; reason: string }> {
364
- const open = await engine.openUserTasks();
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
- await taskCompletions(data).update(completionId, {
475
- reverted: 1,
476
- reverted_by: reverterId,
477
- reverted_note: correction || null,
478
- reverted_at: now(),
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");