@nanobpm/nano-workforce 0.201.5 → 0.203.0

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.
Files changed (83) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +48 -0
  3. package/app/admittedStaging.test.ts +71 -0
  4. package/app/agentActivationLedger.ts +5 -0
  5. package/app/agentCompletion.test.ts +33 -0
  6. package/app/agentCompletion.ts +108 -2
  7. package/app/agentic/README.md +48 -0
  8. package/app/agentic/claim-registry.test.ts +61 -0
  9. package/app/agentic/claim-registry.ts +35 -1
  10. package/app/agentic/families/claim.family.test.ts +216 -0
  11. package/app/agentic/families/claim.family.ts +100 -4
  12. package/app/agentic/vocab/agent-sla-boundary.test.ts +6 -1
  13. package/app/contracts.ts +31 -0
  14. package/app/deliveryGraphDispatch.ts +6 -0
  15. package/app/deliveryGraphReadModel.test.ts +2 -2
  16. package/app/deliveryGraphRun.ts +18 -0
  17. package/app/deliveryRunner.ts +9 -1
  18. package/app/escalationSteward.test.ts +452 -0
  19. package/app/escalationSteward.ts +537 -0
  20. package/app/escalationStewardPoll.test.ts +123 -0
  21. package/app/feature.test.ts +16 -0
  22. package/app/feature.ts +11 -0
  23. package/app/featureDelivery.test.ts +23 -0
  24. package/app/mcpToolSurface.ts +17 -1
  25. package/app/plan.ts +49 -6
  26. package/app/planLowering.test.ts +26 -1
  27. package/app/planLowering.ts +5 -0
  28. package/app/promotionPoll.test.ts +19 -0
  29. package/app/pullRequestReadModel.test.ts +1 -1
  30. package/app/service.test.ts +36 -0
  31. package/app/service.ts +139 -5
  32. package/db/migrations/120_escalation_auto_resolutions.sql +229 -0
  33. package/db/migrations/121_delivery_graph_read_model_auto_resolve.sql +54 -0
  34. package/db/migrations/122_pull_requests_read_model_auto_resolve.sql +56 -0
  35. package/db/migrations/123_escalation_auto_resolutions_selected_option.sql +47 -0
  36. package/db/migrations/124_admitted_epics_auto_resolve.sql +21 -0
  37. package/docs/agent-guide.md +69 -30
  38. package/docs/agentic-cockpit.md +34 -0
  39. package/docs/mcp-runbook.md +2 -0
  40. package/e2e/escalation-steward.e2e.ts +211 -0
  41. package/nano.app.json +10 -1
  42. package/openapi.yaml +365 -0
  43. package/operations/dispatchDeliveryGraph.ts +6 -1
  44. package/operations/listActivePrs.test.ts +60 -1
  45. package/operations/listActivePrs.ts +3 -3
  46. package/operations/listAutoResolutions.ts +83 -0
  47. package/operations/reviewAutoResolution.test.ts +137 -0
  48. package/operations/reviewAutoResolution.ts +42 -0
  49. package/operations/startConvergenceLoop.ts +5 -1
  50. package/operations/startEpicSet.admission.integration.test.ts +2 -2
  51. package/operations/startEpicSet.ts +8 -2
  52. package/operations/startFeature.ts +5 -0
  53. package/operations/startPlanFanout.ts +4 -1
  54. package/package.json +1 -1
  55. package/pages/_nav.json +12 -0
  56. package/pages/auto-resolutions.page.json +191 -0
  57. package/pages/board.page.json +17 -0
  58. package/pages/cockpit.page.json +17 -0
  59. package/pages/delivery-graph-detail.page.json +17 -0
  60. package/pages/delivery-graphs/staged.mount.js +17 -5
  61. package/pages/delivery-graphs.page.json +17 -0
  62. package/pages/epic-detail.page.json +17 -0
  63. package/pages/epic.page.json +19 -1
  64. package/pages/feature.page.json +19 -1
  65. package/pages/home.page.json +22 -0
  66. package/pages/lineage.page.json +17 -0
  67. package/pages/mcp.page.json +17 -0
  68. package/pages/overview.page.json +17 -0
  69. package/pages/tasks.page.json +17 -0
  70. package/pages/velocity.page.json +17 -0
  71. package/resources/processes/escalation-steward.bpmn +162 -0
  72. package/resources/prompts/escalation-steward.md +87 -0
  73. package/test/delivery-graphs-staged-embed.test.ts +13 -0
  74. package/test/derivation-parity/derivation-parity.test.ts +1 -0
  75. package/test/derivation-parity/flows.ts +12 -0
  76. package/workers/converge-feature/worker.ts +6 -1
  77. package/workers/delivery-connector/worker.test.ts +72 -1
  78. package/workers/delivery-connector/worker.ts +53 -2
  79. package/workers/escalation-steward-collect/worker.test.ts +180 -0
  80. package/workers/escalation-steward-collect/worker.ts +99 -0
  81. package/workers/escalation-steward-record/worker.test.ts +740 -0
  82. package/workers/escalation-steward-record/worker.ts +428 -0
  83. package/workers/record-wave/worker.ts +5 -1
@@ -0,0 +1,740 @@
1
+ // Unit coverage for the pr.escalation-steward-record worker (issue #889) — the steward tick's final
2
+ // step. Drives the handler against an in-memory data layer + a completeUserTask-capturing engine and
3
+ // asserts:
4
+ // • a `resolved` decision is submitted through the canonical attributed door with AGENT attribution
5
+ // (recorded in `task_completions`, never a human) AND written to `escalation_auto_resolutions`;
6
+ // • a destructive/irreversible choice (`abandon`) is recorded `abstained` and NEVER submitted;
7
+ // • the loop guard records `abstained` (no submission) once a subject hits the resolution cap;
8
+ // • an `abstained` decision is recorded without any engine completion.
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join, resolve } from "node:path";
12
+ import { test } from "node:test";
13
+ import { assert, assertEquals } from "#test-assert";
14
+ import type { DataLayer } from "@nanobpm/urban";
15
+ import { bootTestApp } from "@nanobpm/urban-testkit";
16
+ import { taskCompletions } from "../../app/agentCompletion.ts";
17
+ import { recordAgentJobObservation } from "../../app/agentActivationLedger.ts";
18
+ import { claimEscalations, escalationAutoResolutions, LOOP_GUARD_MAX_RESOLUTIONS, recordResolution } from "../../app/escalationSteward.ts";
19
+ import type { UserTaskRow } from "../../app/userTasks.ts";
20
+ import handler, { FREE_TEXT_VALUE_KEYS, SELECTED_VALUE_KEYS } from "./worker.ts";
21
+
22
+ const APP_ROOT = resolve(import.meta.dirname, "..", "..");
23
+
24
+ function taskRow(over: Partial<UserTaskRow>): UserTaskRow {
25
+ return {
26
+ user_task_key: "UT-1",
27
+ element_id: "wait-answer",
28
+ kind_label: "PR review",
29
+ subject_type: "pr",
30
+ subject_key: "o/r#1",
31
+ subject_title: "o/r#1",
32
+ subject_url: "https://github.com/o/r/pull/1",
33
+ question: "Which approach?",
34
+ process_key: "PI-1",
35
+ form_key: "pr-escalation",
36
+ created_at: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
37
+ updated_at: new Date().toISOString(),
38
+ ...over,
39
+ };
40
+ }
41
+
42
+ /** Boot the real data layer + a completeUserTask-capturing engine, run the record worker, and return
43
+ * the captured completions plus the post-run records read BEFORE the app stops (the DB closes on
44
+ * stop, so all assertions must read inside the callback). */
45
+ async function run(
46
+ decisions: Record<string, unknown>[],
47
+ seed: (data: DataLayer) => Promise<void>,
48
+ assertAfter: (data: DataLayer, completed: { userTaskKey: string; variables: Record<string, unknown> }[]) => Promise<void>,
49
+ ): Promise<void> {
50
+ const dir = mkdtempSync(join(tmpdir(), "nwf-steward-rec-"));
51
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
52
+ const completed: { userTaskKey: string; variables: Record<string, unknown> }[] = [];
53
+ try {
54
+ await seed(app.db);
55
+ // The record worker completes through `completeEscalationAsAgent`, which resolves the open task via
56
+ // `openUserTasks` (CREATED only) before calling `completeUserTask`. Report every claimed task as an
57
+ // open `wait-answer` escalation so the door resolves it (a key with no open task is a no-op).
58
+ const engine = {
59
+ openUserTasks: async () => {
60
+ const claims = await escalationAutoResolutions(app.db).all();
61
+ return claims
62
+ .filter((c) => c.outcome === "claimed")
63
+ // Report each claimed task under its OWN element_id so the completer validates against the
64
+ // task's real form contract (a trial-merge-decision validates `action`, not `answer`).
65
+ .map((c) => ({ userTaskKey: c.user_task_key, elementId: c.element_id ?? "wait-answer", processInstanceKey: "PI-1" }));
66
+ },
67
+ completeUserTask: (userTaskKey: string, variables: Record<string, unknown>) => {
68
+ completed.push({ userTaskKey, variables });
69
+ return Promise.resolve();
70
+ },
71
+ };
72
+ const job = {
73
+ jobKey: "J-1",
74
+ processInstanceKey: "PI-STEWARD",
75
+ variables: { resolutions: JSON.stringify(decisions) },
76
+ };
77
+ await handler(job as never, { data: app.db, engine: engine as never, log: { info: () => {}, warn: () => {}, error: () => {} } } as never);
78
+ await assertAfter(app.db, completed);
79
+ } finally {
80
+ await app.stop?.();
81
+ rmSync(dir, { recursive: true, force: true });
82
+ }
83
+ }
84
+
85
+ test("record worker: a resolved decision is submitted with AGENT attribution and recorded", async () => {
86
+ await run(
87
+ [{ userTaskKey: "UT-1", outcome: "resolved", choice: "retry", variables: { answer: "Retry with a note." }, rationale: "nothing pushed", evidence: "ls-remote empty", options: ["retry", "abandon"] }],
88
+ async (data) => {
89
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD");
90
+ },
91
+ async (data, completed) => {
92
+ // Submitted through the canonical door exactly once, with the agent's variables.
93
+ assertEquals(completed.length, 1);
94
+ assertEquals(completed[0].userTaskKey, "UT-1");
95
+ assertEquals(completed[0].variables.answer, "Retry with a note.");
96
+ // Recorded `resolved` with the variables + rationale.
97
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
98
+ assertEquals(rows[0].outcome, "resolved");
99
+ assertEquals(rows[0].rationale, "nothing pushed");
100
+ // Attribution: the completion is recorded AGENT, never human (ADR 0046).
101
+ const completions = await taskCompletions(data).find({ user_task_key: "UT-1" });
102
+ assertEquals(completions.length, 1);
103
+ assertEquals(completions[0].actor_kind, "agent", "steward resolutions are agent-attributed, never a human decision");
104
+ assertEquals(completions[0].actor_id.startsWith("escalation-steward:"), true);
105
+ },
106
+ );
107
+ });
108
+
109
+ test("record worker: a destructive/irreversible choice (abandon) is abstained, never submitted", async () => {
110
+ await run(
111
+ [{ userTaskKey: "UT-1", outcome: "resolved", choice: "abandon", variables: { resolution: "abandon" }, rationale: "agent chose abandon" }],
112
+ async (data) => {
113
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD");
114
+ },
115
+ async (data, completed) => {
116
+ assertEquals(completed.length, 0, "a destructive option is NEVER submitted to the engine");
117
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
118
+ assertEquals(rows[0].outcome, "abstained");
119
+ assert(/destructive|irreversible/i.test(rows[0].rationale ?? ""), "the rationale names the destructive guard");
120
+ },
121
+ );
122
+ });
123
+
124
+ test("record worker: an abstained record clears selected_option — no proposed-but-rejected option leaks (issue #889 review)", async () => {
125
+ // The destructive guard converts a `resolved` abandon decision to `abstained`. Because the steward
126
+ // submitted NOTHING, the record's `selected_option` must be NULL — persisting the derived (rejected)
127
+ // `abandon` value would tell consumers the steward "chose abandon" on a record where it chose to
128
+ // abstain, contradicting the OpenAPI contract ("Null when abstained") and misrepresenting the audit.
129
+ await run(
130
+ [{ userTaskKey: "UT-1", outcome: "resolved", choice: "abandon", variables: { resolution: "abandon" }, rationale: "agent chose abandon" }],
131
+ async (data) => {
132
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD");
133
+ },
134
+ async (data, completed) => {
135
+ assertEquals(completed.length, 0, "a destructive option is NEVER submitted to the engine");
136
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
137
+ assertEquals(rows[0].outcome, "abstained");
138
+ assertEquals(rows[0].selected_option, null, "an abstained record has NO chosen option — the rejected value is not persisted");
139
+ },
140
+ );
141
+ });
142
+
143
+ test("record worker: a benign `choice` paired with a destructive `variables` value is abstained (variables win over the hint)", async () => {
144
+ await run(
145
+ // The auxiliary `choice` says "retry" but the submitted variables carry `resolution: "abandon"` —
146
+ // the destructive guard must derive the selected value from the variables FIRST, never wave the
147
+ // destructive submission through on the benign hint.
148
+ [{ userTaskKey: "UT-1", outcome: "resolved", choice: "retry", variables: { resolution: "abandon" }, rationale: "agent contradicts itself" }],
149
+ async (data) => {
150
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD");
151
+ },
152
+ async (data, completed) => {
153
+ assertEquals(completed.length, 0, "a destructive value in variables is NEVER submitted, regardless of the choice hint");
154
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
155
+ assertEquals(rows[0].outcome, "abstained");
156
+ assert(/destructive|irreversible/i.test(rows[0].rationale ?? ""), "the rationale names the destructive guard");
157
+ },
158
+ );
159
+ });
160
+
161
+ test("record worker: the loop guard abstains once a subject hits the resolution cap", async () => {
162
+ await run(
163
+ [{ userTaskKey: "UT-NEW", outcome: "resolved", choice: "retry", variables: { answer: "retry again" } }],
164
+ async (data) => {
165
+ // Seed LOOP_GUARD_MAX_RESOLUTIONS prior RESOLVED rows for the same subject.
166
+ for (let i = 0; i < LOOP_GUARD_MAX_RESOLUTIONS; i++) {
167
+ const key = `UT-OLD-${i}`;
168
+ await claimEscalations(data, [taskRow({ user_task_key: key })], "s");
169
+ await recordResolution(data, key, { outcome: "resolved", submittedVariables: { answer: "x" } });
170
+ }
171
+ // The fresh escalation for the SAME subject, claimed this tick.
172
+ await claimEscalations(data, [taskRow({ user_task_key: "UT-NEW" })], "PI-STEWARD");
173
+ },
174
+ async (data, completed) => {
175
+ assertEquals(completed.length, 0, "the loop guard blocks the submission");
176
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-NEW" });
177
+ assertEquals(rows[0].outcome, "abstained");
178
+ assert(/auto-resolved|isn't working|loop/i.test(rows[0].rationale ?? ""), "the rationale names the loop guard");
179
+ },
180
+ );
181
+ });
182
+
183
+ test("record worker: an abstained decision is recorded without any engine completion", async () => {
184
+ await run(
185
+ [{ userTaskKey: "UT-1", outcome: "abstained", rationale: "unclear — needs a human" }],
186
+ async (data) => {
187
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD");
188
+ },
189
+ async (data, completed) => {
190
+ assertEquals(completed.length, 0);
191
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
192
+ assertEquals(rows[0].outcome, "abstained");
193
+ assertEquals(rows[0].submitted_variables, null);
194
+ assertEquals(rows[0].rationale, "unclear — needs a human");
195
+ },
196
+ );
197
+ });
198
+
199
+ test("record worker: the loop guard is scoped by subject_type — a DIFFERENT subject type sharing the key is NOT tripped (issue #889 review)", async () => {
200
+ await run(
201
+ // A PLAN subject `plan:o/r#1` escalates. A PR subject `pr:o/r#1` (SAME key, DIFFERENT type) has
202
+ // already hit the resolution cap. Before the fix the guard counted by bare `subject_key`, so the
203
+ // PR's resolutions tripped the UNRELATED plan's guard on its FIRST escalation.
204
+ [{ userTaskKey: "UT-PLAN", outcome: "resolved", variables: { directive: "proceed" }, rationale: "ok", evidence: "checked" }],
205
+ async (data) => {
206
+ // Seed LOOP_GUARD_MAX_RESOLUTIONS prior RESOLVED rows for the PR subject (subject_type "pr").
207
+ for (let i = 0; i < LOOP_GUARD_MAX_RESOLUTIONS; i++) {
208
+ const key = `UT-PR-${i}`;
209
+ await claimEscalations(data, [taskRow({ user_task_key: key, subject_type: "pr", subject_key: "o/r#1" })], "s");
210
+ await recordResolution(data, key, { outcome: "resolved", submittedVariables: { answer: "x" } });
211
+ }
212
+ // The fresh escalation is for a PLAN subject with the SAME key but a DIFFERENT type.
213
+ await claimEscalations(data, [taskRow({ user_task_key: "UT-PLAN", subject_type: "plan", subject_key: "o/r#1", element_id: "plan-review-decision", kind_label: "Plan review", form_key: "plan-review-decision" })], "PI-STEWARD");
214
+ },
215
+ async (data, completed) => {
216
+ // The plan subject has ZERO prior resolutions of its OWN type, so the guard must NOT trip.
217
+ assertEquals(completed.length, 1, "a different subject type sharing the key is not tripped by the other's cap");
218
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-PLAN" });
219
+ assertEquals(rows[0].outcome, "resolved", "the unrelated subject type resolves normally");
220
+ },
221
+ );
222
+ });
223
+
224
+ test("record worker: malformed (non-object) decision entries are skipped, never thrown on", async () => {
225
+ await run(
226
+ // `[null]` / a scalar are valid JSON but not decision objects — they must be skipped without
227
+ // incidenting the record task (which would wedge the steward's overlap guard).
228
+ [null, "not-an-object", { userTaskKey: "UT-1", outcome: "resolved", choice: "retry", variables: { answer: "ok" }, rationale: "ok", evidence: "checked" }] as unknown as Record<string, unknown>[],
229
+ async (data) => {
230
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD");
231
+ },
232
+ async (data, completed) => {
233
+ // Only the one well-formed entry is processed; the malformed ones are skipped.
234
+ assertEquals(completed.length, 1);
235
+ assertEquals(completed[0].userTaskKey, "UT-1");
236
+ },
237
+ );
238
+ });
239
+
240
+ test("record worker: a hallucinated key with no live claim held by THIS run is skipped, never recorded over the foreign claim", async () => {
241
+ await run(
242
+ [{ userTaskKey: "UT-FOREIGN", outcome: "resolved", choice: "retry", variables: { answer: "x" } }],
243
+ async (data) => {
244
+ // Claim UT-FOREIGN for a DIFFERENT steward run — this run holds no claim for it.
245
+ await claimEscalations(data, [taskRow({ user_task_key: "UT-FOREIGN" })], "PI-OTHER");
246
+ },
247
+ async (data, completed) => {
248
+ assertEquals(completed.length, 0, "an unowned/foreign key is NEVER submitted");
249
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-FOREIGN" });
250
+ // Fail-closed: the decision is SKIPPED entirely — never recorded over the foreign claim. The
251
+ // claim row stays a LIVE `claimed` placeholder owned by the other run (recordResolution is
252
+ // keyed only on user_task_key, so writing here would finalise a claim this run does not own).
253
+ assertEquals(rows[0].outcome, "claimed", "the foreign claim is left untouched, not finalised");
254
+ assertEquals(rows[0].claimed_by, "PI-OTHER");
255
+ },
256
+ );
257
+ });
258
+
259
+ test("record worker: an abstained decision for a foreign key is skipped, never finalising another run's claim", async () => {
260
+ await run(
261
+ // An ABSTAINED decision carries no submission, but recording it would still finalise the foreign
262
+ // claim (recordResolution overwrites the claimed placeholder keyed on user_task_key alone).
263
+ [{ userTaskKey: "UT-FOREIGN", outcome: "abstained", rationale: "unclear" }],
264
+ async (data) => {
265
+ await claimEscalations(data, [taskRow({ user_task_key: "UT-FOREIGN" })], "PI-OTHER");
266
+ },
267
+ async (data, completed) => {
268
+ assertEquals(completed.length, 0);
269
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-FOREIGN" });
270
+ assertEquals(rows[0].outcome, "claimed", "an abstained decision for a foreign key is skipped, not recorded");
271
+ assertEquals(rows[0].claimed_by, "PI-OTHER");
272
+ },
273
+ );
274
+ });
275
+
276
+ test("record worker: an overlapping tick that RECLAIMED the task mid-run is not finalised by this run's stale write (atomic CAS)", async () => {
277
+ // The race the reviewer cited (issue #889): this run reads its claim on UT-1 and proceeds; before it
278
+ // finalises, its lease lapses and an OVERLAPPING tick (PI-OTHER) releases + reclaims UT-1. This run's
279
+ // recordResolution must then LOSE the owner+lease-scoped CAS — never finalise PI-OTHER's fresh claim.
280
+ await run(
281
+ [{ userTaskKey: "UT-1", outcome: "resolved", choice: "retry", variables: { answer: "x" }, rationale: "ok", evidence: "checked" }],
282
+ async (data) => {
283
+ // This run (PI-STEWARD, the handler's processInstanceKey) claims UT-1 with an ALREADY-EXPIRED
284
+ // lease — by the time it finalises, its claim is stale.
285
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD", -1);
286
+ // The overlapping tick releases the expired claim and reclaims UT-1 for itself.
287
+ const { releaseExpiredClaims } = await import("../../app/escalationSteward.ts");
288
+ await releaseExpiredClaims(data);
289
+ await claimEscalations(data, [taskRow({})], "PI-OTHER");
290
+ },
291
+ async (data, completed) => {
292
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
293
+ // The stale owner's finalise must NOT have overwritten the overlapping run's live claim.
294
+ assertEquals(rows[0].outcome, "claimed", "the overlapping run's claim is not finalised by the stale owner");
295
+ assertEquals(rows[0].claimed_by, "PI-OTHER", "the live claim still belongs to the overlapping run");
296
+ },
297
+ );
298
+ });
299
+
300
+ test("record worker: an EXPIRED-but-still-owned claim is RENEWED and finalised, not discarded (unbounded agent run, issue #889 review)", async () => {
301
+ // The liveness bug the reviewer cited: the claim's 15-minute lease starts at claim time, but the
302
+ // `senior:escalation-steward` agent has NO bounded runtime. On a long agent run the lease lapses
303
+ // before this record step runs. Last round's owner+lease CAS then REJECTED the expired claim and
304
+ // SKIPPED every decision, so the valid agent result was thrown away and the next tick repeated the
305
+ // work. THIS run still owns the claim (no overlapping tick could have reclaimed it — the steward's
306
+ // overlap guard blocks any other tick while this run is ACTIVE), so the record step must RE-ACQUIRE
307
+ // (renew) its own lease atomically and finalise, not discard.
308
+ await run(
309
+ [{ userTaskKey: "UT-1", outcome: "resolved", choice: "retry", variables: { answer: "x" }, rationale: "ok", evidence: "checked" }],
310
+ async (data) => {
311
+ // This run (PI-STEWARD, the handler's processInstanceKey) claims UT-1 with an ALREADY-EXPIRED
312
+ // lease (ttlMs=-1), mimicking an agent that ran longer than the lease TTL. No overlapping tick
313
+ // reclaims it — the row stays `claimed` by PI-STEWARD, just with a lapsed `claimed_until`.
314
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD", -1);
315
+ },
316
+ async (data, completed) => {
317
+ // The decision is RENEWED + submitted, not discarded.
318
+ assertEquals(completed.length, 1, "the owned claim is renewed and the decision is submitted");
319
+ assertEquals(completed[0].userTaskKey, "UT-1");
320
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
321
+ assertEquals(rows[0].outcome, "resolved", "the expired-but-owned claim is finalised, not left `claimed`");
322
+ assertEquals(rows[0].claimed_by, null, "finalising clears the claim");
323
+ },
324
+ );
325
+ });
326
+
327
+ test("record worker: a destructive value smuggled in `variables` (no `choice`) is abstained", async () => {
328
+ await run(
329
+ // No `choice` field — the destructive option is only in the form variables (`resolution`).
330
+ [{ userTaskKey: "UT-1", outcome: "resolved", variables: { resolution: "abandon" }, rationale: "agent chose abandon" }],
331
+ async (data) => {
332
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD");
333
+ },
334
+ async (data, completed) => {
335
+ assertEquals(completed.length, 0, "a destructive option derived from variables is NEVER submitted");
336
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
337
+ assertEquals(rows[0].outcome, "abstained");
338
+ assert(/destructive|irreversible/i.test(rows[0].rationale ?? ""), "the rationale names the destructive guard");
339
+ },
340
+ );
341
+ });
342
+
343
+ test("record worker: a `resolved` decision with malformed `variables` is recorded abstained, not falsely resolved", async () => {
344
+ await run(
345
+ [{ userTaskKey: "UT-1", outcome: "resolved", variables: null, rationale: "claims resolved" }],
346
+ async (data) => {
347
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD");
348
+ },
349
+ async (data, completed) => {
350
+ assertEquals(completed.length, 0, "no completion without valid variables");
351
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
352
+ assertEquals(rows[0].outcome, "abstained", "fail-closed: malformed variables never record a false resolved");
353
+ assertEquals(rows[0].submitted_variables, null);
354
+ },
355
+ );
356
+ });
357
+
358
+ test("record worker: agent_job_key records the ANSWERING agent's activation key, not the record task's job key", async () => {
359
+ await run(
360
+ [{ userTaskKey: "UT-1", outcome: "resolved", choice: "retry", variables: { answer: "Retry." }, rationale: "ok", evidence: "ls-remote empty" }],
361
+ async (data) => {
362
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD");
363
+ // The poller observed the senior:escalation-steward AGENT job (key J-AGENT) activated on this
364
+ // steward process instance — distinct from the record task's own job key (J-1 in `run`).
365
+ await recordAgentJobObservation(data, {
366
+ processInstanceKey: "PI-STEWARD",
367
+ jobType: "senior:escalation-steward",
368
+ worker: "agent-a",
369
+ jobKey: "J-AGENT",
370
+ now: new Date().toISOString(),
371
+ });
372
+ },
373
+ async (data, completed) => {
374
+ assertEquals(completed.length, 1);
375
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
376
+ assertEquals(rows[0].outcome, "resolved");
377
+ assertEquals(
378
+ rows[0].agent_job_key,
379
+ "J-AGENT",
380
+ "provenance points at the agent job that made the decision, not the record service job (J-1)",
381
+ );
382
+ // The leasing worker captured in the SAME activation row is persisted too (issue #889 review) —
383
+ // the Auto-resolutions grid's Worker column reads it, and it was always blank before.
384
+ assertEquals(rows[0].worker, "agent-a", "the leasing worker's provenance is persisted on the record");
385
+ },
386
+ );
387
+ });
388
+
389
+ test("record worker: agent_job_key is NULL (never the record task's own key) when the ledger has no activation row", async () => {
390
+ await run(
391
+ // No recordAgentJobObservation — the best-effort poll missed the fast agent job. The fallback must
392
+ // be an honest NULL, NOT the record service task's own job key (J-1), which is known to identify a
393
+ // DIFFERENT job and would give the audit record confidently-wrong provenance.
394
+ [{ userTaskKey: "UT-1", outcome: "resolved", choice: "retry", variables: { answer: "Retry." }, rationale: "ok", evidence: "checked" }],
395
+ async (data) => {
396
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD");
397
+ },
398
+ async (data, completed) => {
399
+ assertEquals(completed.length, 1);
400
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
401
+ assertEquals(rows[0].outcome, "resolved");
402
+ assertEquals(rows[0].agent_job_key, null, "unknown provenance stays NULL — never the record task's own key (J-1)");
403
+ assertEquals(rows[0].worker, null, "no activation row ⇒ worker provenance stays NULL, never fabricated");
404
+ },
405
+ );
406
+ });
407
+
408
+ test("record worker: a `resolved` decision with no rationale/evidence is abstained (fail-closed audit record)", async () => {
409
+ await run(
410
+ // `resolved` but NO rationale and NO evidence — the PR's promise is a reviewable audit record for
411
+ // every automatic resolution, so this must degrade to `abstained` (left for a human), never
412
+ // auto-complete an escalation with no reviewable basis.
413
+ [{ userTaskKey: "UT-1", outcome: "resolved", choice: "retry", variables: { answer: "x" } }],
414
+ async (data) => {
415
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD");
416
+ },
417
+ async (data, completed) => {
418
+ assertEquals(completed.length, 0, "a resolved decision with no rationale/evidence is NEVER submitted");
419
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
420
+ assertEquals(rows[0].outcome, "abstained", "fail-closed: an unauditable resolved is abstained");
421
+ assert(/rationale|evidence|audit/i.test(rows[0].rationale ?? ""), "the rationale names the missing audit record");
422
+ },
423
+ );
424
+ });
425
+
426
+ test("record worker: the SELECTED option is persisted on the record (the review surface shows what was chosen)", async () => {
427
+ await run(
428
+ // The chosen value is carried in the form variables (`answer`) — `selectedOption` derives it
429
+ // host-side and the record persists it as a first-class column.
430
+ [{ userTaskKey: "UT-1", outcome: "resolved", variables: { answer: "retry" }, rationale: "ok", evidence: "checked" }],
431
+ async (data) => {
432
+ await claimEscalations(data, [taskRow({})], "PI-STEWARD");
433
+ },
434
+ async (data, completed) => {
435
+ assertEquals(completed.length, 1);
436
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
437
+ assertEquals(rows[0].outcome, "resolved");
438
+ assertEquals(rows[0].selected_option, "retry", "the selected option is a first-class column, not buried in submitted_variables");
439
+ },
440
+ );
441
+ });
442
+
443
+ test("record worker: `options` snapshots the REAL form choices host-side, never the agent's self-reported array", async () => {
444
+ await run(
445
+ // The agent MISSTATES the choices (`options: ["only-this"]`), but the trial-merge-decision form's
446
+ // real `action` select is [proceed, rebase, abandon]. The record must persist the host-side snapshot.
447
+ // Uses `rebase` (the steward-safe non-override path on this red gate) so the destructive/override
448
+ // guard doesn't abstain it — this test asserts the OPTIONS snapshot, not the override guard.
449
+ [{ userTaskKey: "UT-TM", outcome: "resolved", variables: { action: "rebase" }, rationale: "ok", evidence: "checked", options: ["only-this"] }],
450
+ async (data) => {
451
+ await claimEscalations(data, [taskRow({ user_task_key: "UT-TM", element_id: "trial-merge-decision", kind_label: "Trial merge", form_key: "trial-merge-decision" })], "PI-STEWARD");
452
+ },
453
+ async (data, completed) => {
454
+ assertEquals(completed.length, 1);
455
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-TM" });
456
+ assertEquals(rows[0].outcome, "resolved");
457
+ assertEquals(
458
+ rows[0].options,
459
+ JSON.stringify(["proceed", "rebase", "abandon"]),
460
+ "the record snapshots the deployed form's real choices, not the agent's self-reported array",
461
+ );
462
+ },
463
+ );
464
+ });
465
+
466
+ test("record worker: a `proceed` on trial-merge-decision OVERRIDES a red integration gate — abstained, never auto-applied (issue #889 review)", async () => {
467
+ await run(
468
+ // On `trial-merge-decision`, `action: "proceed"` explicitly overrides a `suite-failed` D3 trial
469
+ // merge and lets the wave continue past a RED integration gate (SPEC.md §13.2). A value-only guard
470
+ // (`isDestructiveOption("proceed")` is false) would let the steward auto-bypass that red gate. The
471
+ // element-aware guard must abstain it — only the non-override `rebase` path is steward-safe there.
472
+ [{ userTaskKey: "UT-TM", outcome: "resolved", variables: { action: "proceed" }, rationale: "override the red trial", evidence: "checked" }],
473
+ async (data) => {
474
+ await claimEscalations(data, [taskRow({ user_task_key: "UT-TM", element_id: "trial-merge-decision", kind_label: "Trial merge", form_key: "trial-merge-decision" })], "PI-STEWARD");
475
+ },
476
+ async (data, completed) => {
477
+ assertEquals(completed.length, 0, "a red-gate OVERRIDE (proceed on trial-merge-decision) is NEVER auto-applied");
478
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-TM" });
479
+ assertEquals(rows[0].outcome, "abstained", "the override is left for a human");
480
+ assert(/override|red|destructive|irreversible/i.test(rows[0].rationale ?? ""), "the rationale names the red-gate/override guard");
481
+ },
482
+ );
483
+ });
484
+
485
+ test("record worker: an `abandon` on trial-merge-decision is abstained (destructive), and a `proceed` on a NON-trial-merge element is unaffected", async () => {
486
+ await run(
487
+ // Two decisions: (1) abandon on trial-merge-decision — destructive, abstained; (2) proceed on
488
+ // wait-answer (a NON-trial-merge element) — the element-aware guard must NOT abstain a benign
489
+ // `proceed` elsewhere (no over-broadening).
490
+ [
491
+ { userTaskKey: "UT-TM", outcome: "resolved", variables: { action: "abandon" }, rationale: "give up", evidence: "checked" },
492
+ { userTaskKey: "UT-WA", outcome: "resolved", variables: { answer: "proceed" }, rationale: "ok", evidence: "checked" },
493
+ ],
494
+ async (data) => {
495
+ await claimEscalations(data, [
496
+ taskRow({ user_task_key: "UT-TM", element_id: "trial-merge-decision", kind_label: "Trial merge", form_key: "trial-merge-decision" }),
497
+ taskRow({ user_task_key: "UT-WA", element_id: "wait-answer", kind_label: "PR review", form_key: "pr-escalation" }),
498
+ ], "PI-STEWARD");
499
+ },
500
+ async (data, completed) => {
501
+ // Only the benign non-trial-merge proceed is submitted; the trial-merge abandon is abstained.
502
+ assertEquals(completed.length, 1, "the benign proceed on a non-trial-merge element is unaffected");
503
+ assertEquals(completed[0].userTaskKey, "UT-WA");
504
+ const tm = await escalationAutoResolutions(data).find({ user_task_key: "UT-TM" });
505
+ assertEquals(tm[0].outcome, "abstained", "abandon on the trial merge is abstained");
506
+ const wa = await escalationAutoResolutions(data).find({ user_task_key: "UT-WA" });
507
+ assertEquals(wa[0].outcome, "resolved", "the benign proceed elsewhere resolves normally");
508
+ },
509
+ );
510
+ });
511
+
512
+ test("record worker: a foreign SAFE key must NOT shadow a destructive own-form key (selection-key bypass, issue #889 review)", async () => {
513
+ await run(
514
+ // The exact bypass the reviewer cited: `trial-merge-decision`'s OWN select is `action`, but the
515
+ // decision ALSO carries a benign foreign `resolution: "rebase"`. The OLD first-nonblank sweep reads
516
+ // `resolution` BEFORE `action` in SELECTED_VALUE_KEYS, so it judged `rebase` (safe) and SUBMITTED the
517
+ // destructive `action: "abandon"`. The form-scoped guard must judge the own-form `action` — and the
518
+ // two DISTINCT recognized values are a conflict, so it abstains either way and NEVER submits.
519
+ [{ userTaskKey: "UT-TM", outcome: "resolved", variables: { resolution: "rebase", action: "abandon" }, rationale: "sneaky", evidence: "checked" }],
520
+ async (data) => {
521
+ await claimEscalations(data, [taskRow({ user_task_key: "UT-TM", element_id: "trial-merge-decision", kind_label: "Trial merge", form_key: "trial-merge-decision" })], "PI-STEWARD");
522
+ },
523
+ async (data, completed) => {
524
+ assertEquals(completed.length, 0, "a destructive own-form `action` shadowed by a foreign safe `resolution` is NEVER submitted");
525
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-TM" });
526
+ assertEquals(rows[0].outcome, "abstained", "the conflicting/destructive decision is left for a human");
527
+ },
528
+ );
529
+ });
530
+
531
+ test("record worker: conflicting recognized selection fields are abstained as ambiguous (fail-closed), never guessed", async () => {
532
+ await run(
533
+ // Two DISTINCT recognized selection values on a form whose own select is `action` — even with both
534
+ // individually non-destructive-looking, the ambiguity itself must fail closed (the guard cannot
535
+ // attribute the decision to a single choice).
536
+ [{ userTaskKey: "UT-TM", outcome: "resolved", variables: { action: "rebase", mergeDecision: "approve" }, rationale: "ambiguous", evidence: "checked" }],
537
+ async (data) => {
538
+ await claimEscalations(data, [taskRow({ user_task_key: "UT-TM", element_id: "trial-merge-decision", kind_label: "Trial merge", form_key: "trial-merge-decision" })], "PI-STEWARD");
539
+ },
540
+ async (data, completed) => {
541
+ assertEquals(completed.length, 0, "an ambiguous multi-selection decision is never submitted");
542
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-TM" });
543
+ assertEquals(rows[0].outcome, "abstained");
544
+ assert(/conflicting|ambiguous/i.test(rows[0].rationale ?? ""), "the rationale names the conflicting-selection fence");
545
+ },
546
+ );
547
+ });
548
+
549
+ test("record worker: a SINGLE safe own-form selection still resolves (the form-scoped guard does not over-abstain)", async () => {
550
+ await run(
551
+ // Only the own-form `action: "rebase"` (the steward-safe non-override path on trial-merge-decision),
552
+ // no foreign key — must resolve normally, proving the scoping did not break the legitimate path.
553
+ [{ userTaskKey: "UT-TM", outcome: "resolved", variables: { action: "rebase" }, rationale: "rerun the trial after a fix", evidence: "checked" }],
554
+ async (data) => {
555
+ await claimEscalations(data, [taskRow({ user_task_key: "UT-TM", element_id: "trial-merge-decision", kind_label: "Trial merge", form_key: "trial-merge-decision" })], "PI-STEWARD");
556
+ },
557
+ async (data, completed) => {
558
+ assertEquals(completed.length, 1, "a single safe own-form selection resolves normally");
559
+ assertEquals(completed[0].userTaskKey, "UT-TM");
560
+ assertEquals(completed[0].variables.action, "rebase");
561
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-TM" });
562
+ assertEquals(rows[0].outcome, "resolved");
563
+ assertEquals(rows[0].selected_option, "rebase", "the persisted selected option is the own-form value");
564
+ },
565
+ );
566
+ });
567
+
568
+ test("record worker: a `directive`-keyed decision records its selected option (not NULL)", async () => {
569
+ await run(
570
+ // The plan-review-decision form's select key is `directive` (NOT `resolution`/`action`/`decision`).
571
+ // Before the fix, `directive` was missing from SELECTED_VALUE_KEYS, so a valid `{directive:"proceed"}`
572
+ // resolution recorded selected_option=NULL — the review surface couldn't show what was chosen
573
+ // (issue #889 review). The selected option must be derived from the `directive` key.
574
+ [{ userTaskKey: "UT-PRD", outcome: "resolved", variables: { directive: "proceed" }, rationale: "plan is sound", evidence: "reviewed" }],
575
+ async (data) => {
576
+ await claimEscalations(data, [taskRow({ user_task_key: "UT-PRD", element_id: "plan-review-decision", kind_label: "Plan review", form_key: "plan-review-decision" })], "PI-STEWARD");
577
+ },
578
+ async (data, completed) => {
579
+ assertEquals(completed.length, 1, "a valid directive resolution is submitted");
580
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-PRD" });
581
+ assertEquals(rows[0].outcome, "resolved");
582
+ assertEquals(rows[0].selected_option, "proceed", "a `directive`-keyed select must record its chosen value, never NULL");
583
+ },
584
+ );
585
+ });
586
+
587
+ test("record worker: SELECTED_VALUE_KEYS covers EVERY escalation form's select key (drift guard)", async () => {
588
+ // Fix-the-class guard: the selected-option / destructive guard reads the chosen value off these
589
+ // well-known keys, so a NEW `.form` select key that is NOT in SELECTED_VALUE_KEYS would silently
590
+ // record selected_option=NULL and let a destructive value on that key slip the guard (issue #889
591
+ // review — `directive`/`mergeDecision` were the instances). Derive the canonical key set from the
592
+ // deployed forms and assert the guard covers all of them.
593
+ const formsDir = resolve(APP_ROOT, "resources", "forms");
594
+ const { readdirSync, readFileSync } = await import("node:fs");
595
+ const selectKeys = new Set<string>();
596
+ for (const file of readdirSync(formsDir)) {
597
+ if (!file.endsWith(".form")) continue;
598
+ const doc: { components?: { type?: string; key?: string }[] } = JSON.parse(readFileSync(join(formsDir, file), "utf8"));
599
+ for (const c of doc.components ?? []) {
600
+ if ((c.type === "select" || c.type === "radio") && typeof c.key === "string" && c.key) selectKeys.add(c.key);
601
+ }
602
+ }
603
+ const covered = new Set<string>(SELECTED_VALUE_KEYS);
604
+ const missing = [...selectKeys].filter((k) => !covered.has(k));
605
+ assertEquals(missing, [], `every form select key must be in SELECTED_VALUE_KEYS; missing: ${missing.join(", ")}`);
606
+ });
607
+
608
+ test("record worker: a free-text-only form key (never a select/radio anywhere) must NOT leak into SELECTED_VALUE_KEYS (inverse drift guard)", async () => {
609
+ // Fix-the-class guard (the inverse of the drift guard above): a FREE-TEXT field (`textarea`/
610
+ // `textfield`) that is never a `select`/`radio` in ANY form is prose, not a discrete selection. If it
611
+ // leaks into SELECTED_VALUE_KEYS it enters the selection-CONFLICT fence, so a valid answer-path
612
+ // payload (`{ resolution: "answer", answer: "guidance" }` — free-text `answer` beside the `resolution`
613
+ // SELECT) presents two distinct "selection" values and is force-abstained as ambiguous, blocking the
614
+ // supported path (issue #889 review — `answer` was wrongly in the set). Derive the free-text-only keys
615
+ // from the forms and assert none is in SELECTED_VALUE_KEYS. (`choice` is a synthetic auxiliary hint,
616
+ // not a form field, so it is exempt.)
617
+ const formsDir = resolve(APP_ROOT, "resources", "forms");
618
+ const { readdirSync, readFileSync } = await import("node:fs");
619
+ const selectKeys = new Set<string>();
620
+ const freeTextKeys = new Set<string>();
621
+ for (const file of readdirSync(formsDir)) {
622
+ if (!file.endsWith(".form")) continue;
623
+ const doc: { components?: { type?: string; key?: string }[] } = JSON.parse(readFileSync(join(formsDir, file), "utf8"));
624
+ for (const c of doc.components ?? []) {
625
+ if (typeof c.key !== "string" || !c.key) continue;
626
+ if (c.type === "select" || c.type === "radio") selectKeys.add(c.key);
627
+ else if (c.type === "textarea" || c.type === "textfield") freeTextKeys.add(c.key);
628
+ }
629
+ }
630
+ // A key that is free-text in some form AND never a select/radio anywhere is a pure free-text key.
631
+ const pureFreeText = [...freeTextKeys].filter((k) => !selectKeys.has(k));
632
+ const leaked = pureFreeText.filter((k) => (SELECTED_VALUE_KEYS as readonly string[]).includes(k));
633
+ assertEquals(leaked, [], `free-text-only keys must never be in SELECTED_VALUE_KEYS (they belong in FREE_TEXT_VALUE_KEYS); leaked: ${leaked.join(", ")}`);
634
+ // And `answer` — the one free-text accompaniment today — must be classified as free-text, not a select.
635
+ assert((FREE_TEXT_VALUE_KEYS as readonly string[]).includes("answer"), "`answer` must be a FREE_TEXT_VALUE_KEYS member");
636
+ assert(!(SELECTED_VALUE_KEYS as readonly string[]).includes("answer"), "`answer` (free text) must NOT be a SELECTED_VALUE_KEYS member");
637
+ });
638
+
639
+ test("record worker: a feature-escalation answer path (`resolution:answer` + free-text `answer`) RESOLVES, not abstained as a conflict (issue #889 review)", async () => {
640
+ await run(
641
+ // The exact High finding: on `feature-escalation` the SELECT `resolution` offers `answer` (re-dispatch
642
+ // with guidance) and the free-text `answer` textarea carries that guidance. The prior code treated
643
+ // `answer` as a selection key, so `{ resolution: "answer", answer: "..." }` presented two distinct
644
+ // "selection" values (`"answer"` and the guidance prose) and was ALWAYS force-abstained as a conflict,
645
+ // blocking a fully supported, safe escalation path. The free-text field must not enter the conflict
646
+ // fence: the decision resolves on the `resolution` SELECT (`answer`, a safe non-destructive option).
647
+ [{ userTaskKey: "UT-FE", outcome: "resolved", variables: { resolution: "answer", answer: "Re-run with the corrected base branch." }, rationale: "the task just needs the right base", evidence: "inspected the failing run" }],
648
+ async (data) => {
649
+ await claimEscalations(data, [taskRow({ user_task_key: "UT-FE", element_id: "feature-escalation", kind_label: "Feature escalation", form_key: "feature-escalation" })], "PI-STEWARD");
650
+ },
651
+ async (data, completed) => {
652
+ assertEquals(completed.length, 1, "a valid answer-path feature escalation is submitted, not abstained");
653
+ assertEquals(completed[0].userTaskKey, "UT-FE");
654
+ assertEquals(completed[0].variables.resolution, "answer");
655
+ assertEquals(completed[0].variables.answer, "Re-run with the corrected base branch.");
656
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-FE" });
657
+ assertEquals(rows[0].outcome, "resolved", "the answer path resolves — the free-text field is not a selection conflict");
658
+ assertEquals(rows[0].selected_option, "answer", "the selected option is the `resolution` SELECT value, not the prose");
659
+ },
660
+ );
661
+ });
662
+
663
+ test("record worker: a select-less form still records its free-text submitted value as selected_option", async () => {
664
+ await run(
665
+ // `pr-escalation` (element `wait-answer`) has NO select at all — only the free-text `answer`. With
666
+ // `answer` out of SELECTED_VALUE_KEYS, the record must still capture the submitted prose as
667
+ // selected_option via the free-text fallback, so the review surface shows what was submitted.
668
+ [{ userTaskKey: "UT-WA", outcome: "resolved", variables: { answer: "proceed with the documented plan" }, rationale: "the plan is sound", evidence: "read the thread" }],
669
+ async (data) => {
670
+ await claimEscalations(data, [taskRow({ user_task_key: "UT-WA", element_id: "wait-answer", kind_label: "PR review", form_key: "pr-escalation" })], "PI-STEWARD");
671
+ },
672
+ async (data, completed) => {
673
+ assertEquals(completed.length, 1, "a select-less free-text answer still resolves");
674
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-WA" });
675
+ assertEquals(rows[0].outcome, "resolved");
676
+ assertEquals(rows[0].selected_option, "proceed with the documented plan", "the free-text submitted value is captured as selected_option");
677
+ },
678
+ );
679
+ });
680
+
681
+ test("record worker: a service-job RETRY after the engine completion landed recovers the prior agent completion (idempotent, never a false abstain)", async () => {
682
+ // The completion→record pair is NOT atomic: the engine completion lands first, then the claim-row
683
+ // finalise is a SEPARATE write. If that finalise failed transiently, the record service job is
684
+ // retried with the user task ALREADY COMPLETED — `completeEscalationAsAgent` reports "no open
685
+ // completable task". Before the fix the retry degraded the applied decision to `abstained`, losing
686
+ // the submitted variables and falsely auditing an applied automatic decision as a human hand-off
687
+ // (issue #889 review). The retry must detect the prior agent-attributed completion and finalise the
688
+ // ORIGINAL decision idempotently.
689
+ const dir = mkdtempSync(join(tmpdir(), "nwf-steward-rec-retry-"));
690
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
691
+ try {
692
+ // Claim UT-1 for THIS steward run.
693
+ await claimEscalations(app.db, [taskRow({})], "PI-STEWARD");
694
+ // Simulate the PRIOR attempt: the engine completion already landed, recorded as an AGENT
695
+ // completion under THIS steward's agentId — but the claim-row finalise never happened (the row is
696
+ // still `claimed`). Insert the attribution row directly (the engine call already happened).
697
+ const agentId = "escalation-steward:PI-STEWARD";
698
+ await taskCompletions(app.db).insert({
699
+ user_task_key: "UT-1",
700
+ process_instance_key: "PI-1",
701
+ element_id: "wait-answer",
702
+ actor_kind: "agent",
703
+ actor_id: agentId,
704
+ variables_json: JSON.stringify({ answer: "Retry with a note." }),
705
+ reversible: 1,
706
+ auto_applied: 0,
707
+ source_adjudication_id: null,
708
+ reverted: 0,
709
+ reverted_by: null,
710
+ reverted_note: null,
711
+ reverted_at: null,
712
+ created_at: new Date().toISOString(),
713
+ });
714
+ // On the retry the task is NO LONGER OPEN (already completed) — the mock engine reports no open
715
+ // tasks, so the attributed door returns "no open completable task".
716
+ const completed: { userTaskKey: string }[] = [];
717
+ const engine = {
718
+ openUserTasks: async () => [] as { userTaskKey: string }[],
719
+ completeUserTask: (userTaskKey: string) => {
720
+ completed.push({ userTaskKey });
721
+ return Promise.resolve();
722
+ },
723
+ };
724
+ const job = {
725
+ jobKey: "J-1",
726
+ processInstanceKey: "PI-STEWARD",
727
+ variables: { resolutions: JSON.stringify([{ userTaskKey: "UT-1", outcome: "resolved", variables: { answer: "Retry with a note." }, rationale: "nothing pushed", evidence: "ls-remote empty" }]) },
728
+ };
729
+ await handler(job as never, { data: app.db, engine: engine as never, log: { info: () => {}, warn: () => {}, error: () => {} } } as never);
730
+ // The retry must NOT re-complete the task NOR record a false abstain — it recovers the prior
731
+ // agent completion and finalises the claim `resolved` with the original variables.
732
+ assertEquals(completed.length, 0, "no SECOND engine completion on the retry");
733
+ const rows = await escalationAutoResolutions(app.db).find({ user_task_key: "UT-1" });
734
+ assertEquals(rows[0].outcome, "resolved", "the applied decision is recovered as resolved, not falsely abstained");
735
+ assertEquals(JSON.parse(rows[0].submitted_variables ?? "null"), { answer: "Retry with a note." }, "the original submitted variables are recovered, not lost");
736
+ } finally {
737
+ await app.stop?.();
738
+ rmSync(dir, { recursive: true, force: true });
739
+ }
740
+ });