@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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
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
+
7
+ ## [0.171.2](https://github.com/nanobpm/nano-workforce/compare/v0.171.1...v0.171.2) (2026-08-31)
8
+
9
+ ### Bug Fixes
10
+
11
+ * surface + complete readiness/preflight escalations in the Tasks inbox ([#675](https://github.com/nanobpm/nano-workforce/issues/675)) ([9d874e8](https://github.com/nanobpm/nano-workforce/commit/9d874e889f3155b7887bb48032e39c5f78e33ce3)), closes [#674](https://github.com/nanobpm/nano-workforce/issues/674)
12
+
1
13
  ## [0.171.1](https://github.com/nanobpm/nano-workforce/compare/v0.171.0...v0.171.1) (2026-08-31)
2
14
 
3
15
  ### Code Refactoring
@@ -286,6 +286,38 @@ test("empty-plan-escalation is HUMAN-completable but NOT agent-completable (issu
286
286
  assertEquals(completed[0].variables, { directive: "revise", notes: "look again" });
287
287
  });
288
288
 
289
+ test("readiness-escalation(-pf) is HUMAN-completable but NOT agent-completable (issue #674)", async () => {
290
+ // A readiness/preflight gate adjudicates whether upstream is ACTUALLY ready (proceed) or the gate
291
+ // should be abandoned. Like feature-blocked/conformance/empty-plan it is a HUMAN operator decision —
292
+ // an agent must never auto-answer it, or the fleet would silently defeat the very readiness gate the
293
+ // task exists to enforce. So both ids stay OUTSIDE `ESCALATION_TASK_ELEMENTS` (agent-refused) but are
294
+ // retired by the HUMAN completer via the one canonical `complete-user-task` door.
295
+ for (const elementId of ["readiness-escalation-pf", "readiness-escalation"] as const) {
296
+ const stores = { task_completions: { rows: [] as any[], key: "id" } };
297
+ const data = memData(stores);
298
+ const { engine, completed } = fakeEngine([{ userTaskKey: "ut-r", elementId }]);
299
+
300
+ const asAgent = await completeEscalationAsAgent(data, engine, {
301
+ userTaskKey: "ut-r",
302
+ agentId: "bot",
303
+ variables: { resolution: "acknowledge" },
304
+ });
305
+ assertEquals(asAgent.ok, false, `the agent completer refuses ${elementId}`);
306
+ assertEquals(asAgent.reason, "not a completable task");
307
+ assertEquals(completed.length, 0);
308
+
309
+ const asHuman = await completeEscalationAsHuman(data, engine, {
310
+ userTaskKey: "ut-r",
311
+ operatorId: "alice",
312
+ variables: { resolution: "abandon", answer: "upstream never published" },
313
+ });
314
+ assertEquals(asHuman.ok, true, `the human completer retires ${elementId}`);
315
+ assertEquals(asHuman.elementId, elementId);
316
+ assertEquals(completed.length, 1);
317
+ assertEquals(completed[0].variables, { resolution: "abandon", answer: "upstream never published" });
318
+ }
319
+ });
320
+
289
321
  test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
290
322
  const stores = { task_completions: { rows: [] as any[], key: "id" } };
291
323
  const data = memData(stores);
@@ -24,7 +24,7 @@ import { readFileSync } from "node:fs";
24
24
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
25
25
  import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
26
26
  import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
27
- import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT } from "./userTasks.ts";
27
+ import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
28
28
 
29
29
  const now = () => new Date().toISOString();
30
30
 
@@ -101,6 +101,16 @@ export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMEN
101
101
  * Re-exported from the canonical `EMPTY_PLAN_ELEMENT` (app/userTasks.ts) — one source of truth. */
102
102
  export const EMPTY_PLAN_TASK_ELEMENT = EMPTY_PLAN_ELEMENT;
103
103
 
104
+ /** The readiness/preflight escalation user-task element ids (`readiness-escalation-pf` in feature.bpmn's
105
+ * readiness preflight + plan-fanout.bpmn's producer-capability preflight; `readiness-escalation` in
106
+ * readiness-gate.bpmn / wait-gate.bpmn). Like `feature-blocked`, `conformance-escalation` and
107
+ * `empty-plan-escalation` these are HUMAN-only decisions — an agent must never auto-answer a readiness
108
+ * gate (that would silently defeat the "is upstream actually ready?" adjudication the gate exists for),
109
+ * so they live OUTSIDE `ESCALATION_TASK_ELEMENTS` and only the HUMAN completer accepts them (issue
110
+ * #674). Re-exported from the canonical constants in app/userTasks.ts — one source of truth. */
111
+ export const READINESS_ESCALATION_PF_TASK_ELEMENT = READINESS_ESCALATION_PF_ELEMENT;
112
+ export const READINESS_ESCALATION_TASK_ELEMENT = READINESS_ESCALATION_ELEMENT;
113
+
104
114
  /** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
105
115
  * `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
106
116
  * and `conformance-escalation` acknowledgements, PLUS the advisory ACP permission prompt
@@ -115,6 +125,8 @@ export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
115
125
  FEATURE_BLOCKED_TASK_ELEMENT,
116
126
  CONFORMANCE_ESCALATION_TASK_ELEMENT,
117
127
  EMPTY_PLAN_TASK_ELEMENT,
128
+ READINESS_ESCALATION_PF_TASK_ELEMENT,
129
+ READINESS_ESCALATION_TASK_ELEMENT,
118
130
  ACP_PERMISSION_ELEMENT,
119
131
  ]);
120
132
 
@@ -131,6 +143,8 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
131
143
  "feature-blocked": "feature-blocked",
132
144
  [EMPTY_PLAN_TASK_ELEMENT]: "empty-plan-escalation",
133
145
  [CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
146
+ [READINESS_ESCALATION_PF_TASK_ELEMENT]: "readiness-escalation",
147
+ [READINESS_ESCALATION_TASK_ELEMENT]: "readiness-escalation",
134
148
  // NOTE: the delivery-graph `human` node (`DELIVERY_HUMAN_ELEMENT`, ADR 0005 S3) is intentionally
135
149
  // ABSENT here. Unlike the fixed-form escalations above, ONE `delivery-human-task` element is DESIGNED
136
150
  // to render DIFFERENT forms per node (explicit → category → generic → agent-router, `app/deliveryHuman.ts`
@@ -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
  });
@@ -161,6 +161,69 @@ test("pollUserTasks: projects a feature-escalation that lands on a plan-fanout p
161
161
  assertEquals(byKey["ut-embedded-feat"].question, "the agent returned no machine-readable result — enrol the PR?");
162
162
  });
163
163
 
164
+ test("pollUserTasks: projects a readiness-escalation-pf preflight task on a feature run (issue #674)", async () => {
165
+ // The leading readiness preflight (feature.bpmn `pf_*` embedded subprocess) parks on the run's OWN
166
+ // engine instance when it times out before its ReadinessProbe goes green. Before #674 the element id
167
+ // was absent from USER_TASK_KIND_LABELS, so `contextFor`'s leak guard dropped it and the parked run
168
+ // was invisible + uncompletable in the Tasks surface. It must now project as an "Upstream readiness
169
+ // stalled" row on the feature subject, carrying the run's wait rollup as the question.
170
+ const { data, stores } = memData({
171
+ feature_runs: [
172
+ {
173
+ feature_key: "o/r#674",
174
+ status: "running",
175
+ process_key: "fp-674",
176
+ issue_url: "https://github.com/o/r/issues/674",
177
+ title: "Ship the widget",
178
+ delivery_label: "waiting on @scope/upstream@1.2.0 · re-checks every 30s",
179
+ },
180
+ ],
181
+ });
182
+ const engine = fakeEngine({ "fp-674": [{ userTaskKey: "ut-readiness-pf", elementId: "readiness-escalation-pf", formKey: "26" }] });
183
+
184
+ await pollUserTasks(data, engine);
185
+
186
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
187
+ assertEquals(Object.keys(byKey), ["ut-readiness-pf"]);
188
+ assertEquals(byKey["ut-readiness-pf"].element_id, "readiness-escalation-pf");
189
+ assertEquals(byKey["ut-readiness-pf"].kind_label, "Upstream readiness stalled");
190
+ assertEquals(byKey["ut-readiness-pf"].subject_type, "feature");
191
+ assertEquals(byKey["ut-readiness-pf"].subject_key, "o/r#674");
192
+ assertEquals(byKey["ut-readiness-pf"].subject_title, "Ship the widget");
193
+ assertEquals(byKey["ut-readiness-pf"].question, "waiting on @scope/upstream@1.2.0 · re-checks every 30s");
194
+ assertEquals(byKey["ut-readiness-pf"].form_key, "26");
195
+ });
196
+
197
+ test("pollUserTasks: projects a readiness-escalation wait-gate task on a plan, with the wait-gate label as question (issue #674)", async () => {
198
+ // The standalone inter-epic wait-gate cell (readiness-gate.bpmn / wait-gate.bpmn) parks a dependent
199
+ // epic on `readiness-escalation` when its bounded capability wait elapses. It surfaces on the plan
200
+ // subject; the question derivation leans on the wait-gate projection (`plans.wait_gate_label`).
201
+ const { data, stores } = memData({
202
+ plans: [
203
+ {
204
+ plan_key: "o/r#700",
205
+ status: "dispatched",
206
+ process_key: "pp-700",
207
+ issue_url: "https://github.com/o/r/issues/700",
208
+ title: "Dependent epic",
209
+ wait_gate: "escalated",
210
+ wait_gate_label: "escalated · still waiting on @scope/producer@2.0.0 after 24h",
211
+ },
212
+ ],
213
+ });
214
+ const engine = fakeEngine({ "pp-700": [{ userTaskKey: "ut-readiness", elementId: "readiness-escalation" }] });
215
+
216
+ await pollUserTasks(data, engine);
217
+
218
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
219
+ assertEquals(Object.keys(byKey), ["ut-readiness"]);
220
+ assertEquals(byKey["ut-readiness"].element_id, "readiness-escalation");
221
+ assertEquals(byKey["ut-readiness"].kind_label, "Readiness escalation");
222
+ assertEquals(byKey["ut-readiness"].subject_type, "plan");
223
+ assertEquals(byKey["ut-readiness"].subject_key, "o/r#700");
224
+ assertEquals(byKey["ut-readiness"].question, "escalated · still waiting on @scope/producer@2.0.0 after 24h");
225
+ });
226
+
164
227
  test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into user_tasks as \"PR merge\"", async () => {
165
228
  // During the merge phase a PR's process_key points at its merge-loop instance; the merge escalation
166
229
  // parks on a native `wait-merge-answer` userTask (#256) and writes the SAME `escalations` row the
package/app/service.ts CHANGED
@@ -99,6 +99,9 @@ import {
99
99
  PR_WAIT_ANSWER_ELEMENT,
100
100
  PR_WAIT_MERGE_ANSWER_ELEMENT,
101
101
  prEscalations,
102
+ READINESS_ESCALATION_ELEMENT,
103
+ READINESS_ESCALATION_PF_ELEMENT,
104
+ readinessEscalationQuestion,
102
105
  reconcileUserTasks,
103
106
  TRIAL_MERGE_ELEMENT,
104
107
  toOpenEscalation,
@@ -2652,15 +2655,19 @@ export async function pollUserTasks(
2652
2655
  url?: string | null;
2653
2656
  deliveryLabel?: string | null;
2654
2657
  conformanceSummary?: string | null;
2658
+ /** The subject's at-a-glance "waiting on <capability> · …" rollup, denormalised for the readiness
2659
+ * escalation question (issue #674): the wait-gate projection on `plans.wait_gate_label`, or the
2660
+ * feature run's `delivery_label` for the inline preflight. */
2661
+ waitGateLabel?: string | null;
2655
2662
  }
2656
2663
  const subjectByInstance = new Map<string, Subject>();
2657
2664
  for (const run of await featureRuns(data).all()) {
2658
2665
  if (run.process_key) {
2659
- subjectByInstance.set(run.process_key, { type: "feature", key: run.feature_key, title: run.title, url: run.issue_url, deliveryLabel: run.delivery_label });
2666
+ subjectByInstance.set(run.process_key, { type: "feature", key: run.feature_key, title: run.title, url: run.issue_url, deliveryLabel: run.delivery_label, waitGateLabel: run.delivery_label });
2660
2667
  }
2661
2668
  }
2662
2669
  for (const plan of await plans(data).all()) {
2663
- if (plan.process_key) subjectByInstance.set(plan.process_key, { type: "plan", key: plan.plan_key, title: plan.title, url: plan.issue_url });
2670
+ if (plan.process_key) subjectByInstance.set(plan.process_key, { type: "plan", key: plan.plan_key, title: plan.title, url: plan.issue_url, waitGateLabel: plan.wait_gate_label });
2664
2671
  }
2665
2672
  for (const pr of await prs(data).all()) {
2666
2673
  if (pr.process_key) subjectByInstance.set(pr.process_key, { type: "pr", key: pr.pr_key, title: pr.title, url: pr.url });
@@ -2687,6 +2694,8 @@ export async function pollUserTasks(
2687
2694
  [PLAN_REVIEW_ELEMENT]: "plan",
2688
2695
  [TRIAL_MERGE_ELEMENT]: "plan",
2689
2696
  [CONFORMANCE_ESCALATION_ELEMENT]: "plan",
2697
+ [READINESS_ESCALATION_PF_ELEMENT]: "feature",
2698
+ [READINESS_ESCALATION_ELEMENT]: "plan",
2690
2699
  [PR_WAIT_ANSWER_ELEMENT]: "pr",
2691
2700
  [PR_WAIT_MERGE_ANSWER_ELEMENT]: "pr",
2692
2701
  };
@@ -2744,6 +2753,13 @@ export async function pollUserTasks(
2744
2753
  case CONFORMANCE_ESCALATION_ELEMENT:
2745
2754
  question = conformanceEscalationQuestion(subj ? { summary: subj.conformanceSummary } : undefined);
2746
2755
  break;
2756
+ case READINESS_ESCALATION_PF_ELEMENT:
2757
+ case READINESS_ESCALATION_ELEMENT:
2758
+ // The leading readiness/capability gate stalled: surface WHAT it is waiting on. The wait-gate
2759
+ // projection already rendered that clause on the subject row (`plans.wait_gate_label`, or the
2760
+ // feature preflight's `delivery_label`), with a static readiness-stalled fallback (#674).
2761
+ question = readinessEscalationQuestion(subj?.waitGateLabel ?? null);
2762
+ break;
2747
2763
  }
2748
2764
  return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey, formKey: resolvedFormKey };
2749
2765
  };
@@ -2766,22 +2782,36 @@ export async function pollUserTasks(
2766
2782
  await project(t.elementId, t.userTaskKey, t.processInstanceKey, t.rootProcessInstanceKey, t.formKey);
2767
2783
  }
2768
2784
  } else {
2769
- // Reduced-capability fallback (no raw-REST surface): typed-seam per-active-subject scan, tracked-only.
2770
- // The typed `openUserTasks` seam carries no parent/root key, so a child-instance task cannot be
2771
- // correlated herethis path reaches a task only THROUGH the tracked subject whose OWN instance it
2772
- // 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.
2773
2794
  const seen = new Set<string>();
2774
2795
  const scanInstance = async (processKey: string | null | undefined) => {
2775
2796
  if (!processKey || seen.has(processKey)) return;
2776
2797
  seen.add(processKey);
2777
- 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 }[];
2778
2800
  try {
2779
- tasks = await engine.openUserTasks({ processInstanceKey: processKey });
2801
+ [direct, hierarchy] = await Promise.all([
2802
+ engine.openUserTasks({ processInstanceKey: processKey }),
2803
+ engine.openUserTasks({ rootProcessInstanceKey: processKey }),
2804
+ ]);
2780
2805
  } catch (err) {
2781
2806
  console.error(`[poller] user tasks (${processKey}): ${err}`);
2782
2807
  return;
2783
2808
  }
2784
- 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 ?? "");
2785
2815
  };
2786
2816
  for (const status of FEATURE_ACTIVE_STATUSES) for (const run of await featureRuns(data).find({ status })) await scanInstance(run.process_key);
2787
2817
  for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
@@ -2851,27 +2881,32 @@ export async function pollUserTasks(
2851
2881
  // element inside a DESCENDANT instance that the parent's `openUserTasks` never reports. Confirming
2852
2882
  // the parent alone would read "no escalation open" and wrongly flip a genuinely-parked child-cell run
2853
2883
  // back to `running` whenever THIS pass's sweep missed it (truncated/unavailable, so it is absent from
2854
- // `desired`). Include the callActivity descendants when the raw-REST surface is available; the
2855
- // reduced-capability seam (no `rest`) cannot walk the hierarchy, so it stays parent-only (child-cell
2856
- // correlation is a no-op on that path anyway). Any per-instance query error is negative evidence, not
2857
- // proof the run is unparked skip the heal and leave the row for a later pass (parity with before).
2858
- const confirmInstances = [run.process_key];
2859
- 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).
2860
2890
  let stillParked = false;
2861
2891
  let queryErrored = false;
2862
- for (const instanceKey of confirmInstances) {
2863
- let openTasks: { elementId?: string }[];
2864
- try {
2865
- openTasks = await engine.openUserTasks({ processInstanceKey: instanceKey });
2866
- } catch (err) {
2867
- console.error(`[poller] escalated-run self-heal (${run.feature_key} @ ${instanceKey}): ${err}`);
2868
- queryErrored = true;
2869
- break;
2870
- }
2871
- if (openTasks.some((t) => t.elementId === FEATURE_ESCALATION_ELEMENT || t.elementId === HUMAN_ESCALATION_ELEMENT)) {
2872
- stillParked = true;
2873
- 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);
2874
2906
  }
2907
+ } catch (err) {
2908
+ console.error(`[poller] escalated-run self-heal (${run.feature_key} @ ${run.process_key}): ${err}`);
2909
+ queryErrored = true;
2875
2910
  }
2876
2911
  if (queryErrored || stillParked) continue;
2877
2912
  await featureRuns(data).update(run.feature_key, { status: "running", updated_at: at });