@nanobpm/nano-workforce 0.202.0 → 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 (77) hide show
  1. package/CHANGELOG.md +6 -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/vocab/agent-sla-boundary.test.ts +6 -1
  8. package/app/contracts.ts +23 -0
  9. package/app/deliveryGraphDispatch.ts +6 -0
  10. package/app/deliveryGraphReadModel.test.ts +2 -2
  11. package/app/deliveryGraphRun.ts +18 -0
  12. package/app/deliveryRunner.ts +9 -1
  13. package/app/escalationSteward.test.ts +452 -0
  14. package/app/escalationSteward.ts +537 -0
  15. package/app/escalationStewardPoll.test.ts +123 -0
  16. package/app/feature.test.ts +16 -0
  17. package/app/feature.ts +11 -0
  18. package/app/featureDelivery.test.ts +23 -0
  19. package/app/mcpToolSurface.ts +17 -1
  20. package/app/plan.ts +49 -6
  21. package/app/planLowering.test.ts +26 -1
  22. package/app/planLowering.ts +5 -0
  23. package/app/promotionPoll.test.ts +19 -0
  24. package/app/pullRequestReadModel.test.ts +1 -1
  25. package/app/service.test.ts +36 -0
  26. package/app/service.ts +139 -5
  27. package/db/migrations/120_escalation_auto_resolutions.sql +229 -0
  28. package/db/migrations/121_delivery_graph_read_model_auto_resolve.sql +54 -0
  29. package/db/migrations/122_pull_requests_read_model_auto_resolve.sql +56 -0
  30. package/db/migrations/123_escalation_auto_resolutions_selected_option.sql +47 -0
  31. package/db/migrations/124_admitted_epics_auto_resolve.sql +21 -0
  32. package/docs/agent-guide.md +69 -30
  33. package/docs/mcp-runbook.md +2 -0
  34. package/e2e/escalation-steward.e2e.ts +211 -0
  35. package/nano.app.json +10 -1
  36. package/openapi.yaml +365 -0
  37. package/operations/dispatchDeliveryGraph.ts +6 -1
  38. package/operations/listActivePrs.test.ts +60 -1
  39. package/operations/listActivePrs.ts +3 -3
  40. package/operations/listAutoResolutions.ts +83 -0
  41. package/operations/reviewAutoResolution.test.ts +137 -0
  42. package/operations/reviewAutoResolution.ts +42 -0
  43. package/operations/startConvergenceLoop.ts +5 -1
  44. package/operations/startEpicSet.admission.integration.test.ts +2 -2
  45. package/operations/startEpicSet.ts +8 -2
  46. package/operations/startFeature.ts +5 -0
  47. package/operations/startPlanFanout.ts +4 -1
  48. package/package.json +1 -1
  49. package/pages/_nav.json +12 -0
  50. package/pages/auto-resolutions.page.json +191 -0
  51. package/pages/board.page.json +17 -0
  52. package/pages/cockpit.page.json +17 -0
  53. package/pages/delivery-graph-detail.page.json +17 -0
  54. package/pages/delivery-graphs/staged.mount.js +17 -5
  55. package/pages/delivery-graphs.page.json +17 -0
  56. package/pages/epic-detail.page.json +17 -0
  57. package/pages/epic.page.json +19 -1
  58. package/pages/feature.page.json +19 -1
  59. package/pages/home.page.json +22 -0
  60. package/pages/lineage.page.json +17 -0
  61. package/pages/mcp.page.json +17 -0
  62. package/pages/overview.page.json +17 -0
  63. package/pages/tasks.page.json +17 -0
  64. package/pages/velocity.page.json +17 -0
  65. package/resources/processes/escalation-steward.bpmn +162 -0
  66. package/resources/prompts/escalation-steward.md +87 -0
  67. package/test/delivery-graphs-staged-embed.test.ts +13 -0
  68. package/test/derivation-parity/derivation-parity.test.ts +1 -0
  69. package/test/derivation-parity/flows.ts +12 -0
  70. package/workers/converge-feature/worker.ts +6 -1
  71. package/workers/delivery-connector/worker.test.ts +72 -1
  72. package/workers/delivery-connector/worker.ts +53 -2
  73. package/workers/escalation-steward-collect/worker.test.ts +180 -0
  74. package/workers/escalation-steward-collect/worker.ts +99 -0
  75. package/workers/escalation-steward-record/worker.test.ts +740 -0
  76. package/workers/escalation-steward-record/worker.ts +428 -0
  77. package/workers/record-wave/worker.ts +5 -1
@@ -0,0 +1,428 @@
1
+ // pr.escalation-steward-record — the steward tick's final step (issue #889). Takes the
2
+ // `senior:escalation-steward` agent's per-escalation decisions and, for each:
3
+ // • ABSTAIN → records an `abstained` row (the escalation is left for a human);
4
+ // • RESOLVE → submits the chosen variables through the ONE canonical attributed door
5
+ // (`completeEscalationAsAgent`, app/agentCompletion.ts) — which resolves the open task against
6
+ // `ESCALATION_TASK_ELEMENTS`, validates its real form contract, and completes it with AGENT
7
+ // attribution (never a human decision, ADR 0046) — and records a `resolved` row.
8
+ //
9
+ // Host-side guards run BEFORE any submission, so a mis-prompted or hallucinating agent can never
10
+ // force one (fail-closed — every malformed/unsafe decision degrades to `abstained`, never throws,
11
+ // so one bad entry can never incident the record task and wedge the steward's overlap guard):
12
+ // • the CLAIM/OWNERSHIP fence — this steward instance must hold a live claim for the key, so a
13
+ // hallucinated key can never complete an unclaimed (or another run's) task;
14
+ // • the destructive/irreversible-option guard (`isDestructiveOption`) — derived from the decision's
15
+ // OWN selected value (`choice`, else the well-known form fields in `variables`), never trusted
16
+ // from an optional auxiliary field alone; an `abandon`/`force-merge`/credential choice is recorded
17
+ // `abstained`, never submitted;
18
+ // • the loop guard (`loopGuardTripped`) — a subject already auto-resolved N times that escalates
19
+ // again is recorded `abstained`;
20
+ // • the malformed-variables guard — a `resolved` decision whose `variables` are missing/null/not a
21
+ // plain object is converted to `abstained` BEFORE completion, so no false `resolved` record is
22
+ // ever written for a submission that never happened.
23
+ import type { AppJobHandler } from "@nanobpm/urban";
24
+ import { getAgentJobActivation } from "../../app/agentActivationLedger.ts";
25
+ import { completeEscalationAsAgent, escalationFormSelectKeys, priorAgentCompletion, snapshotEscalationFormOptions } from "../../app/agentCompletion.ts";
26
+ import {
27
+ countSubjectResolutions,
28
+ ESCALATION_STEWARD_JOB_TYPE,
29
+ type EscalationAutoResolution,
30
+ escalationAutoResolutions,
31
+ isUnsafeAutoOption,
32
+ LOOP_GUARD_MAX_RESOLUTIONS,
33
+ loopGuardTripped,
34
+ recordResolution,
35
+ renewClaim,
36
+ } from "../../app/escalationSteward.ts";
37
+
38
+ interface In extends Record<string, unknown> {
39
+ stewardInstanceKey?: string;
40
+ /** The agent's decisions, as a JSON string (the job envelope carries lists as serialised strings). */
41
+ resolutions?: string;
42
+ }
43
+
44
+ /** One decision the agent returned for a claimed escalation. */
45
+ interface StewardDecision {
46
+ userTaskKey: string;
47
+ /** `resolved` — submit `variables`; `abstained` — leave for a human. */
48
+ outcome: "resolved" | "abstained";
49
+ variables?: Record<string, unknown>;
50
+ /** The chosen option value (an auxiliary hint for the destructive-option guard). */
51
+ choice?: string;
52
+ rationale?: string;
53
+ evidence?: string;
54
+ options?: unknown[];
55
+ }
56
+
57
+ /** True only for a non-null, non-array plain object — the shape `variables`/`decisions` entries must
58
+ * have. Anything else (`null`, an array, a scalar) is malformed and must never be dereferenced. */
59
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
60
+ return typeof v === "object" && v !== null && !Array.isArray(v);
61
+ }
62
+
63
+ /** The well-known escalation-form SELECT field keys whose value carries the chosen option. This set
64
+ * MUST cover every `select`/`radio` key across `resources/forms/*.form`, so a destructive choice can
65
+ * never slip through on a key the guard doesn't read — the missing-key class is what let a
66
+ * `{directive:"abandon"}` (plan-review / empty-plan) or a `{mergeDecision:"force-merge"}`
67
+ * (merge-approval) submit unguarded and record `selected_option=NULL` (issue #889 review). The
68
+ * canonical keys today are `decision` (delivery), `directive` (plan-review / empty-plan),
69
+ * `resolution` (feature / readiness), `mergeDecision` (merge-approval) and `action` (trial-merge);
70
+ * `choice` is the auxiliary hint.
71
+ *
72
+ * A FREE-TEXT field (`answer`, see {@link FREE_TEXT_VALUE_KEYS}) is deliberately NOT here: it carries
73
+ * prose, not a discrete selection, so it must never participate in the selection-CONFLICT fence nor
74
+ * shadow/override a real select. Including `answer` here was the bug class the reviewer cited — a valid
75
+ * feature/readiness answer-path payload (`{ resolution: "answer", answer: "corrective guidance" }`,
76
+ * where the free-text `answer` accompanies the `resolution` SELECT) presents two distinct "selection"
77
+ * values and is always force-abstained as an ambiguous conflict, blocking a supported escalation path
78
+ * (issue #889 review). Two drift tests (`worker.test.ts`) key this both ways: every `.form` select key
79
+ * is present, and NO key that is free-text in EVERY form (never a `select`/`radio` anywhere) leaks in —
80
+ * so neither a new select key silently falls out of the guard nor a free-text field silently falls into
81
+ * the conflict fence. */
82
+ export const SELECTED_VALUE_KEYS = [
83
+ "choice",
84
+ "resolution",
85
+ "action",
86
+ "decision",
87
+ "directive",
88
+ "mergeDecision",
89
+ ] as const;
90
+
91
+ /** Escalation-form FREE-TEXT field keys (`textarea`/`textfield`) whose value is prose, not a discrete
92
+ * selection. These are read ONLY as the last-resort submitted value for a free-text-only form (e.g.
93
+ * `pr-escalation`'s `answer`, which has no select at all) so the record still persists what was
94
+ * submitted as `selected_option`. They are NEVER fed to the selection-conflict fence or the
95
+ * destructive-option guard — prose is not an option choice, and treating it as one false-abstains the
96
+ * answer path (issue #889 review). `answer` is the only such accompaniment today. */
97
+ export const FREE_TEXT_VALUE_KEYS = ["answer"] as const;
98
+
99
+ /** Derive the option value a decision actually selected — scoped to the SELECT field(s) of the CLAIMED
100
+ * task's ACTUAL form (`elementId`), never a first-nonblank sweep across every recognized key. The
101
+ * destructive guard tests this, so it must reflect what the completion really submits for THIS task:
102
+ * a `trial-merge-decision` (whose form's select is `action`) carrying `{ resolution: "rebase",
103
+ * action: "abandon" }` must be judged on the destructive `action`, never waved through by the benign
104
+ * foreign `resolution` (issue #889 review — the first-nonblank sweep let a safe foreign key shadow a
105
+ * destructive own-form key).
106
+ *
107
+ * Selection order:
108
+ * 1. The task's OWN form select keys (from the canonical form contract via
109
+ * {@link escalationFormSelectKeys}) — the value the completion would genuinely submit.
110
+ * 2. CONFLICT fence (fail-closed): if variables carry MORE THAN ONE distinct nonblank recognized
111
+ * selection value (across the full {@link SELECTED_VALUE_KEYS} set), the decision is ambiguous —
112
+ * an agent stuffing a benign key beside a destructive one. Returns `null` so the caller abstains.
113
+ * 3. Fallback when no recognized SELECT value is present: the auxiliary `choice` hint, else the
114
+ * single free-text value (`answer`, {@link FREE_TEXT_VALUE_KEYS}) a select-less form (e.g.
115
+ * `pr-escalation`) submits — so the record still captures what was submitted. Free-text is read
116
+ * ONLY here, never in the conflict fence above (prose is not a selection, so a `resolution`-SELECT
117
+ * answer path carrying free-text `answer` is not a conflict — issue #889 review).
118
+ *
119
+ * Returns `{ value, conflict }`: `conflict` true means the decision is ambiguous and must be abstained
120
+ * regardless of any single value's safety. */
121
+ function selectedOption(d: StewardDecision, elementId: string | null): { value: string | null; conflict: boolean } {
122
+ if (!isPlainObject(d.variables)) {
123
+ return { value: typeof d.choice === "string" && d.choice.trim() !== "" ? d.choice : null, conflict: false };
124
+ }
125
+ const vars = d.variables;
126
+ // Collect every nonblank recognized SELECT value present, keyed by field. Free-text fields
127
+ // (FREE_TEXT_VALUE_KEYS) are deliberately excluded here — prose is not a selection, so it must never
128
+ // enter the conflict fence nor shadow a real select (issue #889 review).
129
+ const present = new Map<string, string>();
130
+ for (const k of SELECTED_VALUE_KEYS) {
131
+ const v = vars[k];
132
+ if (typeof v === "string" && v.trim() !== "") present.set(k, v.trim());
133
+ }
134
+ // Conflict fence: two or more DISTINCT recognized SELECT values is an ambiguous decision — never
135
+ // guess which the agent meant; abstain. (The same value repeated under two keys is not a conflict.)
136
+ const distinctValues = new Set(present.values());
137
+ if (distinctValues.size > 1) {
138
+ return { value: null, conflict: true };
139
+ }
140
+ // Scope to the task's OWN form select keys first — the value the completion actually submits.
141
+ const ownKeys = elementId != null ? escalationFormSelectKeys(elementId) : null;
142
+ if (ownKeys != null) {
143
+ for (const k of ownKeys) {
144
+ const v = present.get(k);
145
+ if (v != null) return { value: v, conflict: false };
146
+ }
147
+ // The form has select keys but none is set: no recognized selection on the real form. Fall through
148
+ // to any single recognized value, then the choice hint, then the free-text submitted value.
149
+ }
150
+ // Single recognized SELECT value (no conflict) — use it.
151
+ if (present.size === 1) {
152
+ const only = present.values().next().value;
153
+ if (only != null) return { value: only, conflict: false };
154
+ }
155
+ // Fall back to the auxiliary hint.
156
+ if (typeof d.choice === "string" && d.choice.trim() !== "") return { value: d.choice, conflict: false };
157
+ // Last resort: the free-text submitted value a select-less form (e.g. `pr-escalation`'s `answer`)
158
+ // carries — captured so the record is honest, but never fed to the conflict fence / destructive guard.
159
+ for (const k of FREE_TEXT_VALUE_KEYS) {
160
+ const v = vars[k];
161
+ if (typeof v === "string" && v.trim() !== "") return { value: v.trim(), conflict: false };
162
+ }
163
+ return { value: null, conflict: false };
164
+ }
165
+
166
+ /** Narrow a parsed JSON entry to a {@link StewardDecision}: only a non-null plain object qualifies
167
+ * (anything else — `null`, an array, a scalar — is malformed and skipped by the caller). A type
168
+ * predicate, not an `as` cast, so the narrowing is a real runtime guard. */
169
+ function isDecision(v: unknown): v is StewardDecision {
170
+ return isPlainObject(v);
171
+ }
172
+
173
+ /** The REAL form choices for a claimed task, from the snapshot taken host-side at CLAIM time (parsed
174
+ * from the claim row's `options` JSON). The claim snapshot already resolves a delivery-graph human
175
+ * task's dynamic form via its engine `form_key` — which `snapshotEscalationFormOptions(element_id)`
176
+ * cannot, since that task's element id has no static form linkage (issue #889 review). Fall back to the
177
+ * element's static form only when the claim carried no snapshot (a legacy/pre-snapshot claim row), and
178
+ * never throw on malformed stored JSON (degrade to the fallback / null). */
179
+ function claimOptions(claim: EscalationAutoResolution): string[] | null {
180
+ if (claim.options != null) {
181
+ try {
182
+ const parsed: unknown = JSON.parse(claim.options);
183
+ if (Array.isArray(parsed)) return parsed.filter((v): v is string => typeof v === "string");
184
+ } catch {
185
+ // Malformed stored JSON — fall through to re-deriving from the element's static form.
186
+ }
187
+ }
188
+ return claim.element_id != null ? snapshotEscalationFormOptions(claim.element_id) : null;
189
+ }
190
+
191
+ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
192
+ const raw = typeof job.variables.resolutions === "string" ? job.variables.resolutions : "";
193
+ const decisions: StewardDecision[] = [];
194
+ if (raw.trim() !== "") {
195
+ try {
196
+ const parsed: unknown = JSON.parse(raw);
197
+ // Validate EACH entry as a non-null plain object before it is ever dereferenced — valid JSON such
198
+ // as `[null]` must never reach the loop and throw on `d.userTaskKey` (which would incident the
199
+ // record task and, because the steward process then stays ACTIVE, wedge the overlap guard).
200
+ if (Array.isArray(parsed)) {
201
+ for (const entry of parsed) {
202
+ if (isDecision(entry)) decisions.push(entry);
203
+ else app.log.warn("escalation-steward record: skipping a malformed (non-object) decision entry");
204
+ }
205
+ }
206
+ } catch (err) {
207
+ app.log.error("escalation-steward record: unparseable resolutions", { error: err instanceof Error ? err.message : String(err) });
208
+ }
209
+ }
210
+ // This steward instance's key — the claim/ownership fence matches it against the claim row's
211
+ // `claimed_by`, so the record step only ever finalises tasks THIS run claimed.
212
+ const stewardKey = job.processInstanceKey != null ? String(job.processInstanceKey) : `steward-${Date.now()}`;
213
+ const agentId = `escalation-steward:${job.processInstanceKey != null ? String(job.processInstanceKey) : "unknown"}`;
214
+ // Provenance (issue #889 review): `job.jobKey` here is the `pr.escalation-steward-record` SERVICE
215
+ // job, NOT the preceding `senior:escalation-steward` AGENT job that actually made the decisions.
216
+ // Persisting the record key as `agent_job_key` would give the audit record the wrong provenance, so
217
+ // read the agent's activation key from the durable activation ledger (keyed by this SAME steward
218
+ // process instance + the agent job type) — the poller records it while the agent job is live. When
219
+ // the ledger has no row (the best-effort activation poll missed a fast agent job, or engine REST is
220
+ // not configured), preserve NULL rather than store the record task's own key — a key known to
221
+ // identify a DIFFERENT job is a confidently-wrong provenance, worse than an honest "unknown".
222
+ const agentActivation =
223
+ job.processInstanceKey != null
224
+ ? await getAgentJobActivation(app.data, String(job.processInstanceKey), ESCALATION_STEWARD_JOB_TYPE).catch(() => undefined)
225
+ : undefined;
226
+ const agentJobKey = agentActivation?.job_key ?? null;
227
+
228
+ for (const d of decisions) {
229
+ const key = typeof d.userTaskKey === "string" ? d.userTaskKey.trim() : "";
230
+ if (!key) continue;
231
+ // Re-acquire (renew) THIS run's own claim lease BEFORE the ownership fence + finalise (issue #889
232
+ // review). The claim lease is a short crash-recovery TTL, but the `senior:escalation-steward`
233
+ // agent has no bounded runtime, so a long agent run can let the lease lapse before this record
234
+ // step runs. Without renewal the owner+lease CAS in `recordResolution` would REJECT the expired
235
+ // claim and DISCARD the agent's valid decision, and the next tick would repeat the work. The
236
+ // renew is owner-fenced (matched to THIS run), so a claim another run reclaimed — or a
237
+ // released/finalised row — renews nothing and is skipped, preserving the no-double-answer fence.
238
+ // The steward's overlap guard guarantees no OTHER tick touches claims while this run is ACTIVE, so
239
+ // matching `claimed_by` alone is a sufficient exclusivity fence for the renewal.
240
+ const renewed = await renewClaim(app.data, key, stewardKey);
241
+ if (!renewed) {
242
+ app.log.warn("escalation-steward record: claim no longer owned by this run (reclaimed/released), skipping decision", { userTaskKey: key });
243
+ continue;
244
+ }
245
+ // Claim/ownership fence (fail-closed, applied to EVERY decision — not only `resolved`). This
246
+ // steward instance must hold a LIVE claim for the key. Without it a hallucinated key could
247
+ // complete an unclaimed — or a non-opted-in, or another run's — task. Crucially the fence is a
248
+ // SKIP, not a downgrade-to-abstain: `recordResolution` finalises the claim row, so recording ANY
249
+ // decision (even an `abstained`) for a key this run does not own would finalise ANOTHER run's live
250
+ // claim. Skip every decision not owned by this run instead. This read is a fast pre-filter only —
251
+ // the ATOMIC guard is the owner+lease-scoped CAS in `recordResolution` below, which closes the
252
+ // read→finalise race (an overlapping tick reclaiming the task after this read). The lease is
253
+ // UNEXPIRED here because the renew above just extended it for this owned claim.
254
+ const claimRows = await escalationAutoResolutions(app.data).find({ user_task_key: key });
255
+ const claim = claimRows.find((r) => r.outcome === "claimed");
256
+ const fenceNow = new Date().toISOString();
257
+ const ownedByThisRun = claim != null && claim.claimed_by === stewardKey && claim.claimed_until != null && claim.claimed_until >= fenceNow;
258
+ if (!ownedByThisRun) {
259
+ app.log.warn("escalation-steward record: no owned live claim for key, skipping decision (leaving the claim untouched)", { userTaskKey: key });
260
+ continue;
261
+ }
262
+
263
+ // Default to abstain on any malformed/unsafe decision — never submit on ambiguity.
264
+ let outcome: "resolved" | "abstained" = d.outcome === "resolved" ? "resolved" : "abstained";
265
+ let rationale = typeof d.rationale === "string" ? d.rationale : null;
266
+ // The option value the decision actually selected, derived host-side from its OWN form variables
267
+ // scoped to the CLAIMED task's ACTUAL form (never the agent's optional `choice` hint alone, and never
268
+ // a first-nonblank sweep across every recognized key — a foreign safe key must not shadow a
269
+ // destructive own-form key). Computed ONCE here so the destructive guard and the persisted
270
+ // `selected_option` review column read the SAME value (issue #889 review).
271
+ const sel = selectedOption(d, claim.element_id ?? null);
272
+ const selected = sel.value;
273
+
274
+ // Conflicting-selection fence (fail-closed): the decision carries TWO OR MORE DISTINCT recognized
275
+ // selection values (e.g. `{ resolution: "rebase", action: "abandon" }`) — an ambiguous, possibly
276
+ // adversarial payload the guard cannot attribute to a single safe choice. Abstain rather than guess.
277
+ if (outcome === "resolved" && sel.conflict) {
278
+ outcome = "abstained";
279
+ rationale = "abstained: decision carried conflicting selection fields (more than one distinct recognized option value) — ambiguous; left for a human";
280
+ }
281
+
282
+ // Destructive/irreversible-option guard (host-side, fail-closed, ELEMENT-AWARE). Derive the
283
+ // selected value from the decision's own variables FIRST (not only the optional `choice`), so a
284
+ // `choice`-less `abandon` — or a benign `choice` paired with a destructive `variables` value — is
285
+ // caught. `isUnsafeAutoOption` also refuses an element-specific RED-GATE OVERRIDE: on
286
+ // `trial-merge-decision` a `proceed` overrides a `suite-failed` D3 trial merge (SPEC.md §13.2), so
287
+ // only the non-override `rebase` path is steward-safe there (issue #889 review).
288
+ if (outcome === "resolved") {
289
+ if (selected != null && isUnsafeAutoOption(claim.element_id ?? null, selected)) {
290
+ outcome = "abstained";
291
+ rationale = `abstained: '${selected}' is a destructive/irreversible or red-gate-override option on ${claim.element_id ?? "this element"} — left for a human`;
292
+ }
293
+ }
294
+
295
+ if (outcome === "resolved") {
296
+ // Loop guard: look up the subject from the OWNED claim row and refuse to keep re-answering a
297
+ // subject that has already been auto-resolved LOOP_GUARD_MAX_RESOLUTIONS times. Count by BOTH
298
+ // `subject_type` AND `subject_key` (issue #889 review): a bare `subject_key` collides across
299
+ // subject types (a plan `o/r#1` and a PR `o/r#1` share the key), so an unscoped count would let
300
+ // one subject's resolutions force an UNRELATED subject to abstain on its first escalation. Use
301
+ // the owned claim's own subject identity, not `claimRows[0]` (which may be a foreign run's row).
302
+ const subjectType = claim.subject_type ?? null;
303
+ const subjectKey = claim.subject_key ?? null;
304
+ if (subjectKey) {
305
+ const prior = await countSubjectResolutions(app.data, subjectType, subjectKey);
306
+ if (loopGuardTripped(prior, LOOP_GUARD_MAX_RESOLUTIONS)) {
307
+ outcome = "abstained";
308
+ rationale = `abstained: subject ${subjectType ?? "?"}:${subjectKey} already auto-resolved ${prior}× and escalated again — the automatic answer isn't working; a human must decide`;
309
+ }
310
+ }
311
+ }
312
+
313
+ // Malformed-variables guard (fail-closed): a `resolved` decision needs a plain-object `variables`.
314
+ // Missing/null/array-valued variables would otherwise skip completion yet still record a false
315
+ // `resolved`; convert to `abstained` BEFORE the completion branch so the record stays honest. Bind
316
+ // the narrowed value once so the completion + record share it without a re-assertion.
317
+ const vars = isPlainObject(d.variables) ? d.variables : null;
318
+ if (outcome === "resolved" && vars === null) {
319
+ outcome = "abstained";
320
+ rationale = "abstained: `resolved` decision carried no valid `variables` object — left for a human";
321
+ }
322
+
323
+ // Evidence-before-choosing guard (fail-closed, issue #889 review): the PR's promise is that every
324
+ // automatic resolution carries a reviewable audit record — a non-blank rationale AND the evidence
325
+ // the agent checked. A `resolved` decision that omits either would auto-complete an escalation
326
+ // with no reviewable basis, so convert it to `abstained` (left for a human) exactly as malformed
327
+ // variables are. Read the agent's evidence once so the guard and the record agree.
328
+ const evidence = typeof d.evidence === "string" && d.evidence.trim() !== "" ? d.evidence : null;
329
+ if (outcome === "resolved" && (rationale == null || rationale.trim() === "" || evidence === null)) {
330
+ outcome = "abstained";
331
+ rationale = "abstained: `resolved` decision carried no rationale/evidence — the audit record would be incomplete; left for a human";
332
+ }
333
+
334
+ let submitted: Record<string, unknown> | null = null;
335
+ if (outcome === "resolved" && vars !== null) {
336
+ try {
337
+ // The ONE canonical attributed door: resolves the open task against ESCALATION_TASK_ELEMENTS,
338
+ // validates its real form contract, and completes with AGENT attribution (ADR 0046). Pass the
339
+ // claim row's `process_instance_key` so the resolve scans that ONE instance, not an engine-wide
340
+ // `openUserTasks()` per decision — a record tick finalises many claimed tasks in one pass, so an
341
+ // unfiltered global scan each is O(N²) REST load (issue #889 review).
342
+ const result = await completeEscalationAsAgent(app.data, app.engine, {
343
+ userTaskKey: key,
344
+ variables: vars,
345
+ agentId,
346
+ processInstanceKey: claim.process_instance_key ?? null,
347
+ });
348
+ if (result.ok) {
349
+ submitted = vars;
350
+ } else {
351
+ // Idempotent-retry recovery (issue #889 review): the completion→record pair is NOT atomic.
352
+ // The engine completion lands first; the claim-row finalise below is a SEPARATE write. If
353
+ // that finalise failed transiently on a prior attempt, this service job is retried with the
354
+ // user task ALREADY COMPLETED — the door then reports "no open completable task". That must
355
+ // NOT degrade the already-applied decision to a false `abstained` (which would lose the
356
+ // submitted variables and mis-audit an applied automatic decision as a human hand-off).
357
+ // Detect the prior agent-attributed completion and finalise the SAME decision idempotently.
358
+ const prior = await priorAgentCompletion(app.data, key, agentId).catch(() => undefined);
359
+ if (prior) {
360
+ let recovered: Record<string, unknown> | null = null;
361
+ try {
362
+ const parsedVars: unknown = JSON.parse(prior.variables_json);
363
+ if (isPlainObject(parsedVars)) recovered = parsedVars;
364
+ } catch {
365
+ // Malformed stored variables_json — fall back to this attempt's variables below.
366
+ }
367
+ submitted = recovered ?? vars;
368
+ app.log.info("escalation-steward record: recovered a prior agent completion on retry — finalising the original decision idempotently", { userTaskKey: key, completionId: prior.id });
369
+ } else {
370
+ outcome = "abstained";
371
+ rationale = `abstained: completion refused (${result.reason ?? "unknown"}) — left for a human`;
372
+ app.log.warn("escalation-steward record: completion refused, recorded abstained", { userTaskKey: key, reason: result.reason });
373
+ }
374
+ }
375
+ } catch (err) {
376
+ // A failed completion (task already answered by a human, or gone) degrades to abstain — the
377
+ // escalation is simply left for a human. Never fail the whole tick on one task.
378
+ outcome = "abstained";
379
+ rationale = `abstained: completion failed (${err instanceof Error ? err.message : String(err)}) — left for a human`;
380
+ app.log.warn("escalation-steward record: completion failed, recorded abstained", { userTaskKey: key });
381
+ }
382
+ }
383
+
384
+ // ATOMIC owner+lease CAS (issue #889 review): the completion above already landed, but the
385
+ // finalise is fenced on THIS run still holding an UNEXPIRED claim. If an overlapping tick released
386
+ // a lapsed lease and reclaimed the task between the read fence and now, this CAS changes ZERO rows
387
+ // and reports `false` — we then SKIP, leaving the real claim holder's row untouched rather than
388
+ // finalising it (the no-double-answer fence the reviewer cited).
389
+ const finalised = await recordResolution(
390
+ app.data,
391
+ key,
392
+ {
393
+ outcome,
394
+ submittedVariables: submitted,
395
+ // `selected_option` is the option the steward ACTUALLY submitted — so it is only meaningful on a
396
+ // `resolved` record. On an `abstained` record the steward submitted NOTHING, so persist NULL, not
397
+ // the derived (and possibly rejected/destructive) value: recording `abandon` on an abstention would
398
+ // tell the review surface the steward "chose abandon" when it in fact declined to choose (issue
399
+ // #889 review), contradicting the OpenAPI contract ("Null when abstained").
400
+ selectedOption: outcome === "resolved" ? selected : null,
401
+ rationale,
402
+ evidence,
403
+ // Carry through the REAL form choices snapshotted host-side at CLAIM time (from the task's
404
+ // engine-resolved `form_key`, issue #889 review) — never the deciding agent's self-reported
405
+ // `options`, which it may omit or misstate. The claim snapshot already resolves a delivery-graph
406
+ // human task's dynamic form (which `snapshotEscalationFormOptions(element_id)` alone cannot); fall
407
+ // back to the element's static form only when the claim carried no snapshot.
408
+ options: claimOptions(claim),
409
+ agentJobKey,
410
+ // Persist the leasing worker's provenance (issue #889 review): the activation ledger already
411
+ // captured WHICH worker picked up this exact agent job, and the Auto-resolutions grid's Worker
412
+ // column reads this field — leaving it null blanked a provenance the API/UI promise. NULL only
413
+ // when the ledger has no row (a fast job the best-effort activation poll missed), matching the
414
+ // `agentJobKey` null-when-unknown contract above. `model` has no ledger source, so it stays NULL.
415
+ worker: agentActivation?.worker ?? null,
416
+ model: null,
417
+ },
418
+ stewardKey,
419
+ );
420
+ if (!finalised) {
421
+ app.log.warn("escalation-steward record: lost the owner+lease finalise CAS — an overlapping run reclaimed the task; leaving its claim untouched", { userTaskKey: key });
422
+ }
423
+ }
424
+ app.log.info("escalation-steward record", { processed: decisions.length });
425
+ return {};
426
+ };
427
+
428
+ export default handler;
@@ -215,7 +215,11 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
215
215
  }
216
216
 
217
217
  try {
218
- await submitPr(app.data, app.engine, parsed, depPrKeys, MAX_ROUNDS, false, planKey);
218
+ // Propagate the epic's escalation-steward opt-in (issue #889) to the child convergence loop:
219
+ // read the plan row's durable flag and hand it to `submitPr` so the enrolled PR inherits it.
220
+ const planRow = await plans(app.data).get(planKey);
221
+ const autoResolve = planRow?.auto_resolve_escalations === 1;
222
+ await submitPr(app.data, app.engine, parsed, depPrKeys, MAX_ROUNDS, false, planKey, false, autoResolve);
219
223
  } catch (err) {
220
224
  app.log.error(`record-wave: handoff failed for ${parsed.prKey}`, {
221
225
  err: String(err),