@nanobpm/nano-workforce 0.171.1 → 0.171.3

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.
@@ -0,0 +1,102 @@
1
+ // Drift guard for the Tasks-inbox closed sets (issue #674 — fix the class, not the bug).
2
+ //
3
+ // Root cause of #674: the readiness/preflight escalation user tasks (`readiness-escalation-pf`,
4
+ // `readiness-escalation`) were deployed BPMN `<bpmn:userTask>`s with a linked `.form`, but their
5
+ // element ids were never added to the app-tier closed sets that gate surfacing (`USER_TASK_KIND_LABELS`,
6
+ // app/userTasks.ts) and completion (`HUMAN_COMPLETABLE_ELEMENTS` / `ESCALATION_FORM_BY_ELEMENT`,
7
+ // app/agentCompletion.ts). The poller's leak guard (`userTaskKindLabel(id) === undefined`) silently
8
+ // DROPPED every such task, so a parked run went invisible in the Tasks surface AND uncompletable
9
+ // through the one canonical `complete-user-task` door.
10
+ //
11
+ // This test makes that drift structurally impossible: it enumerates EVERY human `<bpmn:userTask>`
12
+ // (bearing a `<zeebe:userTask />`) in the deployed processes (`resources/processes/*.bpmn`) and asserts
13
+ // each element id is a KNOWN Tasks-inbox kind, and — where it declares a static `zeebe:formDefinition`
14
+ // form and is completable — that the completer's form contract resolves to the SAME `.form` the BPMN
15
+ // declares. So a future BPMN user task can never again vanish from the inbox by being absent from the
16
+ // hand-maintained closed set: the closed set's completeness is DERIVED from the deployed BPMN, not
17
+ // asserted blind.
18
+ //
19
+ // On `main` (before #674's registration) this test FAILS on `readiness-escalation-pf` /
20
+ // `readiness-escalation`; it passes once both are registered.
21
+ import { readdirSync, readFileSync } from "node:fs";
22
+ import { test } from "node:test";
23
+ import { assert, assertEquals } from "#test-assert";
24
+ import { escalationFormId, HUMAN_COMPLETABLE_ELEMENTS } from "./agentCompletion.ts";
25
+ import { isDeliveryHumanElement } from "./deliveryHuman.ts";
26
+ import { userTaskKindLabel } from "./userTasks.ts";
27
+
28
+ const PROCESS_DIR = "resources/processes";
29
+
30
+ /** Demo/fixture processes that are deployed for the engine-spine e2e (`e2e/user-task-spine.e2e.ts`)
31
+ * but are NOT part of the workforce escalation surface — their user tasks are deliberately not
32
+ * Tasks-inbox kinds. Kept as a tiny, explicitly-documented file allowlist (not a per-id one) so a new
33
+ * REAL escalation process is still fully guarded. */
34
+ const DEMO_PROCESS_FILES: ReadonlySet<string> = new Set(["spine-demo.bpmn"]);
35
+
36
+ interface DeployedUserTask {
37
+ file: string;
38
+ elementId: string;
39
+ /** The `zeebe:formDefinition formId`, when the task declares a static form. */
40
+ formId: string | null;
41
+ }
42
+
43
+ /** Parse every human `<bpmn:userTask>` (one bearing a `<zeebe:userTask />`, i.e. a native user task an
44
+ * operator answers — not a job-worker task) out of the deployed process BPMN. Text parsing, matching
45
+ * the repo's lightweight model-guard style (convergenceEscalationGuard.test.ts et al.). */
46
+ function deployedHumanUserTasks(): DeployedUserTask[] {
47
+ const out: DeployedUserTask[] = [];
48
+ for (const file of readdirSync(PROCESS_DIR).filter((f) => f.endsWith(".bpmn"))) {
49
+ if (DEMO_PROCESS_FILES.has(file)) continue;
50
+ const xml = readFileSync(`${PROCESS_DIR}/${file}`, "utf8");
51
+ for (const m of xml.matchAll(/<bpmn:userTask\b[^>]*\bid="([^"]+)"([\s\S]*?)<\/bpmn:userTask>/g)) {
52
+ const [, elementId, body] = m;
53
+ if (!/<zeebe:userTask\b/.test(body)) continue; // not a native human user task (no <zeebe:userTask/>)
54
+ const form = body.match(/<zeebe:formDefinition\b[^>]*\bformId="([^"]+)"/);
55
+ out.push({ file, elementId, formId: form ? form[1] : null });
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ test("drift guard: every deployed human user task is a known Tasks-inbox kind (issue #674)", () => {
62
+ const tasks = deployedHumanUserTasks();
63
+ // Sanity: the sweep actually found the deployed escalations (guards against a parser that silently
64
+ // matches nothing and vacuously passes).
65
+ assert(tasks.length > 0, `expected the process sweep to find the deployed user tasks, got ${tasks.length}`);
66
+ assert(
67
+ tasks.some((t) => t.elementId === "readiness-escalation-pf"),
68
+ "expected readiness-escalation-pf among the deployed user tasks",
69
+ );
70
+ assert(
71
+ tasks.some((t) => t.elementId === "readiness-escalation"),
72
+ "expected readiness-escalation among the deployed user tasks",
73
+ );
74
+
75
+ const unsurfaced = tasks.filter((t) => userTaskKindLabel(t.elementId) === undefined);
76
+ assertEquals(
77
+ unsurfaced.map((t) => `${t.file}:${t.elementId}`),
78
+ [],
79
+ "deployed user task(s) are not registered in USER_TASK_KIND_LABELS — pollUserTasks' leak guard would drop them from the Tasks inbox (issue #674)",
80
+ );
81
+ });
82
+
83
+ test("drift guard: every deployed fixed-form user task is human-completable through the canonical door (issue #674)", () => {
84
+ // The delivery-graph `human` node renders DIFFERENT forms per node (variable form, resolved at
85
+ // activation), so it is intentionally absent from the static `ESCALATION_FORM_BY_ELEMENT` contract —
86
+ // exclude it from the fixed-form assertion (it is still asserted to be a KNOWN kind above).
87
+ const fixedForm = deployedHumanUserTasks().filter((t) => t.formId && !isDeliveryHumanElement(t.elementId));
88
+
89
+ const notCompletable = fixedForm.filter((t) => !HUMAN_COMPLETABLE_ELEMENTS.has(t.elementId));
90
+ assertEquals(
91
+ notCompletable.map((t) => `${t.file}:${t.elementId}`),
92
+ [],
93
+ "deployed fixed-form user task(s) are not in HUMAN_COMPLETABLE_ELEMENTS — the canonical complete-user-task door would reject them",
94
+ );
95
+
96
+ const formMismatch = fixedForm.filter((t) => escalationFormId(t.elementId) !== t.formId);
97
+ assertEquals(
98
+ formMismatch.map((t) => `${t.file}:${t.elementId} bpmn=${t.formId} app=${escalationFormId(t.elementId) ?? "undefined"}`),
99
+ [],
100
+ "deployed user task(s) map to a different .form contract in ESCALATION_FORM_BY_ELEMENT than the BPMN declares",
101
+ );
102
+ });
package/app/userTasks.ts CHANGED
@@ -72,6 +72,24 @@ export const ACP_PERMISSION_ELEMENT = "acp-permission";
72
72
  * escalation is filtered out by the unknown-kind guard and silently vanishes from the Tasks inbox. */
73
73
  export const HUMAN_ESCALATION_ELEMENT = "escalation";
74
74
 
75
+ /** The readiness/preflight escalation user task parked when a LEADING readiness/capability PREFLIGHT
76
+ * times out before its `ReadinessProbe` went green — a human decision (acknowledge/proceed vs abandon).
77
+ * This is the `pf_*` embedded-subprocess variant that appears both in `feature.bpmn` (the feature-run
78
+ * readiness preflight) and `plan-fanout.bpmn` (the producer-capability preflight). It parks on the
79
+ * run's OWN engine instance (an embedded subprocess, not a callActivity child), renders the deployed
80
+ * `readiness-escalation` `.form`, and is HUMAN-only: an agent must NOT auto-answer a readiness gate
81
+ * (that would silently defeat the very "is upstream actually ready?" decision the gate exists to make),
82
+ * so it lives in `HUMAN_COMPLETABLE_ELEMENTS`, never in `ESCALATION_TASK_ELEMENTS`. Without this the
83
+ * task's kind is `undefined`, the poller's leak guard drops it, and the run wedges invisibly + unanswerably
84
+ * (issue #674). */
85
+ export const READINESS_ESCALATION_PF_ELEMENT = "readiness-escalation-pf";
86
+
87
+ /** The readiness/wait-gate escalation user task (`readiness-gate.bpmn`, `wait-gate.bpmn`) — the same
88
+ * human readiness decision on the standalone inter-epic wait-gate cell rather than the inline preflight.
89
+ * Same `readiness-escalation` `.form`, same HUMAN-only policy as `READINESS_ESCALATION_PF_ELEMENT`
90
+ * (issue #674). */
91
+ export const READINESS_ESCALATION_ELEMENT = "readiness-escalation";
92
+
75
93
  /** One row per currently-open native user-task escalation, denormalised for the Tasks page. Keyed on
76
94
  * the completable `user_task_key` (a task is open at most once). Present iff the engine reports the
77
95
  * task open; `pollUserTasks` deletes it once the task is gone. */
@@ -186,6 +204,8 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
186
204
  [PR_WAIT_ANSWER_ELEMENT]: "PR review",
187
205
  [PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
188
206
  [CONFORMANCE_ESCALATION_ELEMENT]: "Conformance review",
207
+ [READINESS_ESCALATION_PF_ELEMENT]: "Upstream readiness stalled",
208
+ [READINESS_ESCALATION_ELEMENT]: "Readiness escalation",
189
209
  [DELIVERY_HUMAN_ELEMENT]: "Delivery: human step",
190
210
  [ACP_PERMISSION_ELEMENT]: "Agent permission",
191
211
  };
@@ -342,8 +362,21 @@ export function latestTrialMergeQuestion(audits: readonly TrialMergeAuditRow[]):
342
362
  return latest?.summary ?? null;
343
363
  }
344
364
 
345
- /** One PR review-loop escalation (001_init.sql `escalations`). `status` is open | answered; the poller
346
- * reads the OPEN row's `question`. */
365
+ /** Pure: the "what is the run waiting on" question line for a readiness/preflight escalation
366
+ * (`readiness-escalation-pf` / `readiness-escalation`, issue #674) — the unresolved probe/capability
367
+ * the leading readiness gate stalled on. The wait-gate projection already renders that clause on the
368
+ * subject row: a plan gated behind a producer capability carries it on `plans.wait_gate_label`
369
+ * ("waiting on <clause> · re-checks …", app/waitGate.ts), and a feature preflight carries its rollup
370
+ * on `feature_runs.delivery_label`. Prefer whichever the subject supplies; fall back to a static
371
+ * readiness-stalled line so the Tasks grid never renders a blank question for a parked gate.
372
+ * Always returns a non-empty string — the static fallback guarantees a question is never blank. */
373
+ export function readinessEscalationQuestion(label?: string | null): string {
374
+ const t = typeof label === "string" ? label.trim() : "";
375
+ return (
376
+ t ||
377
+ "The readiness preflight timed out before its ReadinessProbe went green. Proceed against the (now-published) upstream, or abandon the gate."
378
+ );
379
+ }
347
380
  export interface PrEscalationRow {
348
381
  id: number;
349
382
  pr_key: string;
@@ -1,13 +1,14 @@
1
1
  // End-to-end proof for agent-answerable escalations (epic #156, slice U6; ADR 0046). Boots the whole
2
2
  // app against the WASM engine and drives the REAL plan-fanout.bpmn to the implementation-phase task
3
- // escalation — the native `feature-escalation` userTask + form the human path (U2) completes — then
3
+ // escalation — the native `escalation` userTask (formId `feature-escalation`) on the `human-escalation`
4
+ // grandchild the shared implement-cell spawns, which the human path (U2) completes — then
4
5
  // completes it through the HOST-SIDE AGENT COMPLETER (`completeEscalationAsAgent`) instead of a raw
5
6
  // human completion. It proves the three things U6 promises:
6
7
  //
7
8
  // 1. an AGENT assignee completing the SAME `.form` resumes the process with typed vars IDENTICAL to
8
9
  // a human completion — asserted on the cumulative taken sequence flows: `{resolution:"answer"}`
9
- // routes `w_gw_answer -> implement-task`, exactly as the U2 human test asserts (an empty/wrong
10
- // completion would take the abandon default);
10
+ // routes `ic_gw_answer -> record-implementing -> implement-task`, exactly as the U2 human test
11
+ // asserts (an empty/wrong completion would take the abandon default);
11
12
  // 2. attribution is recorded — the `task_completions` ledger row is actor_kind=agent + the agent id
12
13
  // + the submitted variables;
13
14
  // 3. the completion is reversible — a human reverts it, and the ledger records who + when.
@@ -111,7 +112,9 @@ describe("agent-answerable escalations (U6 — same form, agent completer, attri
111
112
  }
112
113
 
113
114
  async function openTask(app: TestApp, processKey: string, elementId: string): Promise<InboxTask> {
114
- const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
115
+ // Root-scoped: after the ADR 0006 S4 composition the escalation user task parks on a
116
+ // `human-escalation` grandchild instance the shared `implement-cell` spawns, not on the parent.
117
+ const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
115
118
  const match = tasks.find((t) => t.elementId === elementId);
116
119
  assert.ok(match, `expected an open ${elementId} user task (open: ${tasks.map((t) => t.elementId).join(", ")})`);
117
120
  return match!;
@@ -134,8 +137,8 @@ describe("agent-answerable escalations (U6 — same form, agent completer, attri
134
137
  },
135
138
  },
136
139
  async ({ app, processKey }) => {
137
- const task = await openTask(app, processKey, "feature-escalation");
138
- assert.ok(task.userTaskKey, "the feature escalation carries a completable userTaskKey");
140
+ const task = await openTask(app, processKey, "escalation");
141
+ assert.ok(task.userTaskKey, "the human-escalation cell task carries a completable userTaskKey");
139
142
 
140
143
  // Complete AS AN AGENT through the host-side completer — the same typed `{resolution, answer}`
141
144
  // a human submits through the inbox, only the caller differs.
@@ -145,18 +148,18 @@ describe("agent-answerable escalations (U6 — same form, agent completer, attri
145
148
  variables: { resolution: "answer", answer: "use v2" },
146
149
  });
147
150
  assert.equal(r.ok, true, "the agent completer accepted the escalation completion");
148
- assert.equal(r.elementId, "feature-escalation");
151
+ assert.equal(r.elementId, "escalation");
149
152
  await app.settle();
150
153
 
151
154
  // IDENTICAL resume to the human path (mirrors U2's human test): the typed resolution loops
152
- // the child back to re-dispatch the SAME task — NOT the abandon default.
155
+ // the cell back to re-dispatch the SAME task through its implementing-reset — NOT the abandon default.
153
156
  const flows = takenFlows(app);
154
157
  assert.ok(
155
- flows.includes("w_gw_answer->implement-task"),
156
- `agent answer routed back to implement-task (flows: ${flows.join(", ")})`,
158
+ flows.includes("ic_gw_answer->record-implementing") && flows.includes("record-implementing->implement-task"),
159
+ `agent answer routed back to implement-task through the cell reset (flows: ${flows.join(", ")})`,
157
160
  );
158
161
  assert.ok(
159
- !flows.includes("w_gw_answer->w_end"),
162
+ !flows.includes("ic_gw_answer->ic_end"),
160
163
  "the abandon (default) flow was NOT taken",
161
164
  );
162
165
 
@@ -168,7 +171,7 @@ describe("agent-answerable escalations (U6 — same form, agent completer, attri
168
171
  const row = completions[0];
169
172
  assert.equal(row.actor_kind, "agent");
170
173
  assert.equal(row.actor_id, "senior:answer-bot");
171
- assert.equal(row.element_id, "feature-escalation");
174
+ assert.equal(row.element_id, "escalation");
172
175
  assert.deepEqual(JSON.parse(row.variables_json), { resolution: "answer", answer: "use v2" });
173
176
  assert.equal(row.reversible, 1, "an agent completion is reversible");
174
177
  assert.equal(row.reverted, 0);
@@ -158,8 +158,9 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
158
158
  );
159
159
  assert.ok(
160
160
  flows.includes("ensure-base-branch->record-feature-implementing") &&
161
- flows.includes("record-feature-implementing->implement-task"),
162
- `the run reaches the implement agent only after the gate (flows: ${flows.join(", ")})`,
161
+ flows.includes("record-feature-implementing->implement") &&
162
+ flows.includes("Start->implement-task"),
163
+ `the run reaches the implement agent (via the implement cell) only after the gate (flows: ${flows.join(", ")})`,
163
164
  );
164
165
  // A green probe never escalates.
165
166
  const tasks = await app.engine.searchUserTasks({ processInstanceKey });
@@ -190,8 +191,9 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
190
191
  );
191
192
  assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "an ungated feature never enters the preflight");
192
193
  assert.ok(
193
- flows.includes("record-feature-implementing->implement-task"),
194
- "an ungated feature reaches the implement agent",
194
+ flows.includes("record-feature-implementing->implement") &&
195
+ flows.includes("Start->implement-task"),
196
+ "an ungated feature reaches the implement agent (via the implement cell)",
195
197
  );
196
198
  } finally {
197
199
  await app.stop();
@@ -3,8 +3,9 @@
3
3
  // • raise-only — the agent opens a PR, `converge` is off → the run ends at `opened`, no hand-off;
4
4
  // • raise + converge — with `converge` on, the opened PR is enrolled into the convergence loop
5
5
  // (a `pull_requests` row appears) and the feature_run lands `converging`;
6
- // • escalate + resume — the agent escalates, a native `feature-escalation` user task parks the
7
- // run, and answering re-dispatches the SAME implement task (mirrors the epic slice).
6
+ // • escalate + resume — the agent escalates, the shared implement-cell spawns a `human-escalation`
7
+ // grandchild whose native `escalation` user task parks the run, and answering re-dispatches the
8
+ // SAME implement task inside the cell (mirrors the epic slice).
8
9
  //
9
10
  // The gateway assertions are the falsifiable core: the WASM engine folds a completed instance's
10
11
  // variables away, so we assert on the cumulative taken sequence flows (an empty/wrong result takes a
@@ -236,23 +237,26 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
236
237
  },
237
238
  { baseBranch: "epic/e2e" },
238
239
  async ({ app, processKey }) => {
239
- const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
240
- const task = tasks.find((t) => t.elementId === "feature-escalation") as InboxTask | undefined;
241
- assert.ok(task?.userTaskKey, "the feature escalation parked a completable native user task");
240
+ // The escalation user task now lives on a `human-escalation` grandchild instance the shared
241
+ // `implement-cell` spawns (ADR 0006 S4), so it is found root-scoped, by the cell's `escalation`
242
+ // element — not `feature-escalation` on the parent (that inline task no longer exists).
243
+ const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
244
+ const task = tasks.find((t) => t.elementId === "escalation") as InboxTask | undefined;
245
+ assert.ok(task?.userTaskKey, "the human-escalation cell parked a completable native user task on a child instance");
242
246
 
243
247
  await app.engine.completeUserTask(task!.userTaskKey, { resolution: "answer", answer: "use v2" });
244
248
  await app.settle();
245
249
 
246
250
  const flows = takenFlows(app);
247
251
  assert.ok(
248
- flows.includes("w_gw_answer->record-feature-implementing"),
249
- `answer re-dispatched through the implementing-reset task (flows: ${flows.join(", ")})`,
252
+ flows.includes("ic_gw_answer->record-implementing"),
253
+ `answer re-dispatched through the cell's implementing-reset task (flows: ${flows.join(", ")})`,
250
254
  );
251
255
  assert.ok(
252
- flows.includes("record-feature-implementing->implement-task"),
253
- `the reset task re-enters the same implement task (flows: ${flows.join(", ")})`,
256
+ flows.includes("record-implementing->implement-task"),
257
+ `the reset task re-enters the same implement task inside the cell (flows: ${flows.join(", ")})`,
254
258
  );
255
- assert.ok(!flows.includes("w_gw_answer->record-feature"), "the abandon (default) flow was NOT taken");
259
+ assert.ok(!flows.includes("ic_gw_answer->ic_end"), "the abandon (default) flow was NOT taken");
256
260
  assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
257
261
  },
258
262
  );
@@ -289,9 +293,9 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
289
293
  assert.equal(parked.status, "escalated", "the run parks at escalated while awaiting the answer");
290
294
  assert.ok(parked.process_key, "the parked run carries its engine process-instance key");
291
295
 
292
- const tasks = await app.engine.searchUserTasks({ processInstanceKey: parked.process_key! });
293
- const task = tasks.find((t) => t.elementId === "feature-escalation") as InboxTask | undefined;
294
- assert.ok(task?.userTaskKey, "the feature escalation parked a completable native user task");
296
+ const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: parked.process_key! });
297
+ const task = tasks.find((t) => t.elementId === "escalation") as InboxTask | undefined;
298
+ assert.ok(task?.userTaskKey, "the human-escalation cell parked a completable native user task on a child instance");
295
299
 
296
300
  await app.engine.completeUserTask(task!.userTaskKey, { resolution: "answer", answer: "use v2" });
297
301
  await app.settle();
@@ -315,19 +319,24 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
315
319
  },
316
320
  { baseBranch: "epic/e2e" },
317
321
  async ({ app, processKey }) => {
318
- const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
319
- const task = tasks.find((t) => t.elementId === "feature-escalation") as InboxTask | undefined;
320
- assert.ok(task?.userTaskKey, "the feature escalation parked a completable native user task");
322
+ const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
323
+ const task = tasks.find((t) => t.elementId === "escalation") as InboxTask | undefined;
324
+ assert.ok(task?.userTaskKey, "the human-escalation cell parked a completable native user task on a child instance");
321
325
 
322
326
  await app.engine.completeUserTask(task!.userTaskKey, { resolution: "abandon" });
323
327
  await app.settle();
324
328
 
325
329
  const flows = takenFlows(app);
326
330
  assert.ok(
327
- flows.includes("w_gw_answer->record-feature"),
328
- `abandon routed to record-feature (flows: ${flows.join(", ")})`,
331
+ flows.includes("ic_gw_answer->ic_end"),
332
+ `abandon took the cell's default abandon flow to the task-done end (flows: ${flows.join(", ")})`,
333
+ );
334
+ assert.ok(!flows.includes("ic_gw_answer->record-implementing"), "the answer loop was NOT taken");
335
+ // The abandoned cell completes and the parent routes to its terminal recorder exactly once.
336
+ assert.ok(
337
+ flows.includes("implement->record-feature"),
338
+ `the completed cell routes the parent to record-feature (flows: ${flows.join(", ")})`,
329
339
  );
330
- assert.ok(!flows.includes("w_gw_answer->record-feature-implementing"), "the answer loop was NOT taken");
331
340
  },
332
341
  );
333
342
  });
@@ -345,16 +354,19 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
345
354
  },
346
355
  { baseBranch: "epic/e2e" },
347
356
  async ({ app, featureKey, processKey }) => {
348
- // The `record-feature-escalation` service task runs on the escalated arm (before the user task),
357
+ // The cell's `record-escalation` service task runs on the escalated arm (before the human task),
349
358
  // so the row already carries the flipped status when the run parks, and the agent's question is
350
359
  // recorded in the `feature_escalations` audit log the poller reads (issue #332 dropped the
351
360
  // denormalised `feature_runs.escalation_question` column).
352
361
  const parked = await featureRow(app, featureKey);
353
362
  assert.equal(parked.status, "escalated", "the escalated status is surfaced on the read model");
354
363
 
355
- const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
356
- const task = tasks.find((t) => t.elementId === "feature-escalation") as InboxTask | undefined;
357
- assert.ok(task?.userTaskKey, "the feature escalation parked a completable native user task");
364
+ // The escalation parks on a `human-escalation` grandchild instance (ADR 0006 S4), so it is found
365
+ // root-scoped, by the cell's `escalation` element — the reduced-path poller correlates that
366
+ // grandchild task back to the feature subject via its rootProcessInstanceKey (real child execution).
367
+ const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
368
+ const task = tasks.find((t) => t.elementId === "escalation") as InboxTask | undefined;
369
+ assert.ok(task?.userTaskKey, "the human-escalation cell parked a completable native user task on a child instance");
358
370
 
359
371
  // The poller projects the parked task onto the Tasks inbox `user_tasks` read-model by reading
360
372
  // the engine directly, sourcing the question from the `feature_escalations` audit log.
@@ -376,9 +388,9 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
376
388
 
377
389
  const flows = takenFlows(app);
378
390
  assert.ok(
379
- flows.includes("w_gw_answer->record-feature-implementing") &&
380
- flows.includes("record-feature-implementing->implement-task"),
381
- `the answer re-dispatched the same implement task through the reset (flows: ${flows.join(", ")})`,
391
+ flows.includes("ic_gw_answer->record-implementing") &&
392
+ flows.includes("record-implementing->implement-task"),
393
+ `the answer re-dispatched the same implement task through the cell's reset (flows: ${flows.join(", ")})`,
382
394
  );
383
395
  assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
384
396
 
@@ -108,7 +108,10 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
108
108
  }
109
109
 
110
110
  async function openTask(app: TestApp, processKey: string, elementId: string): Promise<InboxTask> {
111
- const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
111
+ // Root-scoped: the implement-stage escalation now parks on a `human-escalation` grandchild the
112
+ // shared `implement-cell` spawns (ADR 0006 S4); plan-level tasks (plan-review, trial-merge) still
113
+ // park on the root instance and remain discoverable under a root-scoped search (superset).
114
+ const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
112
115
  const match = tasks.find((t) => t.elementId === elementId);
113
116
  assert.ok(match, `expected an open ${elementId} user task (open: ${tasks.map((t) => t.elementId).join(", ")})`);
114
117
  return match!;
@@ -118,7 +121,7 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
118
121
  * filter) and NOT by a bogus group — proving `zeebe:assignmentDefinition candidateGroups` took. */
119
122
  async function assertAssignmentFilterable(app: TestApp, processKey: string, elementId: string) {
120
123
  const byOperators = await app.engine.searchUserTasks({
121
- processInstanceKey: processKey,
124
+ rootProcessInstanceKey: processKey,
122
125
  candidateGroup: "operators",
123
126
  });
124
127
  assert.ok(
@@ -126,7 +129,7 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
126
129
  `${elementId} is filterable by the operators candidate group`,
127
130
  );
128
131
  const byBogus = await app.engine.searchUserTasks({
129
- processInstanceKey: processKey,
132
+ rootProcessInstanceKey: processKey,
130
133
  candidateGroup: "nobody-here",
131
134
  });
132
135
  assert.ok(
@@ -150,20 +153,21 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
150
153
  }),
151
154
  },
152
155
  async ({ app, processKey }) => {
153
- await openTask(app, processKey, "feature-escalation");
154
- await assertAssignmentFilterable(app, processKey, "feature-escalation");
156
+ await openTask(app, processKey, "escalation");
157
+ await assertAssignmentFilterable(app, processKey, "escalation");
155
158
 
156
- // Never answer — let the SLA elapse. The interrupting boundary cancels the parked task and
157
- // routes to the task-done end (the safe auto-abandon default).
159
+ // Never answer — let the SLA elapse. The interrupting boundary (on the human-escalation cell's
160
+ // `escalation` task) cancels the parked task and routes to its SLA auto-abandon end; the cell
161
+ // then takes its abandon default (the safe auto-abandon of the slice).
158
162
  await advancePastTimer(app, PAST_SLA_MS);
159
163
 
160
164
  const flows = takenFlows(app);
161
165
  assert.ok(
162
- flows.includes("be_feature_sla->w_end"),
163
- `the SLA boundary auto-abandoned to the task-done end (flows: ${flows.join(", ")})`,
166
+ flows.includes("be_he_sla->he_end_sla"),
167
+ `the SLA boundary auto-abandoned to the human-escalation cell's SLA end (flows: ${flows.join(", ")})`,
164
168
  );
165
169
  assert.ok(
166
- !flows.includes("w_gw_answer->implement-task"),
170
+ !flows.includes("ic_gw_answer->record-implementing"),
167
171
  "the human answer loop was NOT taken",
168
172
  );
169
173
  },
@@ -106,7 +106,11 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
106
106
  }
107
107
 
108
108
  async function openTask(app: TestApp, processKey: string, elementId: string): Promise<InboxTask> {
109
- const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
109
+ // Root-scoped: the implement-stage escalation now parks on a `human-escalation` grandchild the
110
+ // shared `implement-cell` spawns (ADR 0006 S4); plan-level tasks (plan-review, empty-plan,
111
+ // trial-merge, the caps-timeout `feature-escalation`) still park on the root and remain
112
+ // discoverable under a root-scoped search (a superset of the parent's own tasks).
113
+ const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
110
114
  const match = tasks.find((t) => t.elementId === elementId);
111
115
  assert.ok(match, `expected an open ${elementId} user task (open: ${tasks.map((t) => t.elementId).join(", ")})`);
112
116
  return match!;
@@ -131,20 +135,20 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
131
135
  },
132
136
  },
133
137
  async ({ app, processKey }) => {
134
- const task = await openTask(app, processKey, "feature-escalation");
135
- assert.ok(task.userTaskKey, "the feature escalation carries a completable userTaskKey");
138
+ const task = await openTask(app, processKey, "escalation");
139
+ assert.ok(task.userTaskKey, "the human-escalation cell task carries a completable userTaskKey");
136
140
 
137
- // Answer it: the typed resolution loops the child back to re-dispatch the SAME task.
141
+ // Answer it: the typed resolution loops the cell back to re-dispatch the SAME task.
138
142
  await app.engine.completeUserTask(task.userTaskKey, { resolution: "answer", answer: "use v2" });
139
143
  await app.settle();
140
144
 
141
145
  const flows = takenFlows(app);
142
146
  assert.ok(
143
- flows.includes("w_gw_answer->implement-task"),
144
- `answer routed back to implement-task (flows: ${flows.join(", ")})`,
147
+ flows.includes("ic_gw_answer->record-implementing") && flows.includes("record-implementing->implement-task"),
148
+ `answer routed back to implement-task through the cell reset (flows: ${flows.join(", ")})`,
145
149
  );
146
150
  assert.ok(
147
- !flows.includes("w_gw_answer->w_end"),
151
+ !flows.includes("ic_gw_answer->ic_end"),
148
152
  "the abandon (default) flow was NOT taken",
149
153
  );
150
154
  },
@@ -163,17 +167,17 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
163
167
  }),
164
168
  },
165
169
  async ({ app, processKey }) => {
166
- const task = await openTask(app, processKey, "feature-escalation");
170
+ const task = await openTask(app, processKey, "escalation");
167
171
  await app.engine.completeUserTask(task.userTaskKey, { resolution: "abandon" });
168
172
  await app.settle();
169
173
 
170
174
  const flows = takenFlows(app);
171
175
  assert.ok(
172
- flows.includes("w_gw_answer->w_end"),
173
- `abandon routed to the task-done end (flows: ${flows.join(", ")})`,
176
+ flows.includes("ic_gw_answer->ic_end"),
177
+ `abandon routed to the cell's task-done end (flows: ${flows.join(", ")})`,
174
178
  );
175
179
  assert.ok(
176
- !flows.includes("w_gw_answer->implement-task"),
180
+ !flows.includes("ic_gw_answer->record-implementing"),
177
181
  "the answer loop was NOT taken",
178
182
  );
179
183
  },
@@ -454,7 +458,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
454
458
  `task with needs routed through the barrier gateway to wait-caps-resolved (flows: ${parked.join(", ")})`,
455
459
  );
456
460
  assert.ok(
457
- !parked.includes("w_gw_needs->implement-task"),
461
+ !parked.includes("w_gw_needs->implement-cell-call"),
458
462
  "the no-needs shortcut was NOT taken for a task that declares needs",
459
463
  );
460
464
  assert.equal(featureBrief, undefined, "the agent has not been dispatched while parked at the barrier");
@@ -471,8 +475,8 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
471
475
 
472
476
  const flows = takenFlows(app);
473
477
  assert.ok(
474
- flows.includes("wait-caps-resolved->implement-task"),
475
- `caps-resolved released the barrier into implement-task (flows: ${flows.join(", ")})`,
478
+ flows.includes("wait-caps-resolved->implement-cell-call") && flows.includes("Start->implement-task"),
479
+ `caps-resolved released the barrier into the implement cell (flows: ${flows.join(", ")})`,
476
480
  );
477
481
  assert.equal(
478
482
  featureBrief,
@@ -534,15 +538,16 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
534
538
  `the caps bound escalated the parked task to the operator (flows: ${flows.join(", ")})`,
535
539
  );
536
540
  assert.ok(
537
- !flows.includes("wait-caps-resolved->implement-task"),
541
+ !flows.includes("wait-caps-resolved->implement-cell-call"),
538
542
  "the resolve arm was withdrawn — the token did not also proceed as if resolved",
539
543
  );
540
544
  assert.equal(featureRan, false, "the agent was NOT dispatched — the unresolved task escalated instead");
541
545
 
542
- // The escalation is a genuine, operable operator decision point: answering it loops the
543
- // child back to re-dispatch the task (the operator having unblocked/decided), exactly like an
544
- // agent-raised escalation — proving this is a real bounded wait + operator escalation, not a
545
- // dead end.
546
+ // The escalation is a genuine, operable operator decision point: answering it releases the
547
+ // caps barrier into the implement cell (the operator having unblocked/decided), proving this is
548
+ // a real bounded wait + operator escalation, not a dead end. This is the plan-fanout's OWN
549
+ // caps-timeout `feature-escalation` task (the fan-out-specific barrier is surrounding
550
+ // orchestration, retained per ADR 0006 S4), distinct from the implement cell's `escalation`.
546
551
  const task = await openTask(app, processKey, "feature-escalation");
547
552
  assert.ok(task.userTaskKey, "the caps-timeout escalation carries a completable userTaskKey");
548
553
  await app.engine.completeUserTask(task.userTaskKey, { resolution: "answer", answer: "shipped it manually" });
@@ -550,8 +555,8 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
550
555
 
551
556
  const answered = takenFlows(app);
552
557
  assert.ok(
553
- answered.includes("w_gw_answer->implement-task"),
554
- `answering the caps escalation routed back to implement-task (flows: ${answered.join(", ")})`,
558
+ answered.includes("w_gw_answer->implement-cell-call") && answered.includes("Start->implement-task"),
559
+ `answering the caps escalation released the barrier into the implement cell (flows: ${answered.join(", ")})`,
555
560
  );
556
561
  assert.equal(featureRan, true, "the agent was dispatched once the operator answered the caps escalation");
557
562
  },
package/nano.app.json CHANGED
@@ -172,10 +172,6 @@
172
172
  "taskType": "pr.record-wave",
173
173
  "handler": "workers/record-wave/worker.ts"
174
174
  },
175
- {
176
- "taskType": "pr.record-wave-escalation",
177
- "handler": "workers/record-wave-escalation/worker.ts"
178
- },
179
175
  {
180
176
  "taskType": "pr.record-feature",
181
177
  "handler": "workers/record-feature/worker.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.171.1",
3
+ "version": "0.171.3",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -63,12 +63,12 @@
63
63
  },
64
64
  "dependencies": {
65
65
  "@nanobpm/agentic": "^0.10.0",
66
- "@nanobpm/urban": "^0.88.1",
66
+ "@nanobpm/urban": "^0.90.0",
67
67
  "bpmn-auto-layout": "^2.0.0-alpha.2"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@biomejs/biome": "^2.4.11",
71
- "@nanobpm/urban-testkit": "^1.0.1",
71
+ "@nanobpm/urban-testkit": "^1.2.0",
72
72
  "@nanobpm/workflow": "^0.14.0",
73
73
  "@semantic-release/changelog": "^7.0.0",
74
74
  "@semantic-release/git": "^11.0.0",