@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
@@ -140,6 +140,22 @@ test("startFeature: humanApproval is persisted as 0/1 and seeded onto the instan
140
140
  }
141
141
  });
142
142
 
143
+ test("startFeature: autoResolveEscalations is persisted as 0/1 and seeded onto the instance (issue #889)", async () => {
144
+ for (const autoResolve of [true, false]) {
145
+ const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
146
+ let vars: any = null;
147
+ const engine = {
148
+ createInstance: (req: any) => {
149
+ vars = req.variables;
150
+ return Promise.resolve({ processInstanceKey: "PI-1" });
151
+ },
152
+ } as any;
153
+ await startFeature(memData(stores), engine, PARSED, "main", true, true, null, {}, false, autoResolve);
154
+ assertEquals(stores.feature_runs.rows[0].auto_resolve_escalations, autoResolve ? 1 : 0);
155
+ assertEquals(vars.autoResolveEscalations, autoResolve);
156
+ }
157
+ });
158
+
143
159
  test("startFeature: seeds the single task slice + base-branch brief onto the instance", async () => {
144
160
  let captured: any = null;
145
161
  const engine = {
package/app/feature.ts CHANGED
@@ -64,6 +64,9 @@ export interface FeatureRun {
64
64
  /** 1 ⇒ a human must approve the converged PR before the merge-loop runs (issue #826). Only
65
65
  * meaningful with `auto_merge = 1`; the `startFeature` operation pins it to 0 otherwise. */
66
66
  human_approval: number;
67
+ /** 1 ⇒ this run OPTED IN to the escalation steward (issue #889): its open escalations are eligible
68
+ * for the steward to auto-resolve (recorded reviewably). Default 0 — escalations stay human-only. */
69
+ auto_resolve_escalations: number;
67
70
  outcome: string | null;
68
71
  /** Human rollup detail. Projected by `pollFeatureDelivery` (fix: Feature history stuck at
69
72
  * `converging`) and also written by `pr.record-blocked-ack` with the operator's disposition
@@ -357,6 +360,7 @@ export async function startFeature(
357
360
  customInstructions: string | null = null,
358
361
  readiness: FeatureReadinessOptions = {},
359
362
  humanApproval = false,
363
+ autoResolveEscalations = false,
360
364
  ): Promise<StartFeatureResult> {
361
365
  // Intake-time readiness gate (issue #295): the probes the run must satisfy before it implements,
362
366
  // and the bound its preflight escalation timers fire off. Both are load-bearing together —
@@ -456,6 +460,7 @@ export async function startFeature(
456
460
  converge: converge ? 1 : 0,
457
461
  auto_merge: autoMerge ? 1 : 0,
458
462
  human_approval: humanApproval ? 1 : 0,
463
+ auto_resolve_escalations: autoResolveEscalations ? 1 : 0,
459
464
  outcome: null,
460
465
  delivery_label: null,
461
466
  // Clear the operator tick-off so a re-dispatched run is NOT silently dropped into History when
@@ -479,6 +484,7 @@ export async function startFeature(
479
484
  converge: converge ? 1 : 0,
480
485
  auto_merge: autoMerge ? 1 : 0,
481
486
  human_approval: humanApproval ? 1 : 0,
487
+ auto_resolve_escalations: autoResolveEscalations ? 1 : 0,
482
488
  outcome: null,
483
489
  delivery_label: null,
484
490
  created_at: ts,
@@ -516,6 +522,11 @@ export async function startFeature(
516
522
  autoMerge,
517
523
  // Park the converged PR at a human approval user task before the merge-loop (issue #826).
518
524
  humanApproval,
525
+ // Opt-in escalation steward (issue #889): seeded as an engine-owned process variable so the
526
+ // run's escalations carry the opt-in, AND persisted on the `feature_runs` row (above) — the
527
+ // steward's eligibility derivation reads the ROW (the durable source), so an agent-completion
528
+ // clobber of the variable can never silently opt a run in or out (#887/#888).
529
+ autoResolveEscalations,
519
530
  // A single-issue feature run OWNS its issue (the whole issue is the slice), so the agent may
520
531
  // claim it with a "starting work" comment on a first run (resources/prompts/feature.md). Epic slices
521
532
  // (plan-fanout) deliberately DO NOT set this — their `issue` is the shared parent epic, which
@@ -218,6 +218,29 @@ test("pollFeatureDelivery: the re-enroll keeps the run's human approval gate (is
218
218
  assertEquals(vars?.humanApproval, true, "a healed enrollment must not silently drop the approval gate");
219
219
  });
220
220
 
221
+ test("pollFeatureDelivery: the re-enroll propagates the run's escalation-steward opt-in (issue #889 review)", async () => {
222
+ const { data, stores } = memData();
223
+ // An OPTED-IN converging run whose PR row is missing — the re-enroll must carry the run's
224
+ // `auto_resolve_escalations` onto the fresh PR row/instance, not drop it to the `false` default.
225
+ stores.feature_runs = [
226
+ { feature_key: "o/r#9", status: "converging", pr_key: "o/r#409", auto_merge: 1, human_approval: 0, auto_resolve_escalations: 1, delivery_label: null },
227
+ ];
228
+ stores.pull_requests = [];
229
+ let vars: Record<string, unknown> | undefined;
230
+ const engine = {
231
+ searchProcessInstances: async () => [],
232
+ createInstance: async (req: { variables?: Record<string, unknown> }) => {
233
+ vars = req.variables;
234
+ return { processInstanceKey: "pi-409" };
235
+ },
236
+ } as any;
237
+
238
+ await pollFeatureDelivery(data, engine);
239
+
240
+ assertEquals(vars?.autoResolveEscalations, true, "a healed enrollment must not silently drop the steward opt-in");
241
+ assertEquals(stores.pull_requests[0]?.auto_resolve_escalations, 1, "the PR row persists the opt-in");
242
+ });
243
+
221
244
  test("pollFeatureDelivery: a converging run whose PR row is PARTIALLY enrolled (present, non-terminal, process_key NULL) is re-enrolled — a create-instance crash after the row insert does not wedge it (PR #809)", async () => {
222
245
  const { data, stores } = memData();
223
246
  stores.feature_runs = [
@@ -63,6 +63,9 @@ export const CURATED_MCP_TOOLS: readonly string[] = [
63
63
  "listEscalations",
64
64
  "getLineage",
65
65
  "getPrHistory",
66
+ // ── Escalation steward (issue #889) ──────────────────────────────────────
67
+ "listAutoResolutions",
68
+ "reviewAutoResolution",
66
69
  // ── Engine-truth reads (wedge triage) ────────────────────────────────────
67
70
  "urban_debug_search_process_instances",
68
71
  "urban_debug_search_element_instance_wait_states",
@@ -122,8 +125,21 @@ export const MCP_TOOL_COUNT_BUDGET = 60;
122
125
  * `target` description in `openapi.yaml` documents the new accepted handle. Measured 87,559 bytes
123
126
  * serialized (87,497 → 87,559 — over the old ceiling's 3-byte headroom). Deliberate, documented
124
127
  * growth of a real new accepted input format — not schema fat.
128
+ *
129
+ * RAISE PROVENANCE — 88_000 → 89_500 (#889): the opt-in escalation steward adds two read/review
130
+ * tools — `listAutoResolutions` (the Auto-resolutions read, with the `AutoResolution` record schema)
131
+ * and `reviewAutoResolution` (dismiss/intervene) — plus the `autoResolveEscalations` opt-in field on
132
+ * the start bodies. Measured 88,866 bytes serialized (over the old ceiling's 866-byte headroom).
133
+ * Deliberate, documented growth of a real new reviewable-record surface — not schema fat.
134
+ *
135
+ * RAISE PROVENANCE — 89_500 → 91_000 (#889 review): ten `openapi.yaml` descriptions that reference
136
+ * `(issue #889)` were unquoted plain scalars, so the `#` comment marker silently truncated each at
137
+ * `(issue` — the projected MCP schemas carried the cut text. Quoting them restores the full,
138
+ * intended description text (a correctness fix, not new surface). Measured 90,420 bytes serialized
139
+ * (over the old ceiling's 920-byte headroom). Deliberate, documented growth of restored description
140
+ * text — not schema fat.
125
141
  */
126
- export const MCP_SURFACE_BYTES_BUDGET = 88_000;
142
+ export const MCP_SURFACE_BYTES_BUDGET = 91_000;
127
143
 
128
144
  /**
129
145
  * The eagerly-loaded curated subset MUST stay materially smaller than the full surface — otherwise it
package/app/plan.ts CHANGED
@@ -98,6 +98,9 @@ export interface Plan {
98
98
  // admission); the column stays NULLABLE ONLY to grandfather pre-ADR-0003 / in-flight rows that
99
99
  // carry NULL — those must remain readable, so do NOT add a NOT NULL migration.
100
100
  base_branch: string | null;
101
+ // Opt-in escalation steward (120_escalation_auto_resolutions.sql, issue #889): 1 ⇒ this epic's
102
+ // escalations (and its spawned child loops) are eligible for steward auto-resolution. Default 0.
103
+ auto_resolve_escalations: number;
101
104
  // The derived epic delivery signal (029_plan_delivery.sql) was RETIRED as a stored column (epic
102
105
  // #412): `delivery` / `delivery_label` are now a DERIVED SQL VIEW (`plan_delivery` /
103
106
  // `plan_read_model`, 061), computed from the SAME pure `deriveDelivery`/`TERMINAL_STATUSES`
@@ -356,6 +359,10 @@ export interface AdmittedEpic {
356
359
  issue_number: number;
357
360
  issue_url: string;
358
361
  base_branch: string;
362
+ /** Escalation-steward opt-in staged at admission (issue #889). Persisted so the lowering
363
+ * (`lowerAdmittedSet`) can forward it to `startPlan` — without it a batch-admitted epic silently
364
+ * stayed opted out even when the caller opted in. 0|1 (SQLite boolean); absent on pre-124 rows ⇒ 0. */
365
+ auto_resolve_escalations: number;
359
366
  created_at: string;
360
367
  }
361
368
  export const admittedEpics = (data: DataLayer) =>
@@ -368,28 +375,55 @@ export const admittedPlanDeps = (data: DataLayer) =>
368
375
  data.table<PlanDep>("admitted_plan_deps", "plan_key");
369
376
 
370
377
  /** The fields an admission caller supplies for one staged admitted epic; `created_at` is stamped
371
- * here (mirrors {@link PlanDepInput}). */
372
- export type AdmittedEpicInput = Omit<AdmittedEpic, "created_at">;
378
+ * here (mirrors {@link PlanDepInput}). The escalation-steward opt-in (issue #889) is OPTIONAL on the
379
+ * input even though it is required on the persisted {@link AdmittedEpic} row: `recordAdmittedEpic`
380
+ * defaults an omitted value to `0` (opted out), so the typed API matches that default-off behavior
381
+ * and a caller that does not care about the steward need not spell it out. */
382
+ export type AdmittedEpicInput = Omit<AdmittedEpic, "created_at" | "auto_resolve_escalations"> & {
383
+ auto_resolve_escalations?: number;
384
+ };
373
385
 
374
386
  /** Stage one admitted epic (issue #292 slice S2). Idempotent on `plan_key`: a re-submitted set that
375
387
  * re-admits the same epic is a no-op returning the existing staged row, so the whole set door stays
376
- * idempotent (mirroring {@link recordAdmittedPlanDep}). */
388
+ * idempotent (mirroring {@link recordAdmittedPlanDep}).
389
+ *
390
+ * The escalation-steward opt-in is MONOTONIC on the existing row (issue #889 review): if an opted-out
391
+ * admission was staged and the request crashed before lowering, a retry that now opts in must WIDEN
392
+ * the staged row to opted-in — otherwise the epic starts opted out despite the caller's opt-in. This
393
+ * mirrors `submitPr`'s live-loop adoption (service.ts): an opted-in caller adopting an existing
394
+ * non-opted-in row widens it, never narrows an opted-in row back. Only ever flips 0→1. */
377
395
  export async function recordAdmittedEpic(
378
396
  data: DataLayer,
379
397
  epic: AdmittedEpicInput,
380
398
  ): Promise<AdmittedEpic> {
381
399
  const table = admittedEpics(data);
400
+ // The ONE canonical widening (issue #889 review): an opted-in caller observing a non-opted-in
401
+ // staged row widens it 0→1 and returns the widened row; otherwise returns the row unchanged. Shared
402
+ // by BOTH the read-existing path AND the concurrent-insert catch path, so the race below can never
403
+ // skip the widening the single-threaded path applies.
404
+ const widen = async (staged: AdmittedEpic): Promise<AdmittedEpic> => {
405
+ if (epic.auto_resolve_escalations === 1 && staged.auto_resolve_escalations !== 1) {
406
+ await table.update(epic.plan_key, { auto_resolve_escalations: 1 });
407
+ return { ...staged, auto_resolve_escalations: 1 };
408
+ }
409
+ return staged;
410
+ };
382
411
  const existing = await table.get(epic.plan_key);
383
- if (existing) return existing;
384
- const row: AdmittedEpic = { ...epic, created_at: now() };
412
+ if (existing) return widen(existing);
413
+ // Default the opt-in to 0 (opted out) when a caller omits it, so the staged row always carries the
414
+ // flag explicitly and the lowering never reads an undefined it would silently coerce.
415
+ const row: AdmittedEpic = { ...epic, auto_resolve_escalations: epic.auto_resolve_escalations ?? 0, created_at: now() };
385
416
  try {
386
417
  await table.insert(row);
387
418
  return row;
388
419
  } catch (err) {
389
420
  // Concurrent re-admission of the same epic races on the PRIMARY KEY (plan_key); honour the
390
421
  // idempotent no-op contract by returning the winning row rather than surfacing the collision.
422
+ // The winning row may be an opted-OUT insert that beat an opted-IN caller — apply the SAME
423
+ // monotonic widening before returning it, or the raced opt-in is silently lost (issue #889
424
+ // review): the caller opted in but the staged row stays 0.
391
425
  const raced = await table.get(epic.plan_key);
392
- if (raced) return raced;
426
+ if (raced) return widen(raced);
393
427
  throw err;
394
428
  }
395
429
  }
@@ -895,6 +929,9 @@ export interface StartPlanOptions {
895
929
  readinessProbes?: ReadinessProbe[];
896
930
  probeTimeout?: string;
897
931
  probePollEvery?: string;
932
+ /** Opt-in escalation steward (issue #889): when true, this epic's escalations — and every child
933
+ * convergence/merge loop it spawns — become eligible for steward auto-resolution. Default false. */
934
+ autoResolveEscalations?: boolean;
898
935
  }
899
936
 
900
937
  /** Register a plan row (if new) and start the plan-fanout process. Idempotent on
@@ -981,6 +1018,7 @@ export async function startPlan(
981
1018
  epic_phase: EPIC_PHASE.PLANNING,
982
1019
  blackboard_token: token,
983
1020
  base_branch: base,
1021
+ auto_resolve_escalations: opts.autoResolveEscalations === true ? 1 : 0,
984
1022
  updated_at: ts,
985
1023
  });
986
1024
  } else {
@@ -996,6 +1034,7 @@ export async function startPlan(
996
1034
  epic_phase: EPIC_PHASE.PLANNING,
997
1035
  blackboard_token: token,
998
1036
  base_branch: base,
1037
+ auto_resolve_escalations: opts.autoResolveEscalations === true ? 1 : 0,
999
1038
  created_at: ts,
1000
1039
  updated_at: ts,
1001
1040
  });
@@ -1022,6 +1061,10 @@ export async function startPlan(
1022
1061
  // `operators` candidate group); an operator/agent can claim/reassign via the task inbox.
1023
1062
  escalationSlaTimeout: ESCALATION_SLA_TIMEOUT,
1024
1063
  escalationAssignee: null,
1064
+ // Opt-in escalation steward (issue #889): carried on the instance so child processes inherit it,
1065
+ // AND persisted on the `plans` row (the durable source the steward's eligibility reads), so an
1066
+ // agent-completion clobber of the variable can never silently opt an epic in or out (#887/#888).
1067
+ autoResolveEscalations: opts.autoResolveEscalations === true,
1025
1068
  // Agent-task liveness SLA (issue #849): PRE-SEED the canonical bound for the fan-out's
1026
1069
  // external agent tasks. Each slice's implementation agent (via the implement-cell
1027
1070
  // callActivity's explicit `agentSlaTimeout` input mapping) is bounded today; the wave's own
@@ -58,7 +58,7 @@ function makeData() {
58
58
  return { data, engine, tables, started };
59
59
  }
60
60
 
61
- function stageEpic(tables: Map<string, Record<string, unknown>[]>, planKey: string, base: string) {
61
+ function stageEpic(tables: Map<string, Record<string, unknown>[]>, planKey: string, base: string, autoResolve = 0) {
62
62
  const rows = tables.get("admitted_epics") ?? [];
63
63
  tables.set("admitted_epics", rows);
64
64
  const [repo, num] = planKey.split("#");
@@ -68,6 +68,7 @@ function stageEpic(tables: Map<string, Record<string, unknown>[]>, planKey: stri
68
68
  issue_number: Number(num),
69
69
  issue_url: `https://github.com/${repo}/issues/${num}`,
70
70
  base_branch: base,
71
+ auto_resolve_escalations: autoResolve,
71
72
  created_at: "t0",
72
73
  });
73
74
  }
@@ -184,3 +185,27 @@ test("lowerAdmittedSet is idempotent: re-running neither double-starts an epic n
184
185
  assertEquals(started.length, 2, "no epic re-started on a re-run");
185
186
  assertEquals((tables.get("plan_deps") ?? []).length, 1, "no duplicate durable edge on a re-run");
186
187
  });
188
+
189
+ // ── issue #889: the escalation-steward opt-in reaches batch-admitted epics ──────────────────────
190
+ test("lowerAdmittedSet forwards the staged escalation-steward opt-in to startPlan (issue #889 — batch epic path)", async () => {
191
+ const { data, engine, tables, started } = makeData();
192
+ // Stage one opted-IN epic and one opted-OUT epic. The batch door (`startEpicSet`) records the
193
+ // caller's per-epic `autoResolveEscalations` onto the staging row; the lowering must forward it to
194
+ // `startPlan` (which seeds the `autoResolveEscalations` instance variable + the durable plans row),
195
+ // exactly as the single-issue door does. Before the fix the flag was dropped at the staging seam,
196
+ // so every batch-admitted epic stayed opted out.
197
+ stageEpic(tables, "o/r#1", "epic/in", 1);
198
+ stageEpic(tables, "o/r#2", "epic/out", 0);
199
+
200
+ await noFetch(() => lowerAdmittedSet(data, engine, ["o/r#1", "o/r#2"]));
201
+
202
+ const byKey = new Map(started.map((s) => [s.variables?.["planKey"], s.variables ?? {}]));
203
+ assertEquals(byKey.get("o/r#1")?.["autoResolveEscalations"], true, "opted-in epic propagates the steward opt-in");
204
+ assertEquals(byKey.get("o/r#2")?.["autoResolveEscalations"], false, "opted-out epic stays opted out");
205
+
206
+ // And the durable plans rows carry it (the steward eligibility reads the durable row, not the var).
207
+ const plans = tables.get("plans") ?? [];
208
+ const planByKey = new Map(plans.map((p) => [p["plan_key"], p]));
209
+ assertEquals(planByKey.get("o/r#1")?.["auto_resolve_escalations"], 1, "opted-in plans row persisted");
210
+ assertEquals(planByKey.get("o/r#2")?.["auto_resolve_escalations"], 0, "opted-out plans row persisted");
211
+ });
@@ -166,6 +166,11 @@ export async function lowerAdmittedSet(
166
166
  readinessProbes: gate?.probes,
167
167
  probeTimeout: gate?.probeTimeout,
168
168
  probePollEvery: gate?.probePollEvery,
169
+ // Forward the escalation-steward opt-in staged at admission (issue #889) so a batch-admitted
170
+ // epic honours the caller's choice exactly like the single-issue door. `=== 1` coerces the
171
+ // SQLite 0|1 (and any pre-124 row that predates the column reads as 0 ⇒ opted out, the safe
172
+ // default).
173
+ autoResolveEscalations: staged.auto_resolve_escalations === 1,
169
174
  });
170
175
  }
171
176
 
@@ -163,6 +163,25 @@ test("pollPromotion: a landed epic on an epic/* base opens exactly one epic/*→
163
163
  assert(prRow, "promotion PR row registered by submitPr");
164
164
  });
165
165
 
166
+ test("pollPromotion: the promotion PR inherits the epic's escalation-steward opt-in (issue #889 review)", async () => {
167
+ const { data, stores } = memData();
168
+ const { engine, instances } = recordingEngine();
169
+ // An OPTED-IN epic — its promotion PR must be enrolled with the steward opt-in, not the `false` default.
170
+ stores.plans = [
171
+ { plan_key: "o/r#295", repo: "o/r", title: "Assertion DSL", status: "done", base_branch: "epic/test-dsl", delivery: "landed", promotion_pr: null, promotion_state: null, auto_resolve_escalations: 1 },
172
+ ];
173
+ stores.plan_tasks = [{ id: 1, plan_key: "o/r#295", pr_key: "o/r#299" }];
174
+ stores.pull_requests = [{ pr_key: "o/r#299", status: "merged" }];
175
+ const state = freshRepo();
176
+
177
+ await withGithub(state, () => pollPromotion(data, engine, "tok"));
178
+
179
+ assertEquals(instances.length, 1);
180
+ assertEquals(instances[0].variables.autoResolveEscalations, true, "the promotion PR is enrolled opted-in");
181
+ const prRow = stores.pull_requests.find((p) => p.pr_key === "o/r#500");
182
+ assertEquals(prRow?.auto_resolve_escalations, 1, "the PR row persists the opt-in");
183
+ });
184
+
166
185
  test("pollPromotion: re-running is idempotent — no duplicate promotion PR", async () => {
167
186
  const { data, stores } = memData();
168
187
  const { engine } = recordingEngine();
@@ -33,7 +33,7 @@ import {
33
33
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
34
34
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
35
35
 
36
- const READ_MODEL_MIGRATION = "118_pull_requests_read_model_churn_escalated_round.sql";
36
+ const READ_MODEL_MIGRATION = "122_pull_requests_read_model_auto_resolve.sql";
37
37
 
38
38
  // The real base `pull_requests` columns, in schema order — DERIVED from the migration chain (not a
39
39
  // hand-kept list that could silently omit one), used by both the drift guard and the e2e stand-in.
@@ -1142,6 +1142,42 @@ test("a gated submitPr adopting a live loop narrows it to humanApproval (issue #
1142
1142
  });
1143
1143
  });
1144
1144
 
1145
+ // Steward opt-in is monotonic too (issue #889 review): an opted-in caller adopting a live loop widens
1146
+ // the durable subject row to opted-in (the steward reads `auto_resolve_escalations` off the row, not a
1147
+ // process variable), and a non-opted-in retry must never downgrade an already-opted-in row. This is
1148
+ // safety-critical propagation — without it an opted-in adopter silently inherits a non-opted-in loop and
1149
+ // its escalations stay human-only. These pin both directions of the monotonic write at service.ts.
1150
+ test("steward opt-in adoption is monotonic: an opted-in adopter upgrades a live opted-out row (issue #889)", async () => {
1151
+ await withGithubOff(async () => {
1152
+ const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
1153
+ // Live loop started WITHOUT the opt-in (auto_resolve_escalations unset → 0).
1154
+ const live = adoptLoop("converging", "ACTIVE");
1155
+ // An opted-in adopter (autoResolveEscalations = true, the 9th arg) must widen the row to opted-in.
1156
+ assertEquals(
1157
+ await submitPr(live.data, live.engine, pr, [], 20, false, null, false, true),
1158
+ { prKey: pr.prKey, alreadyRunning: true },
1159
+ );
1160
+ const row = (await live.data.table("pull_requests", "pr_key").get(pr.prKey)) as { auto_resolve_escalations?: number } | null;
1161
+ assertEquals(row?.auto_resolve_escalations, 1, "opted-in adopter upgrades the live row to opted-in");
1162
+ });
1163
+ });
1164
+
1165
+ test("steward opt-in adoption never downgrades: an opted-out retry leaves an opted-in row alone (issue #889)", async () => {
1166
+ await withGithubOff(async () => {
1167
+ const pr = { repo: "owner/repo", number: 14, url: "https://github.com/owner/repo/pull/14", prKey: "owner/repo#14" };
1168
+ // Live loop already opted in.
1169
+ const live = adoptLoop("converging", "ACTIVE");
1170
+ (live.data.table("pull_requests", "pr_key") as any).update(pr.prKey, { auto_resolve_escalations: 1 });
1171
+ // A non-opted-in adopter (autoResolveEscalations = false) must NOT clear the existing opt-in.
1172
+ assertEquals(
1173
+ await submitPr(live.data, live.engine, pr, [], 20, false, null, false, false),
1174
+ { prKey: pr.prKey, alreadyRunning: true },
1175
+ );
1176
+ const row = (await live.data.table("pull_requests", "pr_key").get(pr.prKey)) as { auto_resolve_escalations?: number } | null;
1177
+ assertEquals(row?.auto_resolve_escalations, 1, "an opted-out retry never downgrades an opted-in row");
1178
+ });
1179
+ });
1180
+
1145
1181
  test("human approval is pinned off when NANO_PR_AUTO_MERGE is off — the PR never merges (issue #826)", async () => {
1146
1182
  // `AUTO_MERGE` is read once at import, so load a fresh copy of the module under the env.
1147
1183
  const prev = process.env["NANO_PR_AUTO_MERGE"];
package/app/service.ts CHANGED
@@ -34,10 +34,11 @@ import { isUniqueConstraintFence } from "./dbFence.ts";
34
34
  import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./delivery.ts";
35
35
  import { DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
36
36
  import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
37
- import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels, reconcileOriginalInstanceBeforeRelaunch, reconcileStaleLaunchClaim } from "./deliveryGraphRun.ts";
37
+ import { DELIVERY_GRAPH_TERMINAL_STATUSES, deliveryGraphRuns, deliveryGraphRunsTracking, deriveDeliveryPhase, parseHumanLabels, reconcileOriginalInstanceBeforeRelaunch, reconcileStaleLaunchClaim } from "./deliveryGraphRun.ts";
38
38
  import { decodeLivePrompt, deliveryHumanContextQuestion, deliveryHumanContextUrl, isDeliveryHumanElement, needsLiveDeliveryPrompt } from "./deliveryHuman.ts";
39
39
  import { fleetSupportsDurableResume } from "./durableResume.ts";
40
40
  import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
41
+ import { ESCALATION_STEWARD_PROCESS_ID } from "./escalationSteward.ts";
41
42
  import { deriveFeatureCompletion, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns, featureRunsTracking, foldCompletedFeatureRun } from "./feature.ts";
42
43
  import {
43
44
  classifyMergeability,
@@ -98,7 +99,7 @@ import { ENGINE_TERMINAL_STATES } from "./reconcile.ts";
98
99
  // (submitPr/startMerge) and re-exported below so the long-standing `import { repoEnvelopeVars } from
99
100
  // "./service.ts"` call sites (and its tests) keep resolving.
100
101
  import { repoEnvelopeVars } from "./repoEnvelope.ts";
101
- import { clampNudgeMinutes, isReviewStale, reviewWaitTimeout } from "./reviewWait.ts";
102
+ import { clampNudgeMinutes, isoDurationToMs, isReviewStale, reviewWaitTimeout } from "./reviewWait.ts";
102
103
  import { trialMergeAudits } from "./trialMerge.ts";
103
104
  import {
104
105
  buildUserTaskRow,
@@ -399,6 +400,9 @@ export interface PullRequest {
399
400
  // `pr_key` so the Lineage UI join resolves; a legacy NULL is tolerated the same way by the
400
401
  // lineage read projection (`pollLineage`), which self-roots on `pr_key`.
401
402
  root_request_key: string | null;
403
+ // Opt-in escalation steward (120_escalation_auto_resolutions.sql, issue #889): 1 ⇒ this PR's open
404
+ // escalations are eligible for the steward to auto-resolve (recorded reviewably). Default 0.
405
+ auto_resolve_escalations: number;
402
406
  }
403
407
 
404
408
  interface PrDependency {
@@ -621,9 +625,10 @@ export async function submitPr(
621
625
  convergeOnly = false,
622
626
  rootRequestKey: string | null = null,
623
627
  humanApproval = false,
628
+ autoResolveEscalations = false,
624
629
  ) {
625
630
  return withPrSubmitLock(parsed.prKey, () =>
626
- submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval),
631
+ submitPrCritical(data, engine, parsed, dependsOn, maxRounds, convergeOnly, rootRequestKey, humanApproval, autoResolveEscalations),
627
632
  );
628
633
  }
629
634
 
@@ -636,6 +641,7 @@ async function submitPrCritical(
636
641
  convergeOnly = false,
637
642
  rootRequestKey: string | null = null,
638
643
  humanApproval = false,
644
+ autoResolveEscalations = false,
639
645
  ) {
640
646
  // Human approval before merge (issue #826) gates only a PR that `pr.finalize` would hand to the
641
647
  // merge-loop — its own predicate, `AUTO_MERGE && convergeOnly !== true`. A run that won't merge
@@ -677,6 +683,15 @@ async function submitPrCritical(
677
683
  await engine.setVariables({ scopeKey: loopKey, variables: { humanApproval: true } });
678
684
  }
679
685
  }
686
+ // Steward opt-in is monotonic too (issue #889 review): an opted-in caller adopting a live loop —
687
+ // possibly a non-opted-in one another run started — widens it to opted-in so the steward can serve
688
+ // its escalations, rather than silently inheriting the loop's non-opted-in state. The steward reads
689
+ // the flag off the SUBJECT row (`optInByProcessKey`), not a process variable, so the durable row
690
+ // update is the whole propagation — no `setVariables` is needed (or possible: the loop never reads
691
+ // it). Idempotent; a retry against our own opted-in loop is a no-op write.
692
+ if (autoResolveEscalations && existing.auto_resolve_escalations !== 1) {
693
+ await table.update(parsed.prKey, { auto_resolve_escalations: 1 });
694
+ }
680
695
  return { prKey: parsed.prKey, alreadyRunning: true };
681
696
  }
682
697
 
@@ -759,6 +774,7 @@ async function submitPrCritical(
759
774
  current_round: 1,
760
775
  abandon_token: abandonToken,
761
776
  root_request_key: effectiveRoot,
777
+ auto_resolve_escalations: autoResolveEscalations ? 1 : 0,
762
778
  created_at: ts,
763
779
  updated_at: ts,
764
780
  });
@@ -808,6 +824,10 @@ async function submitPrCritical(
808
824
  // Human approval before merge (issue #826): a converged PR parks at the `merge-approval` user
809
825
  // task before `pr.finalize` hands it to the merge-loop. Pinned off when the PR won't merge.
810
826
  humanApproval: gated,
827
+ // Opt-in escalation steward (issue #889): carried on the instance AND persisted on the
828
+ // `pull_requests` row (the durable source the steward's eligibility reads), so a clobbered
829
+ // variable can never silently opt a PR in or out (#887/#888).
830
+ autoResolveEscalations,
811
831
  // Cooperative abandon check (#76): the capability URL + the abort brief appended to the
812
832
  // review-round agent's prompt, so it can stop before pushing if the run is cancelled.
813
833
  abandonUrl: abUrl,
@@ -884,6 +904,9 @@ async function submitPrCritical(
884
904
  merged_at: null,
885
905
  abandon_token: abandonToken,
886
906
  root_request_key: effectiveRoot,
907
+ // Record the opt-in on the row (the durable source the steward's eligibility reads) so a
908
+ // fresh run's flag is authoritative even across a reopen (issue #889).
909
+ auto_resolve_escalations: autoResolveEscalations ? 1 : 0,
887
910
  // Publish the fresh run's identity in the SAME write as the status flip (may be null if the
888
911
  // instance could not be created — a phantom row a retry re-enrolls, as on first submit).
889
912
  process_key: processKey,
@@ -1018,6 +1041,9 @@ export interface ActivePr {
1018
1041
  activeWorker: string | null;
1019
1042
  /** ISO ts the current activation lease expires; null when not activated. */
1020
1043
  leaseUntil: string | null;
1044
+ /** Whether this PR opted in to the escalation steward (issue #889) — its open escalations are
1045
+ * eligible for steward auto-resolution. Shown in `/status` per PR. */
1046
+ autoResolveEscalations: boolean;
1021
1047
  }
1022
1048
 
1023
1049
  /** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
@@ -1073,6 +1099,56 @@ export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
1073
1099
  updatedAt: p.updated_at,
1074
1100
  activeWorker: p.active_worker ?? null,
1075
1101
  leaseUntil: p.lease_until ?? null,
1102
+ autoResolveEscalations: p.auto_resolve_escalations === 1,
1103
+ }));
1104
+ }
1105
+
1106
+ /** A delivery-graph run currently in flight, as reported by the status endpoint (issue #889 §1 —
1107
+ * "Show it in `/status` for each PR or delivery"). Mirrors {@link ActivePr} for the delivery side. */
1108
+ export interface ActiveDelivery {
1109
+ runKey: string;
1110
+ title: string | null;
1111
+ status: string;
1112
+ phase: string | null;
1113
+ processKey: string | null;
1114
+ openEscalation: OpenEscalation | null;
1115
+ updatedAt: string;
1116
+ /** Whether this run opted in to the escalation steward (issue #889) — its open escalations are
1117
+ * eligible for steward auto-resolution. Derived from the durable `auto_resolve_escalations` flag,
1118
+ * never a clobbered process variable. Shown in `/status` per delivery. */
1119
+ autoResolveEscalations: boolean;
1120
+ }
1121
+
1122
+ /** Every delivery-graph run not in a terminal state (done/failed/abandoned), newest-updated first.
1123
+ * Backs the delivery half of the GET status endpoint (issue #889 §1): a delivery run that opted in to
1124
+ * the escalation steward must surface that durable flag alongside its live escalation, exactly as a PR
1125
+ * does. The structured `openEscalation` pointer is derived from the SAME `user_tasks` read model as
1126
+ * `activePrs` (subject_type `delivery`, keyed by `run_key`), so a delivery node's parked human/escalation
1127
+ * task carries its completable `userTaskKey` here too. */
1128
+ export async function activeDeliveries(data: DataLayer): Promise<ActiveDelivery[]> {
1129
+ // `derived_status` is a plain `string` (the tracking VIEW's fold column), while the terminal set is
1130
+ // the narrow `DeliveryGraphRunStatus` union — classify via a `readonly string[]` view (the SAME
1131
+ // idiom `TERMINAL_STATUSES: readonly string[]` gives the PR side) rather than a banned `as` cast.
1132
+ const terminal: readonly string[] = DELIVERY_GRAPH_TERMINAL_STATUSES;
1133
+ const active = (await deliveryGraphRunsTracking(data).all())
1134
+ .filter((r) => !terminal.includes(r.derived_status))
1135
+ .sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
1136
+ const openEscByRun = new Map<string, OpenEscalation>();
1137
+ for (const t of (await userTasks(data).find({ subject_type: "delivery" })).sort((a, b) =>
1138
+ a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0,
1139
+ )) {
1140
+ if (openEscByRun.has(t.subject_key)) continue;
1141
+ openEscByRun.set(t.subject_key, toOpenEscalation(t));
1142
+ }
1143
+ return active.map((r) => ({
1144
+ runKey: r.run_key,
1145
+ title: r.title ?? null,
1146
+ status: r.status,
1147
+ phase: r.phase ?? null,
1148
+ processKey: r.process_key ?? null,
1149
+ openEscalation: openEscByRun.get(r.run_key) ?? null,
1150
+ updatedAt: r.updated_at,
1151
+ autoResolveEscalations: r.auto_resolve_escalations === 1,
1076
1152
  }));
1077
1153
  }
1078
1154
 
@@ -2559,7 +2635,10 @@ export async function pollPromotion(data: DataLayer, engine: EngineClient, token
2559
2635
  }
2560
2636
  if (prStatus === null) {
2561
2637
  const parsed = parsePr(plan.promotion_pr);
2562
- if (parsed) await submitPr(data, engine, parsed);
2638
+ // Thread the epic's escalation-steward opt-in (issue #889 review) so a re-enrolled
2639
+ // promotion PR inherits the parent epic's enrolment rather than dropping to the `false`
2640
+ // default (which would also overwrite a previously persisted opt-in with 0).
2641
+ if (parsed) await submitPr(data, engine, parsed, [], MAX_ROUNDS, false, null, false, plan.auto_resolve_escalations === 1);
2563
2642
  }
2564
2643
  continue;
2565
2644
  }
@@ -2596,7 +2675,9 @@ export async function pollPromotion(data: DataLayer, engine: EngineClient, token
2596
2675
  updated_at: now(),
2597
2676
  });
2598
2677
  const parsed = parsePr(promotionPrKey);
2599
- if (parsed) await submitPr(data, engine, parsed);
2678
+ // Thread the epic's escalation-steward opt-in (issue #889 review) so the freshly opened
2679
+ // promotion PR is enrolled with the parent epic's enrolment, not the `false` default.
2680
+ if (parsed) await submitPr(data, engine, parsed, [], MAX_ROUNDS, false, null, false, plan.auto_resolve_escalations === 1);
2600
2681
  console.log(
2601
2682
  `[poller] promotion PR ${result.created ? "opened" : "reused"} ${promotionPrKey} (${base} -> ${target})`,
2602
2683
  );
@@ -2705,6 +2786,10 @@ export async function pollFeatureDelivery(
2705
2786
  run.auto_merge !== 1,
2706
2787
  run.feature_key,
2707
2788
  run.human_approval === 1,
2789
+ // Thread the run's escalation-steward opt-in (issue #889 review) so a re-enrolled PR
2790
+ // inherits the feature run's enrolment — and a partial-enrollment repair never overwrites
2791
+ // the PR row's previously persisted opt-in with the `false` default.
2792
+ run.auto_resolve_escalations === 1,
2708
2793
  );
2709
2794
  }
2710
2795
  }
@@ -3779,6 +3864,46 @@ export async function pollUserTasks(
3779
3864
  }
3780
3865
  }
3781
3866
 
3867
+ /** Escalation-steward tick (issue #889): start ONE `escalation-steward` instance per interval when the
3868
+ * loop is enabled (`NANO_ESCALATION_STEWARD` not off) and the previous tick's instance has finished.
3869
+ * The collect step's own overlap guard is the authoritative no-op for a still-active prior run; this
3870
+ * pass adds the interval pacing + the master switch. Self-scheduled via `pollOnce`; the interval is
3871
+ * `NANO_ESCALATION_STEWARD_INTERVAL` (default `R/PT30M` — the poll reads its `PT…` duration part).
3872
+ * Best-effort + idempotent — a failed start is logged and retried next pass, never wedges the loop. */
3873
+ let lastStewardTick = 0;
3874
+ export async function pollEscalationSteward(engine: EngineClient) {
3875
+ const disabled = /^(0|off|false|no)$/i.test(readEnvOr("NANO_ESCALATION_STEWARD", "1"));
3876
+ if (disabled) return;
3877
+ // Parse the interval through the ONE canonical ISO-8601 duration parser (`isoDurationToMs`,
3878
+ // app/reviewWait.ts) so the pacing can never drift from the grammar every other timer in the app
3879
+ // uses. The env may carry a bare duration (`PT30M`) or a repeat interval (`R/PT30M`); strip an
3880
+ // optional `R…/` prefix and parse the `P…` period. A malformed value falls back to 30 min; a
3881
+ // zero/negative interval is clamped to that fallback so a steward never starts every poll.
3882
+ const DEFAULT_INTERVAL_MS = 30 * 60 * 1000;
3883
+ const cycle = readEnvOr("NANO_ESCALATION_STEWARD_INTERVAL", "PT30M");
3884
+ const period = cycle.includes("/") ? cycle.slice(cycle.lastIndexOf("/") + 1) : cycle;
3885
+ const parsedMs = isoDurationToMs(period, "PT30M");
3886
+ const intervalMs = parsedMs > 0 ? parsedMs : DEFAULT_INTERVAL_MS;
3887
+ const nowMs = Date.now();
3888
+ if (nowMs - lastStewardTick < intervalMs) return;
3889
+ // Overlap guard (cheap, pre-start): don't start a new tick while a prior one is still ACTIVE.
3890
+ const active = await engine.searchProcessInstances({ processDefinitionId: ESCALATION_STEWARD_PROCESS_ID, state: "ACTIVE" });
3891
+ if (active.length > 0) return;
3892
+ const minAgeMs = Number(readEnvOr("NANO_ESCALATION_STEWARD_MIN_AGE_MS", "300000"));
3893
+ // Advance the tick ONLY after a successful start: if `createInstance` throws, leaving
3894
+ // `lastStewardTick` untouched lets the next pass retry immediately rather than suppressing retries
3895
+ // for the whole interval (the "retried next pass" contract).
3896
+ // Seed the canonical agent-task SLA (`agentSlaTimeout`, issue #849) so the steward-agent's
3897
+ // interrupting timer boundary (`be_steward_sla`) bounds a hung agent run — without it a wedged
3898
+ // agent parks the instance ACTIVE forever and the overlap guard above suppresses every later tick
3899
+ // (issue #889 review).
3900
+ await engine.createInstance({
3901
+ processDefinitionId: ESCALATION_STEWARD_PROCESS_ID,
3902
+ variables: { minAgeMs, agentSlaTimeout: AGENT_SLA_TIMEOUT },
3903
+ });
3904
+ lastStewardTick = nowMs;
3905
+ }
3906
+
3782
3907
  /** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
3783
3908
  * (when the engine REST endpoint is supplied) the job-activation visibility pass and the
3784
3909
  * technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`.
@@ -3799,6 +3924,15 @@ export async function pollOnce(
3799
3924
  await pollFeatureDelivery(data, engine);
3800
3925
  await pollLineage(data);
3801
3926
  await pollUserTasks(data, engine, engineRest);
3927
+ // The escalation steward is an OPTIONAL, best-effort pass: a steward-specific failure (a search or
3928
+ // instance-start error) must never reject `pollOnce` and starve the unrelated epic / taskless-plan /
3929
+ // delivery-graph reconciliation passes that follow it. Catch + log it independently so the canonical
3930
+ // poller always continues (issue #889 review).
3931
+ try {
3932
+ await pollEscalationSteward(engine);
3933
+ } catch (err) {
3934
+ console.error(`[poller] escalation-steward tick: ${err instanceof Error ? err.message : String(err)}`);
3935
+ }
3802
3936
  await pollEpicPhase(data, engine, engineRest);
3803
3937
  await pollTasklessPlanTermination(data, engine);
3804
3938
  await pollDeliveryGraphPhase(data, engine);