@nanobpm/nano-workforce 0.189.0 → 0.189.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/service.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  // `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
10
10
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
11
11
  import { ABANDONED_STATUS, abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
12
- import { escalationFormId } from "./agentCompletion.ts";
12
+ import { matchAdjudication, prAdjudications, resetAdjudications } from "./adjudications.ts";
13
+ import { completeEscalationAutoApplied, escalationFormId } from "./agentCompletion.ts";
13
14
  import { agentSlaTimeout } from "./agentSla.ts";
14
15
  import {
15
16
  CAPS_RESOLVED_MESSAGE,
@@ -585,6 +586,14 @@ export async function submitPr(
585
586
  for (const e of await escs(data).find({ pr_key: parsed.prKey, status: "open" })) {
586
587
  await escs(data).update(e.id, { status: "stale" });
587
588
  }
589
+ // A fresh convergence run must ALSO start with a clean durable adjudication memory (issue #806,
590
+ // Copilot review): the auto-resume replays a prior `(PR, question)` answer forever, so a re-opened
591
+ // PR whose question recurs would silently auto-apply the stale decision and an operator could never
592
+ // force a fresh one. This PR's adjudications are invalidated on reopen — but the reset is deferred
593
+ // to AFTER `process_key` is advanced to the new instance (see below), NOT here: clearing the memory
594
+ // while `process_key` still names the OLD instance leaves a window where a delayed old-instance
595
+ // answer job still passes the worker's staleness gate and reinserts its adjudication into the fresh
596
+ // run (Copilot review of #806). Advancing the run identity FIRST, then clearing, fences that job.
588
597
  // Re-open a previously converged/abandoned/merged PR for a fresh convergence run.
589
598
  await table.update(parsed.prKey, {
590
599
  status: "converging",
@@ -682,8 +691,44 @@ export async function submitPr(
682
691
  },
683
692
  });
684
693
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
685
- if (processKey != null) {
686
- await table.update(parsed.prKey, { process_key: processKey });
694
+ // The `process_key` advance and the adjudication reset below are the two writes that MAKE the new
695
+ // run authoritative. If EITHER throws, the newly created instance is already live but the reopen is
696
+ // only half-committed — and a retry would short-circuit at the `alreadyRunning` idempotency gate
697
+ // (the new instance is ACTIVE, so `derived_status` is non-terminal), never re-running the reset. A
698
+ // failed reset would then leave the fresh run replaying STALE adjudication memory indefinitely
699
+ // (Copilot review). So roll the just-created run back on failure: terminate it and rethrow, so the
700
+ // submission is NOT treated as started. Terminating flips the PR's derived tracking status to a
701
+ // terminal edge (`abandoned`) via the `instanceTracking` reconciler, making the PR resubmittable so
702
+ // a retry re-creates a fresh instance and re-runs the reset cleanly — no orphaned run auto-applies
703
+ // stale decisions in the meantime.
704
+ try {
705
+ if (processKey != null) {
706
+ await table.update(parsed.prKey, { process_key: processKey });
707
+ }
708
+ // Invalidate this PR's durable adjudication memory for the fresh run (issue #806, Copilot review) —
709
+ // deferred to HERE, after `process_key` is advanced to the new instance above, so the reset happens
710
+ // UNDER the new run identity. On reopen (`existing`), any delayed old-instance answer job is now
711
+ // rejected by the worker's staleness gate (its `processInstanceKey` no longer matches the advanced
712
+ // `process_key`), so it cannot reinsert a stale adjudication after the reset; and the worker reads
713
+ // `process_key` as late as possible so it observes this advance. The insert-if-absent record then
714
+ // re-learns the operator's new answer for the new run. Runs unconditionally (even if `processKey` is
715
+ // null: the memory must still be clean for the fresh run). The wipe is a SINGLE atomic `DELETE`
716
+ // (`resetAdjudications`), never a row-by-row loop, so a crash mid-reset cannot leave a partially
717
+ // cleared memory (Copilot review of #806).
718
+ if (existing) {
719
+ await resetAdjudications(data, parsed.prKey);
720
+ }
721
+ } catch (err) {
722
+ if (processInstanceKey != null) {
723
+ try {
724
+ await engine.cancelInstance({ processInstanceKey: String(processInstanceKey) });
725
+ } catch (cancelErr) {
726
+ // Best-effort: a failed rollback-cancel leaves the instance for the abandon/reconcile poller
727
+ // to reap, but must not mask the original error that the caller needs to see and retry on.
728
+ console.warn(`[submit] ${parsed.prKey} rollback-cancel of ${processInstanceKey} failed: ${cancelErr}`);
729
+ }
730
+ }
731
+ throw err;
687
732
  }
688
733
  return { prKey: parsed.prKey, processKey };
689
734
  }
@@ -2839,12 +2884,67 @@ export async function pollUserTasks(
2839
2884
  // Desired set, deduped by completable key (a task is open at most once; guard a page overlap / a
2840
2885
  // subject seen under two statuses mid-pass).
2841
2886
  const desiredByKey = new Map<string, UserTaskRow>();
2887
+ // Keys auto-resumed from a durable adjudication this pass (issue #806). The reduced-capability scan
2888
+ // visits each instance twice (direct + callActivity hierarchy), and both queries snapshot the task
2889
+ // BEFORE the resume removes it, so the second visit would otherwise re-attempt a now-gone completion
2890
+ // and fall through to projecting the very row we just retired. Recording the key keeps the resume
2891
+ // one-shot and out of the inbox.
2892
+ const resumedByKey = new Set<string>();
2842
2893
  const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string, rootProcessInstanceKey: string, formKey: string) => {
2843
2894
  if (!elementId) return;
2844
2895
  const rowKey = userTaskKey.trim();
2845
- if (!rowKey || desiredByKey.has(rowKey)) return;
2896
+ if (!rowKey || desiredByKey.has(rowKey) || resumedByKey.has(rowKey)) return;
2846
2897
  const ctx = await contextFor(elementId, userTaskKey, processInstanceKey, rootProcessInstanceKey, formKey);
2847
2898
  if (!ctx) return;
2899
+ // Durable adjudication auto-resume (issue #806): before surfacing a NEW convergence `wait-answer`
2900
+ // to a human, check whether THIS PR already has a settled adjudication for the SAME question
2901
+ // (canonical `questionFingerprint`). If it does, resume the loop with the recorded answer through
2902
+ // the canonical `completeUserTaskAttributed` door — attributed to the prior adjudicator and marked
2903
+ // `auto_applied` (a machine replay, reversible so a human can still override) so it is never
2904
+ // laundered into a first-hand irreversible human authority (Copilot review of #806) — instead of
2905
+ // re-parking a human on an already-answered question (PR #800 / proc 46310: the same design
2906
+ // question escalated at round 2 and again at round 13). Scoped to the review loop's `wait-answer`
2907
+ // on a real PR key; on any resolution failure the task still projects, so an un-resumable question
2908
+ // always reaches a human (fail-open to the human).
2909
+ if (elementId === PR_WAIT_ANSWER_ELEMENT && ctx.subjectType === "pr" && ctx.question && parsePr(ctx.subjectKey)) {
2910
+ try {
2911
+ // The adjudication LOOKUP lives inside this fail-open `try` (not just the resume) so a transient
2912
+ // `pr_adjudications.find` error never rejects `project` and aborts `pollUserTasks` mid-pass — the
2913
+ // task still projects and the question always reaches a human (SPEC: adjudication-resolution
2914
+ // failures fail open to the human).
2915
+ const adjudication = matchAdjudication(await prAdjudications(data).find({ pr_key: ctx.subjectKey }), ctx.question);
2916
+ // Only auto-resume when the prior adjudicator's provenance is KNOWN. A settled row with a blank
2917
+ // `adjudicated_by` (completed out of band, so `latestAdjudicator` returned no actor) must NOT be
2918
+ // manufactured into a synthetic `human` actor — that would audit an unknown-provenance replay as
2919
+ // a first-hand human decision. Fail open to a fresh human task instead (Copilot review of #806).
2920
+ const adjudicatedBy = adjudication?.adjudicated_by?.trim();
2921
+ if (adjudication && adjudicatedBy) {
2922
+ const resumed = await completeEscalationAutoApplied(data, engine, {
2923
+ userTaskKey: rowKey,
2924
+ // The sweep already discovered this task's owning instance — hand it to the resolve so the
2925
+ // auto-apply scans that ONE instance, not every open user task engine-wide (issue #806
2926
+ // Copilot review: an unfiltered per-task scan makes a single poll pass O(N²) across N
2927
+ // already-adjudicated PRs). `contextFor`/the sweep report the task's direct instance, so the
2928
+ // filtered scan finds exactly this task; a miss still fails open to the human.
2929
+ processInstanceKey,
2930
+ variables: { answer: adjudication.answer },
2931
+ actor: {
2932
+ kind: adjudication.adjudicated_kind === "agent" ? "agent" : "human",
2933
+ id: adjudicatedBy,
2934
+ },
2935
+ // Link the auto-apply back to the replayed adjudication (issue #806) so a human revert of the
2936
+ // resulting completion invalidates this exact decision instead of it being silently re-applied.
2937
+ adjudicationId: adjudication.id,
2938
+ });
2939
+ if (resumed.ok) {
2940
+ resumedByKey.add(rowKey);
2941
+ return;
2942
+ }
2943
+ }
2944
+ } catch (err) {
2945
+ console.error(`[poller] adjudication auto-resume (${ctx.subjectKey}): ${err}`);
2946
+ }
2947
+ }
2848
2948
  const row = buildUserTaskRow(ctx, at);
2849
2949
  if (row) desiredByKey.set(rowKey, row);
2850
2950
  };
@@ -51,6 +51,27 @@ function memData(stores: Stores) {
51
51
  return {
52
52
  table: withTrackingViews((name: string, key: string) =>
53
53
  memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
54
+ // Emulates the atomic bulk `DELETE FROM "pr_adjudications" WHERE "pr_key" = ?` submitPr issues via
55
+ // `data.open().exec` to reset a reopened PR's adjudication memory (Copilot review of #806). The SQL
56
+ // is validated against real SQLite in app/adjudications.test.ts; here it need only mutate the store.
57
+ open: () => ({
58
+ exec: async (sql: string, params: any[] = []) => {
59
+ if (/DELETE FROM "pr_adjudications" WHERE "pr_key" = \?/.test(sql)) {
60
+ const store = stores.pr_adjudications;
61
+ let changed = 0;
62
+ if (store) {
63
+ for (let i = store.rows.length - 1; i >= 0; i--) {
64
+ if (store.rows[i].pr_key === params[0]) {
65
+ store.rows.splice(i, 1);
66
+ changed++;
67
+ }
68
+ }
69
+ }
70
+ return { changed };
71
+ }
72
+ throw new Error(`unexpected exec sql: ${sql}`);
73
+ },
74
+ }),
54
75
  } as any;
55
76
  }
56
77
 
@@ -0,0 +1,61 @@
1
+ -- 109_pr_adjudications.sql — issue #806: persist wait-answer human adjudications so an
2
+ -- already-answered convergence question does not re-escalate.
3
+ --
4
+ -- The convergence loop's `wait-answer` escalation (`record-answer` → resume) applies a human's
5
+ -- answer to the CURRENT round but keeps NO durable memory that "(this PR, this question) was already
6
+ -- adjudicated to X". A stateless later round that re-derives the identical escalation condition
7
+ -- re-parks a human from scratch (PR #800 / proc 46310: the same design question escalated at round 2
8
+ -- and again at round 13, both answered identically). A human adjudication is NOT GitHub-derivable —
9
+ -- it is a fact the app itself must remember — so it lives here, durably.
10
+ --
11
+ -- One row per (PR, question) a human has settled: `pr.answer-escalation` (record-answer) writes it on
12
+ -- answering, and the poller (`pollUserTasks`) reads it before surfacing a NEW `wait-answer` — when the
13
+ -- question's fingerprint matches an existing row it auto-resumes with the recorded answer (attributed
14
+ -- to the prior adjudicator) instead of re-escalating a human.
15
+ --
16
+ -- • question_fingerprint — the canonical `normalizeAdvisoryText` + `fingerprint` digest of the
17
+ -- escalation question (app/github.ts `questionFingerprint`), the SAME line-stable normalisation
18
+ -- advisory acks use; so only a byte/semantic-identical, already-answered question is suppressed
19
+ -- while a materially different question still escalates. No second fingerprint implementation.
20
+ -- • answer / adjudicated_by / adjudicated_kind / adjudicated_at — the settled answer, who settled it,
21
+ -- whether they were a `human` or an `agent` (ADR 0046), and when, so the auto-resume replays the
22
+ -- exact decision AND preserves the original attribution kind — a human-settled decision replays as
23
+ -- human, an agent-settled one as agent, so an auto-apply can never launder an agent decision into an
24
+ -- irreversible human authority (Copilot review of #806).
25
+ -- • invalidated_at — a TOMBSTONE set when a human REVERTS the auto-applied completion that replayed
26
+ -- this decision (`revertAgentCompletion` → `invalidateAdjudication`, Copilot review of #806). A plain
27
+ -- DELETE is NOT race-safe: the reverted completion's `record-answer` job can be redelivered
28
+ -- (at-least-once) AFTER the delete and re-insert the SAME `(pr_key, question_fingerprint)`, so the
29
+ -- next poller pass re-auto-applies and silently undoes the revert. Keeping the row as a tombstone lets
30
+ -- the `UNIQUE (pr_key, question_fingerprint)` fence make that redelivered re-insert a no-op, and
31
+ -- `matchAdjudication` skips a tombstoned row so it never auto-applies again. The tombstone is cleared
32
+ -- only by `resetAdjudications` on a fresh-run re-submit. NULL for a live, replayable decision.
33
+ -- • source_completion_id — the `task_completions.id` of the WINNING completion that produced this
34
+ -- decision (issue #806 review). A FIRST-HAND agent answer to a `wait-answer` records its own durable
35
+ -- adjudication (`adjudicated_kind="agent"`) but — unlike a machine auto-apply — its ledger row has
36
+ -- `auto_applied=0` and NO `source_adjudication_id`, so a human revert of that reversible agent
37
+ -- completion could not previously find and tombstone the decision it created, and the poller would
38
+ -- re-auto-apply the reverted answer. Linking every convergence adjudication to its winning completion
39
+ -- lets `revertAgentCompletion` invalidate the decision on ANY reversible agent revert, not only a
40
+ -- machine replay (`invalidateAdjudicationByCompletion`). INSERT-if-absent, so only the ORIGINAL
41
+ -- first-hand completion is recorded; a later auto-apply's re-record is a UNIQUE no-op that leaves the
42
+ -- link pointing at the first-hand winner. NULL for a legacy/uncorrelated answer.
43
+ --
44
+ -- `UNIQUE (pr_key, question_fingerprint)` keeps one settled answer per (PR, question); the surrogate
45
+ -- `id` PK gives the `Table<T>` gateway a single-column key. Forward-only, additive (expand). Numbered
46
+ -- after the current highest committed prefix (104) in the pre-assigned 109–110 block (#806); the
47
+ -- runner wraps each file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
48
+ CREATE TABLE IF NOT EXISTS pr_adjudications (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ pr_key TEXT NOT NULL REFERENCES pull_requests(pr_key),
51
+ question_fingerprint TEXT NOT NULL,
52
+ answer TEXT,
53
+ adjudicated_by TEXT,
54
+ adjudicated_kind TEXT,
55
+ adjudicated_at TEXT NOT NULL,
56
+ invalidated_at TEXT,
57
+ source_completion_id INTEGER,
58
+ UNIQUE (pr_key, question_fingerprint)
59
+ );
60
+ CREATE INDEX IF NOT EXISTS idx_pradj_pr ON pr_adjudications(pr_key);
61
+ CREATE INDEX IF NOT EXISTS idx_pradj_srccompletion ON pr_adjudications(source_completion_id);
@@ -0,0 +1,34 @@
1
+ -- 110_task_completions_auto_applied.sql — issue #806 (Copilot review): mark an AUTO-APPLIED escalation
2
+ -- completion so it is distinguishable from a fresh human/agent submission in the attribution ledger.
3
+ --
4
+ -- The convergence poller auto-resumes an already-answered `wait-answer` by replaying a durable
5
+ -- adjudication through the SAME `completeUserTaskAttributed` door a human/agent uses (app/service.ts,
6
+ -- issue #806). Without a marker that replay is INDISTINGUISHABLE from a real, first-hand submission in
7
+ -- `task_completions`, and — recorded as an irreversible authority — it could launder an earlier
8
+ -- agent-originated decision into an unchallengeable human one. `auto_applied=1` records "this
9
+ -- completion is a machine replay of a prior decision, not a fresh submission"; the app also records
10
+ -- such completions `reversible=1` so a human can always override an auto-applied answer.
11
+ --
12
+ -- Forward-only, additive (expand): a nullable-defaulted `ADD COLUMN`, so every existing completion
13
+ -- reads back `auto_applied=0` (a genuine first-hand submission). Numbered after 109 in the pre-assigned
14
+ -- 109–110 block (#806); the runner wraps each file in its own transaction, so no BEGIN/COMMIT here.
15
+ ALTER TABLE task_completions ADD COLUMN auto_applied INTEGER NOT NULL DEFAULT 0;
16
+
17
+ -- Link an AUTO-APPLIED escalation completion back to the durable adjudication it replayed (issue #806,
18
+ -- Copilot review), so a human's revert of that completion can invalidate the exact decision. The
19
+ -- convergence poller auto-resumes an already-answered `wait-answer` by replaying a `pr_adjudications`
20
+ -- row through `completeEscalationAutoApplied` (app/service.ts); recording WHICH adjudication it replayed
21
+ -- lets `revertAgentCompletion` tombstone that decision (`invalidateAdjudication`) so the revert becomes a
22
+ -- real override — the next round re-parks a human — instead of the poller silently re-applying the same
23
+ -- overridden answer. NULL for every first-hand (human/agent) submission and for legacy rows; only an
24
+ -- auto-apply carries a source adjudication. Additive (expand): a nullable `ADD COLUMN`. Folded into this
25
+ -- file to keep the whole change inside the pre-assigned 109–110 block (#806, Copilot review) rather than
26
+ -- consuming an unallocated 111 prefix.
27
+ ALTER TABLE task_completions ADD COLUMN source_adjudication_id INTEGER;
28
+
29
+ -- `latestAdjudicator` (workers/answer-escalation) now looks a completion up by `process_instance_key`
30
+ -- for every convergence answer, to correlate the settled adjudicator's attribution (#806). The ledger
31
+ -- is append-only and only carried an index on `user_task_key` (026_agent_completion.sql), so that
32
+ -- lookup would scan the whole completion history as the fleet grows. Index `process_instance_key` too
33
+ -- (additive/expand — a new index, no existing shape touched).
34
+ CREATE INDEX idx_task_completions_pik ON task_completions(process_instance_key);
@@ -80,7 +80,7 @@ test("complete-user-task: completes a plan-review escalation and drops its read-
80
80
  assertEquals(res.status, 200);
81
81
  assertEquals(res.body.ok, true);
82
82
  assertEquals(res.body.elementId, "plan-review-decision");
83
- assertEquals(completed, [{ userTaskKey: "ut-1", variables: { directive: "revise", notes: "narrow scope" } }]);
83
+ assertEquals(completed, [{ userTaskKey: "ut-1", variables: { directive: "revise", notes: "narrow scope", completedUserTaskKey: "ut-1", completedCompletionId: 1 } }]);
84
84
  assertEquals(stores.user_tasks, []);
85
85
  // Attribution recorded as a human completion.
86
86
  assertEquals(stores.task_completions.length, 1);
@@ -93,7 +93,7 @@ test("complete-user-task: completes a trial-merge escalation with the typed acti
93
93
  const res = await call(app, { userTaskKey: "ut-2", variables: { action: "rebase" } });
94
94
 
95
95
  assertEquals(res.status, 200);
96
- assertEquals(completed, [{ userTaskKey: "ut-2", variables: { action: "rebase" } }]);
96
+ assertEquals(completed, [{ userTaskKey: "ut-2", variables: { action: "rebase", completedUserTaskKey: "ut-2", completedCompletionId: 1 } }]);
97
97
  });
98
98
 
99
99
  test("complete-user-task: a missing userTaskKey is a 400", async () => {
@@ -120,7 +120,7 @@ test("complete-user-task: completes a feature-blocked acknowledgement with the t
120
120
  const res = await call(app, { userTaskKey: "ut-4", variables: { note: "reassigned" } });
121
121
  assertEquals(res.status, 200);
122
122
  assertEquals(res.body.elementId, "feature-blocked");
123
- assertEquals(completed, [{ userTaskKey: "ut-4", variables: { note: "reassigned" } }]);
123
+ assertEquals(completed, [{ userTaskKey: "ut-4", variables: { note: "reassigned", completedUserTaskKey: "ut-4", completedCompletionId: 1 } }]);
124
124
  });
125
125
 
126
126
  test("complete-user-task: completes a feature-escalation answer (issue #332)", async () => {
@@ -128,7 +128,7 @@ test("complete-user-task: completes a feature-escalation answer (issue #332)", a
128
128
  const res = await call(app, { userTaskKey: "ut-6", variables: { resolution: "answer", answer: "use v2" } });
129
129
  assertEquals(res.status, 200);
130
130
  assertEquals(res.body.elementId, "feature-escalation");
131
- assertEquals(completed, [{ userTaskKey: "ut-6", variables: { resolution: "answer", answer: "use v2" } }]);
131
+ assertEquals(completed, [{ userTaskKey: "ut-6", variables: { resolution: "answer", answer: "use v2", completedUserTaskKey: "ut-6", completedCompletionId: 1 } }]);
132
132
  });
133
133
 
134
134
  test("complete-user-task: refuses a non-completable internal user task (400)", async () => {
@@ -161,5 +161,5 @@ test("complete-user-task: a read-model cleanup failure does not mask a resumed c
161
161
 
162
162
  assertEquals(res.status, 200);
163
163
  assertEquals(res.body.ok, true);
164
- assertEquals(completed, [{ userTaskKey: "ut-5", variables: { directive: "revise", notes: "narrow scope" } }]);
164
+ assertEquals(completed, [{ userTaskKey: "ut-5", variables: { directive: "revise", notes: "narrow scope", completedUserTaskKey: "ut-5", completedCompletionId: 1 } }]);
165
165
  });
@@ -126,7 +126,7 @@ test("listEscalations: round-trip — the listed userTaskKey is exactly what com
126
126
  assertEquals(done.status, 200);
127
127
  assertEquals(done.body.ok, true);
128
128
  assertEquals(done.body.elementId, "wait-answer");
129
- assertEquals(completed, [{ userTaskKey: "ut-answer", variables: { answer: "v2" } }]);
129
+ assertEquals(completed, [{ userTaskKey: "ut-answer", variables: { answer: "v2", completedUserTaskKey: "ut-answer", completedCompletionId: 1 } }]);
130
130
  // The answered task's read-model row is dropped, so a re-list no longer shows it.
131
131
  assertEquals(stores.user_tasks, []);
132
132
  const reListed = await callList(app);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.189.0",
3
+ "version": "0.189.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -88,6 +88,10 @@
88
88
  <nano:shape id="PrAnswerEscalationIn" name="Answer escalation — input">
89
89
  <nano:extend name="prKey" type="string" />
90
90
  <nano:extend name="answer" type="string" optional="true" />
91
+ <nano:extend name="answerContext" type="string" optional="true" />
92
+ <nano:extend name="completedUserTaskKey" type="string" optional="true" />
93
+ <nano:extend name="completedCompletionId" type="integer" optional="true" />
94
+ <nano:extend name="escalationId" type="integer" optional="true" />
91
95
  </nano:shape>
92
96
  <nano:shape id="PrFinalizeIn" name="Mark converged — input">
93
97
  <nano:extend name="prKey" type="string" />
@@ -308,9 +312,14 @@
308
312
  <zeebe:ioMapping>
309
313
  <zeebe:input source="=prKey" target="prKey" />
310
314
  <zeebe:input source="=answer" target="answer" />
315
+ <zeebe:input source="=&#34;convergence&#34;" target="answerContext" />
316
+ <zeebe:input source="=completedUserTaskKey" target="completedUserTaskKey" />
317
+ <zeebe:input source="=completedCompletionId" target="completedCompletionId" />
311
318
  <zeebe:output source="=(if scopePending = true then answer else scopeAnswer)" target="scopeAnswer" />
312
319
  <zeebe:output source="=0" target="huskRetries" />
313
320
  <zeebe:output source="=false" target="reviewStale" />
321
+ <zeebe:output source="=null" target="completedUserTaskKey" />
322
+ <zeebe:output source="=null" target="completedCompletionId" />
314
323
  </zeebe:ioMapping>
315
324
  </bpmn:extensionElements>
316
325
  <bpmn:incoming>f_answerRecord</bpmn:incoming>
@@ -329,6 +329,7 @@
329
329
  <zeebe:ioMapping>
330
330
  <zeebe:input source="=prKey" target="prKey" />
331
331
  <zeebe:input source="=answer" target="answer" />
332
+ <zeebe:input source="=&#34;merge&#34;" target="answerContext" />
332
333
  </zeebe:ioMapping>
333
334
  </bpmn:extensionElements>
334
335
  <bpmn:incoming>f_m_answerRecord</bpmn:incoming>
@@ -17,21 +17,26 @@
17
17
  // durable rows; it returns no variables, leaving the submitted `answer` untouched so it flows on to
18
18
  // the next review round.
19
19
  import type { AppJobHandler } from "@nanobpm/urban";
20
+ import { generationGuard, recordAdjudication } from "../../app/adjudications.ts";
21
+ import { taskCompletions } from "../../app/agentCompletion.ts";
20
22
  import type { WorkerInputs } from "../../nano-generated/worker-io.d.ts";
21
23
 
22
24
  interface Escalation extends Record<string, unknown> {
23
25
  id: number;
24
26
  pr_key: string;
25
27
  status: string;
28
+ question: string;
26
29
  answer: string | null;
27
30
  answered_at: string | null;
28
31
  }
29
32
 
30
33
  // The PR-row fields this worker reconciles when an escalation is answered. Only `status`/`updated_at`
31
- // are written; the rest of the row is untouched.
34
+ // are written; the rest of the row is untouched. `process_key` is READ (never written here) to reject a
35
+ // delayed/redelivered job from a superseded process instance (Copilot review of #806).
32
36
  interface PullRequest extends Record<string, unknown> {
33
37
  pr_key: string;
34
38
  status: string;
39
+ process_key: string | null;
35
40
  updated_at: string;
36
41
  }
37
42
 
@@ -42,10 +47,62 @@ type In = WorkerInputs["pr.answer-escalation"];
42
47
  function nonBlank(v: unknown): string | undefined {
43
48
  return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
44
49
  }
50
+ // A finite integer variable, or undefined when absent/blank/non-numeric. The `completedCompletionId`
51
+ // envelope field is typed `integer`, so it arrives as a number; tolerate a numeric string defensively.
52
+ function nonBlankInt(v: unknown): number | undefined {
53
+ if (typeof v === "number") return Number.isFinite(v) ? v : undefined;
54
+ if (typeof v === "string" && v.trim() !== "") {
55
+ const n = Number(v);
56
+ return Number.isFinite(n) ? n : undefined;
57
+ }
58
+ return undefined;
59
+ }
45
60
 
46
61
  const handler: AppJobHandler<In> = async (job, app) => {
47
62
  const { prKey } = job.variables;
48
63
  const answer = nonBlank(job.variables.answer);
64
+ // The exact identity of the `wait-*` completion that resumed THIS token (Copilot review of #806),
65
+ // stamped by `completeUserTaskAttributed` on the resumed token. Used to correlate the durable
66
+ // adjudication to the completion the engine actually accepted; absent for an out-of-band resume.
67
+ const completedUserTaskKey = nonBlank(job.variables.completedUserTaskKey);
68
+ // The exact ledger id of the winning completion (Copilot review of #806), stamped by
69
+ // `completeUserTaskAttributed`. When present it uniquely identifies the winner — even against a
70
+ // same-answer losing racer on the same `user_task_key` — so record-answer selects that exact row
71
+ // rather than correlating by answer. Absent for an out-of-band resume.
72
+ const completedCompletionId = nonBlankInt(job.variables.completedCompletionId);
73
+ // The durable id of the escalation THIS completion answers (Copilot review of #806). The originating
74
+ // `pr.persist-escalation` returns its inserted `escalations.id` as a process variable (`EscalationOut`),
75
+ // which the `wait-*` completion carries through here. Answering by this exact id — rather than the
76
+ // newest open row — is what makes a REDELIVERED older answer safe: if the process has since opened a
77
+ // NEWER escalation (a different question), a stale Q1 `record-answer` whose `escalationId` no longer
78
+ // matches any open row is a no-op instead of misfiling Q1's answer under Q2's fingerprint and retiring
79
+ // Q2 unanswered. Absent for a pre-#806 / out-of-band resume — we then fall back to the newest open row.
80
+ const escalationId = nonBlankInt(job.variables.escalationId);
81
+ const prs = app.data.table<PullRequest>("pull_requests", "pr_key");
82
+ // Reject a delayed/redelivered job from a SUPERSEDED process instance (Copilot review of #806).
83
+ // `pull_requests.process_key` always tracks the CURRENT loop instance for this PR — the convergence
84
+ // instance at submit, reassigned to the merge instance at merge-start (app/service.ts). A job whose
85
+ // `processInstanceKey` no longer matches is from an old run that a re-submit (or the merge hand-off)
86
+ // replaced; accepting it would let a stale process reinsert its adjudication after the fresh run's
87
+ // reset, or even answer the new run's open escalation, so a re-submit would not be a reliable
88
+ // fresh-decision boundary. No-op such jobs BEFORE any adjudication/escalation/PR write. When the
89
+ // current process is unknown (no row / null `process_key`) we cannot classify the job as stale, so
90
+ // we proceed rather than silently drop a legitimate operator answer.
91
+ //
92
+ // The `process_key` is read AS LATE AS POSSIBLE — immediately before the writes below, not at handler
93
+ // entry — so a concurrent re-submit that advances `process_key` and resets the adjudication memory is
94
+ // observed here (Copilot review of #806). Paired with submitPr advancing `process_key` BEFORE it
95
+ // clears the memory, this shrinks the reset window to a single check-then-write step: a straggler is
96
+ // rejected once the new identity is installed, and any pre-advance insert is cleared by the
97
+ // post-advance reset.
98
+ const jobProcessKey = job.processInstanceKey != null ? String(job.processInstanceKey) : undefined;
99
+ // This one worker services BOTH loops' answer steps (`record-answer` in the convergence loop AND
100
+ // `record-merge-answer` in the merge loop, #256). Only a CONVERGENCE answer may feed the durable
101
+ // adjudication memory the convergence poller replays — a merge-loop decision recorded here would let
102
+ // a later convergence `wait-answer` with the same text replay a merge-context answer that was never a
103
+ // convergence adjudication (Copilot review of #806). The originating step stamps `answerContext` via a
104
+ // literal ioMapping so this reconcile can tell them apart.
105
+ const isConvergence = nonBlank(job.variables.answerContext) === "convergence";
49
106
  const escs = app.data.table<Escalation>("escalations", "id");
50
107
  // Retire EVERY still-open escalation for this PR. `pr.persist-escalation` always INSERTs a new
51
108
  // open row, so a retry/duplicate activation can leave more than one open — answering only the
@@ -53,24 +110,147 @@ const handler: AppJobHandler<In> = async (job, app) => {
53
110
  // is still `escalated`. Answer the newest (it carries the operator's reply) and mark any remaining
54
111
  // open rows `stale`, mirroring `submitPr`'s resubmit cleanup.
55
112
  const open = (await escs.find({ pr_key: prKey, status: "open" })).sort((a, b) => b.id - a.id);
56
- if (open.length > 0) {
113
+ const currentProcessKey = nonBlank((await prs.find({ pr_key: prKey }))[0]?.process_key);
114
+ if (currentProcessKey !== undefined && jobProcessKey !== undefined && jobProcessKey !== currentProcessKey) {
115
+ return {};
116
+ }
117
+ // The escalation this completion actually answers. Prefer the exact `escalationId` the winning
118
+ // completion carried; fall back to the newest open row only when it is absent (a pre-#806 / out-of-band
119
+ // resume). If an `escalationId` was carried but no OPEN row bears it, this is a redelivered answer for
120
+ // an escalation that has already been retired (or superseded by a newer question) — no-op rather than
121
+ // misfile the answer under a different escalation's question (Copilot review of #806).
122
+ const target = escalationId != null ? open.find((e) => e.id === escalationId) : open[0];
123
+ if (target !== undefined) {
57
124
  const ts = new Date().toISOString();
58
- await escs.update(open[0].id, {
59
- answer: answer ?? null,
60
- status: "answered",
61
- answered_at: ts,
62
- });
63
- for (const dup of open.slice(1)) {
64
- await escs.update(dup.id, { status: "stale" });
125
+ // Persist the DURABLE adjudication FIRST, BEFORE the escalation/PR rows transition off `open`
126
+ // (issue #806, Copilot review — crash safety). `recordAdjudication` is INSERT-if-absent idempotent,
127
+ // so if the worker crashes after this write but before the row transitions, a retry re-finds the
128
+ // still-open row and re-records a no-op; whereas recording it LAST would mean a crash in the window
129
+ // after the rows flip loses the adjudication entirely (the retry finds no open row and returns), so
130
+ // the answered question could re-escalate after restart. Scoped to convergence answers only.
131
+ if (isConvergence) {
132
+ const adjudicator = await latestAdjudicator(app, job.processInstanceKey, answer, completedUserTaskKey, completedCompletionId);
133
+ await recordAdjudication(app.data, {
134
+ prKey,
135
+ question: target.question,
136
+ answer,
137
+ adjudicatedBy: adjudicator?.id,
138
+ adjudicatedKind: adjudicator?.kind,
139
+ // Link the decision to the winning completion (issue #806 review) so a later revert of that
140
+ // completion can tombstone it even when it is a FIRST-HAND agent answer with no
141
+ // `source_adjudication_id`. INSERT-if-absent, so it pins to the original first-hand winner.
142
+ sourceCompletionId: completedCompletionId,
143
+ // Run generation this answer belongs to: every adjudication write is fenced on the PR's current
144
+ // `process_key` still matching it, so a pre-reset straggler (one that passed the check above,
145
+ // then paused across a re-submit that advanced `process_key` and cleared the memory) cannot
146
+ // resurrect a stale adjudication after the reset (issue #806, Copilot review). Undefined when the
147
+ // job carries no instance key — the fence then fails open, exactly like the staleness gate above.
148
+ expectedProcessKey: jobProcessKey,
149
+ });
150
+ }
151
+ // Fence the escalation/PR transitions on the run generation too (Copilot review of #806). The
152
+ // ownership check above is READ-TIME — not atomic with these writes — so in the window between it
153
+ // and here a concurrent re-submit could advance `process_key` and open a FRESH escalation; a naive
154
+ // `escs.update`/`prs.update` would then answer the new run's snapshot and flip its PR to
155
+ // `converging`. Each write is now a guarded conditional statement carrying the SAME `generationGuard`
156
+ // predicate as the adjudication writes (one guard, no second implementation): it applies only while
157
+ // the PR's current `process_key` still matches this job's generation, so a superseded worker touches
158
+ // nothing. Fails open when `jobProcessKey` is undefined, exactly like the gate above.
159
+ const db = app.data.open();
160
+ const guard = generationGuard(prKey, jobProcessKey);
161
+ await db.exec(
162
+ `UPDATE "escalations" SET "answer" = ?, "status" = 'answered', "answered_at" = ? WHERE "id" = ? AND ${guard.sql}`,
163
+ [answer ?? null, ts, target.id, ...guard.params],
164
+ );
165
+ // Mark any OTHER still-open row `stale` (a `pr.persist-escalation` retry can leave more than one
166
+ // open for the same question). We retire every open row except the one we just answered so a phantom
167
+ // `activePrs` never keeps deriving while the PR is `escalated`.
168
+ for (const dup of open) {
169
+ if (dup.id === target.id) continue;
170
+ await db.exec(
171
+ `UPDATE "escalations" SET "status" = 'stale' WHERE "id" = ? AND ${guard.sql}`,
172
+ [dup.id, ...guard.params],
173
+ );
65
174
  }
66
175
  // Move the PR off `status="escalated"` back to
67
176
  // `"converging"` now that the question is answered. Without this the row stays `escalated` (with
68
177
  // a now-null derived `openEscalation`) until the re-entered round's `persist-round` runs — a
69
178
  // `/status` inconsistency and a divergence from the merge path both loops are meant to share.
70
- const prs = app.data.table<PullRequest>("pull_requests", "pr_key");
71
- await prs.update(prKey, { status: "converging", updated_at: ts });
179
+ await db.exec(
180
+ `UPDATE "pull_requests" SET "status" = 'converging', "updated_at" = ? WHERE "pr_key" = ? AND ${guard.sql}`,
181
+ [ts, prKey, ...guard.params],
182
+ );
72
183
  }
73
184
  return {};
74
185
  };
75
186
 
187
+ /** Who just completed this `wait-answer`: the `{ id, kind }` of the `task_completions` row that
188
+ * actually WON the user-task race, so the durable adjudication is attributed to the completion the
189
+ * engine accepted — never a losing racer's transient row NOR an older round's completion. Both
190
+ * canonical completers (`completeUserTaskAttributed`) insert their ledger row BEFORE calling
191
+ * `completeUserTask` and the loser only removes its row AFTER the engine rejects it, so a bare
192
+ * "newest row for this process instance" can transiently select a higher-id LOSER; and because a
193
+ * convergence-loop instance is REUSED across rounds, an older round's completion (or a delayed
194
+ * same-answer redelivery) with a higher id can also linger for the SAME process instance (Copilot
195
+ * review of #806). Correlate on the EXACT completion identity: the resumed token carries the
196
+ * `completedCompletionId` — the ledger id of the exact completion the engine accepted — so the winner
197
+ * is the row with that id, unambiguous even when both racers submitted the IDENTICAL answer (answer
198
+ * correlation alone cannot separate two same-answer rows on one `user_task_key`; the higher-id one may
199
+ * be the loser). A pre-#806-fix out-of-band resume that carried no `completedCompletionId` falls back
200
+ * to the exact `completedUserTaskKey` + winning-answer correlation, and attributes ONLY when that
201
+ * leaves exactly one candidate — an ambiguous same-answer set fails open rather than guess a winner.
202
+ * The KIND (`human`/`agent`, ADR
203
+ * 0046) is preserved so a later auto-resume replays with the ORIGINAL attribution and never launders
204
+ * an agent decision into a human one. `undefined` (fails open to a fresh human task) when the identity
205
+ * is unavailable or no ledger row exactly matches — rather than attribute a wrong one. */
206
+ async function latestAdjudicator(app: Parameters<AppJobHandler<In>>[1], processInstanceKey: unknown, winningAnswer: string | undefined, completedUserTaskKey: string | undefined, completedCompletionId: number | undefined): Promise<{ id: string; kind: string } | undefined> {
207
+ // Exact ledger-id match by PRIMARY KEY. The engine resumed this token with exactly ONE completion's
208
+ // variables, and that completion stamped its own (globally unique) ledger id here — so look the
209
+ // winner up DIRECTLY by primary key, BEFORE the process-instance fallback below. It must NOT be found
210
+ // via a `process_instance_key`-scoped query: the canonical completers (`completeUserTaskAttributed`)
211
+ // resolve the task through the typed `openUserTasks` seam, which deliberately omits `processInstanceKey`
212
+ // (app/service.ts), so EVERY real ledger row is stored with `process_instance_key = null`. A
213
+ // `process_instance_key`-filtered lookup therefore never matched the winning row and always failed
214
+ // open — recording a null adjudicator and letting the convergence poller re-park an already-answered
215
+ // question (Copilot review). Selecting nothing still returns undefined and fails open (never attribute
216
+ // a wrong row).
217
+ if (completedCompletionId != null) {
218
+ const exact = await taskCompletions(app.data).get(completedCompletionId);
219
+ return exact ? { id: exact.actor_id, kind: exact.actor_kind } : undefined;
220
+ }
221
+ // Process-instance fallback for a resume that carried no completion id (a pre-fix / out-of-band
222
+ // token): require the carried user-task identity, then — when the winning answer is known — keep only
223
+ // rows whose recorded answer matches it, dropping the same-task losing racer whose recorded submission
224
+ // differs. Without a completion id there is no evidence WHICH row won, so attribute ONLY when the
225
+ // candidate set is exactly one (Copilot review of #806): more than one same-answer row on this
226
+ // `user_task_key` is the very ambiguity the completion id was added to resolve — picking the newest
227
+ // could attribute to the losing racer — so return undefined and fail open to a human rather than guess.
228
+ const key = processInstanceKey != null ? String(processInstanceKey) : "";
229
+ if (key === "") return undefined;
230
+ const rows = await taskCompletions(app.data).find({ process_instance_key: key });
231
+ if (completedUserTaskKey == null || completedUserTaskKey === "") return undefined;
232
+ let candidates = rows.filter((r) => String(r.user_task_key) === completedUserTaskKey);
233
+ if (winningAnswer != null && winningAnswer !== "") {
234
+ candidates = candidates.filter((r) => completionAnswer(r.variables_json) === winningAnswer);
235
+ }
236
+ if (candidates.length !== 1) return undefined;
237
+ const only = candidates[0];
238
+ return { id: only.actor_id, kind: only.actor_kind };
239
+ }
240
+
241
+ /** The trimmed `answer` field recorded in a completion's `variables_json`, or undefined when the JSON
242
+ * is unparseable or carries no string answer. Used to correlate a ledger row with the submission the
243
+ * engine actually accepted (see `latestAdjudicator`). */
244
+ function completionAnswer(variablesJson: unknown): string | undefined {
245
+ if (typeof variablesJson !== "string") return undefined;
246
+ try {
247
+ const parsed: unknown = JSON.parse(variablesJson);
248
+ if (parsed === null || typeof parsed !== "object" || !("answer" in parsed)) return undefined;
249
+ const answer = parsed.answer;
250
+ return typeof answer === "string" && answer.trim() !== "" ? answer.trim() : undefined;
251
+ } catch {
252
+ return undefined;
253
+ }
254
+ }
255
+
76
256
  export default handler;