@nanobpm/nano-workforce 0.171.2 → 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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.171.3](https://github.com/nanobpm/nano-workforce/compare/v0.171.2...v0.171.3) (2026-08-31)
2
+
3
+ ### Code Refactoring
4
+
5
+ * compose implement → implement-cell (feature + plan-fanout) ([#646](https://github.com/nanobpm/nano-workforce/issues/646)) ([#656](https://github.com/nanobpm/nano-workforce/issues/656)) ([caca54e](https://github.com/nanobpm/nano-workforce/commit/caca54e42caf0a1db83a8f15092c7c834e05e81f)), closes [#642](https://github.com/nanobpm/nano-workforce/issues/642) [#360](https://github.com/nanobpm/nano-workforce/issues/360) [#642](https://github.com/nanobpm/nano-workforce/issues/642) [#642](https://github.com/nanobpm/nano-workforce/issues/642)
6
+
1
7
  ## [0.171.2](https://github.com/nanobpm/nano-workforce/compare/v0.171.1...v0.171.2) (2026-08-31)
2
8
 
3
9
  ### Bug Fixes
@@ -56,10 +56,14 @@ function manifestBindings(): Binding[] {
56
56
  }
57
57
 
58
58
  // The deployed process models — scanned for their prompt-bearing agent tasks, the real dispatch
59
- // corpus the door's verbs must already exist in.
59
+ // corpus the door's verbs must already exist in. Includes the shared S4 atomic cells (ADR 0006): the
60
+ // implement/escalation and trial-merge agent tasks now live in `implement-cell` / `merge-cell`, which
61
+ // `feature.bpmn` and the `plan-fanout` MI body compose by `callActivity`.
60
62
  const MODEL_FILES = [
61
63
  "feature.bpmn",
62
64
  "plan-fanout.bpmn",
65
+ "implement-cell.bpmn",
66
+ "merge-cell.bpmn",
63
67
  "convergence-loop.bpmn",
64
68
  "merge-loop.bpmn",
65
69
  "retro.bpmn",
@@ -85,7 +89,7 @@ test("VERB PARITY: every mapped dispatch verb is a real senior:* agent task in t
85
89
  });
86
90
 
87
91
  test("VERB PARITY: the implementation kinds keep the pre-collapse senior:feature target", () => {
88
- // feature.bpmn's implement task and plan-fanout.bpmn's per-slice implement task both dispatch
92
+ // implement-cell.bpmn's agent task (composed by feature.bpmn and the plan-fanout MI body) dispatches
89
93
  // senior:feature today — the door preserves that for the single-issue implementation kinds.
90
94
  assertEquals(dispatchJobTypeForKind("feature"), "senior:feature");
91
95
  assertEquals(dispatchJobTypeForKind("plan-task"), "senior:feature");
@@ -39,10 +39,11 @@ export type EscalationKind =
39
39
  | "dead-end-base"
40
40
  // `mergeProtocol` (app/mergeProtocol.ts) — the repo's declared land method.
41
41
  | "merge-protocol"
42
- // plan-fanout `w_gw` "clean terminal?" gateway — the implement-stage escalation net (issue #360).
43
- // Any non-clean-terminal slice outcome routes through the `record-wave-escalation` worker, which
44
- // classifies with this kind: the agent's own answerable question passes through, and a no-machine-
45
- // readable result (or a blank-question `escalated`) is synthesised into an answerable one.
42
+ // The shared `implement-cell`'s `ic_gw` "clean terminal?" gateway — the implement-stage escalation
43
+ // net (issue #360). Any non-clean-terminal slice outcome routes through the cell's unified
44
+ // `record-feature-escalation` worker, which classifies with this kind: the agent's own answerable
45
+ // question passes through, and a no-machine-readable result (or a blank-question `escalated`) is
46
+ // synthesised into an answerable one.
46
47
  | "task";
47
48
 
48
49
  /** Everything the classifier may need from any raise site. Each field is consumed only by the
@@ -99,3 +99,96 @@ test("feature.bpmn composes its converge step via callActivity to converge-cell
99
99
  );
100
100
  }
101
101
  });
102
+
103
+ test("feature.bpmn composes its implement step via callActivity to implement-cell — no inlined implement serviceTask", () => {
104
+ // The implement seam (deferred out of #632, tracked in #646) composes the shared `implement-cell`
105
+ // via a `callActivity` with an explicit `zeebe:ioMapping`, rather than an inlined `senior:feature`
106
+ // serviceTask plus a per-caller escalation loop. The atomic cell owns its own escalation retry
107
+ // (through the shared `human-escalation` cell), so the parent no longer carries the
108
+ // `record-feature-escalation` / `feature-escalation` / answer-loop gateways.
109
+ const xml = flat("feature");
110
+ assert(
111
+ /<bpmn:callActivity\b[^>]*\bid="implement"[\s\S]*?<zeebe:calledElement\b[^>]*\bprocessId="implement-cell"/.test(xml),
112
+ "feature.bpmn must compose implement as a callActivity to implement-cell",
113
+ );
114
+ assert(
115
+ !/<bpmn:serviceTask\b[^>]*\bid="implement-task"/.test(xml),
116
+ "feature.bpmn must not keep an inlined implement-task serviceTask once the cell is composed",
117
+ );
118
+ // The inlined escalation loop is relocated into the atomic cell — the parent path must not keep it.
119
+ assert(
120
+ !/<bpmn:serviceTask\b[^>]*\bid="record-feature-escalation"/.test(xml),
121
+ "feature.bpmn must not keep the inlined record-feature-escalation serviceTask once the cell owns escalation",
122
+ );
123
+ assert(
124
+ !/<bpmn:userTask\b[^>]*\bid="feature-escalation"/.test(xml),
125
+ "feature.bpmn must not keep the inlined feature-escalation userTask once the cell owns escalation",
126
+ );
127
+ // Pin the explicit `zeebe:ioMapping` on the implement callActivity block alone (no `propagateAll*`).
128
+ const implementBlock =
129
+ xml.match(/<bpmn:callActivity\b[^>]*\bid="implement"[\s\S]*?<\/bpmn:callActivity>/)?.[0] ?? "";
130
+ assert(
131
+ /<zeebe:ioMapping>[\s\S]*?<\/zeebe:ioMapping>/.test(implementBlock),
132
+ "the implement callActivity must carry an explicit zeebe:ioMapping",
133
+ );
134
+ assert(
135
+ /<zeebe:input\b[^>]*\btarget="task"/.test(implementBlock),
136
+ "the implement callActivity ioMapping must map the task slice into the child scope",
137
+ );
138
+ for (const field of ["status", "summary", "pr"]) {
139
+ assert(
140
+ new RegExp(`<zeebe:output\\b[^>]*\\btarget="${field}"`).test(implementBlock),
141
+ `the implement callActivity ioMapping must map the child result field ${field} back into the parent scope`,
142
+ );
143
+ }
144
+ });
145
+
146
+ test("plan-fanout.bpmn composes its per-wave implement step via a callActivity to implement-cell", () => {
147
+ // Per ADR 0006 §2 the composition "replaces the inlined segments, not the surrounding orchestration":
148
+ // the multi-instance `implement` subProcess (one token per wave task) keeps its fan-out-specific
149
+ // capability barrier (`caps-prepare` / `wait-caps`) as surrounding orchestration, and its inlined
150
+ // implement/escalation SEGMENT (the per-wave `senior:feature` serviceTask, the `w_gw` "clean
151
+ // terminal?" gateway, and the `record-wave-escalation` recorder) collapses into a `callActivity` to
152
+ // the shared `implement-cell` — the atomic cell now owns the agent loop and its escalation through the
153
+ // shared `human-escalation` cell ("a wave IS a callActivity", #464/#646). The MI stays on the
154
+ // subProcess (one token per wave); the cell is invoked once per token.
155
+ const xml = flat("plan-fanout");
156
+ // The MI element is the `implement` subProcess (one token per wave), preserved as the caps-barrier
157
+ // host; assert its multi-instance loop over `waveTasks`.
158
+ const implementSub =
159
+ xml.match(/<bpmn:subProcess\b[^>]*\bid="implement"[\s\S]*?<\/bpmn:subProcess>/)?.[0] ?? "";
160
+ assert(implementSub.length > 0, "plan-fanout.bpmn must keep the per-wave implement subProcess (the caps-barrier host)");
161
+ assert(
162
+ /<bpmn:multiInstanceLoopCharacteristics>[\s\S]*?inputCollection="=waveTasks"/.test(implementSub),
163
+ "the implement subProcess must be multi-instance — one token per wave task",
164
+ );
165
+ // The caps barrier stays in the wave (fan-out-specific, not part of the atomic cell).
166
+ assert(
167
+ /<zeebe:taskDefinition\b[^>]*\btype="pr.caps-prepare"/.test(implementSub),
168
+ "the capability barrier (caps-prepare) stays in the wave as surrounding orchestration",
169
+ );
170
+ // The implement/escalation segment is replaced by a callActivity to the shared implement-cell.
171
+ const cellCall =
172
+ implementSub.match(/<bpmn:callActivity\b[^>]*\bid="implement-cell-call"[\s\S]*?<\/bpmn:callActivity>/)?.[0] ?? "";
173
+ assert(
174
+ /<zeebe:calledElement\b[^>]*\bprocessId="implement-cell"/.test(cellCall),
175
+ "plan-fanout.bpmn must compose the per-wave implement as a callActivity to implement-cell",
176
+ );
177
+ assert(
178
+ /<zeebe:ioMapping>[\s\S]*?<\/zeebe:ioMapping>/.test(cellCall),
179
+ "the implement-cell callActivity must carry an explicit zeebe:ioMapping (no propagateAll*)",
180
+ );
181
+ assert(
182
+ /<zeebe:input\b[^>]*\btarget="task"/.test(cellCall) && /<zeebe:input\b[^>]*\btarget="subjectKey"/.test(cellCall),
183
+ "the implement-cell callActivity must map the wave task and the plan subject key into the child scope",
184
+ );
185
+ // The inlined per-wave agent loop + escalation recorder no longer sit on the fan-out path.
186
+ assert(
187
+ !/<bpmn:serviceTask\b[^>]*\bid="implement-task"/.test(implementSub),
188
+ "plan-fanout.bpmn must not keep an inlined implement-task serviceTask once the cell is composed",
189
+ );
190
+ assert(
191
+ !/type="pr.record-wave-escalation"/.test(xml),
192
+ "plan-fanout.bpmn must not keep the inlined record-wave-escalation recorder (folded into the cell's recorder)",
193
+ );
194
+ });
@@ -1,40 +1,43 @@
1
- // Structural guard for the wave subprocess's "clean terminal?" gateway (w_gw) — the implement-stage
2
- // escalation net (#358/#360). The whole point of the net is that a slice with NO clean terminal
3
- // status escalates to a human. The no-result case (implement-task completes with `status`
4
- // missing/undefined) is EXACTLY what must escalate, so the gateway must not depend on a `not(...)`
5
- // negation that FEEL leaves `null` for a missing `status` (a null condition takes NO flow and would
6
- // fall through to the default). We eliminate that failure mode categorically: ESCALATE is the
7
- // DEFAULT flow and DONE is gated on the closed set of clean terminal statuses so anything that is
8
- // not a recognised clean terminal (including a missing/undefined status) escalates, regardless of
9
- // how the engine evaluates equality against null.
1
+ // Structural guard for the implement cell's "clean terminal?" gateway (ic_gw) — the implement-stage
2
+ // escalation net (#358/#360), now owned by the shared `implement-cell` (ADR 0006 S4). Before the S4
3
+ // composition this gateway lived inline as `plan-fanout.bpmn`'s `w_gw` and `feature.bpmn`'s equivalent;
4
+ // composing the atomic cell relocated it (and its `record-escalation` recorder + `human-escalation`
5
+ // loop) into `implement-cell.bpmn`, which BOTH `feature.bpmn` and the `plan-fanout` MI body compose by
6
+ // `callActivity`. The invariant is unchanged: a slice with NO clean terminal status escalates to a
7
+ // human. The no-result case (the agent completes with `status` missing/undefined) is EXACTLY what must
8
+ // escalate, so the gateway must not depend on a `not(...)` negation that FEEL leaves `null` for a
9
+ // missing `status` (a null condition takes NO flow and would fall through to the default). We eliminate
10
+ // that failure mode categorically: ESCALATE is the DEFAULT flow and DONE is gated on the closed set of
11
+ // clean terminal statuses — so anything that is not a recognised clean terminal (including a
12
+ // missing/undefined status) escalates, regardless of how the engine evaluates equality against null.
10
13
  //
11
14
  // Pure text assertions over the committed BPMN (no engine), matching the repo's model-guard style.
12
15
  import { readFileSync } from "node:fs";
13
16
  import { test } from "node:test";
14
17
  import { assert, assertStringIncludes } from "#test-assert";
15
18
 
16
- const bpmn = readFileSync("resources/processes/plan-fanout.bpmn", "utf8");
19
+ const bpmn = readFileSync("resources/processes/implement-cell.bpmn", "utf8");
17
20
  const flat = bpmn.replace(/\s+/g, " ");
18
21
 
19
- const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="w_gw"[^>]*>/)?.[0] ?? "";
20
- const wDone = flat.match(/<bpmn:sequenceFlow\b[^>]*\bid="w_done"[^>]*\/>|<bpmn:sequenceFlow\b[^>]*\bid="w_done"[\s\S]*?<\/bpmn:sequenceFlow>/)?.[0] ?? "";
21
- const wEscalate = flat.match(/<bpmn:sequenceFlow\b[^>]*\bid="w_escalate"[^>]*\/>|<bpmn:sequenceFlow\b[^>]*\bid="w_escalate"[\s\S]*?<\/bpmn:sequenceFlow>/)?.[0] ?? "";
22
+ const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="ic_gw"[^>]*>/)?.[0] ?? "";
23
+ const icDone = flat.match(/<bpmn:sequenceFlow\b[^>]*\bid="ic_done"[^>]*\/>|<bpmn:sequenceFlow\b[^>]*\bid="ic_done"[\s\S]*?<\/bpmn:sequenceFlow>/)?.[0] ?? "";
24
+ const icEscalate = flat.match(/<bpmn:sequenceFlow\b[^>]*\bid="ic_escalate"[^>]*\/>|<bpmn:sequenceFlow\b[^>]*\bid="ic_escalate"[\s\S]*?<\/bpmn:sequenceFlow>/)?.[0] ?? "";
22
25
 
23
- test("w_gw: ESCALATE is the default flow, so a missing/undefined status can never fall through to done", () => {
24
- assert(gw, "w_gw gateway must exist");
25
- assertStringIncludes(gw, 'default="w_escalate"', "escalate must be the default — the no-result case escalates, never silently completes");
26
+ test("ic_gw: ESCALATE is the default flow, so a missing/undefined status can never fall through to done", () => {
27
+ assert(gw, "ic_gw gateway must exist");
28
+ assertStringIncludes(gw, 'default="ic_escalate"', "escalate must be the default — the no-result case escalates, never silently completes");
26
29
  });
27
30
 
28
- test("w_gw: DONE is gated on the closed set of clean terminal statuses (not a fragile not(...) negation)", () => {
29
- assert(wDone, "w_done flow must exist");
30
- assertStringIncludes(wDone, "conditionExpression", "the done flow must be conditional, not the default");
31
- assertStringIncludes(wDone, 'status = "opened"', "done requires a recognised clean terminal status");
32
- assertStringIncludes(wDone, 'status = "blocked"', "done requires a recognised clean terminal status");
33
- assertStringIncludes(wDone, 'status = "skipped"', "done requires a recognised clean terminal status");
31
+ test("ic_gw: DONE is gated on the closed set of clean terminal statuses (not a fragile not(...) negation)", () => {
32
+ assert(icDone, "ic_done flow must exist");
33
+ assertStringIncludes(icDone, "conditionExpression", "the done flow must be conditional, not the default");
34
+ assertStringIncludes(icDone, 'status = "opened"', "done requires a recognised clean terminal status");
35
+ assertStringIncludes(icDone, 'status = "blocked"', "done requires a recognised clean terminal status");
36
+ assertStringIncludes(icDone, 'status = "skipped"', "done requires a recognised clean terminal status");
34
37
  });
35
38
 
36
- test("w_gw: the escalate flow carries no condition — it is the unconditional default sink", () => {
37
- assert(wEscalate, "w_escalate flow must exist");
38
- assert(!wEscalate.includes("conditionExpression"), "escalate is the default flow and must carry no condition");
39
- assert(!wEscalate.includes("not("), "escalate must not depend on a not(...) negation that FEEL leaves null for a missing status");
39
+ test("ic_gw: the escalate flow carries no condition — it is the unconditional default sink", () => {
40
+ assert(icEscalate, "ic_escalate flow must exist");
41
+ assert(!icEscalate.includes("conditionExpression"), "escalate is the default flow and must carry no condition");
42
+ assert(!icEscalate.includes("not("), "escalate must not depend on a not(...) negation that FEEL leaves null for a missing status");
40
43
  });
@@ -66,8 +66,15 @@ test("a never-green producer escalates (bounded) without wedging: probe timeout
66
66
  assert(hasFlow("be_pf_sla", "pf_end"), "an elapsed escalation SLA settles the preflight instead of wedging");
67
67
  });
68
68
 
69
- test("the bound resolvedArtifacts version rides the implement task's appendPrompt", () => {
70
- const task = flat.match(/<bpmn:serviceTask\b[^>]*\bid="implement-task"[\s\S]*?<\/bpmn:serviceTask>/);
71
- assert(task, "implement-task must exist");
72
- assertStringIncludes(task![0], "resolvedArtifacts", "the bound pkg@version is threaded into the slice prompt");
69
+ test("the bound resolvedArtifacts version rides the implement cell's appendPrompt", () => {
70
+ // The per-wave implement/escalation segment is composed into `implement-cell` (ADR 0006 S4), so the
71
+ // bound `pkg@version`s ride the MI `implement-cell` callActivity's ioMapping into the cell, whose
72
+ // `implement-task` threads them into the slice prompt's appendPrompt.
73
+ const call = flat.match(/<bpmn:callActivity\b[^>]*\bid="implement-cell-call"[\s\S]*?<\/bpmn:callActivity>/);
74
+ assert(call, "the implement-cell callActivity must exist");
75
+ assertStringIncludes(call![0], "resolvedArtifacts", "the bound pkg@version is threaded into the cell's slice prompt");
76
+ const cell = readFileSync("resources/processes/implement-cell.bpmn", "utf8").replace(/\s+/g, " ");
77
+ const task = cell.match(/<bpmn:serviceTask\b[^>]*\bid="implement-task"[\s\S]*?<\/bpmn:serviceTask>/);
78
+ assert(task, "implement-cell must own the implement-task");
79
+ assertStringIncludes(task![0], "resolvedArtifacts", "the cell's implement-task appendPrompt consumes the bound pkg@version");
73
80
  });
package/app/service.ts CHANGED
@@ -2782,22 +2782,36 @@ export async function pollUserTasks(
2782
2782
  await project(t.elementId, t.userTaskKey, t.processInstanceKey, t.rootProcessInstanceKey, t.formKey);
2783
2783
  }
2784
2784
  } else {
2785
- // Reduced-capability fallback (no raw-REST surface): typed-seam per-active-subject scan, tracked-only.
2786
- // The typed `openUserTasks` seam carries no parent/root key, so a child-instance task cannot be
2787
- // correlated herethis path reaches a task only THROUGH the tracked subject whose OWN instance it
2788
- // parks on (root correlation is a no-op, passed as "").
2785
+ // Reduced-capability fallback (no raw-REST surface): typed-seam per-active-subject scan. The typed
2786
+ // seam now carries the parent/root keys (`@nanobpm/urban` 0.90 / `@nanobpm/engine-wasm` 0.8.6,
2787
+ // Magikcraft/nano-bpm#977), sounlike the pre-#646 tracked-only path a task parked inside a
2788
+ // callActivity CHILD instance (the shared `human-escalation`/`implement-cell` cells, ADR 0006 S4)
2789
+ // correlates back to this tracked subject too: each subject instance is scanned BOTH directly (its
2790
+ // own top-level escalations) AND across its whole callActivity hierarchy via the `rootProcessInstanceKey`
2791
+ // filter (its native-child cell escalations), so an `implement-cell` → `human-escalation` grandchild
2792
+ // escalation surfaces on this path exactly as it does on the raw-REST sweep (issue #633/#646). Deduped
2793
+ // by `userTaskKey` in `project`, so the direct + hierarchy passes never double-project a shared task.
2789
2794
  const seen = new Set<string>();
2790
2795
  const scanInstance = async (processKey: string | null | undefined) => {
2791
2796
  if (!processKey || seen.has(processKey)) return;
2792
2797
  seen.add(processKey);
2793
- let tasks: { userTaskKey: string; elementId?: string; formKey?: string }[];
2798
+ let direct: { userTaskKey: string; elementId?: string; formKey?: string }[];
2799
+ let hierarchy: { userTaskKey: string; elementId?: string; processInstanceKey?: string; rootProcessInstanceKey?: string; formKey?: string }[];
2794
2800
  try {
2795
- tasks = await engine.openUserTasks({ processInstanceKey: processKey });
2801
+ [direct, hierarchy] = await Promise.all([
2802
+ engine.openUserTasks({ processInstanceKey: processKey }),
2803
+ engine.openUserTasks({ rootProcessInstanceKey: processKey }),
2804
+ ]);
2796
2805
  } catch (err) {
2797
2806
  console.error(`[poller] user tasks (${processKey}): ${err}`);
2798
2807
  return;
2799
2808
  }
2800
- for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey, "", t.formKey ?? "");
2809
+ // Direct tasks park on the subject's OWN instance, so their process/root instance IS `processKey`.
2810
+ for (const t of direct) await project(t.elementId, t.userTaskKey, processKey, processKey, t.formKey ?? "");
2811
+ // Hierarchy tasks carry their own (child) `processInstanceKey` and the shared `rootProcessInstanceKey`
2812
+ // (= this subject) the engine reports, so `contextFor` correlates the child-instance task to the
2813
+ // tracked subject via the root rather than stranding it as an orphan.
2814
+ for (const t of hierarchy) await project(t.elementId, t.userTaskKey, t.processInstanceKey ?? processKey, t.rootProcessInstanceKey ?? processKey, t.formKey ?? "");
2801
2815
  };
2802
2816
  for (const status of FEATURE_ACTIVE_STATUSES) for (const run of await featureRuns(data).find({ status })) await scanInstance(run.process_key);
2803
2817
  for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
@@ -2867,27 +2881,32 @@ export async function pollUserTasks(
2867
2881
  // element inside a DESCENDANT instance that the parent's `openUserTasks` never reports. Confirming
2868
2882
  // the parent alone would read "no escalation open" and wrongly flip a genuinely-parked child-cell run
2869
2883
  // back to `running` whenever THIS pass's sweep missed it (truncated/unavailable, so it is absent from
2870
- // `desired`). Include the callActivity descendants when the raw-REST surface is available; the
2871
- // reduced-capability seam (no `rest`) cannot walk the hierarchy, so it stays parent-only (child-cell
2872
- // correlation is a no-op on that path anyway). Any per-instance query error is negative evidence, not
2873
- // proof the run is unparked skip the heal and leave the row for a later pass (parity with before).
2874
- const confirmInstances = [run.process_key];
2875
- if (rest) confirmInstances.push(...(await searchDescendantInstanceKeys(rest.base, rest.headers, run.process_key)));
2884
+ // `desired`). The typed seam now carries the parent/root keys (`@nanobpm/urban` 0.90, #646), so BOTH
2885
+ // surfaces walk the hierarchy: the raw-REST surface enumerates descendant instances, and the
2886
+ // reduced-capability seam (no `rest`) confirms via the `rootProcessInstanceKey` filter a
2887
+ // root-scoped `openUserTasks` reports every open task across the run's whole tree in one query. Any
2888
+ // query error is negative evidence, not proof the run is unparked — skip the heal and leave the row
2889
+ // for a later pass (parity with before).
2876
2890
  let stillParked = false;
2877
2891
  let queryErrored = false;
2878
- for (const instanceKey of confirmInstances) {
2879
- let openTasks: { elementId?: string }[];
2880
- try {
2881
- openTasks = await engine.openUserTasks({ processInstanceKey: instanceKey });
2882
- } catch (err) {
2883
- console.error(`[poller] escalated-run self-heal (${run.feature_key} @ ${instanceKey}): ${err}`);
2884
- queryErrored = true;
2885
- break;
2886
- }
2887
- if (openTasks.some((t) => t.elementId === FEATURE_ESCALATION_ELEMENT || t.elementId === HUMAN_ESCALATION_ELEMENT)) {
2888
- stillParked = true;
2889
- break;
2892
+ try {
2893
+ if (rest) {
2894
+ const confirmInstances = [run.process_key, ...(await searchDescendantInstanceKeys(rest.base, rest.headers, run.process_key))];
2895
+ for (const instanceKey of confirmInstances) {
2896
+ const openTasks = await engine.openUserTasks({ processInstanceKey: instanceKey });
2897
+ if (openTasks.some((t) => t.elementId === FEATURE_ESCALATION_ELEMENT || t.elementId === HUMAN_ESCALATION_ELEMENT)) {
2898
+ stillParked = true;
2899
+ break;
2900
+ }
2901
+ }
2902
+ } else {
2903
+ // Reduced path: one root-scoped query covers the parent AND every callActivity descendant.
2904
+ const openTasks = await engine.openUserTasks({ rootProcessInstanceKey: run.process_key });
2905
+ stillParked = openTasks.some((t) => t.elementId === FEATURE_ESCALATION_ELEMENT || t.elementId === HUMAN_ESCALATION_ELEMENT);
2890
2906
  }
2907
+ } catch (err) {
2908
+ console.error(`[poller] escalated-run self-heal (${run.feature_key} @ ${run.process_key}): ${err}`);
2909
+ queryErrored = true;
2891
2910
  }
2892
2911
  if (queryErrored || stillParked) continue;
2893
2912
  await featureRuns(data).update(run.feature_key, { status: "running", updated_at: at });
@@ -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