@nanobpm/nano-workforce 0.63.0 → 0.65.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 (42) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/app/agentCompletion.test.ts +51 -0
  3. package/app/agentCompletion.ts +50 -12
  4. package/app/feature.ts +80 -2
  5. package/app/featureEscalation.test.ts +180 -0
  6. package/app/service.ts +45 -1
  7. package/db/migrations/031_feature_escalation_surface.sql +28 -0
  8. package/e2e/feature-run.e2e.ts +58 -0
  9. package/nano.app.json +6 -1
  10. package/openapi.yaml +52 -0
  11. package/operations/answerFeatureEscalation.ts +68 -0
  12. package/package.json +2 -2
  13. package/pages/feature.page.json +39 -3
  14. package/pages/overview.page.json +37 -2
  15. package/resources/processes/convergence-loop.bpmn +10 -0
  16. package/resources/processes/feature.bpmn +104 -58
  17. package/resources/processes/merge-loop.bpmn +2 -0
  18. package/resources/processes/plan-fanout.bpmn +42 -1
  19. package/resources/processes/retro.bpmn +20 -0
  20. package/scripts/pages-contract.test.ts +1 -1
  21. package/workers/answer-escalation/worker.ts +3 -5
  22. package/workers/arm-merge/worker.ts +3 -3
  23. package/workers/converge-feature/worker.ts +3 -5
  24. package/workers/ensure-base-branch/worker.ts +3 -4
  25. package/workers/finalize/worker.ts +4 -16
  26. package/workers/mark-merged/worker.ts +3 -3
  27. package/workers/merge/worker.ts +3 -11
  28. package/workers/persist-escalation/worker.ts +5 -22
  29. package/workers/persist-round/worker.ts +6 -16
  30. package/workers/record-dependency/worker.ts +5 -4
  31. package/workers/record-feature/worker.ts +9 -6
  32. package/workers/record-feature-escalation/worker.test.ts +74 -0
  33. package/workers/record-feature-escalation/worker.ts +42 -0
  34. package/workers/record-plan/worker.ts +3 -0
  35. package/workers/record-plan-review/worker.ts +3 -6
  36. package/workers/record-results/worker.ts +3 -3
  37. package/workers/record-trial-merge/worker.ts +4 -0
  38. package/workers/record-wave/worker.ts +3 -0
  39. package/workers/resolve-trial-attention/worker.ts +3 -5
  40. package/workers/retro-gather/worker.ts +3 -3
  41. package/workers/retro-record/worker.ts +4 -8
  42. package/workers/select-wave/worker.ts +3 -4
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.65.0](https://github.com/nanobpm/nano-workforce/compare/v0.64.0...v0.65.0) (2026-08-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * surface native feature-run escalations in the nwf UI ([#213](https://github.com/nanobpm/nano-workforce/issues/213)) ([cf14724](https://github.com/nanobpm/nano-workforce/commit/cf1472419e2c68be4902a1fce2dabfcf39f2ae54)), closes [#210](https://github.com/nanobpm/nano-workforce/issues/210) [#210](https://github.com/nanobpm/nano-workforce/issues/210)
7
+
8
+ # [0.64.0](https://github.com/nanobpm/nano-workforce/compare/v0.63.0...v0.64.0) (2026-08-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * type workers off the generated data envelope ([#201](https://github.com/nanobpm/nano-workforce/issues/201)) ([#208](https://github.com/nanobpm/nano-workforce/issues/208)) ([792083f](https://github.com/nanobpm/nano-workforce/commit/792083f153050862a11224288b0e27e769ab06ba)), closes [nano-ide#225](https://github.com/nano-ide/issues/225) [#225](https://github.com/nanobpm/nano-workforce/issues/225) [#228](https://github.com/nanobpm/nano-workforce/issues/228) [#211](https://github.com/nanobpm/nano-workforce/issues/211) [#211](https://github.com/nanobpm/nano-workforce/issues/211)
14
+
1
15
  # [0.63.0](https://github.com/nanobpm/nano-workforce/compare/v0.62.0...v0.63.0) (2026-08-13)
2
16
 
3
17
 
@@ -16,6 +16,7 @@ import { test } from "node:test";
16
16
  import { assert, assertEquals, assertRejects } from "#test-assert";
17
17
  import {
18
18
  completeEscalationAsAgent,
19
+ completeEscalationAsHuman,
19
20
  completeUserTaskAttributed,
20
21
  latestCompletion,
21
22
  revertAgentCompletion,
@@ -139,6 +140,56 @@ test("agent completer is a no-op for an unknown userTaskKey", async () => {
139
140
  assertEquals(completed.length, 0);
140
141
  });
141
142
 
143
+ test("a HUMAN operator completes a feature escalation via the SAME attributed resume path (issue #210)", async () => {
144
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
145
+ const data = memData(stores);
146
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-1", elementId: "feature-escalation" }]);
147
+
148
+ const r = await completeEscalationAsHuman(data, engine, {
149
+ userTaskKey: "ut-1",
150
+ operatorId: "alice",
151
+ variables: { resolution: "answer", answer: "use v2" },
152
+ });
153
+
154
+ assertEquals(r.ok, true);
155
+ assertEquals(r.elementId, "feature-escalation");
156
+
157
+ // Identical resume path to the agent/task-inbox: completeUserTask with the exact typed variables.
158
+ assertEquals(completed.length, 1);
159
+ assertEquals(completed[0].variables, { resolution: "answer", answer: "use v2" });
160
+
161
+ // Attribution recorded as a HUMAN completion — the authority, so NOT reversible.
162
+ const row = stores.task_completions.rows[0] as TaskCompletion;
163
+ assertEquals(row.actor_kind, "human");
164
+ assertEquals(row.actor_id, "alice");
165
+ assertEquals(row.reversible, 0, "a human completion is the authority (not reversible)");
166
+ });
167
+
168
+ test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
169
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
170
+ const data = memData(stores);
171
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-x", elementId: "decide" }]);
172
+
173
+ const notEsc = await completeEscalationAsHuman(data, engine, {
174
+ userTaskKey: "ut-x",
175
+ operatorId: "alice",
176
+ variables: { resolution: "abandon" },
177
+ });
178
+ assertEquals(notEsc.ok, false);
179
+ assertEquals(notEsc.reason, "not an escalation task");
180
+
181
+ const missing = await completeEscalationAsHuman(data, engine, {
182
+ userTaskKey: "ut-missing",
183
+ operatorId: "alice",
184
+ variables: { resolution: "abandon" },
185
+ });
186
+ assertEquals(missing.ok, false);
187
+ assertEquals(missing.reason, "no open escalation task");
188
+
189
+ assertEquals(completed.length, 0, "neither refusal completes a task");
190
+ assertEquals(stores.task_completions.rows.length, 0, "and no attribution row is written");
191
+ });
192
+
142
193
  test("a human can revert/override an agent completion (recording who + when + corrective note)", async () => {
143
194
  const stores = { task_completions: { rows: [] as any[], key: "id" } };
144
195
  const data = memData(stores);
@@ -148,6 +148,23 @@ export interface AgentCompleteResult {
148
148
  elementId?: string;
149
149
  }
150
150
 
151
+ /** Resolve a parked escalation user task by key: return its `elementId` if it is one of the migrated
152
+ * escalation tasks, or a failure reason otherwise. Shared by the agent and human completers so both
153
+ * refuse a non-escalation / missing target the exact same way (a key with no matching open
154
+ * escalation task is a 404-style no-op). */
155
+ async function resolveEscalationTask(
156
+ engine: EngineClient,
157
+ userTaskKey: string,
158
+ ): Promise<{ ok: true; elementId: string } | { ok: false; reason: string }> {
159
+ const open = await engine.searchUserTasks();
160
+ const match = open.find((t) => t.userTaskKey === userTaskKey);
161
+ if (!match) return { ok: false, reason: "no open escalation task" };
162
+ if (!match.elementId || !ESCALATION_TASK_ELEMENTS.has(match.elementId)) {
163
+ return { ok: false, reason: "not an escalation task" };
164
+ }
165
+ return { ok: true, elementId: match.elementId };
166
+ }
167
+
151
168
  /** Complete an escalation user task AS AN AGENT (ADR 0046). Resolves the parked task by its key,
152
169
  * refuses anything that is not one of the migrated escalation tasks, and routes the typed form
153
170
  * variables through the shared attributed completer with the agent's identity. Reuses the exact
@@ -163,24 +180,45 @@ export async function completeEscalationAsAgent(
163
180
  const agentId = input.agentId.trim();
164
181
  if (!agentId) return { ok: false, reason: "agentId is required" };
165
182
 
166
- const open = await engine.searchUserTasks();
167
- const match = open.find((t) => t.userTaskKey === userTaskKey);
168
- if (!match) return { ok: false, reason: "no open escalation task" };
169
- if (!match.elementId || !ESCALATION_TASK_ELEMENTS.has(match.elementId)) {
170
- return { ok: false, reason: "not an escalation task" };
171
- }
183
+ const resolved = await resolveEscalationTask(engine, userTaskKey);
184
+ if (!resolved.ok) return resolved;
172
185
 
173
186
  const { completionId } = await completeUserTaskAttributed(
174
187
  data,
175
188
  engine,
176
- {
177
- userTaskKey,
178
- elementId: match.elementId,
179
- variables: input.variables,
180
- },
189
+ { userTaskKey, elementId: resolved.elementId, variables: input.variables },
181
190
  { kind: "agent", id: agentId },
182
191
  );
183
- return { ok: true, completionId, userTaskKey, elementId: match.elementId };
192
+ return { ok: true, completionId, userTaskKey, elementId: resolved.elementId };
193
+ }
194
+
195
+ /** Complete an escalation user task AS A HUMAN operator (issue #210). The exact twin of
196
+ * `completeEscalationAsAgent`, but attributed to a human: it drives the SAME canonical
197
+ * `completeUserTaskAttributed` with the operator's typed form variables, so the nwf UI's answer
198
+ * affordance resumes the process through the one implementation a human uses from the task inbox —
199
+ * no parallel completion path — while recording WHO answered in the `task_completions` ledger. A
200
+ * human completion is the authority (not reversible). A key with no matching open escalation task is
201
+ * a 404-style no-op. */
202
+ export async function completeEscalationAsHuman(
203
+ data: DataLayer,
204
+ engine: EngineClient,
205
+ input: { userTaskKey: string; variables: Record<string, unknown>; operatorId: string },
206
+ ): Promise<AgentCompleteResult> {
207
+ const userTaskKey = input.userTaskKey.trim();
208
+ if (!userTaskKey) return { ok: false, reason: "userTaskKey is required" };
209
+ const operatorId = input.operatorId.trim();
210
+ if (!operatorId) return { ok: false, reason: "operatorId is required" };
211
+
212
+ const resolved = await resolveEscalationTask(engine, userTaskKey);
213
+ if (!resolved.ok) return resolved;
214
+
215
+ const { completionId } = await completeUserTaskAttributed(
216
+ data,
217
+ engine,
218
+ { userTaskKey, elementId: resolved.elementId, variables: input.variables },
219
+ { kind: "human", id: operatorId },
220
+ );
221
+ return { ok: true, completionId, userTaskKey, elementId: resolved.elementId };
184
222
  }
185
223
 
186
224
  export interface RevertResult {
package/app/feature.ts CHANGED
@@ -45,12 +45,25 @@ export interface FeatureRun {
45
45
  * outcome is written to `status` itself; this carries the sub-state / note (e.g. "merged",
46
46
  * "waiting_review", or "operator: <note>"). */
47
47
  delivery_label: string | null;
48
+ /** The parked `feature-escalation` user task's `question`, persisted at escalation entry by the
49
+ * `record-feature-escalation` worker (NOT by `pollFeatureEscalations` while parked, which
50
+ * deliberately never writes it) so the pages can show what the agent asked. NULL whenever the run
51
+ * is not parked at an escalation — cleared on the exit paths (`record-feature` / the answer
52
+ * operation), and, as a self-heal, by `pollFeatureEscalations` when a previously-observed task is
53
+ * completed out-of-band (see `deriveFeatureEscalationPatch`). */
54
+ escalation_question: string | null;
55
+ /** The completable native `feature-escalation` user-task key the answer affordance posts to
56
+ * (`completeUserTaskAttributed`) and the pages gate the answer controls on (`showWhenField`). Set by
57
+ * `pollFeatureEscalations` while parked; NULL otherwise. */
58
+ escalation_user_task_key: string | null;
48
59
  created_at: string;
49
60
  updated_at: string;
50
61
  }
51
62
 
52
63
  export const FEATURE_RUN_STATUSES = [
53
- "running", // the agent is implementing (including while parked at an escalation user task)
64
+ "running", // the agent is implementing
65
+ "escalated", // NON-terminal: the run is parked at the `feature-escalation` operator user task,
66
+ // waiting on a human answer (denormalised from the parked user task by pollFeatureEscalations)
54
67
  "opened", // a PR was raised and the run ends here (converge was not requested)
55
68
  "converging", // the opened PR was handed to the convergence loop (live state via pr_key → pull_requests)
56
69
  "awaiting_operator", // NON-terminal: the run is blocked and parked at the feature-blocked operator user task
@@ -70,7 +83,9 @@ export type FeatureRunStatus = typeof FEATURE_RUN_STATUSES[number];
70
83
  * `merged`/`converged`/`abandoned` — those are equally terminal, so redispatch gating is unaffected.
71
84
  * `awaiting_operator` is deliberately EXCLUDED (non-terminal): while a blocked run is parked at the
72
85
  * feature-blocked operator user task its instance is still alive, so a re-dispatch of the same issue
73
- * must short-circuit (no orphaned parallel instance) until the operator acknowledges it. */
86
+ * must short-circuit (no orphaned parallel instance) until the operator acknowledges it. `escalated`
87
+ * is EXCLUDED for the same reason: a run parked at the `feature-escalation` user task is still alive,
88
+ * so a re-dispatch must short-circuit until the human answers (or the SLA fires). */
74
89
  export const FEATURE_TERMINAL_STATUSES: readonly FeatureRunStatus[] = [
75
90
  "opened",
76
91
  "converging",
@@ -117,6 +132,65 @@ export function deriveFeatureDelivery(prStatus: string | null): FeatureDeliveryR
117
132
  }
118
133
  }
119
134
 
135
+ /** The `feature-escalation` user-task element id (feature.bpmn) — the native operator wait a run
136
+ * parks on when the agent escalates. `pollFeatureEscalations` reconciles it onto the read model. */
137
+ export const FEATURE_ESCALATION_ELEMENT = "feature-escalation";
138
+
139
+ /** The parked `feature-escalation` user task, as `pollFeatureEscalations` observes it via
140
+ * `searchUserTasks`: the completable user-task key the pages drive an attributed answer against.
141
+ *
142
+ * The agent's `question` is NOT read from here — the WASM testkit engine does not surface a user
143
+ * task's `zeebe:ioMapping`-mapped local variables through `searchUserTasks`, so relying on it would
144
+ * make the question untestable. Instead the `record-feature-escalation` service task (feature.bpmn)
145
+ * persists `question` onto the row at escalation entry — see `workers/record-feature-escalation`. */
146
+ export interface FeatureEscalationParked {
147
+ userTaskKey: string;
148
+ }
149
+
150
+ /** Pure source of truth for the escalation read-model reconcile (`pollFeatureEscalations`): given a
151
+ * run and whether it is currently parked at `feature-escalation`, return the minimal `feature_runs`
152
+ * patch reconciling the run's LIVENESS (status + completable-task pointer) with the observed park
153
+ * state (or null when nothing changed, so the poller skips the write). Idempotent, and — crucially —
154
+ * self-healing across the brief window between the `record-feature-escalation` service task and the
155
+ * user task actually appearing: a premature "not parked" reset to `running` is re-flipped to
156
+ * `escalated` on the next pass once the task is observed.
157
+ *
158
+ * - parked → flip `status` to `escalated` and denormalise the completable `userTaskKey` so the pages
159
+ * can drive an attributed answer. It never writes `escalation_question` while parked — that is the
160
+ * service task's to own (set) and the exit paths' to clear (record-feature / the answer operation),
161
+ * so the poller can never clobber the persisted question during that self-healing window.
162
+ * - un-parked → clear the completable-task pointer; a run still marked `escalated` has resumed
163
+ * (answered / looped back to implement-task), so it returns to `running`. A run already advanced
164
+ * past `escalated` by a downstream worker keeps that status — only the pointer is cleared. Once the
165
+ * pointer was actually OBSERVED (non-NULL) and the task is now gone, `escalation_question` is also
166
+ * cleared here, self-healing a question left populated when the task was completed out-of-band
167
+ * (bypassing the answer operation). This is gated on the observed pointer precisely so it cannot
168
+ * fire in the pre-observation self-healing window, where the pointer is still NULL. */
169
+ export function deriveFeatureEscalationPatch(
170
+ run: Pick<FeatureRun, "status" | "escalation_user_task_key">,
171
+ parked: FeatureEscalationParked | null,
172
+ ): Partial<FeatureRun> | null {
173
+ const patch: Partial<FeatureRun> = {};
174
+ if (parked) {
175
+ if (run.status !== "escalated") patch.status = "escalated";
176
+ if (run.escalation_user_task_key !== parked.userTaskKey) patch.escalation_user_task_key = parked.userTaskKey;
177
+ } else {
178
+ if (run.status === "escalated") patch.status = "running";
179
+ // Un-park cleanup — fires ONLY once the poller has actually OBSERVED the task (pointer non-NULL)
180
+ // and it is now gone. This self-heals a `question` left populated when the task was completed
181
+ // out-of-band (e.g. an external task UI, bypassing the answer operation that normally clears it),
182
+ // which would otherwise keep the UI showing an Escalation on a run that has resumed. Gating on
183
+ // the pointer being non-NULL is what makes it safe: during the brief self-healing window between
184
+ // `record-feature-escalation` (which persists the question but leaves the pointer NULL) and the
185
+ // task appearing, the pointer is NULL, so this never clobbers the freshly-persisted question.
186
+ if (run.escalation_user_task_key !== null) {
187
+ patch.escalation_user_task_key = null;
188
+ patch.escalation_question = null;
189
+ }
190
+ }
191
+ return Object.keys(patch).length > 0 ? patch : null;
192
+ }
193
+
120
194
  export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
121
195
 
122
196
  /** The deterministic task id for a single-issue run — the implementation agent branches
@@ -157,6 +231,8 @@ export async function startFeature(
157
231
  auto_merge: autoMerge ? 1 : 0,
158
232
  outcome: null,
159
233
  delivery_label: null,
234
+ escalation_question: null,
235
+ escalation_user_task_key: null,
160
236
  updated_at: ts,
161
237
  });
162
238
  } else {
@@ -173,6 +249,8 @@ export async function startFeature(
173
249
  auto_merge: autoMerge ? 1 : 0,
174
250
  outcome: null,
175
251
  delivery_label: null,
252
+ escalation_question: null,
253
+ escalation_user_task_key: null,
176
254
  created_at: ts,
177
255
  updated_at: ts,
178
256
  });
@@ -0,0 +1,180 @@
1
+ // Read-model derivation test for the FEATURE-run escalation reconcile (issue #210 — feature-run
2
+ // escalations were invisible in the nwf UI). When a feature run escalates it parks on the native
3
+ // `feature-escalation` user task; `feature_runs` (which the pages read) stayed `running` with
4
+ // nothing to show. Two collaborators fix that: the `record-feature-escalation` service task persists
5
+ // the `status`-flip's companion `question` at escalation entry (it can read the process variable
6
+ // while it is still in scope), and `deriveFeatureEscalationPatch` — the pure source of truth tested
7
+ // here — reconciles the run's LIVENESS (status + completable-task pointer) that `pollFeatureEscalations`
8
+ // projects onto the row. The poller never touches `escalation_question`, so it can never clobber the
9
+ // service task's write during the self-healing window before the user task is observable.
10
+ import { test } from "node:test";
11
+ import { assertEquals } from "#test-assert";
12
+ import type { DataLayer, EngineClient } from "@nanobpm/urban";
13
+ import { deriveFeatureEscalationPatch } from "./feature.ts";
14
+ import { pollFeatureEscalations } from "./service.ts";
15
+
16
+ // biome-ignore lint/suspicious/noExplicitAny: tiny in-memory table double, mirrors featureDelivery.test.ts
17
+ function memData(): { data: DataLayer; stores: Record<string, any[]> } {
18
+ // biome-ignore lint/suspicious/noExplicitAny: see above
19
+ const stores: Record<string, any[]> = {};
20
+ function tbl(name: string, pk = "id") {
21
+ // biome-ignore lint/suspicious/noExplicitAny: see above
22
+ const rows = (stores[name] ??= [] as any[]);
23
+ // biome-ignore lint/suspicious/noExplicitAny: see above
24
+ const match = (r: any, where: any) => Object.entries(where).every(([k, v]) => r[k] === v);
25
+ return {
26
+ async all() {
27
+ return rows.slice();
28
+ },
29
+ // biome-ignore lint/suspicious/noExplicitAny: see above
30
+ async get(id: any) {
31
+ return rows.find((r) => r[pk] === id);
32
+ },
33
+ // biome-ignore lint/suspicious/noExplicitAny: see above
34
+ async find(where: any = {}) {
35
+ return rows.filter((r) => match(r, where));
36
+ },
37
+ // biome-ignore lint/suspicious/noExplicitAny: see above
38
+ async insert(row: any) {
39
+ rows.push({ ...row });
40
+ return row[pk];
41
+ },
42
+ // biome-ignore lint/suspicious/noExplicitAny: see above
43
+ async update(id: any, patch: any) {
44
+ const r = rows.find((row) => row[pk] === id);
45
+ if (r) Object.assign(r, patch);
46
+ },
47
+ };
48
+ }
49
+ const data = { table: (n: string, pk?: string) => tbl(n, pk) } as unknown as DataLayer;
50
+ return { data, stores };
51
+ }
52
+
53
+ /** A fake engine whose open user tasks are keyed by processInstanceKey (the only field
54
+ * pollFeatureEscalations queries on). */
55
+ function fakeEngine(byInstance: Record<string, { userTaskKey: string; elementId?: string }[]>): EngineClient {
56
+ return {
57
+ searchUserTasks: (filter?: { processInstanceKey?: string }) =>
58
+ Promise.resolve(filter?.processInstanceKey ? (byInstance[filter.processInstanceKey] ?? []) : []),
59
+ } as unknown as EngineClient;
60
+ }
61
+
62
+ test("deriveFeatureEscalationPatch: a running run parked at feature-escalation flips to escalated + records the key", () => {
63
+ const patch = deriveFeatureEscalationPatch(
64
+ { status: "running", escalation_user_task_key: null },
65
+ { userTaskKey: "ut-9" },
66
+ );
67
+ assertEquals(patch, { status: "escalated", escalation_user_task_key: "ut-9" });
68
+ });
69
+
70
+ test("deriveFeatureEscalationPatch: an already-escalated run with the key recorded yields no patch (idempotent)", () => {
71
+ const patch = deriveFeatureEscalationPatch(
72
+ { status: "escalated", escalation_user_task_key: "ut-9" },
73
+ { userTaskKey: "ut-9" },
74
+ );
75
+ assertEquals(patch, null);
76
+ });
77
+
78
+ test("deriveFeatureEscalationPatch: an escalated run that un-parked resumes to running, pointer + question cleared", () => {
79
+ const patch = deriveFeatureEscalationPatch({ status: "escalated", escalation_user_task_key: "ut-9" }, null);
80
+ assertEquals(patch, { status: "running", escalation_user_task_key: null, escalation_question: null });
81
+ });
82
+
83
+ test("deriveFeatureEscalationPatch: a run past escalated with a stale pointer clears the pointer + question", () => {
84
+ const patch = deriveFeatureEscalationPatch({ status: "awaiting_operator", escalation_user_task_key: "ut-9" }, null);
85
+ assertEquals(patch, { escalation_user_task_key: null, escalation_question: null });
86
+ });
87
+
88
+ // Self-heal: a task completed out-of-band (external UI, bypassing the answer operation) leaves the
89
+ // run un-parked but with the pointer still recording the observed task. The poller clears BOTH the
90
+ // pointer and the now-stale question so the UI stops surfacing an Escalation on a resumed run.
91
+ test("deriveFeatureEscalationPatch: un-park after an out-of-band completion clears the stale question", () => {
92
+ const patch = deriveFeatureEscalationPatch({ status: "escalated", escalation_user_task_key: "ut-9" }, null);
93
+ assertEquals(patch?.escalation_question, null);
94
+ });
95
+
96
+ // The pre-observation self-healing window (record-feature-escalation has persisted the question but
97
+ // the task is not yet visible, so the pointer is still NULL): a premature "not parked" pass must NOT
98
+ // clobber the freshly-persisted question — only reset the transient status, re-flipped next pass.
99
+ test("deriveFeatureEscalationPatch: the pre-observation self-healing window never clears the question", () => {
100
+ const patch = deriveFeatureEscalationPatch({ status: "escalated", escalation_user_task_key: null }, null);
101
+ assertEquals(patch, { status: "running" });
102
+ });
103
+
104
+ test("deriveFeatureEscalationPatch: a clean running run not parked yields no patch", () => {
105
+ const patch = deriveFeatureEscalationPatch({ status: "running", escalation_user_task_key: null }, null);
106
+ assertEquals(patch, null);
107
+ });
108
+
109
+ test("pollFeatureEscalations: a parked run is flipped to escalated with the completable key (question left to the service task)", async () => {
110
+ const { data, stores } = memData();
111
+ stores.feature_runs = [
112
+ { feature_key: "o/r#1", status: "running", process_key: "100", escalation_question: null, escalation_user_task_key: null },
113
+ ];
114
+ const engine = fakeEngine({ "100": [{ userTaskKey: "ut-1", elementId: "feature-escalation" }] });
115
+
116
+ await pollFeatureEscalations(data, engine);
117
+
118
+ assertEquals(stores.feature_runs[0].status, "escalated");
119
+ assertEquals(stores.feature_runs[0].escalation_user_task_key, "ut-1");
120
+ // The poller does not synthesise the question — that is the record-feature-escalation service task's.
121
+ assertEquals(stores.feature_runs[0].escalation_question, null);
122
+ });
123
+
124
+ test("pollFeatureEscalations: an escalated run whose task is gone resumes to running, clears the pointer + stale question", async () => {
125
+ const { data, stores } = memData();
126
+ stores.feature_runs = [
127
+ { feature_key: "o/r#2", status: "escalated", process_key: "200", escalation_question: "Q", escalation_user_task_key: "ut-2" },
128
+ ];
129
+ const engine = fakeEngine({ "200": [] });
130
+
131
+ await pollFeatureEscalations(data, engine);
132
+
133
+ assertEquals(stores.feature_runs[0].status, "running");
134
+ assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
135
+ // The observed task is gone → self-heal the now-stale question so the UI stops surfacing it.
136
+ assertEquals(stores.feature_runs[0].escalation_question, null);
137
+ });
138
+
139
+ test("pollFeatureEscalations: a re-observed parked run keeps its persisted question, only filling the key", async () => {
140
+ const { data, stores } = memData();
141
+ stores.feature_runs = [
142
+ { feature_key: "o/r#3", status: "escalated", process_key: "300", escalation_question: "kept", escalation_user_task_key: null },
143
+ ];
144
+ const engine = fakeEngine({ "300": [{ userTaskKey: "ut-3", elementId: "feature-escalation" }] });
145
+
146
+ await pollFeatureEscalations(data, engine);
147
+
148
+ assertEquals(stores.feature_runs[0].status, "escalated");
149
+ assertEquals(stores.feature_runs[0].escalation_user_task_key, "ut-3");
150
+ assertEquals(stores.feature_runs[0].escalation_question, "kept");
151
+ });
152
+
153
+ test("pollFeatureEscalations: only touches running/escalated runs, and never one without a process_key", async () => {
154
+ const { data, stores } = memData();
155
+ stores.feature_runs = [
156
+ { feature_key: "o/r#4", status: "opened", process_key: "400", escalation_question: null, escalation_user_task_key: null },
157
+ { feature_key: "o/r#5", status: "running", process_key: null, escalation_question: null, escalation_user_task_key: null },
158
+ ];
159
+ const engine = fakeEngine({ "400": [{ userTaskKey: "ut-4", elementId: "feature-escalation" }] });
160
+
161
+ await pollFeatureEscalations(data, engine);
162
+
163
+ // opened is terminal → not a candidate; running with no process_key → skipped.
164
+ assertEquals(stores.feature_runs[0].status, "opened");
165
+ assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
166
+ assertEquals(stores.feature_runs[1].status, "running");
167
+ });
168
+
169
+ test("pollFeatureEscalations: a parked non-escalation task (feature-blocked) does not flip the run", async () => {
170
+ const { data, stores } = memData();
171
+ stores.feature_runs = [
172
+ { feature_key: "o/r#6", status: "running", process_key: "600", escalation_question: null, escalation_user_task_key: null },
173
+ ];
174
+ const engine = fakeEngine({ "600": [{ userTaskKey: "ut-6", elementId: "feature-blocked" }] });
175
+
176
+ await pollFeatureEscalations(data, engine);
177
+
178
+ assertEquals(stores.feature_runs[0].status, "running");
179
+ assertEquals(stores.feature_runs[0].escalation_user_task_key, null);
180
+ });
package/app/service.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  // `Table<T>` surface), not hand-written SQL. Row shapes are declared inline here.
10
10
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
11
11
  import { abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
12
- import { deriveFeatureDelivery, featureRuns } from "./feature.ts";
12
+ import { deriveFeatureDelivery, deriveFeatureEscalationPatch, FEATURE_ESCALATION_ELEMENT, type FeatureRun, featureRuns } from "./feature.ts";
13
13
  import {
14
14
  classifyMergeability,
15
15
  ensureFreshHeadRun,
@@ -1283,6 +1283,49 @@ export async function pollFeatureDelivery(data: DataLayer) {
1283
1283
  }
1284
1284
  }
1285
1285
 
1286
+ /** Reconcile each in-flight FEATURE run against its native `feature-escalation` user task (issue
1287
+ * #210 — feature-run escalations were invisible in the nwf UI). When a feature run escalates it parks
1288
+ * on the `feature-escalation` operator user task (an engine wait); no worker runs, so `feature_runs`
1289
+ * — which the schema-driven pages read — stayed `running` with nothing to show. This is the
1290
+ * `feature_runs` twin of `pollFeatureDelivery`: for each run that can be parked at (or resuming from)
1291
+ * the escalation, read its open user tasks and project the parked task onto the row via the pure
1292
+ * `deriveFeatureEscalationPatch` — flipping `status` to `escalated` and denormalising the escalation's
1293
+ * completable `userTaskKey` so the pages can drive an answer, and flipping back to `running` (clearing
1294
+ * the pointer) once it un-parks. It never writes `escalation_question` — that is persisted by the
1295
+ * `record-feature-escalation` worker at escalation entry and cleared on the exit paths, so the poller
1296
+ * can never clobber the source of truth for the question.
1297
+ *
1298
+ * Candidates are only the runs that could be parked here — `running` (may have just escalated) and
1299
+ * `escalated` (may have just resumed) — queried via the `feature_runs(status)` index, so the pass
1300
+ * stays O(in-flight), not O(total runs). Terminal-ward transitions THROUGH `record-feature` (answer
1301
+ * → abandon, SLA auto-abandon, done) clear the pointer in that worker, so a run that has already left
1302
+ * `escalated` never needs sweeping here. Best-effort + idempotent — per-run failures are isolated. */
1303
+ export async function pollFeatureEscalations(data: DataLayer, engine: EngineClient) {
1304
+ const seen = new Set<string>();
1305
+ const candidates: FeatureRun[] = [];
1306
+ for (const status of ["running", "escalated"] as const) {
1307
+ for (const run of await featureRuns(data).find({ status })) {
1308
+ if (seen.has(run.feature_key)) continue;
1309
+ seen.add(run.feature_key);
1310
+ candidates.push(run);
1311
+ }
1312
+ }
1313
+ for (const run of candidates) {
1314
+ if (!run.process_key) continue;
1315
+ try {
1316
+ const tasks = await engine.searchUserTasks({ processInstanceKey: run.process_key });
1317
+ const task = tasks.find((t) => t.elementId === FEATURE_ESCALATION_ELEMENT);
1318
+ const parked = task ? { userTaskKey: task.userTaskKey } : null;
1319
+ const patch = deriveFeatureEscalationPatch(run, parked);
1320
+ if (patch) {
1321
+ await featureRuns(data).update(run.feature_key, { ...patch, updated_at: now() });
1322
+ }
1323
+ } catch (err) {
1324
+ console.error(`[poller] feature escalation ${run.feature_key}: ${err}`);
1325
+ }
1326
+ }
1327
+ }
1328
+
1286
1329
  /** One full poll pass: advance the review stage, the merge stage, the wave-merge barrier, and
1287
1330
  * (when the engine REST endpoint is supplied) the job-activation visibility pass and the
1288
1331
  * technical-incident surfacing pass. Called on the self-scheduling loop in `main.ts`. */
@@ -1297,6 +1340,7 @@ export async function pollOnce(
1297
1340
  await pollWaveGates(data, engine, token);
1298
1341
  await pollDelivery(data);
1299
1342
  await pollFeatureDelivery(data);
1343
+ await pollFeatureEscalations(data, engine);
1300
1344
  if (engineRest) {
1301
1345
  await pollJobActivation(data, engineRest.restAddress, engineRest.token);
1302
1346
  await pollIncidents(data, engineRest.restAddress, engineRest.token);
@@ -0,0 +1,28 @@
1
+ -- Surface native feature-run escalations in the nwf UI (issue #210).
2
+ --
3
+ -- When a feature run (`feature.bpmn`) escalates to a human it parks on the native
4
+ -- `feature-escalation` user task (`candidateGroups=operators`). That wait was
5
+ -- invisible in the nwf UI: the schema-driven pages read `feature_runs`, but the
6
+ -- open user task (the actual wait) is engine state, not a row here, so a run could
7
+ -- sit blocked on a human decision indefinitely with no visible signal, and
8
+ -- `feature_runs.status` stayed `running`.
9
+ --
10
+ -- The `record-feature-escalation` service task (feature.bpmn) runs on the
11
+ -- `escalated` arm, before the user task is created, and persists the escalation
12
+ -- onto the row: it flips `status` to `escalated` and denormalises the agent's
13
+ -- `question` here (it reads the process variable while it is still in scope — the
14
+ -- poller can't, as the WASM engine does not surface task-local user-task variables
15
+ -- via `searchUserTasks`). The poller (`pollFeatureEscalations` in app/service.ts)
16
+ -- then fills the parked `userTaskKey` in once the user task is observable, resets
17
+ -- `status` back to `running` when the run un-parks, and `record-feature` / the
18
+ -- answer operation clear both columns again on exit. Both columns are NULL
19
+ -- whenever the run is not parked at `feature-escalation`.
20
+ --
21
+ -- `escalation_user_task_key` is the completable native user-task key the answer
22
+ -- affordance posts to (`completeUserTaskAttributed`); it also gates the answer
23
+ -- controls in the pages (`showWhenField`, which is JS-truthy, so NULL correctly
24
+ -- hides them). Forward-only, additive (expand): both columns are nullable with no
25
+ -- default. Numbered after the current highest prefix (030); the runner wraps each
26
+ -- file in its own transaction, so this file must NOT contain BEGIN/COMMIT.
27
+ ALTER TABLE feature_runs ADD COLUMN escalation_question TEXT;
28
+ ALTER TABLE feature_runs ADD COLUMN escalation_user_task_key TEXT;
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
22
22
  import type { EngineJob } from "@nanobpm/urban/runtime";
23
23
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
24
24
  import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
25
+ import { pollFeatureEscalations } from "../app/service.ts";
25
26
 
26
27
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
28
 
@@ -56,6 +57,8 @@ interface FeatureRow {
56
57
  status: string;
57
58
  pr_key: string | null;
58
59
  delivery_label: string | null;
60
+ escalation_question: string | null;
61
+ escalation_user_task_key: string | null;
59
62
  }
60
63
  interface PrRow {
61
64
  pr_key: string;
@@ -253,4 +256,59 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
253
256
  },
254
257
  );
255
258
  });
259
+
260
+ test("escalate → the poller surfaces it on the read model, and the operator answer resolves it (issue #210)", async () => {
261
+ let calls = 0;
262
+ await withApp(
263
+ {
264
+ "senior:feature": () => {
265
+ calls += 1;
266
+ return calls === 1
267
+ ? { status: "escalated", question: "Which API should I use?", summary: "parked for a human" }
268
+ : { status: "opened", pr: "owner/repo#210", summary: "resumed and opened" };
269
+ },
270
+ },
271
+ { baseBranch: "epic/e2e" },
272
+ async ({ app, featureKey }) => {
273
+ // The `record-feature-escalation` service task runs on the escalated arm (before the user task),
274
+ // so the row already carries the flipped status + the agent's question when the run parks — the
275
+ // read model the pages read is no longer blind to the native user-task wait (the #210 bug).
276
+ const parked = await featureRow(app, featureKey);
277
+ assert.equal(parked.status, "escalated", "the escalated status is surfaced on the read model");
278
+ assert.equal(parked.escalation_question, "Which API should I use?", "the agent's question is surfaced");
279
+
280
+ // The poller fills in the completable user-task key (which the service task can't know — the
281
+ // task doesn't exist yet when it runs) so the UI can drive an attributed answer.
282
+ await pollFeatureEscalations(app.db, app.engine);
283
+ const escalated = await featureRow(app, featureKey);
284
+ assert.ok(escalated.escalation_user_task_key, "the poller denormalised the completable user-task key");
285
+ assert.equal(escalated.status, "escalated", "the run stays escalated while parked");
286
+
287
+ // Answer through the app's OWN operation (the nwf UI's answer affordance) — the attributed
288
+ // completer resumes the SAME implement task a human would from the task inbox.
289
+ const answered = await app.api?.call("answerFeatureEscalation", {
290
+ body: { userTaskKey: escalated.escalation_user_task_key, resolution: "answer", answer: "use v2" },
291
+ });
292
+ assert.equal(answered?.status, 200, "the operator answer completed the escalation task");
293
+ await app.settle();
294
+
295
+ const flows = takenFlows(app);
296
+ assert.ok(
297
+ flows.includes("w_gw_answer->implement-task"),
298
+ `the answer re-dispatched the same implement task (flows: ${flows.join(", ")})`,
299
+ );
300
+ assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
301
+
302
+ // The run opened its PR; the escalation pointer + question were cleared once resolved.
303
+ const settled = await featureRow(app, featureKey);
304
+ assert.equal(settled.status, "opened", "the resumed run opened its PR");
305
+ assert.equal(settled.escalation_user_task_key, null, "the escalation pointer was cleared once resolved");
306
+ assert.equal(settled.escalation_question, null, "the surfaced question was cleared once resolved");
307
+
308
+ // A further poll pass is an idempotent no-op — a terminal run is not a candidate.
309
+ await pollFeatureEscalations(app.db, app.engine);
310
+ assert.equal((await featureRow(app, featureKey)).status, "opened");
311
+ },
312
+ );
313
+ });
256
314
  });