@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
@@ -126,6 +126,19 @@ test("#460/#511/#569: Dispatch is the operator's launch — posts the digest to
126
126
  assert(!/approvalToken/.test(MOUNT_JS), "staged.mount.js must NOT carry the removed replayable approvalToken");
127
127
  });
128
128
 
129
+ test("#889: the dispatch confirmation offers an escalation-steward opt-in and posts autoResolveEscalations", () => {
130
+ // Issue #889 §1 requires the opt-in on EVERY UI start pathway. The delivery-graph dispatch is one:
131
+ // the inline Dispatch confirmation must carry an "Automatically resolve escalations" checkbox, and
132
+ // the POST body must include `autoResolveEscalations: true` when the operator checks it (the dispatch
133
+ // door already accepts it — operations/dispatchDeliveryGraph.ts). Without the control a UI user can
134
+ // never opt a dispatched graph in.
135
+ assert(/data-dispatch-autoresolve=/.test(MOUNT_JS), "staged.mount.js must render an escalation-steward opt-in checkbox in the dispatch confirmation");
136
+ assert(/Automatically resolve escalations/.test(MOUNT_JS), "the opt-in control must be labelled for the operator");
137
+ // The checkbox state is tracked on the pending confirmation and included in the POST payload.
138
+ assert(/autoResolveEscalations/.test(MOUNT_JS), "staged.mount.js must thread autoResolveEscalations into the dispatch payload");
139
+ assert(/pending\.autoResolveEscalations/.test(MOUNT_JS), "the opt-in checkbox state is tracked on the pending dispatch confirmation");
140
+ });
141
+
129
142
  test("#520/#569: Dismiss is the operator's discard — posts the digest to the dismiss door, behind an in-DOM confirm, and launches nothing", () => {
130
143
  assertModuleAnchored("dismissUrl", "app/api/actions/delivery-graph/dismiss");
131
144
  assert(/data-dismiss=/.test(MOUNT_JS), "staged.mount.js must render a per-row Dismiss affordance carrying the digest");
@@ -57,6 +57,7 @@ test("every corpus model is either ported or has a documented blocker", () => {
57
57
  "merge-cell",
58
58
  "wait-gate",
59
59
  "human-escalation",
60
+ "escalation-steward",
60
61
  ];
61
62
  assertEquals(
62
63
  PORTS.map((p) => p.model),
@@ -273,4 +273,16 @@ export const PORTS: readonly PortEntry[] = [
273
273
  model: "human-escalation",
274
274
  blockedReason: `${MULTI_START_END_BLOCK} (human-escalation: 1 start, 3 ends)`,
275
275
  },
276
+ {
277
+ model: "escalation-steward",
278
+ blockedReason:
279
+ "blocked (agent-task marker + gateway merge): single top-level start/end, but its " +
280
+ "`senior:escalation-steward` task carries the <zeebe:agentDefinition agentType=\"external\" /> " +
281
+ "marker issue #745 requires on every prompt-bearing senior:* task (a marker the published " +
282
+ "@nanobpm/workflow compiler cannot emit — the class-3 block), AND its `any eligible?` " +
283
+ "exclusive gateway is a two-way split whose `eligible`/`none` out-edges re-converge on the " +
284
+ "shared `done` end event (an explicit-join topology the structured-only builder cannot emit " +
285
+ "without inserting its own gateway). Awaits an agent-marker option on task() and an " +
286
+ "arbitrary-graph / explicit-join builder upstream in @nanobpm/workflow (nano-ide).",
287
+ },
276
288
  ];
@@ -47,12 +47,17 @@ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
47
47
  pr_key: parsed.prKey,
48
48
  updated_at: new Date().toISOString(),
49
49
  });
50
- await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, !autoMerge, featureKey, humanApproval);
50
+ // Propagate the feature run's escalation-steward opt-in (issue #889) to the convergence loop:
51
+ // read the durable flag off the `feature_runs` row (the source of truth) and hand it to `submitPr`.
52
+ const featureRow = await featureRuns(app.data).get(featureKey);
53
+ const autoResolve = featureRow?.auto_resolve_escalations === 1;
54
+ await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, !autoMerge, featureKey, humanApproval, autoResolve);
51
55
  app.log.info("converge-feature: enrolled PR into convergence loop", {
52
56
  featureKey,
53
57
  prKey: parsed.prKey,
54
58
  convergeOnly: !autoMerge,
55
59
  humanApproval,
60
+ autoResolveEscalations: autoResolve,
56
61
  });
57
62
  return {};
58
63
  };
@@ -6,7 +6,7 @@ import { test } from "node:test";
6
6
  import { assert, assertEquals, assertThrows } from "#test-assert";
7
7
  import { PROCESS_ID } from "../../app/service.ts";
8
8
  import { withTrackingViews } from "../../test/trackingViews.ts";
9
- import handler, { readConnectorInput, readConvergeInput, safeStringify } from "./worker.ts";
9
+ import handler, { readConnectorInput, readConvergeInput, safeStringify, setLaunchStampSleep } from "./worker.ts";
10
10
 
11
11
  function memTable(rows: Record<string, unknown>[], key: string) {
12
12
  return {
@@ -41,6 +41,7 @@ function fakeApp() {
41
41
  escalations: { rows: [], key: "id" },
42
42
  pr_dependencies: { rows: [], key: "pr_key" },
43
43
  delivery_connector_dispatches: { rows: [], key: "id" },
44
+ delivery_graph_runs: { rows: [], key: "run_key" },
44
45
  };
45
46
  const created: { processDefinitionId?: string; variables?: Record<string, unknown> }[] = [];
46
47
  let nextId = 1;
@@ -317,3 +318,73 @@ test("handler: a misconfigured converge connector (no parseable pr) fails CLOSED
317
318
  assertEquals(stores.delivery_connector_dispatches.rows.length, 0, "no junk ledger row is claimed");
318
319
  });
319
320
  });
321
+
322
+ test("handler: the escalation-steward opt-in is resolved from the DURABLE delivery_graph_runs row, NOT the clobberable process variable (issue #889 review)", async () => {
323
+ await withGithubOff(async () => {
324
+ const { app, stores, created } = fakeApp();
325
+ // The run row for THIS process instance OPTED IN (auto_resolve_escalations = 1), keyed by
326
+ // `process_key` = the connector job's processInstanceKey.
327
+ stores.delivery_graph_runs.rows.push({ run_key: "run-1", process_key: "PI-graph", auto_resolve_escalations: 1, status: "running" });
328
+ // ...but an upstream agent completion CLOBBERED the inherited process variable to `false`. Before
329
+ // the fix the connector read `job.variables.autoResolveEscalations === true` → false, enrolling the
330
+ // PR as opted-OUT and contradicting the durable record. The durable row must win.
331
+ await handler(
332
+ { variables: { target: "converge-merge", payload: { pr: "owner/repo#21" }, autoResolveEscalations: false }, processInstanceKey: "PI-graph", elementId: "n2" } as never,
333
+ app,
334
+ );
335
+ assertEquals(created.length, 1, "the PR was enrolled");
336
+ assertEquals(created[0]?.variables?.autoResolveEscalations, true, "the durable run row's opt-in wins over the clobbered process variable");
337
+ });
338
+ });
339
+
340
+ test("handler: the escalation-steward opt-in FAILS CLOSED when no delivery_graph_runs row matches this process instance", async () => {
341
+ await withGithubOff(async () => {
342
+ const { app, created } = fakeApp();
343
+ // No delivery_graph_runs row for PI-graph (and the process variable claims opted-IN). With no
344
+ // durable record the connector must NOT trust the clobberable variable — fail closed to opted-OUT.
345
+ await handler(
346
+ { variables: { target: "converge-merge", payload: { pr: "owner/repo#22" }, autoResolveEscalations: true }, processInstanceKey: "PI-graph", elementId: "n2" } as never,
347
+ app,
348
+ );
349
+ assertEquals(created.length, 1, "the PR was still enrolled");
350
+ assertEquals(created[0]?.variables?.autoResolveEscalations, false, "no durable run row ⇒ fail closed to opted-out, never trust the process variable");
351
+ });
352
+ });
353
+
354
+ test("handler: a launch-in-flight run row (process_key not yet stamped) is retried, NOT converted to opted-out (issue #889 review — launch race)", async () => {
355
+ await withGithubOff(async () => {
356
+ const { app, stores, created } = fakeApp();
357
+ // THE LAUNCH RACE: `dispatchDeliveryGraphRun` claims the run row with `process_key = NULL`, starts
358
+ // the engine instance, and only THEN stamps the returned key. This connector job activated BEFORE
359
+ // that post-launch write, so the row is present (opted IN) but its `process_key` is still NULL. A
360
+ // single read keyed on `process_key` finds NO match → false → permanently enrolls the opted-in PR
361
+ // as opted OUT. The lookup must treat a NULL process_key as TRANSIENT and re-poll until the stamp
362
+ // lands. DETERMINISTIC (AGENTS.md — no real-timer race): the inter-poll sleep is overridden to a
363
+ // test-controlled signal that stamps `process_key` after a fixed number of polls (poll 1 and 2 see
364
+ // the launch-in-flight NULL, poll 3 sees the stamp) — no wall-clock timer, so the outcome cannot
365
+ // depend on scheduler load.
366
+ const row: Record<string, unknown> = { run_key: "run-1", process_key: null, auto_resolve_escalations: 1, status: "running" };
367
+ stores.delivery_graph_runs.rows.push(row);
368
+ let polls = 0;
369
+ setLaunchStampSleep(() => {
370
+ polls += 1;
371
+ if (polls === 2) row.process_key = "PI-graph"; // the dispatch's stamp lands after the 2nd poll
372
+ return Promise.resolve();
373
+ });
374
+ try {
375
+ await handler(
376
+ { variables: { target: "converge-merge", payload: { pr: "owner/repo#23" } }, processInstanceKey: "PI-graph", elementId: "n2" } as never,
377
+ app,
378
+ );
379
+ } finally {
380
+ setLaunchStampSleep();
381
+ }
382
+ assert(polls >= 2, "the launch-in-flight row was re-polled across the launch window, not read once and converted to opted-out");
383
+ assertEquals(created.length, 1, "the PR was enrolled");
384
+ assertEquals(
385
+ created[0]?.variables?.autoResolveEscalations,
386
+ true,
387
+ "a launch-in-flight run row is retried until its process_key stamp lands — never read as opted-out",
388
+ );
389
+ });
390
+ });
@@ -7,7 +7,7 @@
7
7
  // envelope) so it is unit-testable without the engine and shares ONE implementation with any other
8
8
  // caller; this worker is the thin engine adapter that resolves the effective dedupe key from the job's
9
9
  // author-supplied `dedupeKey` or its stable engine identity (`processInstanceKey:elementId`).
10
- import type { AppJobHandler } from "@nanobpm/urban";
10
+ import type { AppJobHandler, DataLayer } from "@nanobpm/urban";
11
11
  import {
12
12
  type BoundFact,
13
13
  type ConnectorDispatchResult,
@@ -17,6 +17,7 @@ import {
17
17
  isConvergeTarget,
18
18
  resolveConvergePr,
19
19
  } from "../../app/deliveryConnector.ts";
20
+ import { deliveryGraphRuns } from "../../app/deliveryGraphRun.ts";
20
21
  import { isPrSettled, MAX_ROUNDS, type ParsedPr, parsePr, submitPr } from "../../app/service.ts";
21
22
 
22
23
  // Typed off the compiled `connector` subProcess ioMapping (a RUNTIME-generated definition, so there is
@@ -34,6 +35,49 @@ function isPlainObject(v: unknown): v is Record<string, unknown> {
34
35
  return typeof v === "object" && v !== null && !Array.isArray(v);
35
36
  }
36
37
 
38
+ /** Resolve the escalation-steward opt-in for THIS delivery run from the DURABLE
39
+ * `delivery_graph_runs.auto_resolve_escalations` flag (keyed by the run's `process_key`), NOT the
40
+ * inherited `autoResolveEscalations` process variable (issue #889 review). The process variable is
41
+ * clobberable: an upstream agent node that completes with `autoResolveEscalations: false` in its
42
+ * output overwrites the run-root variable downstream, so reading it here would enroll the PR with the
43
+ * WRONG opt-in — contradicting the durable-source guarantee the steward's eligibility relies on. The
44
+ * run row is the record of truth. FAIL CLOSED: no matching run row (or a non-1 flag) ⇒ not opted in.
45
+ *
46
+ * LAUNCH RACE (issue #889 review): `dispatchDeliveryGraphRun` claims the run row with
47
+ * `process_key = NULL`, starts the engine instance, and only THEN stamps the returned key
48
+ * (`app/deliveryGraphDispatch.ts`). This connector job can activate BEFORE that post-launch write, so
49
+ * a single read keyed on `process_key` finds NO match and would convert the launch-in-flight row to a
50
+ * permanent opted-OUT enrollment. A NULL `process_key` is TRANSIENT (launch in flight), not proof of
51
+ * "no run" — so re-poll across the launch window until the stamp lands (or the budget runs out, after
52
+ * which we fail closed as before). */
53
+ const LAUNCH_STAMP_POLLS = 20;
54
+ const LAUNCH_STAMP_POLL_MS = 250;
55
+ /** The scheduler the poll loop sleeps on between launch-stamp reads. Overridable in tests via
56
+ * {@link setLaunchStampSleep} so a regression drives the retry by a deterministic READ COUNT (the fake
57
+ * table stamps `process_key` after N polls) rather than racing real wall-clock timers — AGENTS.md
58
+ * forbids a test whose outcome depends on scheduler load. */
59
+ let launchStampSleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms));
60
+ /** Test-only seam: override the inter-poll sleep. Pass no argument to restore the real timer. */
61
+ export function setLaunchStampSleep(sleep?: (ms: number) => Promise<void>): void {
62
+ launchStampSleep = sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
63
+ }
64
+ async function resolveAutoResolveEscalations(data: DataLayer, processInstanceKey: string | null): Promise<boolean> {
65
+ if (!processInstanceKey) return false;
66
+ const runs = deliveryGraphRuns(data);
67
+ for (let attempt = 0; attempt < LAUNCH_STAMP_POLLS; attempt++) {
68
+ const rows = await runs.all();
69
+ const run = rows.find((r) => r.process_key != null && String(r.process_key) === String(processInstanceKey));
70
+ if (run != null) return run.auto_resolve_escalations === 1;
71
+ // No row carries this process_key yet. If a launch-in-flight row (NULL process_key) is still
72
+ // waiting for its stamp, re-poll; otherwise there is genuinely no run for this instance — fail
73
+ // closed without burning the remaining budget.
74
+ const launchInFlight = rows.some((r) => r.process_key == null && r.status === "running");
75
+ if (!launchInFlight) return false;
76
+ if (attempt < LAUNCH_STAMP_POLLS - 1) await launchStampSleep(LAUNCH_STAMP_POLL_MS);
77
+ }
78
+ return false;
79
+ }
80
+
37
81
  /** Validate + normalise the untyped job variables into the shape the connector surface accepts. Job
38
82
  * variables are untyped at runtime, so a misconfigured node (or a hand-seeded instance) could hand us a
39
83
  * blank `target`, a scalar `payload`, or a non-array `boundFacts`. Fail CLOSED on a blank `target` (a
@@ -167,12 +211,19 @@ const handler: AppJobHandler<In, ConnectorDispatchResult> = async (job, app) =>
167
211
  });
168
212
  return { detail: `${converge.parsed.prKey} already terminal — enrollment skipped (at-most-once resume-safe)` };
169
213
  }
170
- await submitPr(app.data, app.engine, converge.parsed, converge.dependsOn, MAX_ROUNDS, converge.convergeOnly, dedupeKey);
214
+ // Propagate the delivery run's escalation-steward opt-in (issue #889) to the enrolled PR.
215
+ // Resolve it from the DURABLE `delivery_graph_runs` row (keyed by this job's process
216
+ // instance), NOT the inherited `autoResolveEscalations` process variable — an upstream agent
217
+ // completion can clobber that variable, so the durable row is the only trustworthy source
218
+ // (issue #889 review). Fails closed (false) when no run row matches.
219
+ const autoResolve = await resolveAutoResolveEscalations(app.data, job.processInstanceKey != null ? String(job.processInstanceKey) : null);
220
+ await submitPr(app.data, app.engine, converge.parsed, converge.dependsOn, MAX_ROUNDS, converge.convergeOnly, dedupeKey, false, autoResolve);
171
221
  app.log.info("delivery-connector: enrolled PR into convergence loop", {
172
222
  target,
173
223
  dedupeKey,
174
224
  prKey: converge.parsed.prKey,
175
225
  convergeOnly: converge.convergeOnly,
226
+ autoResolveEscalations: autoResolve,
176
227
  });
177
228
  return { detail: `enrolled ${converge.parsed.prKey} into convergence loop (convergeOnly=${converge.convergeOnly})` };
178
229
  }
@@ -0,0 +1,180 @@
1
+ // Unit coverage for the pr.escalation-steward-collect worker (issue #889) — the steward tick's first
2
+ // step. Drives the handler against the real data layer + a searchProcessInstances stub and asserts:
3
+ // • only OPTED-IN, past-min-age, answerable, unclaimed escalations are collected + claimed;
4
+ // • an escalation on non-opted-in work is NEVER collected (the core safety invariant);
5
+ // • the overlap guard: a still-ACTIVE prior steward instance makes the tick a no-op.
6
+ import { mkdtempSync, rmSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join, resolve } from "node:path";
9
+ import { test } from "node:test";
10
+ import { assertEquals } from "#test-assert";
11
+ import type { DataLayer } from "@nanobpm/urban";
12
+ import { bootTestApp } from "@nanobpm/urban-testkit";
13
+ import { escalationAutoResolutions } from "../../app/escalationSteward.ts";
14
+ import handler from "./worker.ts";
15
+
16
+ const APP_ROOT = resolve(import.meta.dirname, "..", "..");
17
+
18
+ async function withApp(
19
+ fn: (data: DataLayer, run: (vars?: Record<string, unknown>, activeSteward?: string[]) => Promise<{ eligibleCount: number; claimed: string[]; skippedOverlap: boolean }>) => Promise<void>,
20
+ ): Promise<void> {
21
+ const dir = mkdtempSync(join(tmpdir(), "nwf-steward-col-"));
22
+ const app = await bootTestApp(APP_ROOT, { env: { NANO_APP_DB_URL: `file:${join(dir, "app.db")}` } });
23
+ try {
24
+ const run = async (vars: Record<string, unknown> = {}, activeSteward: string[] = []) => {
25
+ const engine = {
26
+ searchProcessInstances: (filter?: { processDefinitionId?: string; state?: string }) => {
27
+ // Report the listed keys as ACTIVE steward instances (the overlap probe).
28
+ const list = filter?.processDefinitionId === "escalation-steward"
29
+ ? activeSteward.map((k) => ({ processInstanceKey: k, state: "ACTIVE" }))
30
+ : [];
31
+ return Promise.resolve(list);
32
+ },
33
+ };
34
+ const job = { jobKey: "J-1", processInstanceKey: "PI-THIS", variables: vars };
35
+ return (await handler(job as never, { data: app.db, engine: engine as never, log: { info: () => {}, warn: () => {}, error: () => {} } } as never)) as {
36
+ eligibleCount: number;
37
+ claimed: string[];
38
+ skippedOverlap: boolean;
39
+ };
40
+ };
41
+ await fn(app.db, run);
42
+ } finally {
43
+ await app.stop?.();
44
+ rmSync(dir, { recursive: true, force: true });
45
+ }
46
+ }
47
+
48
+ /** Seed an open user_tasks row + its owning subject (with the given opt-in flag). */
49
+ async function seedEscalation(
50
+ data: DataLayer,
51
+ opts: { userTaskKey: string; processKey: string; optedIn: boolean; elementId?: string; ageMinutes?: number; subjectKey?: string; formKey?: string },
52
+ ): Promise<void> {
53
+ const ts = new Date().toISOString();
54
+ const created = new Date(Date.now() - (opts.ageMinutes ?? 10) * 60 * 1000).toISOString();
55
+ await data.table("pull_requests", "pr_key").insert({
56
+ pr_key: opts.subjectKey ?? "o/r#1", repo: "o/r", number: 1, url: "u", status: "converging", current_round: 0,
57
+ process_key: opts.processKey, auto_resolve_escalations: opts.optedIn ? 1 : 0, created_at: ts, updated_at: ts,
58
+ });
59
+ await data.table("user_tasks", "user_task_key").insert({
60
+ user_task_key: opts.userTaskKey, element_id: opts.elementId ?? "wait-answer", kind_label: "PR review",
61
+ subject_type: "pr", subject_key: opts.subjectKey ?? "o/r#1", subject_title: "t", subject_url: "u",
62
+ question: "q", process_key: opts.processKey, form_key: opts.formKey ?? "pr-escalation", created_at: created, updated_at: ts,
63
+ });
64
+ }
65
+
66
+ test("collect worker: collects + claims an opted-in, past-min-age, answerable escalation", async () => {
67
+ await withApp(async (data, run) => {
68
+ await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: true });
69
+ const out = await run();
70
+ assertEquals(out.skippedOverlap, false);
71
+ assertEquals(out.eligibleCount, 1);
72
+ assertEquals(out.claimed.length, 1);
73
+ // The claim is recorded (a `claimed` placeholder row) so a racing tick is fenced out.
74
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
75
+ assertEquals(rows.length, 1);
76
+ assertEquals(rows[0].outcome, "claimed");
77
+ });
78
+ });
79
+
80
+ test("collect worker: an escalation on NON-opted-in work is never collected (safety invariant)", async () => {
81
+ await withApp(async (data, run) => {
82
+ await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: false });
83
+ const out = await run();
84
+ assertEquals(out.eligibleCount, 0, "non-opted-in work is never touched");
85
+ assertEquals(out.claimed.length, 0);
86
+ const rows = await escalationAutoResolutions(data).all();
87
+ assertEquals(rows.length, 0, "no claim row is written");
88
+ });
89
+ });
90
+
91
+ test("collect worker: a human-only kind (merge-approval) is never collected even when opted in", async () => {
92
+ await withApp(async (data, run) => {
93
+ await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: true, elementId: "merge-approval" });
94
+ const out = await run();
95
+ assertEquals(out.eligibleCount, 0, "a human-only kind is never steward-collected");
96
+ });
97
+ });
98
+
99
+ test("collect worker: a delivery-human CREDENTIAL/manual task is never collected even when opted in", async () => {
100
+ // A dynamic delivery-human task whose resolved form is the manual OTP/OIDC credential publish form
101
+ // must fail closed — the agent completer does NOT validate dynamic delivery-human forms, so a
102
+ // mis-prompted steward could otherwise blind-complete a credential task (issue #889 review).
103
+ for (const formKey of ["delivery-human-publish", "delivery-human-ack", "no-such-form"]) {
104
+ await withApp(async (data, run) => {
105
+ await seedEscalation(data, { userTaskKey: "UT-DG", processKey: "PI-PR", optedIn: true, elementId: "delivery-human-task__publish", formKey });
106
+ const out = await run();
107
+ assertEquals(out.eligibleCount, 0, `delivery-human + ${formKey} is never steward-collected`);
108
+ assertEquals((await escalationAutoResolutions(data).all()).length, 0, "no claim row is written");
109
+ });
110
+ }
111
+ });
112
+
113
+ test("collect worker: a delivery-human task on a steward-safe form IS collected when opted in", async () => {
114
+ await withApp(async (data, run) => {
115
+ await seedEscalation(data, { userTaskKey: "UT-DG", processKey: "PI-PR", optedIn: true, elementId: "delivery-human-task__step", formKey: "delivery-human-generic" });
116
+ const out = await run();
117
+ assertEquals(out.eligibleCount, 1, "delivery-human + generic (typed-value) form is steward-answerable");
118
+ });
119
+ });
120
+
121
+ test("collect worker: a too-fresh escalation (under min age) is left for a human already answering", async () => {
122
+ await withApp(async (data, run) => {
123
+ await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: true, ageMinutes: 0 });
124
+ const out = await run({ minAgeMs: 300_000 });
125
+ assertEquals(out.eligibleCount, 0, "a just-opened escalation is not yet eligible");
126
+ });
127
+ });
128
+
129
+ test("collect worker: an explicit process-key opt-OUT is trusted, never overridden by a colliding opted-in subject key in another table", async () => {
130
+ await withApp(async (data, run) => {
131
+ // The PR's OWN run opted OUT (auto_resolve=0). Its escalation parks with process_key=PI-PR,
132
+ // subject_type="pr", subject_key="o/r#1".
133
+ await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: false });
134
+ // A DIFFERENT subject table has a row whose run_key COLLIDES with the PR's subject_key but opted IN.
135
+ const ts = new Date().toISOString();
136
+ await data.table("delivery_graph_runs", "run_key").insert({
137
+ run_key: "o/r#1", process_key: "PI-DELIVERY", digest: "d", status: "running",
138
+ auto_resolve_escalations: 1, created_at: ts, updated_at: ts,
139
+ });
140
+ const out = await run();
141
+ // Old bug: the un-scoped subject fallback matched the colliding delivery row (opted in) and OR-ed
142
+ // PAST the PR's explicit opt-OUT, collecting a task the PR owner opted OUT of. Fix: a direct opt-OUT
143
+ // is trusted (only an ABSENT direct lookup falls back) AND the fallback is compound-keyed by
144
+ // subject_type, so a cross-table key collision can never make a non-opted-in task eligible.
145
+ assertEquals(out.eligibleCount, 0, "an explicit opt-OUT is never overridden by a colliding opted-in subject key");
146
+ assertEquals(out.claimed.length, 0);
147
+ const rows = await escalationAutoResolutions(data).all();
148
+ assertEquals(rows.length, 0, "no claim row is written for an opted-out subject");
149
+ });
150
+ });
151
+
152
+ test("collect worker: the overlap guard makes a tick a no-op while a prior steward run is ACTIVE", async () => {
153
+ await withApp(async (data, run) => {
154
+ await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: true });
155
+ const out = await run({}, ["PI-PRIOR"]);
156
+ assertEquals(out.skippedOverlap, true, "a still-active prior run skips this tick");
157
+ assertEquals(out.eligibleCount, 0);
158
+ assertEquals(out.claimed.length, 0);
159
+ });
160
+ });
161
+
162
+ test("collect worker: an escalation the steward ALREADY abstained/resolved is never re-claimed (no repeated abstentions)", async () => {
163
+ await withApp(async (data, run) => {
164
+ const { recordResolution } = await import("../../app/escalationSteward.ts");
165
+ await seedEscalation(data, { userTaskKey: "UT-1", processKey: "PI-PR", optedIn: true });
166
+ // First tick claims + abstains (the task stays OPEN — an abstention does not complete it).
167
+ const first = await run({});
168
+ assertEquals(first.claimed.length, 1, "the first tick claims the eligible escalation");
169
+ await recordResolution(data, "UT-1", { outcome: "abstained", rationale: "unclear — needs a human" });
170
+ // The NEXT tick must NOT re-claim the same `user_task_key`: the partial claim fence no longer
171
+ // covers the finalised row, so without the final-record exclusion the steward would abstain the
172
+ // same open task every tick forever (issue #889 review).
173
+ const second = await run({});
174
+ assertEquals(second.claimed.length, 0, "an already-abstained task is never re-claimed");
175
+ assertEquals(second.eligibleCount, 0);
176
+ const rows = await escalationAutoResolutions(data).find({ user_task_key: "UT-1" });
177
+ assertEquals(rows.length, 1, "exactly ONE durable row — no repeated abstention records");
178
+ assertEquals(rows[0].outcome, "abstained");
179
+ });
180
+ });
@@ -0,0 +1,99 @@
1
+ // pr.escalation-steward-collect — the steward tick's first step (issue #889). Collects the OPEN
2
+ // escalation user tasks that are ELIGIBLE for auto-resolution and claims them for THIS steward run,
3
+ // so the agent dispatched next has a bounded, non-overlapping work set.
4
+ //
5
+ // Eligibility (app/escalationSteward.ts, the ONE derivation):
6
+ // • the escalation's owning work OPTED IN (`auto_resolve_escalations` on its subject row, resolved
7
+ // via the task's process/root instance key);
8
+ // • the kind is steward-answerable (never a human-only / irreversible / credential kind);
9
+ // • the task is past the minimum age (a human already answering wins);
10
+ // • it is not already claimed by a live steward run (the claim/lease fence).
11
+ //
12
+ // Overlap guard: if the PREVIOUS steward instance is still ACTIVE, this tick claims nothing and
13
+ // reports `skippedOverlap` — the process's gateway then routes straight to done (a no-op tick), so
14
+ // two steward runs never double-answer.
15
+ import type { AppJobHandler } from "@nanobpm/urban";
16
+ import {
17
+ claimEscalations,
18
+ DEFAULT_MIN_AGE_MS,
19
+ ESCALATION_STEWARD_PROCESS_ID,
20
+ finalRecordedTaskKeys,
21
+ isEligibleEscalation,
22
+ liveClaimedTaskKeys,
23
+ optInByProcessKey,
24
+ optInBySubjectKey,
25
+ } from "../../app/escalationSteward.ts";
26
+ import { type UserTaskRow, userTasks } from "../../app/userTasks.ts";
27
+
28
+ interface In extends Record<string, unknown> {
29
+ stewardInstanceKey?: string;
30
+ minAgeMs?: number;
31
+ }
32
+
33
+ interface Out extends Record<string, unknown> {
34
+ eligibleCount: number;
35
+ /** The claimed escalations, serialised for the agent's `claimedTasks` input (JSON). */
36
+ claimed: string[];
37
+ skippedOverlap: boolean;
38
+ }
39
+
40
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
41
+ const stewardKey = job.processInstanceKey != null ? String(job.processInstanceKey) : `steward-${Date.now()}`;
42
+ const minAgeMs = typeof job.variables.minAgeMs === "number" && job.variables.minAgeMs >= 0 ? job.variables.minAgeMs : DEFAULT_MIN_AGE_MS;
43
+
44
+ // Overlap guard: a still-active prior steward instance means this tick is a no-op.
45
+ const active = await app.engine.searchProcessInstances({ processDefinitionId: ESCALATION_STEWARD_PROCESS_ID, state: "ACTIVE" });
46
+ const prior = active.filter((i) => String(i.processInstanceKey) !== stewardKey);
47
+ if (prior.length > 0) {
48
+ app.log.info("escalation-steward collect: prior run still active — no-op tick", { active: prior.length });
49
+ return { eligibleCount: 0, claimed: [], skippedOverlap: true };
50
+ }
51
+
52
+ const open = await userTasks(app.data).all();
53
+ const optInByProc = await optInByProcessKey(app.data);
54
+ const optInBySubject = await optInBySubjectKey(app.data);
55
+ // The claim fence's partial UNIQUE index only fences an OPEN claim; once a row is finalised
56
+ // (`resolved`/`abstained`) it leaves that index, and because an abstention does NOT complete the
57
+ // task, the same `user_task_key` would be re-claimed every tick (issue #889 review). Union the
58
+ // already-final keys into the exclusion set so a task the steward already answered/abstained is
59
+ // never re-claimed (a genuinely NEW escalation gets a NEW engine key, so this never blocks one).
60
+ const claimedKeys = await liveClaimedTaskKeys(app.data);
61
+ for (const k of await finalRecordedTaskKeys(app.data)) claimedKeys.add(k);
62
+ const at = Date.now();
63
+
64
+ // Resolve each task's opt-in from its owning subject. A top-level escalation's `process_key` IS the
65
+ // subject's instance; an escalation parked INSIDE a callActivity child cell (the shared
66
+ // `human-escalation`/`implement-cell`) instead carries the CHILD's `process_key` (no subject row
67
+ // tracks it) but the PARENT run's `subject_key` (pollUserTasks correlates it via the root instance).
68
+ // So check the direct `process_key` first; only when that lookup is ABSENT (no subject row keyed on
69
+ // this process instance at all) fall back to the correlated `<subject_type>:<subject_key>`. A direct
70
+ // lookup that returned `false` is an explicit opt-OUT and MUST be trusted — never OR-ed past with a
71
+ // subject-key fallback that could flip it back to eligible (issue #889 review). The subject fallback
72
+ // is COMPOUND-keyed by `subject_type` so a `plan_key`/`pr_key`/`run_key` colliding across tables
73
+ // can't cross-match. A task with no tracked subject either way is NOT opted in (fail-closed).
74
+ const eligible: UserTaskRow[] = [];
75
+ for (const row of open) {
76
+ const procKey = row.process_key ?? "";
77
+ const directOptIn = procKey !== "" ? optInByProc.get(procKey) : undefined;
78
+ const optedIn =
79
+ directOptIn !== undefined
80
+ ? directOptIn
81
+ : row.subject_key != null && row.subject_key !== "" &&
82
+ optInBySubject.get(`${row.subject_type}:${row.subject_key}`) === true;
83
+ if (isEligibleEscalation(row, { optedIn, minAgeMs, claimedUserTaskKeys: claimedKeys, at })) {
84
+ eligible.push(row);
85
+ }
86
+ }
87
+
88
+ const claimed = await claimEscalations(app.data, eligible, stewardKey);
89
+ app.log.info("escalation-steward collect", { open: open.length, eligible: eligible.length, claimed: claimed.length });
90
+ // Serialise the claimed rows for the agent (the message/job envelope is scalar-only for lists, so
91
+ // carry each as a compact JSON string the agent + record step parse).
92
+ return {
93
+ eligibleCount: claimed.length,
94
+ claimed: claimed.map((r) => JSON.stringify(r)),
95
+ skippedOverlap: false,
96
+ };
97
+ };
98
+
99
+ export default handler;