@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
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.203.0](https://github.com/nanobpm/nano-workforce/compare/v0.202.0...v0.203.0) (2026-10-13)
2
+
3
+ ### Features
4
+
5
+ * **escalation-steward:** opt-in auto-resolution of escalations with a reviewable record (Closes [#889](https://github.com/nanobpm/nano-workforce/issues/889)) ([#892](https://github.com/nanobpm/nano-workforce/issues/892)) ([cd1c9eb](https://github.com/nanobpm/nano-workforce/commit/cd1c9eb58bc44b7af0c66d4c1a7bc615920a46d2)), closes [887/#888](https://github.com/887/nano-workforce/issues/888) [#833](https://github.com/nanobpm/nano-workforce/issues/833) [o/r#1](https://github.com/o/r/issues/1) [o/r#1](https://github.com/o/r/issues/1)
6
+
1
7
  ## [0.202.0](https://github.com/nanobpm/nano-workforce/compare/v0.201.5...v0.202.0) (2026-10-12)
2
8
 
3
9
  ### Features
package/SPEC.md CHANGED
@@ -1310,6 +1310,54 @@ re-park (a human node has no separate escalation twin to re-park onto). The gate
1310
1310
  like the escalation resume's no-required-target case), and a no-emit acknowledgement node
1311
1311
  has nothing to validate.
1312
1312
 
1313
+ ## 13.4 Escalation steward (`escalation-steward.bpmn`) — issue #889
1314
+
1315
+ An **opt-in**, timer-paced loop that reviews a run's OPEN escalations and submits a recommended
1316
+ choice for each, so work keeps moving when no human is watching — while every automatic decision
1317
+ stays **reviewable after the fact** in the Auto-resolutions view. It is strictly opt-in: only work
1318
+ whose start request set `autoResolveEscalations: true` (persisted as `auto_resolve_escalations` on
1319
+ the subject row) is ever touched; everything else is fail-closed out.
1320
+
1321
+ - **Pacing + master switch.** `pollEscalationSteward` (a `pollOnce` pass) starts ONE
1322
+ `escalation-steward` instance per interval — `NANO_ESCALATION_STEWARD_INTERVAL` (default `PT30M`,
1323
+ parsed through the canonical `isoDurationToMs`) — when the loop is enabled (`NANO_ESCALATION_STEWARD`,
1324
+ default on) and no prior tick is still ACTIVE. The pass is **best-effort and isolated**: a
1325
+ steward-specific failure is caught and logged so it never starves the poller's other liveness
1326
+ passes, and the tick timestamp advances only after a successful start (a failed start retries next
1327
+ pass).
1328
+ - **Eligibility + the claim fence.** The collect step (`pr.escalation-steward-collect`) gathers the
1329
+ open escalation user tasks whose owning subject OPTED IN — resolved off the subject row by
1330
+ `process_key`, falling back to the root-correlated `subject_key` so an escalation parked inside a
1331
+ callActivity child cell (`human-escalation`/`implement-cell`) inherits its parent run's opt-in —
1332
+ that are past a short minimum age (`NANO_ESCALATION_STEWARD_MIN_AGE_MS`, default 5 min, so a human
1333
+ already answering wins), of a steward-answerable kind, and not already claimed. It claims them with
1334
+ a durable compare-and-swap lease (`escalation_auto_resolutions` `claimed` placeholder + a partial
1335
+ UNIQUE index), so two overlapping ticks (or a tick racing a human) never double-answer; an expired
1336
+ lease is released so a crashed steward never wedges a task.
1337
+ - **The agent + the record.** The `senior:escalation-steward` agent reviews the claimed escalations
1338
+ and returns one decision each (`resolved` + typed form `variables`, or `abstained`). The record
1339
+ step (`pr.escalation-steward-record`) submits each resolution through the ONE canonical attributed
1340
+ door (`completeEscalationAsAgent`, AGENT attribution — never a human decision, ADR 0046) and writes
1341
+ exactly one durable `escalation_auto_resolutions` row per acted escalation.
1342
+ - **Fail-closed guards (defence-in-depth under the prompt's own abstain rules).** Before any
1343
+ submission the record step enforces, in one place: the **claim/ownership fence** (this run must
1344
+ hold a live claim for the key — a hallucinated or foreign key is **skipped**, leaving any other
1345
+ run's claim untouched, *not* recorded `abstained`: `recordResolution` finalises the claim row keyed
1346
+ only by `user_task_key`, so writing even an `abstained` row for a key this run does not own would
1347
+ finalise another run's live claim); the **destructive-option guard**
1348
+ (`abandon`/`force-merge`/credential, derived from the decision's own selected value scoped to the
1349
+ task's ACTUAL form — abstained, never submitted; a foreign safe key can never shadow a destructive
1350
+ own-form key, and a decision carrying conflicting selection fields is abstained as ambiguous); the
1351
+ **loop guard** (a subject auto-resolved `LOOP_GUARD_MAX_RESOLUTIONS` times
1352
+ that escalates again is abstained); and the **malformed-decision guards** (a non-object entry is
1353
+ skipped; a `resolved` decision with no valid `variables` object is recorded `abstained`, never a
1354
+ false `resolved`). Human-only / irreversible kinds (`merge-approval`, `acp-permission`,
1355
+ readiness gates, `feature-blocked`, `conformance-`/`empty-plan-`escalation) are refused before the
1356
+ agent is even dispatched.
1357
+ - **The reviewable record.** Every final decision (`resolved`/`abstained`) is listed in
1358
+ Auto-resolutions, attributed to the steward agent, where an operator can **Dismiss** (reviewed, no
1359
+ action) or **Intervene** (stepped in) with a note — a record is never a hidden machine decision.
1360
+
1313
1361
  ## 14. Open questions / future
1314
1362
 
1315
1363
  - **Provisioning the existing PR branch** — resolved: the `c8ctl` host-git
@@ -23,9 +23,20 @@ function memData() {
23
23
  find: (q: any) =>
24
24
  Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
25
25
  insert: (r: any) => {
26
+ // Enforce the PRIMARY KEY (plan_key) uniqueness the real SQLite table has, so the
27
+ // concurrent-insert race path (a colliding second insert throws) is exercisable.
28
+ if (rows.some((x) => x[key] === r[key])) {
29
+ return Promise.reject(new Error(`UNIQUE constraint failed: ${name}.${key}`));
30
+ }
26
31
  rows.push(r);
27
32
  return Promise.resolve(r);
28
33
  },
34
+ update: (k: any, patch: any) => {
35
+ const i = rows.findIndex((r) => r[key] === k);
36
+ if (i === -1) return Promise.resolve({ changed: 0 });
37
+ rows[i] = { ...rows[i], ...patch };
38
+ return Promise.resolve({ changed: 1 });
39
+ },
29
40
  };
30
41
  },
31
42
  } as any,
@@ -59,6 +70,66 @@ test("recordAdmittedEpic is idempotent on plan_key (no second row, existing wins
59
70
  assertEquals(again.base_branch, "epic/a"); // the existing staged row is returned, not overwritten
60
71
  });
61
72
 
73
+ test("recordAdmittedEpic widens the staged escalation opt-in monotonically on an idempotent retry", async () => {
74
+ const { data, tables } = memData();
75
+ // Stage the epic OPTED OUT (the caller omitted the flag → 0), as a crashed-before-lowering request
76
+ // would leave it.
77
+ const first = await recordAdmittedEpic(data, EPIC);
78
+ assertEquals(first.auto_resolve_escalations, 0, "an omitted opt-in stages as 0 (opted out)");
79
+ // Retry the SAME admission now opting in: the staged row must WIDEN to opted-in (issue #889 review),
80
+ // not stay opted out — otherwise the epic starts opted out despite the caller's opt-in.
81
+ const retried = await recordAdmittedEpic(data, { ...EPIC, auto_resolve_escalations: 1 });
82
+ assertEquals(tables.get("admitted_epics")!.length, 1, "still one staged row (idempotent)");
83
+ assertEquals(retried.auto_resolve_escalations, 1, "the retry widened the staged opt-in to 1");
84
+ assertEquals(retried.created_at, first.created_at, "the original staged row is preserved");
85
+ assertEquals(tables.get("admitted_epics")![0].auto_resolve_escalations, 1, "the widening is persisted");
86
+ });
87
+
88
+ test("recordAdmittedEpic never narrows an opted-in staged row back to opted-out", async () => {
89
+ const { data, tables } = memData();
90
+ await recordAdmittedEpic(data, { ...EPIC, auto_resolve_escalations: 1 });
91
+ // A retry that omits (or explicitly clears) the flag must NOT narrow the staged opt-in back to 0 —
92
+ // the flag is monotonic (mirrors submitPr's live-loop adoption, service.ts).
93
+ const retried = await recordAdmittedEpic(data, { ...EPIC, auto_resolve_escalations: 0 });
94
+ assertEquals(retried.auto_resolve_escalations, 1, "an opted-in staged row is never narrowed to 0");
95
+ assertEquals(tables.get("admitted_epics")![0].auto_resolve_escalations, 1);
96
+ });
97
+
98
+ test("recordAdmittedEpic applies the monotonic widening on the concurrent-insert race path", async () => {
99
+ // Simulate the race the reviewer cited (issue #889): an opted-OUT admission's insert WINS, so by
100
+ // the time the opted-IN caller runs, the row already exists — but the opted-in caller's initial
101
+ // `get` raced ahead of that commit and observed NO row, so it proceeds to `insert`, collides on the
102
+ // PRIMARY KEY, and lands in the catch path. The catch must apply the SAME 0→1 widening before
103
+ // returning the raced row, or the opt-in is silently lost.
104
+ const { data, tables } = memData();
105
+ // The winning racer commits its opted-out row directly (as its own successful insert).
106
+ await recordAdmittedEpic(data, { ...EPIC, auto_resolve_escalations: 0 });
107
+ assertEquals(tables.get("admitted_epics")![0].auto_resolve_escalations, 0, "the racer staged opted-out");
108
+
109
+ // Force the opted-in caller's FIRST `get` to miss (the race window: it read before the racer's
110
+ // commit became visible); the catch-path re-read then sees the committed row.
111
+ const realTable = (data as any).table;
112
+ let missOnce = true;
113
+ (data as any).table = (name: string, key: string) => {
114
+ const t = realTable(name, key);
115
+ if (name !== "admitted_epics") return t;
116
+ return {
117
+ ...t,
118
+ get: (k: any) => (missOnce ? ((missOnce = false), Promise.resolve(null)) : t.get(k)),
119
+ };
120
+ };
121
+
122
+ // The opted-in caller: get misses → insert collides (mock enforces PK uniqueness) → catch path.
123
+ const raced = await recordAdmittedEpic(data, { ...EPIC, auto_resolve_escalations: 1 });
124
+ assertEquals(raced.auto_resolve_escalations, 1, "the catch path widens the raced opted-out row to 1");
125
+ assertEquals(
126
+ tables.get("admitted_epics")!.find((r) => r.plan_key === EPIC.plan_key)!.auto_resolve_escalations,
127
+ 1,
128
+ "the widening is persisted on the raced row",
129
+ );
130
+ assertEquals(tables.get("admitted_epics")!.length, 1, "still exactly one staged row (no duplicate)");
131
+ });
132
+
62
133
  test("recordAdmittedPlanDep stages an edge and stays out of plan_deps", async () => {
63
134
  const { data, tables } = memData();
64
135
  await recordAdmittedPlanDep(data, {
@@ -34,6 +34,11 @@ export const AGENT_SLA_JOB_TYPES: readonly string[] = [
34
34
  "senior:retro", // retro synthesize
35
35
  "senior:fix-ci", // merge-loop CI-fix cell (be_fixci_sla)
36
36
  "senior:rebase", // merge-loop rebase cell (be_rebase_sla)
37
+ // Tracked for AUDIT PROVENANCE, not an SLA boundary (issue #889 review): the escalation-steward
38
+ // record step reads this row's `job_key` to persist the ACTUAL answering agent's activation key on
39
+ // the durable auto-resolution (rather than its own record-task key), so the review surface points at
40
+ // the job that made the decision.
41
+ "senior:escalation-steward", // escalation-steward agent (escalation-steward.bpmn)
37
42
  ];
38
43
 
39
44
  /** One ledger row: whether (and when) a worker picked up an agent job for a given process instance. */
@@ -21,6 +21,8 @@ import {
21
21
  escalationFormId,
22
22
  latestCompletion,
23
23
  revertAgentCompletion,
24
+ snapshotEscalationFormOptions,
25
+ snapshotUserTaskFormOptions,
24
26
  type TaskCompletion,
25
27
  validateEscalationVariables,
26
28
  } from "./agentCompletion.ts";
@@ -958,3 +960,34 @@ test("feature-escalation demands non-blank answer on the answer path, but not on
958
960
  "the abandon path does not demand the hidden answer field",
959
961
  );
960
962
  });
963
+
964
+ test("snapshotUserTaskFormOptions: resolves the engine form_key first (delivery-graph human tasks)", () => {
965
+ // A delivery-graph human task's element id is dynamic and has NO static escalationFormId mapping, so
966
+ // the element-only snapshot returns null — but its engine form_key names the real deployed form. The
967
+ // form_key must win so the options are the REAL choices, not NULL (issue #889 review).
968
+ assertEquals(snapshotEscalationFormOptions("delivery-human-task__publish"), null, "no static form for a dynamic element id");
969
+ assertEquals(
970
+ snapshotUserTaskFormOptions("delivery-escalation", "delivery-human-task__publish"),
971
+ ["continue", "retry"],
972
+ "the engine form_key resolves the deployed form's real options",
973
+ );
974
+ });
975
+
976
+ test("snapshotUserTaskFormOptions: falls back to the element's static form when form_key is blank", () => {
977
+ // No engine form_key — fall back to the fixed-form element's static linkage (trial-merge-decision →
978
+ // [proceed, rebase, abandon]).
979
+ assertEquals(snapshotUserTaskFormOptions(null, "trial-merge-decision"), ["proceed", "rebase", "abandon"]);
980
+ assertEquals(snapshotUserTaskFormOptions(" ", "trial-merge-decision"), ["proceed", "rebase", "abandon"], "a blank form_key falls through to the element form");
981
+ });
982
+
983
+ test("snapshotUserTaskFormOptions: an unresolvable form_key never throws (safe read)", () => {
984
+ // `formContract` does a readFileSync that THROWS on a missing file — a form_key that names no bundled
985
+ // form must degrade to the element fallback / null, never throw (a throw in the claim loop wedges the
986
+ // steward). With neither a resolvable form_key NOR a mappable element, the result is null.
987
+ assertEquals(snapshotUserTaskFormOptions("no-such-form", "delivery-human-task__x"), null, "unresolvable key + dynamic element → null, no throw");
988
+ assertEquals(
989
+ snapshotUserTaskFormOptions("no-such-form", "trial-merge-decision"),
990
+ ["proceed", "rebase", "abandon"],
991
+ "an unresolvable form_key falls back to the element's static form",
992
+ );
993
+ });
@@ -296,6 +296,82 @@ export function validateEscalationVariables(
296
296
  return null;
297
297
  }
298
298
 
299
+ /** Snapshot the REAL option values an escalation's deployed `.form` offers (the union of every `select`
300
+ * field's allowed `values`), read from the canonical form contract host-side. The escalation-steward
301
+ * record step persists THIS — never the deciding agent's self-reported `options` array, which the agent
302
+ * may omit or misstate (issue #889 review) — so the reviewable record shows the choices that were
303
+ * genuinely on offer. Returns `null` when the element has no linked form (nothing to snapshot). */
304
+ export function snapshotEscalationFormOptions(elementId: string): string[] | null {
305
+ const formId = escalationFormId(elementId);
306
+ if (!formId) return null;
307
+ return optionUnionForForm(formId);
308
+ }
309
+
310
+ /** The SELECT-field KEYS a fixed-form escalation's deployed `.form` carries (e.g. `["action"]` for
311
+ * `trial-merge-decision`, `["directive"]` for `plan-review-decision`), read from the SAME canonical
312
+ * form contract the completer validates against — one derivation, no second field list. The
313
+ * escalation-steward record step uses this to scope its destructive-option guard to the field the
314
+ * task's ACTUAL form submits, so a foreign recognized key (a `resolution` on a `trial-merge-decision`,
315
+ * whose own select is `action`) can never shadow the real destructive choice (issue #889 review).
316
+ * Returns `null` when the element has no linked form (nothing to scope to — the caller falls back to
317
+ * its conservative recognized-key sweep). Never throws on an unreadable form (yields `null`). */
318
+ export function escalationFormSelectKeys(elementId: string): string[] | null {
319
+ const formId = escalationFormId(elementId);
320
+ if (!formId) return null;
321
+ let contract: FormContract;
322
+ try {
323
+ contract = formContract(formId);
324
+ } catch {
325
+ return null;
326
+ }
327
+ return Object.keys(contract.allowed);
328
+ }
329
+
330
+ /** The union of every `select` field's allowed option values in a deployed `.form`, or `null` when the
331
+ * form id is blank/unreadable (a missing or non-`.form` key never throws — see the try/catch). Shared
332
+ * by {@link snapshotEscalationFormOptions} (fixed-form escalations, keyed off the element) and
333
+ * {@link snapshotUserTaskFormOptions} (keyed off the engine-resolved `form_key`). */
334
+ function optionUnionForForm(formId: string): string[] | null {
335
+ let contract: FormContract;
336
+ try {
337
+ contract = formContract(formId);
338
+ } catch {
339
+ // `formContract` does a `readFileSync` and THROWS on a missing/unreadable `.form` — a `form_key`
340
+ // can be a key that doesn't name a bundled form. Treat that as "nothing to snapshot", never a throw
341
+ // that would incident the record tick.
342
+ return null;
343
+ }
344
+ const out: string[] = [];
345
+ for (const values of Object.values(contract.allowed)) {
346
+ for (const v of values) if (!out.includes(v)) out.push(v);
347
+ }
348
+ return out;
349
+ }
350
+
351
+ /** Snapshot the REAL option values a parked user task's deployed form offers, resolving the form from
352
+ * the engine-reported `formKey` FIRST and falling back to the element's static form linkage — the SAME
353
+ * precedence the Tasks grid denormalises with (`app/service.ts`), so there is one derivation. This is
354
+ * what the escalation-steward record step persists for a DELIVERY-GRAPH human task, whose element id is
355
+ * dynamic (`delivery-human-task__<node>`) and so has NO static `escalationFormId` mapping — snapshotting
356
+ * off `element_id` alone would record `NULL` options for exactly those tasks (issue #889 review). Reads
357
+ * the `formKey` safely (a key that doesn't name a bundled `.form` yields `null`, never a throw).
358
+ * Returns `null` when neither source resolves a readable form. */
359
+ export function snapshotUserTaskFormOptions(
360
+ formKey: string | null | undefined,
361
+ elementId: string | null | undefined,
362
+ ): string[] | null {
363
+ const engineKey = typeof formKey === "string" ? formKey.trim() : "";
364
+ if (engineKey) {
365
+ const fromKey = optionUnionForForm(engineKey);
366
+ if (fromKey != null) return fromKey;
367
+ }
368
+ if (elementId) {
369
+ const formId = escalationFormId(elementId);
370
+ if (formId) return optionUnionForForm(formId);
371
+ }
372
+ return null;
373
+ }
374
+
299
375
  /** The canonical attributed completer. Records an attribution row in `task_completions` and THEN
300
376
  * completes the user task with the exact typed `variables` — so the ledger row can never be lost by a
301
377
  * resume that fires before the write. If the engine completion throws (a failed/rejected completion,
@@ -401,6 +477,30 @@ export async function latestCompletion(
401
477
  return newest;
402
478
  }
403
479
 
480
+ /** The newest NON-reverted completion an AGENT recorded for a user-task key under a specific
481
+ * `agentId`, or undefined. The escalation-steward record step uses this to make its
482
+ * completion→record pair idempotent across a service-job retry (issue #889 review): the engine
483
+ * completion lands first, then the record worker finalises the claim row — if that finalise fails
484
+ * transiently, the retry finds the user task already COMPLETED (the attributed door reports "no open
485
+ * completable task") and must NOT degrade the already-applied decision to a false `abstained`.
486
+ * Detecting the prior agent-attributed row lets the retry recover the original submitted variables
487
+ * and finalise the SAME decision instead. Reverted rows are excluded: a human overrode that
488
+ * completion, so it is not a live applied decision to recover. */
489
+ export async function priorAgentCompletion(
490
+ data: DataLayer,
491
+ userTaskKey: string,
492
+ agentId: string,
493
+ ): Promise<TaskCompletion | undefined> {
494
+ const rows = await taskCompletions(data).find({ user_task_key: userTaskKey });
495
+ let newest: TaskCompletion | undefined;
496
+ for (const row of rows) {
497
+ if (row.actor_kind !== "agent" || row.actor_id !== agentId) continue;
498
+ if (row.reverted === 1) continue;
499
+ if (!newest || row.id > newest.id) newest = row;
500
+ }
501
+ return newest;
502
+ }
503
+
404
504
  export interface AgentCompleteResult {
405
505
  ok: boolean;
406
506
  reason?: string;
@@ -460,14 +560,20 @@ function isCompletableElement(elementId: string, allowed: ReadonlySet<string>):
460
560
  export async function completeEscalationAsAgent(
461
561
  data: DataLayer,
462
562
  engine: EngineClient,
463
- input: { userTaskKey: string; variables: Record<string, unknown>; agentId: string },
563
+ input: { userTaskKey: string; variables: Record<string, unknown>; agentId: string; processInstanceKey?: string | null },
464
564
  ): Promise<AgentCompleteResult> {
465
565
  const userTaskKey = input.userTaskKey.trim();
466
566
  if (!userTaskKey) return { ok: false, reason: "userTaskKey is required" };
467
567
  const agentId = input.agentId.trim();
468
568
  if (!agentId) return { ok: false, reason: "agentId is required" };
469
569
 
470
- const resolved = await resolveEscalationTask(engine, userTaskKey);
570
+ // When the caller already knows the task's owning process instance (the escalation-steward record
571
+ // worker does — it holds the claim row's `process_instance_key`), scope the resolve scan to that ONE
572
+ // instance instead of an engine-wide `openUserTasks()` per decision. A record tick finalises MANY
573
+ // claimed tasks in one pass, so an unfiltered global scan each is O(N²) REST load (issue #889 review);
574
+ // the instance filter makes it O(N) and never changes the outcome (the task lives in exactly that
575
+ // instance). A bare-key caller (a one-off human/agent door) omits it and keeps the engine-wide scan.
576
+ const resolved = await resolveEscalationTask(engine, userTaskKey, ESCALATION_TASK_ELEMENTS, input.processInstanceKey ?? undefined);
471
577
  if (!resolved.ok) return resolved;
472
578
 
473
579
  const invalid = validateEscalationVariables(resolved.elementId, input.variables);
@@ -150,7 +150,12 @@ test("externalAgentTasksMissingSlaBoundary rejects a timer with no timeDuration,
150
150
  // exactly these processes so a regression on a *bounded* process is caught while the deferred
151
151
  // two stay out. merge-loop.bpmn's agent tasks (`fix-ci` / `rebase`) were bounded in an earlier
152
152
  // #849 commit, so it belongs here too — every already-bounded process stays regression-guarded.
153
- const BOUNDED_PROCESSES = ["implement-cell.bpmn", "merge-cell.bpmn", "merge-loop.bpmn", "retro.bpmn"] as const;
153
+ // escalation-steward.bpmn's `steward-agent` was bounded for the #889 review (a hung steward
154
+ // instance otherwise parks ACTIVE forever, suppressing every later tick via the poller's overlap
155
+ // guard and wedging claim recovery); its boundary routes to `done` (the steward has no escalation
156
+ // path of its own — on SLA the tick ends and the 15-min claim TTL frees its claims for the next
157
+ // tick), which satisfies the guard's "routes somewhere" requirement.
158
+ const BOUNDED_PROCESSES = ["implement-cell.bpmn", "merge-cell.bpmn", "merge-loop.bpmn", "retro.bpmn", "escalation-steward.bpmn"] as const;
154
159
 
155
160
  test("DEFECT-CLASS GUARD: every external agent task in a bounded process carries an interrupting timer SLA boundary", () => {
156
161
  let anyAgentTasks = false;
package/app/contracts.ts CHANGED
@@ -179,6 +179,29 @@ export const ENV_CONTRACTS = {
179
179
  semantics: "How long to wait for a review before nudging/escalating (FEEL/ISO-8601 duration).",
180
180
  default: "PT30M",
181
181
  },
182
+ NANO_ESCALATION_STEWARD: {
183
+ category: "env",
184
+ name: "NANO_ESCALATION_STEWARD",
185
+ owner: "app/escalationSteward.ts",
186
+ semantics:
187
+ "Escalation-steward master switch (issue #889): `off`/`0`/`false`/`no` disables the timer loop entirely; anything else (or unset) leaves it enabled.",
188
+ },
189
+ NANO_ESCALATION_STEWARD_INTERVAL: {
190
+ category: "env",
191
+ name: "NANO_ESCALATION_STEWARD_INTERVAL",
192
+ owner: "app/escalationSteward.ts",
193
+ semantics:
194
+ "How often the escalation-steward timer fires (FEEL/ISO-8601 duration or `R/PT…` cycle), pacing the steward tick (issue #889).",
195
+ default: "R/PT30M",
196
+ },
197
+ NANO_ESCALATION_STEWARD_MIN_AGE_MS: {
198
+ category: "env",
199
+ name: "NANO_ESCALATION_STEWARD_MIN_AGE_MS",
200
+ owner: "app/escalationSteward.ts",
201
+ semantics:
202
+ "Minimum age (ms) an open escalation must reach before the steward considers it, so a human already answering wins (issue #889).",
203
+ default: "300000",
204
+ },
182
205
  NANO_PR_REVIEW_NUDGE_MINUTES: {
183
206
  category: "env",
184
207
  name: "NANO_PR_REVIEW_NUDGE_MINUTES",
@@ -227,6 +227,9 @@ export async function dispatchDeliveryGraphRun(
227
227
  title,
228
228
  humanLabels: buildHumanLabels(compiled),
229
229
  createdAt: existing?.created_at,
230
+ // Opt-in escalation steward (issue #889): persist the run's opt-in so the steward's eligibility
231
+ // derivation reads it off the durable row.
232
+ autoResolveEscalations: options.autoResolveEscalations === true,
230
233
  };
231
234
 
232
235
  // Claim the run durably BEFORE the side effect (the at-most-once dispatch fence). A losing racer
@@ -344,6 +347,9 @@ export async function dispatchDeliveryGraphRun(
344
347
  repository: options.repository,
345
348
  baseBranch: options.baseBranch,
346
349
  repoless: options.repoless,
350
+ // Opt-in escalation steward (issue #889): seeded as a run-root variable so every node's
351
+ // escalation inherits it (and the delivery-connector propagates it to any enrolled PR).
352
+ autoResolveEscalations: options.autoResolveEscalations,
347
353
  });
348
354
  } catch (err) {
349
355
  await markClaimFailed();
@@ -39,7 +39,7 @@ import { applyMigrationSet, readMigrationSetFromDisk } from "../test/migrations.
39
39
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
40
40
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
41
41
 
42
- const READ_MODEL_MIGRATION = "096_delivery_graph_read_model_list_bucket.sql";
42
+ const READ_MODEL_MIGRATION = "121_delivery_graph_read_model_auto_resolve.sql";
43
43
 
44
44
  // A minimal in-memory DB carrying the base `delivery_graph_runs` / `pull_requests` shapes the VIEW
45
45
  // reads, plus stand-ins for the managed `<table>__tracking` derived VIEWs urban provisions at mount
@@ -53,7 +53,7 @@ function viewDb(): DatabaseSync {
53
53
  run_key TEXT PRIMARY KEY, process_key TEXT, process_definition_id TEXT, digest TEXT,
54
54
  status TEXT, side_effecting INTEGER, node_count INTEGER, human_node_count INTEGER,
55
55
  side_effect_count INTEGER, title TEXT, phase TEXT, phase_node_id TEXT, human_labels TEXT,
56
- created_at TEXT, updated_at TEXT, acknowledged_at TEXT, derived_status_override TEXT);
56
+ created_at TEXT, updated_at TEXT, acknowledged_at TEXT, auto_resolve_escalations INTEGER, derived_status_override TEXT);
57
57
  CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, root_request_key TEXT, status TEXT,
58
58
  derived_status_override TEXT);`,
59
59
  );
@@ -28,6 +28,7 @@ import type { CompileDeliveryGraphResult } from "../nano-generated/api-io.d.ts";
28
28
  import { isUniqueConstraintFence } from "./dbFence.ts";
29
29
  import { redactFreeText } from "./deliveryGraphCompiler.ts";
30
30
  import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement, stripDeliveryEscalationTwinSuffix } from "./deliveryHuman.ts";
31
+ import { derivedTrackingTable } from "./instanceTracking.ts";
31
32
 
32
33
  const now = () => new Date().toISOString();
33
34
 
@@ -52,6 +53,9 @@ export interface DeliveryGraphRun {
52
53
  * the `delivery_graph_read_model` VIEW folds its `list_bucket` to 'history'; NULL while the run is
53
54
  * live or terminal-but-undismissed (it stays in Active until an operator ticks it off). */
54
55
  acknowledged_at: string | null;
56
+ /** Opt-in escalation steward (120_escalation_auto_resolutions.sql, issue #889): 1 ⇒ this run's
57
+ * escalations are eligible for steward auto-resolution. Default 0. */
58
+ auto_resolve_escalations: number;
55
59
  }
56
60
 
57
61
  /** The run lifecycle. `awaiting-approval` is RESERVED but no longer produced (issue #460 moved dispatch
@@ -98,6 +102,18 @@ export const DELIVERY_PHASE = {
98
102
  export const deliveryGraphRuns = (data: DataLayer) =>
99
103
  data.table<DeliveryGraphRun>("delivery_graph_runs", "run_key");
100
104
 
105
+ /** A `delivery_graph_runs` row as seen through its derived tracking VIEW
106
+ * (`delivery_graph_runs__tracking`): the base columns plus urban's ADR-0065 `derived_status`, which
107
+ * folds the reconciler's terminal edge (out-of-band terminate → `failed`) over the worker-owned
108
+ * transient. */
109
+ export type TrackedDeliveryGraphRun = DeliveryGraphRun & { derived_status: string };
110
+ /** Read-only accessor over the delivery-run derived tracking VIEW. Use this — and read
111
+ * `derived_status`, not `status` — for any terminal-edge classification (e.g. the `/status` active
112
+ * list), so an out-of-band-terminated run (whose base row is still `running`) is correctly seen as
113
+ * `failed`. Mirrors `prsTracking` / `featureRunsTracking`. Writes stay on `deliveryGraphRuns`. */
114
+ export const deliveryGraphRunsTracking = (data: DataLayer) =>
115
+ derivedTrackingTable<TrackedDeliveryGraphRun>(data, "delivery_graph_runs", "run_key");
116
+
101
117
  /** One row of the `delivery_graph_run_identity` SIDE table (migration 113) — the run's LOSSLESS
102
118
  * content-identity fingerprint, kept OFF the heavily-projected `delivery_graph_runs` base row so no
103
119
  * drift-guarded read-model/compat view has to carry it. Keyed by `run_key`. */
@@ -493,6 +509,7 @@ export function buildDeliveryGraphRunRow(input: {
493
509
  processDefinitionId?: string | null;
494
510
  humanLabels?: Record<string, string>;
495
511
  createdAt?: string;
512
+ autoResolveEscalations?: boolean;
496
513
  }): DeliveryGraphRun {
497
514
  const at = now();
498
515
  return {
@@ -509,6 +526,7 @@ export function buildDeliveryGraphRunRow(input: {
509
526
  phase: input.phase,
510
527
  phase_node_id: null,
511
528
  human_labels: input.humanLabels ? JSON.stringify(input.humanLabels) : null,
529
+ auto_resolve_escalations: input.autoResolveEscalations === true ? 1 : 0,
512
530
  created_at: input.createdAt ?? at,
513
531
  updated_at: at,
514
532
  acknowledged_at: null,
@@ -55,6 +55,10 @@ export interface DeliveryRunTimeouts {
55
55
  probePollEvery?: string;
56
56
  /** Optional explicit assignee for `human` nodes + escalation tasks (else candidate-group routed). */
57
57
  escalationAssignee?: string | null;
58
+ /** Opt-in escalation steward (issue #889): when true, this run's escalations (human nodes, service
59
+ * node escalation twins, and any `converge*` connector's enrolled PR) become eligible for steward
60
+ * auto-resolution. Seeded as a run-root variable and persisted on the `delivery_graph_runs` row. */
61
+ autoResolveEscalations?: boolean;
58
62
  }
59
63
 
60
64
  export interface DeliveryRunOptions extends DeliveryRunTimeouts {
@@ -96,7 +100,7 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
96
100
  repoless?: boolean;
97
101
  }
98
102
 
99
- const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
103
+ const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee" | "autoResolveEscalations">> = {
100
104
  nodeTimeout: DELIVERY_NODE_DEFAULT_TIMEOUT,
101
105
  probeTimeout: "PT30M",
102
106
  probePollEvery: msToIsoDuration(DEFAULT_EVERY_MS),
@@ -253,6 +257,10 @@ export async function runDeliveryGraph(
253
257
  // bounds a node's whole retry budget; this seeds the shared `agentSlaTimeout` knob so any
254
258
  // agent cell modelled with the fleet-wide SLA boundary resolves it too.
255
259
  agentSlaTimeout: AGENT_SLA_TIMEOUT,
260
+ // Opt-in escalation steward (issue #889): seeded as a run-root variable so every node's
261
+ // escalation (and the delivery-connector's `converge*` enrollment) inherits the opt-in. The
262
+ // durable record the steward's eligibility reads is the `delivery_graph_runs` row (dispatch).
263
+ autoResolveEscalations: options.autoResolveEscalations === true,
256
264
  },
257
265
  });
258
266
  // The engine can yield a numeric key; `DeliveryRunHandle.processInstanceKey` is typed `string` and