@nanobpm/nano-workforce 0.167.3 → 0.168.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.168.0](https://github.com/nanobpm/nano-workforce/compare/v0.167.4...v0.168.0) (2026-08-31)
2
+
3
+ ### Features
4
+
5
+ * correlate callActivity child-cell escalations & epic phase via parent/root keys ([#633](https://github.com/nanobpm/nano-workforce/issues/633)) ([#657](https://github.com/nanobpm/nano-workforce/issues/657)) ([22b8674](https://github.com/nanobpm/nano-workforce/commit/22b86746548627921a1bc049b7999eeaa8fb5104)), closes [Magikcraft/nano-bpm#977](https://github.com/Magikcraft/nano-bpm/issues/977) [#464](https://github.com/nanobpm/nano-workforce/issues/464) [#591](https://github.com/nanobpm/nano-workforce/issues/591)
6
+
7
+ ## [0.167.4](https://github.com/nanobpm/nano-workforce/compare/v0.167.3...v0.167.4) (2026-08-31)
8
+
9
+ ### Build System
10
+
11
+ * **deps:** bump @nanobpm/engine-wasm to 0.8.5 (callActivity propagation parity) ([#651](https://github.com/nanobpm/nano-workforce/issues/651)) ([3d7518d](https://github.com/nanobpm/nano-workforce/commit/3d7518d8291fb1ff4432ef98868a5a67b040af96)), closes [Magikcraft/nano-bpm#1059](https://github.com/Magikcraft/nano-bpm/issues/1059) [Magikcraft/nano-bpm#980](https://github.com/Magikcraft/nano-bpm/issues/980) [Magikcraft/nano-bpm#1086](https://github.com/Magikcraft/nano-bpm/issues/1086) [#1085](https://github.com/nanobpm/nano-workforce/issues/1085) [nano-workforce#632](https://github.com/nanobpm/nano-workforce/issues/632) [nano-workforce#633](https://github.com/nanobpm/nano-workforce/issues/633)
12
+
1
13
  ## [0.167.3](https://github.com/nanobpm/nano-workforce/compare/v0.167.2...v0.167.3) (2026-08-31)
2
14
 
3
15
  ### Bug Fixes
@@ -68,6 +68,8 @@ export const taskCompletions = (data: DataLayer) =>
68
68
  * agent path is scoped to escalations — it can never complete an arbitrary internal user task. */
69
69
  export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
70
70
  "feature-escalation",
71
+ "escalation", // shared human-escalation cell (human-escalation.bpmn, ADR 0006 S4 #603/#633) — the same
72
+ // agent-answerable feature-escalation task, relocated into a callActivity child cell
71
73
  "plan-review-decision",
72
74
  "trial-merge-decision",
73
75
  "wait-answer", // PR review-loop escalation (convergence-loop.bpmn, U3)
@@ -121,6 +123,7 @@ export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
121
123
  * validates against the SAME `.form` the task inbox renders — one contract, no second field list. */
122
124
  const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
123
125
  "feature-escalation": "feature-escalation",
126
+ "escalation": "feature-escalation", // shared human-escalation cell renders the SAME feature-escalation form (#603/#633)
124
127
  "plan-review-decision": "plan-review-decision",
125
128
  "trial-merge-decision": "trial-merge-decision",
126
129
  "wait-answer": "pr-escalation",
@@ -157,3 +157,67 @@ test("pollEpicPhase skips a live epic that has no engine instance yet", async ()
157
157
  assertEquals(called, false);
158
158
  });
159
159
  });
160
+
161
+ /** Stub `globalThis.fetch` so `pollEpicPhase`'s callActivity hierarchy walk (issue #633) reads its
162
+ * descendant instances from `childrenByParent` (keyed on the queried `parentProcessInstanceKey`), and
163
+ * 404s any other path so a stray call is loud. Returns a restore fn. */
164
+ function stubProcessInstanceSearch(childrenByParent: Record<string, string[]>): () => void {
165
+ const orig = globalThis.fetch;
166
+ // biome-ignore lint/suspicious/noExplicitAny: minimal fetch double for the raw-REST search surface
167
+ globalThis.fetch = (async (url: string | URL, init?: any) => {
168
+ const u = String(url);
169
+ if (!u.endsWith("/process-instances/search")) return new Response("not found", { status: 404 });
170
+ const body = JSON.parse(init?.body ?? "{}");
171
+ const parent: string = body?.filter?.parentProcessInstanceKey ?? "";
172
+ const items = (childrenByParent[parent] ?? []).map((k) => ({ processInstanceKey: k }));
173
+ return new Response(JSON.stringify({ items }), {
174
+ status: 200,
175
+ headers: { "content-type": "application/json" },
176
+ });
177
+ }) as typeof fetch;
178
+ return () => {
179
+ globalThis.fetch = orig;
180
+ };
181
+ }
182
+
183
+ test("pollEpicPhase derives from an element INSIDE a callActivity CHILD cell via parent/root traversal (issue #633)", async () => {
184
+ // ADR 0006 S4 (#603/#633): once a wave/slice runs as a callActivity CHILD cell, the furthest-reached
185
+ // live token can sit INSIDE that child instance ("child-pi"), not on the parent plan-fanout spine
186
+ // ("pi-1"). The parent instance shows only a settled (COMPLETED) `record-plan`, so a parent-ONLY read
187
+ // would leave the phase at PLANNING; walking the hierarchy (parent → child) surfaces the child's ACTIVE
188
+ // `review-plan`, advancing the phase to Reviewing. The pure `deriveEpicPhaseLive` is unchanged — only
189
+ // its INPUT is widened to the whole instance hierarchy.
190
+ await withData(async (data) => {
191
+ await seedPlan(data, { epic_phase: EPIC_PHASE.PLANNING });
192
+ const engine = {
193
+ searchElementInstances: async ({ processInstanceKey }: { processInstanceKey: string }) =>
194
+ processInstanceKey === "child-pi"
195
+ ? [{ elementInstanceKey: "c1", processInstanceKey: "child-pi", elementId: "review-plan", state: "ACTIVE" }]
196
+ : [{ elementInstanceKey: "e1", processInstanceKey: "pi-1", elementId: "record-plan", state: "COMPLETED" }],
197
+ };
198
+ const restore = stubProcessInstanceSearch({ "pi-1": ["child-pi"], "child-pi": [] });
199
+ try {
200
+ await pollEpicPhase(data, engine as never, { restAddress: "http://engine.test/v2" });
201
+ } finally {
202
+ restore();
203
+ }
204
+ assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.REVIEWING);
205
+ });
206
+ });
207
+
208
+ test("pollEpicPhase without a raw-REST surface reads the parent instance ALONE (no traversal, pre-#633 behaviour)", async () => {
209
+ // The typed seam cannot enumerate children, so with no `engineRest` the walk degrades to the parent
210
+ // plan-fanout instance only — a child-cell token is invisible and the phase stays at PLANNING. This
211
+ // pins the two-arg (no-REST) call the unit path and degraded hosts use.
212
+ await withData(async (data) => {
213
+ await seedPlan(data, { epic_phase: EPIC_PHASE.PLANNING });
214
+ const engine = {
215
+ searchElementInstances: async ({ processInstanceKey }: { processInstanceKey: string }) =>
216
+ processInstanceKey === "child-pi"
217
+ ? [{ elementInstanceKey: "c1", processInstanceKey: "child-pi", elementId: "review-plan", state: "ACTIVE" }]
218
+ : [{ elementInstanceKey: "e1", processInstanceKey: "pi-1", elementId: "record-plan", state: "COMPLETED" }],
219
+ };
220
+ await pollEpicPhase(data, engine as never);
221
+ assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.PLANNING);
222
+ });
223
+ });
@@ -370,7 +370,7 @@ test("pollUserTasks: an instance whose only task is COMPLETED surfaces no row",
370
370
 
371
371
  /** A single task as the raw Camunda-8 `/v2/user-tasks/search` reports it — carries `processInstanceKey`
372
372
  * (the typed seam omits it) so the sweep can map a task back to its subject for enrichment. */
373
- type RawTask = { userTaskKey: string; elementId?: string; processInstanceKey?: string; state?: string; formKey?: string | number | null };
373
+ type RawTask = { userTaskKey: string; elementId?: string; processInstanceKey?: string; rootProcessInstanceKey?: string | number | null; state?: string; formKey?: string | number | null };
374
374
 
375
375
  /** Stub `globalThis.fetch` so `pollUserTasks`' engine-first sweep reads its open tasks from `tasks`.
376
376
  * Honours the `page.from`/`page.limit` pagination the sweep drives, and 404s any other path so a stray
@@ -444,7 +444,6 @@ test("pollUserTasks (engine-first): orphaned plan-review and PR-wait escalations
444
444
  });
445
445
 
446
446
  test("pollUserTasks (engine-first): a TRACKED task is still fully enriched from its subject row (no regression)", async () => {
447
- // Enrich, don't gate: when a subject row DOES reference the task's instance, title/url/question come
448
447
  // from it exactly as the per-subject scan produced — the sweep maps by `processInstanceKey`.
449
448
  const { data, stores } = memData({
450
449
  feature_runs: [
@@ -479,6 +478,40 @@ test("pollUserTasks (engine-first): a TRACKED task is still fully enriched from
479
478
  assertEquals(byKey["ut-plan"].question, "scope too broad");
480
479
  });
481
480
 
481
+ test("pollUserTasks (engine-first): a child-cell escalation correlates to its PARENT run via rootProcessInstanceKey (issue #633)", async () => {
482
+ // ADR 0006 S4 (#603/#633): once a slice's implement step runs as a callActivity CHILD cell, the
483
+ // agent-stuck escalation parks on the shared `human-escalation` cell's `escalation` element inside a
484
+ // CHILD instance ("child-pi") whose key NO subject row tracks — the owning feature run is tracked under
485
+ // the PARENT/root instance ("fp-10") the engine reports as `rootProcessInstanceKey`. The poller must
486
+ // correlate the child-instance task back to the parent run (subject + question + kind), not strand it
487
+ // as an orphan keyed to the raw child instance.
488
+ const { data, stores } = memData({
489
+ feature_runs: [
490
+ { feature_key: "o/r#10", status: "escalated", process_key: "fp-10", issue_url: "https://github.com/o/r/issues/10", title: "Add the framework selector", delivery_label: null },
491
+ ],
492
+ feature_escalations: [
493
+ { id: 1, feature_key: "o/r#10", question: "which framework?", created_at: "2025-01-01T00:00:00.000Z", job_key: "j1" },
494
+ ],
495
+ });
496
+ const restore = stubUserTaskSearch([
497
+ { userTaskKey: "ut-child", elementId: "escalation", processInstanceKey: "child-pi", rootProcessInstanceKey: "fp-10", state: "CREATED" },
498
+ ]);
499
+ try {
500
+ await pollUserTasks(data, fakeEngine({}), REST);
501
+ } finally {
502
+ restore();
503
+ }
504
+
505
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
506
+ assertEquals(Object.keys(byKey), ["ut-child"]);
507
+ assertEquals(byKey["ut-child"].element_id, "escalation");
508
+ assertEquals(byKey["ut-child"].kind_label, "Feature escalation");
509
+ assertEquals(byKey["ut-child"].subject_type, "feature");
510
+ assertEquals(byKey["ut-child"].subject_key, "o/r#10"); // correlated to the PARENT run, not the child instance
511
+ assertEquals(byKey["ut-child"].subject_title, "Add the framework selector");
512
+ assertEquals(byKey["ut-child"].question, "which framework?"); // same feature_escalations log via the parent subject
513
+ });
514
+
482
515
  test("pollUserTasks (engine-first): never leaks a non-escalation element nor a non-CREATED task", async () => {
483
516
  // The `USER_TASK_KIND_LABELS` gate keeps an arbitrary internal user task out of the inbox, and the
484
517
  // defensive state re-filter drops a lagging COMPLETED/CANCELED read (a dead affordance, #294) even if
@@ -825,6 +858,47 @@ test("pollUserTasks (engine-first): does NOT heal a JUST-escalated run inside th
825
858
  assertEquals(byKey["o/r#old"].status, "running", "a genuinely-stranded (old) run is still healed");
826
859
  });
827
860
 
861
+ test("pollUserTasks (engine-first): does NOT heal an escalated run parked in a callActivity CHILD instance the sweep missed (issue #633)", async () => {
862
+ // A run's escalation can park inside a callActivity CHILD instance on the shared `human-escalation`
863
+ // cell's `escalation` element (ADR 0006 S4, #633), correlated back to the parent run via the root key.
864
+ // When this pass's engine-first sweep is truncated/unavailable, that child task is absent from `desired`,
865
+ // so the run falls through to the per-instance confirmation. Confirming ONLY the parent `process_key`
866
+ // (whose own open tasks are empty — the escalation lives in the CHILD instance) would read "no
867
+ // escalation open" and wrongly flip a genuinely-parked run back to `running`. The confirmation must
868
+ // include the callActivity descendants when the raw-REST surface is available.
869
+ const stale = new Date(Date.now() - 60 * 60_000).toISOString(); // past the heal grace window
870
+ const { data, stores } = memData({
871
+ feature_runs: [
872
+ { feature_key: "o/r#child", status: "escalated", process_key: "fp-parent", updated_at: stale, issue_url: null, title: "parked in child cell", delivery_label: null },
873
+ ],
874
+ });
875
+ // The sweep is unavailable (returns empty), so fp-parent is NOT confirmed parked via `desired`; the
876
+ // descendant walk over `/process-instances/search` surfaces the child instance carrying the escalation.
877
+ const orig = globalThis.fetch;
878
+ // biome-ignore lint/suspicious/noExplicitAny: minimal fetch double for the raw-REST search surfaces
879
+ globalThis.fetch = (async (url: string | URL, init?: any) => {
880
+ const u = String(url);
881
+ if (u.endsWith("/user-tasks/search")) {
882
+ return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { "content-type": "application/json" } });
883
+ }
884
+ if (u.endsWith("/process-instances/search")) {
885
+ const parent = JSON.parse(init?.body ?? "{}")?.filter?.parentProcessInstanceKey;
886
+ const items = parent === "fp-parent" ? [{ processInstanceKey: "child-1" }] : [];
887
+ return new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } });
888
+ }
889
+ return new Response("not found", { status: 404 });
890
+ }) as typeof fetch;
891
+ // The escalation is parked in the CHILD instance on the `escalation` element, not on the parent.
892
+ const engine = fakeEngine({ "fp-parent": [], "child-1": [{ userTaskKey: "ut-child", elementId: "escalation" }] });
893
+ try {
894
+ await pollUserTasks(data, engine, REST);
895
+ } finally {
896
+ globalThis.fetch = orig;
897
+ }
898
+ const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
899
+ assertEquals(byKey["o/r#child"].status, "escalated", "a run parked in a callActivity child cell survives when the sweep missed it");
900
+ });
901
+
828
902
  test("pollUserTasks (typed-seam fallback): self-heals an escalated run with no open feature-escalation task (issue #642)", async () => {
829
903
  // The reduced-capability path scans FEATURE_ACTIVE_STATUSES instances (incl. `escalated`) directly,
830
904
  // so the per-instance open-task read is just as authoritative for the self-heal.
package/app/service.ts CHANGED
@@ -88,6 +88,7 @@ import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
88
88
  import { trialMergeAudits } from "./trialMerge.ts";
89
89
  import {
90
90
  buildUserTaskRow,
91
+ HUMAN_ESCALATION_ELEMENT,
91
92
  latestFeatureEscalationQuestion,
92
93
  latestOpenEscalationQuestion,
93
94
  latestPlanReviewFindings,
@@ -2270,6 +2271,14 @@ interface UserTaskSearchItem {
2270
2271
  userTaskKey?: string | number;
2271
2272
  elementId?: string;
2272
2273
  processInstanceKey?: string | number;
2274
+ /** The top-level ancestor process instance in this task's callActivity hierarchy — present for
2275
+ * hierarchies created on an engine carrying the read-model parent/root keys (Magikcraft/nano-bpm#977,
2276
+ * engine-wasm ≥ 0.8.4). Equals `processInstanceKey` for a top-level task; for a task parked inside a
2277
+ * callActivity CHILD instance (the shared `human-escalation`/`implement-cell` cells, ADR 0006 S4) it
2278
+ * is the PARENT run's instance, so the poller can correlate the child-instance task back to the
2279
+ * tracked feature/plan subject (issue #633). Absent (`null`) on a pre-#977 engine or a top-level
2280
+ * task. The wire may send a JSON number or string. */
2281
+ rootProcessInstanceKey?: string | number | null;
2273
2282
  state?: string;
2274
2283
  /** The engine's resolution of the task's `.form` linkage (its `formId="X"`) to the deployed form's
2275
2284
  * key, attached to the open task. Denormalised onto the row so the collapsed Tasks grid can render
@@ -2282,6 +2291,10 @@ interface OpenUserTask {
2282
2291
  userTaskKey: string;
2283
2292
  elementId: string;
2284
2293
  processInstanceKey: string;
2294
+ /** The top-level ancestor instance key (`""` when the engine did not report one — a pre-#977 engine
2295
+ * or a top-level task), used to correlate a callActivity child-instance task back to its parent run
2296
+ * (issue #633). */
2297
+ rootProcessInstanceKey: string;
2285
2298
  /** The engine-reported `formKey`, or "" when the search omitted it (the poller then falls back to the
2286
2299
  * kind's static `.form` linkage). */
2287
2300
  formKey: string;
@@ -2326,7 +2339,7 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
2326
2339
  const userTaskKey = it.userTaskKey == null ? "" : String(it.userTaskKey);
2327
2340
  if (!userTaskKey || seen.has(userTaskKey)) continue;
2328
2341
  seen.add(userTaskKey);
2329
- out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey), formKey: it.formKey == null ? "" : String(it.formKey) });
2342
+ out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey), rootProcessInstanceKey: it.rootProcessInstanceKey == null ? "" : String(it.rootProcessInstanceKey), formKey: it.formKey == null ? "" : String(it.formKey) });
2330
2343
  }
2331
2344
  if (items.length < limit) break; // last page
2332
2345
  from += items.length;
@@ -2360,23 +2373,121 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
2360
2373
  * completed task's row is deleted (answered here, via the task inbox, or out-of-band) and `showCount`
2361
2374
  * reflects live pending work. Best-effort + idempotent — per-instance failures are isolated so one bad
2362
2375
  * instance never stalls the pass. */
2376
+ /** The subset of a Camunda-8 `/v2/process-instances/search` result item this app reads to walk a
2377
+ * callActivity instance hierarchy (issue #633). `processInstanceKey` is the instance; the engine's
2378
+ * read-model parent/root keys (Magikcraft/nano-bpm#977) let a caller relate a child instance to its
2379
+ * ancestors — this pass only needs `processInstanceKey` (it filters BY `parentProcessInstanceKey`, so
2380
+ * every returned row is by construction a child of the queried parent). Keys are stringified
2381
+ * defensively (the wire may send a JSON number or string). */
2382
+ interface ProcessInstanceSearchItem {
2383
+ processInstanceKey?: string | number;
2384
+ }
2385
+
2386
+ /** Enumerate every DESCENDANT process instance of `rootKey` in its callActivity hierarchy (issue #633)
2387
+ * over the raw Camunda-8 `/v2/process-instances/search` surface, walking `parentProcessInstanceKey`
2388
+ * breadth-first. Returns the descendant instance keys (NOT including `rootKey` itself). The engine
2389
+ * exposes a `parentProcessInstanceKey` filter but no `rootProcessInstanceKey` one, so the hierarchy is
2390
+ * walked level by level (parent → children → grandchildren): the fine-grained cells nest two deep
2391
+ * (`plan-fanout` → `implement-cell` → `human-escalation`), and a deeper future graph is covered by the
2392
+ * BFS. Bounded by a total-instance guard so a pathological/looping hierarchy can neither explode nor
2393
+ * spin; best-effort transport — a failed page/level returns what was gathered so far. Deduped so a
2394
+ * re-parented row can't be walked twice. */
2395
+ async function searchDescendantInstanceKeys(base: string, headers: Record<string, string>, rootKey: string): Promise<string[]> {
2396
+ const out: string[] = [];
2397
+ const seen = new Set<string>([rootKey]);
2398
+ let frontier = [rootKey];
2399
+ const limit = 100;
2400
+ const MAX_INSTANCES = 1000; // hard cap so a pathological hierarchy can't unbounded-fan-out this pass
2401
+ for (let depth = 0; depth < 64 && frontier.length > 0 && out.length < MAX_INSTANCES; depth++) {
2402
+ const next: string[] = [];
2403
+ for (const parent of frontier) {
2404
+ let from = 0;
2405
+ for (let guard = 0; guard < 1000; guard++) {
2406
+ let items: ProcessInstanceSearchItem[];
2407
+ try {
2408
+ const res = await fetch(`${base}/process-instances/search`, {
2409
+ method: "POST",
2410
+ headers,
2411
+ body: JSON.stringify({ filter: { parentProcessInstanceKey: parent }, page: { from, limit } }),
2412
+ });
2413
+ if (!res.ok) break; // engine unhappy → walk what we have, retry next pass
2414
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
2415
+ const body = (await res.json()) as { items?: ProcessInstanceSearchItem[] };
2416
+ items = body.items ?? [];
2417
+ } catch (err) {
2418
+ console.error(`[poller] process-instance hierarchy walk (${parent}): ${err}`);
2419
+ break;
2420
+ }
2421
+ for (const it of items) {
2422
+ const key = it.processInstanceKey == null ? "" : String(it.processInstanceKey);
2423
+ if (!key || seen.has(key)) continue;
2424
+ seen.add(key);
2425
+ out.push(key);
2426
+ next.push(key);
2427
+ if (out.length >= MAX_INSTANCES) break;
2428
+ }
2429
+ if (items.length < limit || out.length >= MAX_INSTANCES) break; // last page / capped
2430
+ from += items.length;
2431
+ }
2432
+ }
2433
+ frontier = next;
2434
+ }
2435
+ return out;
2436
+ }
2437
+
2438
+ /** Gather the element instances of an epic's plan-fanout instance AND every callActivity DESCENDANT of
2439
+ * it (issue #633) into ONE flat list the canonical `deriveEpicPhaseLive` reads — so once a wave/slice
2440
+ * runs as a child cell instance (ADR 0006 S4) the "furthest-reached live element" derivation still sees
2441
+ * the token position INSIDE the child, not just the parent spine. Extends the derivation's INPUT via the
2442
+ * engine's native parent/root traversal rather than adding a second phase deriver (derivation over
2443
+ * duplication). Without the raw-REST surface (`rest` null — unit tests / a degraded no-REST host) it
2444
+ * degrades to the parent instance alone (the pre-composition behaviour), since the typed seam cannot
2445
+ * enumerate children. */
2446
+ async function gatherHierarchyElementInstances(
2447
+ engine: Pick<EngineClient, "searchElementInstances">,
2448
+ rest: { base: string; headers: Record<string, string> } | null,
2449
+ rootKey: string,
2450
+ ): Promise<Awaited<ReturnType<EngineClient["searchElementInstances"]>>> {
2451
+ const instanceKeys = [rootKey];
2452
+ if (rest) {
2453
+ const descendants = await searchDescendantInstanceKeys(rest.base, rest.headers, rootKey);
2454
+ instanceKeys.push(...descendants);
2455
+ }
2456
+ const all: Awaited<ReturnType<EngineClient["searchElementInstances"]>> = [];
2457
+ for (const key of instanceKeys) {
2458
+ all.push(...(await engine.searchElementInstances({ processInstanceKey: key })));
2459
+ }
2460
+ return all;
2461
+ }
2462
+
2363
2463
  /** Poll pass (S8, #542 / ADR 0006 §4b): reconcile each LIVE epic's `plans.epic_phase` from the engine
2364
2464
  * element-instance model — the PURE read-model derivation that RETIRES the write-time stamp the spine
2365
2465
  * workers used to write. For each plan still live (`EPIC_LIVE_STATUSES`) with a running instance, read
2366
- * its element instances (`searchElementInstances`, nano-ide#473) and project the furthest-reached
2367
- * active spine element onto its domain phase (`deriveEpicPhaseLive`, app/epicPhase.ts the SAME
2368
- * `ELEMENT_PHASE` structural map the stamp used). The wave label rides the `plan_wave_progress` rollup
2369
- * VIEW (the single wave-frontier source, 060/082), so the Implementing band reads `wave n/t` without a
2370
- * second wave derivation. Writes only on a real change (a steady-state pass is a no-op) and leaves the
2371
- * last phase untouched when nothing active marks one (`null`), so a plan parked on non-spine plumbing
2372
- * never clobbers to blank. The terminal `Dispatched` phase is a COMPLETION marker (no ACTIVE token to
2373
- * read once the instance ends), so a second pass derives it from the durable terminal status
2466
+ * its element instances (`searchElementInstances`, nano-ide#473) the plan-fanout instance AND, once
2467
+ * the raw-REST surface is available, every callActivity CHILD cell instance (ADR 0006 S4, #603/#633),
2468
+ * gathered via the engine's native parent/root traversal (`gatherHierarchyElementInstances`) so the
2469
+ * furthest-reached element INSIDE a child cell is seen and project the furthest-reached active spine
2470
+ * element onto its domain phase (`deriveEpicPhaseLive`, app/epicPhase.ts the SAME `ELEMENT_PHASE`
2471
+ * structural map the stamp used). The wave label rides the `plan_wave_progress` rollup VIEW (the single
2472
+ * wave-frontier source, 060/082), so the Implementing band reads `wave n/t` without a second wave
2473
+ * derivation. Writes only on a real change (a steady-state pass is a no-op) and leaves the last phase
2474
+ * untouched when nothing active marks one (`null`), so a plan parked on non-spine plumbing never
2475
+ * clobbers to blank. The terminal `Dispatched` phase is a COMPLETION marker (no ACTIVE token to read
2476
+ * once the instance ends), so a second pass derives it from the durable terminal status
2374
2477
  * (`deriveTerminalEpicPhase` over `done` epics) rather than the fleeting ACTIVE `record-results` token
2375
2478
  * a coarse poll would miss. Best-effort + idempotent — a per-plan failure is isolated. */
2376
2479
  export async function pollEpicPhase(
2377
2480
  data: DataLayer,
2378
2481
  engine: Pick<EngineClient, "searchElementInstances">,
2482
+ engineRest?: { restAddress: string; token?: string },
2379
2483
  ) {
2484
+ const rest = engineRest
2485
+ ? (() => {
2486
+ const headers: Record<string, string> = { "content-type": "application/json" };
2487
+ if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
2488
+ return { base: engineRest.restAddress.replace(/\/+$/, ""), headers };
2489
+ })()
2490
+ : null;
2380
2491
  const waveByPlan = new Map<string, { current: number | null; total: number | null }>();
2381
2492
  for (const w of await data
2382
2493
  .table<{ plan_key: string; wave_count: number | null; current_wave: number | null }>(
@@ -2396,7 +2507,7 @@ export async function pollEpicPhase(
2396
2507
  for (const plan of await plans(data).find({ status })) {
2397
2508
  if (!plan.process_key) continue;
2398
2509
  try {
2399
- const elements = await engine.searchElementInstances({ processInstanceKey: plan.process_key });
2510
+ const elements = await gatherHierarchyElementInstances(engine, rest, plan.process_key);
2400
2511
  const phase = deriveEpicPhaseLive(elements, waveByPlan.get(plan.plan_key) ?? undefined);
2401
2512
  if (phase !== null && phase !== plan.epic_phase) {
2402
2513
  await plans(data).update(plan.plan_key, { epic_phase: phase, updated_at: now() });
@@ -2535,6 +2646,15 @@ export async function pollUserTasks(
2535
2646
  engineRest?: { restAddress: string; token?: string },
2536
2647
  ) {
2537
2648
  const at = now();
2649
+ // Raw-REST context (present in production; the reduced-capability seam host passes no `engineRest`) —
2650
+ // used by the engine-first sweep AND the escalation self-heal's callActivity-hierarchy confirmation.
2651
+ const rest = engineRest
2652
+ ? (() => {
2653
+ const headers: Record<string, string> = { "content-type": "application/json" };
2654
+ if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
2655
+ return { base: engineRest.restAddress.replace(/\/+$/, ""), headers };
2656
+ })()
2657
+ : null;
2538
2658
 
2539
2659
  // ── Enrichment: subject descriptors keyed by the ENGINE process-instance the task parks on ────────
2540
2660
  // Built from EVERY subject row (regardless of status), so a task on a subject whose row already went
@@ -2578,6 +2698,7 @@ export async function pollUserTasks(
2578
2698
  // when tracking is lost, so the fallback row still buckets correctly on the page.
2579
2699
  const DEFAULT_SUBJECT_TYPE: Readonly<Record<string, "feature" | "plan" | "pr">> = {
2580
2700
  [FEATURE_ESCALATION_ELEMENT]: "feature",
2701
+ [HUMAN_ESCALATION_ELEMENT]: "feature",
2581
2702
  [FEATURE_BLOCKED_ELEMENT]: "feature",
2582
2703
  [PLAN_REVIEW_ELEMENT]: "plan",
2583
2704
  [TRIAL_MERGE_ELEMENT]: "plan",
@@ -2590,9 +2711,17 @@ export async function pollUserTasks(
2590
2711
  // element + the instance it parks on) into its desired-row context, enriching from its subject row
2591
2712
  // when the instance is tracked or a per-kind fallback when it is orphaned. Returns `null` for a
2592
2713
  // non-escalation element (the leak guard) so an arbitrary internal user task can never reach the inbox.
2593
- const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string, formKey: string): Promise<UserTaskContext | null> => {
2714
+ //
2715
+ // Parent/root correlation (#603/#633): a task parked inside a callActivity CHILD instance (the shared
2716
+ // `human-escalation`/`implement-cell` cells, ADR 0006 S4) has a `processInstanceKey` no subject row
2717
+ // tracks — its owning feature/plan run is the PARENT run, tracked under the `rootProcessInstanceKey`
2718
+ // the engine reports (Magikcraft/nano-bpm#977). So the subject resolves off the DIRECT instance first
2719
+ // and falls back to the ROOT (top-level ancestor) instance, correlating the child-instance escalation
2720
+ // back to the parent run rather than stranding it as an orphan.
2721
+ const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string, rootProcessInstanceKey: string, formKey: string): Promise<UserTaskContext | null> => {
2594
2722
  if (userTaskKindLabel(elementId) === undefined) return null;
2595
- const subj = subjectByInstance.get(processInstanceKey);
2723
+ const root = rootProcessInstanceKey.trim();
2724
+ const subj = subjectByInstance.get(processInstanceKey) ?? (root && root !== processInstanceKey ? subjectByInstance.get(root) : undefined);
2596
2725
  // Orphaned-task fallback: the kind implies its aggregate even when no subject row references the
2597
2726
  // instance. A delivery-human node's id is inlined (`delivery-human-task__<node>`), so its bucket is
2598
2727
  // derived from the predicate rather than the static per-element table.
@@ -2606,9 +2735,13 @@ export async function pollUserTasks(
2606
2735
  let question: string | null = null;
2607
2736
  switch (elementId) {
2608
2737
  case FEATURE_ESCALATION_ELEMENT:
2738
+ case HUMAN_ESCALATION_ELEMENT:
2609
2739
  // The escalate arm writes the synthesised question to `feature_escalations` keyed by the subject
2610
2740
  // (a standalone slice's `feature_key`, or the epic's `plan_key` for a plan-embedded slice, which
2611
2741
  // has no standalone `feature_runs` row) — the same key `subjectKey` resolves to for either subject.
2742
+ // The shared `human-escalation` cell (`escalation`) is the same feature-escalation task relocated
2743
+ // into a callActivity child, so it reads the SAME `feature_escalations` log via the correlated
2744
+ // (parent/root) subject.
2612
2745
  question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: subjectKey }));
2613
2746
  break;
2614
2747
  case FEATURE_BLOCKED_ELEMENT:
@@ -2634,25 +2767,25 @@ export async function pollUserTasks(
2634
2767
  // Desired set, deduped by completable key (a task is open at most once; guard a page overlap / a
2635
2768
  // subject seen under two statuses mid-pass).
2636
2769
  const desiredByKey = new Map<string, UserTaskRow>();
2637
- const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string, formKey: string) => {
2770
+ const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string, rootProcessInstanceKey: string, formKey: string) => {
2638
2771
  if (!elementId) return;
2639
2772
  const rowKey = userTaskKey.trim();
2640
2773
  if (!rowKey || desiredByKey.has(rowKey)) return;
2641
- const ctx = await contextFor(elementId, userTaskKey, processInstanceKey, formKey);
2774
+ const ctx = await contextFor(elementId, userTaskKey, processInstanceKey, rootProcessInstanceKey, formKey);
2642
2775
  if (!ctx) return;
2643
2776
  const row = buildUserTaskRow(ctx, at);
2644
2777
  if (row) desiredByKey.set(rowKey, row);
2645
2778
  };
2646
2779
 
2647
- if (engineRest) {
2648
- const base = engineRest.restAddress.replace(/\/+$/, "");
2649
- const headers: Record<string, string> = { "content-type": "application/json" };
2650
- if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
2651
- for (const t of await sweepOpenEscalationTasks(base, headers)) {
2652
- await project(t.elementId, t.userTaskKey, t.processInstanceKey, t.formKey);
2780
+ if (rest) {
2781
+ for (const t of await sweepOpenEscalationTasks(rest.base, rest.headers)) {
2782
+ await project(t.elementId, t.userTaskKey, t.processInstanceKey, t.rootProcessInstanceKey, t.formKey);
2653
2783
  }
2654
2784
  } else {
2655
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 here — this path reaches a task only THROUGH the tracked subject whose OWN instance it
2788
+ // parks on (root correlation is a no-op, passed as "").
2656
2789
  const seen = new Set<string>();
2657
2790
  const scanInstance = async (processKey: string | null | undefined) => {
2658
2791
  if (!processKey || seen.has(processKey)) return;
@@ -2664,7 +2797,7 @@ export async function pollUserTasks(
2664
2797
  console.error(`[poller] user tasks (${processKey}): ${err}`);
2665
2798
  return;
2666
2799
  }
2667
- for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey, t.formKey ?? "");
2800
+ for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey, "", t.formKey ?? "");
2668
2801
  };
2669
2802
  for (const status of FEATURE_ACTIVE_STATUSES) for (const run of await featureRuns(data).find({ status })) await scanInstance(run.process_key);
2670
2803
  for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
@@ -2702,32 +2835,62 @@ export async function pollUserTasks(
2702
2835
  // is never guessed at.
2703
2836
  //
2704
2837
  // Presence in THIS pass's `desired` set is itself POSITIVE evidence of parking (truncation only ever
2705
- // DROPS tasks, never invents one), so a run whose `feature-escalation` task was already swept is
2706
- // genuinely parked — skip its per-instance `openUserTasks` RPC entirely (an avoidable N+1 on every
2707
- // tick). Only a run NOT confirmed parked by the sweep falls through to the per-instance check below.
2708
- const sweptParkedEscalations = new Set(
2709
- desired.filter((r) => r.element_id === FEATURE_ESCALATION_ELEMENT && r.process_key).map((r) => r.process_key),
2710
- );
2838
+ // DROPS tasks, never invents one), so a run whose escalation task was already swept is genuinely
2839
+ // parked — skip its per-instance `openUserTasks` RPC entirely (an avoidable N+1 on every tick). Only a
2840
+ // run NOT confirmed parked by the sweep falls through to the per-instance check below.
2841
+ //
2842
+ // A run's escalation may park on its OWN inline `feature-escalation` (same instance as `process_key`)
2843
+ // OR — once its implement step runs as a callActivity child cell (ADR 0006 S4, #603/#633) — on the
2844
+ // shared `human-escalation` cell's `escalation` element inside a CHILD instance, correlated back to
2845
+ // the run via the parent/root key (so the desired row's `subject_key` is the run's `feature_key`, not
2846
+ // the child instance). So the swept set is keyed by BOTH the task's own `process_key` AND its
2847
+ // correlated `subject_key`, and a run counts as parked when either matches (`process_key` for the
2848
+ // inline case, `feature_key` for the child-cell case).
2849
+ const sweptParkedEscalations = new Set<string>();
2850
+ for (const r of desired) {
2851
+ if (r.element_id !== FEATURE_ESCALATION_ELEMENT && r.element_id !== HUMAN_ESCALATION_ELEMENT) continue;
2852
+ if (r.subject_type !== "feature") continue;
2853
+ if (r.process_key) sweptParkedEscalations.add(r.process_key);
2854
+ if (r.subject_key) sweptParkedEscalations.add(r.subject_key);
2855
+ }
2711
2856
  for (const run of await featureRuns(data).find({ status: "escalated" })) {
2712
2857
  if (!run.process_key) continue;
2713
- if (sweptParkedEscalations.has(run.process_key)) continue; // already seen parked this pass — no RPC, no heal
2858
+ if (sweptParkedEscalations.has(run.process_key) || sweptParkedEscalations.has(run.feature_key)) continue; // already seen parked this pass — no RPC, no heal
2714
2859
  // A just-written escalation may not have its `feature-escalation` user task yet: the sole writer,
2715
2860
  // `record-feature-escalation`, stamps `updated_at` immediately BEFORE the engine creates the task.
2716
2861
  // Skip healing inside the grace window so this pass never races that transition and steals a fresh
2717
2862
  // escalation; a genuinely-stranded (old, or timestamp-less) row is past the window and still healed.
2718
2863
  const escalatedAt = Date.parse(run.updated_at ?? "");
2719
2864
  if (Number.isFinite(escalatedAt) && Date.now() - escalatedAt < FEATURE_ESCALATION_HEAL_GRACE_MS) continue;
2720
- let openTasks: { elementId?: string }[];
2721
- try {
2722
- openTasks = await engine.openUserTasks({ processInstanceKey: run.process_key });
2723
- } catch (err) {
2724
- // Negative evidence from a failed query is not proof the run is unparked — leave it for a later pass.
2725
- console.error(`[poller] escalated-run self-heal (${run.feature_key} @ ${run.process_key}): ${err}`);
2726
- continue;
2727
- }
2728
- if (!openTasks.some((t) => t.elementId === FEATURE_ESCALATION_ELEMENT)) {
2729
- await featureRuns(data).update(run.feature_key, { status: "running", updated_at: at });
2865
+ // Confirm across the run's WHOLE callActivity hierarchy, not just its parent instance: a child-cell
2866
+ // escalation (ADR 0006 S4, #603/#633) parks on the shared `human-escalation` cell's `escalation`
2867
+ // element inside a DESCENDANT instance that the parent's `openUserTasks` never reports. Confirming
2868
+ // the parent alone would read "no escalation open" and wrongly flip a genuinely-parked child-cell run
2869
+ // 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)));
2876
+ let stillParked = false;
2877
+ 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;
2890
+ }
2730
2891
  }
2892
+ if (queryErrored || stillParked) continue;
2893
+ await featureRuns(data).update(run.feature_key, { status: "running", updated_at: at });
2731
2894
  }
2732
2895
  }
2733
2896
 
@@ -2751,7 +2914,7 @@ export async function pollOnce(
2751
2914
  await pollFeatureDelivery(data);
2752
2915
  await pollLineage(data);
2753
2916
  await pollUserTasks(data, engine, engineRest);
2754
- await pollEpicPhase(data, engine);
2917
+ await pollEpicPhase(data, engine, engineRest);
2755
2918
  await pollTasklessPlanTermination(data, engine);
2756
2919
  await pollDeliveryGraphPhase(data, engine);
2757
2920
  await pollDeliveryProposals(data);
package/app/userTasks.ts CHANGED
@@ -60,6 +60,17 @@ export const PR_WAIT_MERGE_ANSWER_ELEMENT = "wait-merge-answer";
60
60
  * hire — a `yolo`-policy request never reaches this path (see `app/agentic/permission-bridge.ts`). */
61
61
  export const ACP_PERMISSION_ELEMENT = "acp-permission";
62
62
 
63
+ /** The shared human-escalation cell's escalation user task (`human-escalation.bpmn`, ADR 0006 S4,
64
+ * issue #603/#633). The fine-grained `implement-cell` delegates its escalation to the reusable
65
+ * `human-escalation` cell via `callActivity`, so once a wave/slice runs as a child instance the
66
+ * agent-stuck escalation parks on THIS element id (`escalation`) inside that child — NOT on the
67
+ * parent's inline `feature-escalation`. It renders the SAME `feature-escalation` form and is the same
68
+ * agent-answerable task escalation, so the inbox surfaces it under the one "Feature escalation" kind
69
+ * and the poller correlates it back to the parent run via the engine's native parent/root instance
70
+ * keys (Magikcraft/nano-bpm#977, the #464 option-B decision). Without this the child-instance
71
+ * escalation is filtered out by the unknown-kind guard and silently vanishes from the Tasks inbox. */
72
+ export const HUMAN_ESCALATION_ELEMENT = "escalation";
73
+
63
74
  /** One row per currently-open native user-task escalation, denormalised for the Tasks page. Keyed on
64
75
  * the completable `user_task_key` (a task is open at most once). Present iff the engine reports the
65
76
  * task open; `pollUserTasks` deletes it once the task is gone. */
@@ -92,6 +103,7 @@ export const userTasks = (data: DataLayer) => data.table<UserTaskRow>("user_task
92
103
  * ignored by `buildUserTaskRow`, so an arbitrary internal user task can never leak into the inbox. */
93
104
  export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
94
105
  [FEATURE_ESCALATION_ELEMENT]: "Feature escalation",
106
+ [HUMAN_ESCALATION_ELEMENT]: "Feature escalation",
95
107
  [FEATURE_BLOCKED_ELEMENT]: "Blocked feature run",
96
108
  [PLAN_REVIEW_ELEMENT]: "Plan review",
97
109
  [EMPTY_PLAN_ELEMENT]: "Empty plan",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.167.3",
3
+ "version": "0.168.0",
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",