@nanobpm/nano-workforce 0.167.4 → 0.168.1

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.1](https://github.com/nanobpm/nano-workforce/compare/v0.168.0...v0.168.1) (2026-08-31)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **cockpit:** render the agentic transcript beneath the supply table ([#660](https://github.com/nanobpm/nano-workforce/issues/660)) ([#662](https://github.com/nanobpm/nano-workforce/issues/662)) ([ed2c041](https://github.com/nanobpm/nano-workforce/commit/ed2c041e0e4508e825324ed2b398bf64b5c66c37))
6
+
7
+ ## [0.168.0](https://github.com/nanobpm/nano-workforce/compare/v0.167.4...v0.168.0) (2026-08-31)
8
+
9
+ ### Features
10
+
11
+ * 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)
12
+
1
13
  ## [0.167.4](https://github.com/nanobpm/nano-workforce/compare/v0.167.3...v0.167.4) (2026-08-31)
2
14
 
3
15
  ### Build System
@@ -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",
@@ -0,0 +1,102 @@
1
+ // #660 — the browser transcript bundle is DERIVED from the typed core, and it RENDERS (never dumps raw
2
+ // `nwfTranscriptEvent` JSON).
3
+ //
4
+ // Two guarantees:
5
+ // 1. Drift guard — the committed `pages/cockpit/generated/*.js` is byte-identical to a fresh transpile
6
+ // of the `.ts` core, so the deployed browser render path can never silently drift from the typed,
7
+ // tested source (the exact failure mode #660 was: a hand-copy that lost the render path).
8
+ // 2. Behaviour — importing the GENERATED module the browser actually loads and rendering it into the
9
+ // DOM double proves a `nwfTranscriptEvent` chunk is surfaced as derived turns/tool/diff/permission
10
+ // cards, and NEVER verbatim.
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { test } from "node:test";
15
+ import { assert, assertEquals } from "#test-assert";
16
+ import { cockpitBrowserBundle } from "../../../scripts/build-cockpit-browser.ts";
17
+ import { FakeDocument, FakeElement } from "../../../test/agentic-cockpit-doubles.ts";
18
+ import { TRANSCRIPT_EVENT_MARKER, TRANSCRIPT_EVENT_VERSION } from "../transcript-events.ts";
19
+ // The module under test is the GENERATED browser artifact the deployed cockpit imports — NOT the .ts
20
+ // source — so this exercises exactly the code path the browser runs.
21
+ import { renderDerivedTranscript } from "../../../pages/cockpit/generated/transcript-derive.js";
22
+
23
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
24
+
25
+ test("the committed browser bundle is byte-identical to its typed source (no drift surface)", () => {
26
+ for (const { out, content } of cockpitBrowserBundle()) {
27
+ const committed = readFileSync(resolve(repoRoot, out), "utf8");
28
+ assertEquals(
29
+ committed,
30
+ content,
31
+ `${out} is stale — regenerate with: node --experimental-strip-types scripts/build-cockpit-browser.ts`,
32
+ );
33
+ }
34
+ });
35
+
36
+ const doc = new FakeDocument();
37
+
38
+ function env(kind: string, extra: Record<string, unknown> = {}): string {
39
+ return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, kind, ...extra });
40
+ }
41
+
42
+ /** A page whose chunks are `nwfTranscriptEvent` envelopes: messages, a tool call/result diff, a permission prompt. */
43
+ function report(): {
44
+ stream: string;
45
+ from: number;
46
+ gap: boolean;
47
+ nextOffset: number;
48
+ entries: Array<{ offset: number; chunk: string }>;
49
+ } {
50
+ const chunks = [
51
+ env("turn", { index: 0 }),
52
+ env("message", { role: "user", text: "please build it" }),
53
+ env("tool-call", { name: "edit", callId: "c1", args: { path: "a.txt", oldText: "one\n", newText: "two\n" } }),
54
+ env("tool-result", { callId: "c1", ok: true, content: "done" }),
55
+ env("message", { role: "assistant", text: "built it" }),
56
+ env("permission", {
57
+ phase: "request",
58
+ callId: "p1",
59
+ policy: "escalate",
60
+ title: "Run shell?",
61
+ toolName: "bash",
62
+ options: [
63
+ { optionId: "ok", name: "Allow", kind: "allow-once" },
64
+ { optionId: "no", name: "Deny", kind: "reject-once" },
65
+ ],
66
+ }),
67
+ ];
68
+ return { stream: "job:1", from: 0, gap: false, nextOffset: chunks.length, entries: chunks.map((chunk, offset) => ({ offset, chunk })) };
69
+ }
70
+
71
+ test("regression: a nwfTranscriptEvent chunk is rendered as derived cards, NEVER surfaced verbatim", () => {
72
+ const host = new FakeElement("div");
73
+ renderDerivedTranscript(host as never, doc, report() as never);
74
+ // The rendered structured view exists…
75
+ assertEquals(host.byClass("cockpit-transcript-derived").length, 1);
76
+ // …and the raw envelope marker is nowhere in the rendered text (the #660 bug: raw JSON echoed).
77
+ assert(!host.text().includes(TRANSCRIPT_EVENT_MARKER), "rendered transcript must not contain the raw nwfTranscriptEvent marker");
78
+ });
79
+
80
+ test("feature: messages coalesce into one turn, tool/diff card renders, permission prompt renders", () => {
81
+ const host = new FakeElement("div");
82
+ renderDerivedTranscript(host as never, doc, report() as never);
83
+
84
+ // Message coalescing: both the user and assistant messages fold under a SINGLE derived turn section.
85
+ const turns = host.byClass("cockpit-transcript-turn");
86
+ assertEquals(turns.length, 1);
87
+ const roles = turns[0]?.byClass("cockpit-transcript-message").map((n) => n.getAttribute("data-role")) ?? [];
88
+ assertEquals(roles, ["user", "assistant"]);
89
+
90
+ // Tool card with a synthesized diff (structured edit args → add/del lines).
91
+ const tool = host.byData("tool", "edit")[0];
92
+ assert(tool !== undefined, "the tool card is rendered");
93
+ assertEquals(tool?.getAttribute("data-tool-kind"), "diff");
94
+ const diffKinds = host.byClass("cockpit-transcript-diff-line").map((n) => n.getAttribute("data-diff-line"));
95
+ assert(diffKinds.includes("add") && diffKinds.includes("del"), "the diff shows add + del lines");
96
+
97
+ // Permission prompt with interactive Allow/Deny options.
98
+ const perm = host.byData("permission", "request")[0];
99
+ assert(perm !== undefined, "the permission prompt is rendered");
100
+ assertEquals(perm?.getAttribute("data-status"), "pending");
101
+ assertEquals(host.byClass("cockpit-transcript-permission-option").length, 2);
102
+ });
@@ -0,0 +1,162 @@
1
+ // #660 — the DEPLOYED browser adapter (pages/cockpit/mount.js) renders the transcript for BOTH a live
2
+ // drill and a past-session replay, and NEVER surfaces a raw `nwfTranscriptEvent` chunk verbatim.
3
+ //
4
+ // This drives mount.js end-to-end on Node against a real (linkedom) DOM, a stub relay WebSocket, and a
5
+ // stub `fetch`, so it exercises the actual live-drill sink wiring and the replay fetch→render path — the
6
+ // two seams that used to write relay chunks straight to xterm. It also asserts the rendered transcript
7
+ // region sits directly beneath the Workers — supply table.
8
+ import { test } from "node:test";
9
+ import { assert, assertEquals } from "#test-assert";
10
+ import { encodeFrame } from "@nanobpm/agentic/protocol";
11
+ import { parseHTML } from "linkedom";
12
+ import { TRANSCRIPT_EVENT_MARKER, TRANSCRIPT_EVENT_VERSION } from "../transcript-events.ts";
13
+
14
+ function envChunk(kind: string, extra: Record<string, unknown> = {}): string {
15
+ return JSON.stringify({ [TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION, kind, ...extra });
16
+ }
17
+
18
+ /** A stub browser WebSocket that records instances and lets a test drive open + inbound frames by hand. */
19
+ class StubWebSocket {
20
+ static readonly instances: StubWebSocket[] = [];
21
+ binaryType = "";
22
+ readonly url: string;
23
+ readonly #listeners = new Map<string, Array<(event: unknown) => void>>();
24
+ constructor(url: string) {
25
+ this.url = url;
26
+ StubWebSocket.instances.push(this);
27
+ }
28
+ addEventListener(type: string, handler: (event: unknown) => void): void {
29
+ const list = this.#listeners.get(type) ?? [];
30
+ list.push(handler);
31
+ this.#listeners.set(type, list);
32
+ }
33
+ send(): void {}
34
+ close(): void {}
35
+ fireOpen(): void {
36
+ for (const h of this.#listeners.get("open") ?? []) h({});
37
+ }
38
+ /** Deliver one relay frame (as the browser would: an ArrayBuffer message event). */
39
+ deliver(frame: unknown): void {
40
+ const bytes = encodeFrame(frame as never);
41
+ for (const h of this.#listeners.get("message") ?? []) h({ data: bytes.buffer });
42
+ }
43
+ }
44
+
45
+ /** Install a linkedom DOM + stub WebSocket/fetch as globals mount.js reads; returns a cleanup fn. */
46
+ function installEnv(fetchImpl: (url: string) => Promise<unknown>): () => void {
47
+ const { window, document } = parseHTML("<!doctype html><html><body><main id='root'></main></body></html>");
48
+ const g = globalThis as Record<string, unknown>;
49
+ const saved = {
50
+ window: g.window,
51
+ document: g.document,
52
+ location: g.location,
53
+ WebSocket: g.WebSocket,
54
+ fetch: g.fetch,
55
+ };
56
+ g.window = window;
57
+ g.document = document;
58
+ g.location = { hash: "", href: "http://app.test/cockpit/", pathname: "/cockpit/", search: "" };
59
+ g.WebSocket = StubWebSocket;
60
+ g.fetch = (url: unknown) => fetchImpl(String(url));
61
+ StubWebSocket.instances.length = 0;
62
+ return () => {
63
+ g.window = saved.window;
64
+ g.document = saved.document;
65
+ g.location = saved.location;
66
+ g.WebSocket = saved.WebSocket;
67
+ g.fetch = saved.fetch;
68
+ };
69
+ }
70
+
71
+ const SUPPLY = { leaves: [], correlations: [] };
72
+
73
+ /** A fetch stub answering the supply poll, the past-sessions list, and a single-stream replay. */
74
+ function fetchStub(replay?: unknown) {
75
+ return (url: string): Promise<unknown> => {
76
+ const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: async () => body });
77
+ if (url.includes("/supply")) return ok(SUPPLY);
78
+ if (replay !== undefined && /\/transcripts\/[^/]+$/.test(url)) return ok(replay);
79
+ if (url.includes("/transcripts")) return ok({ sessions: [] });
80
+ return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
81
+ };
82
+ }
83
+
84
+ const OPTS = {
85
+ reportUrl: "http://app.test/app/api/agentic/supply",
86
+ transcriptsUrl: "http://app.test/app/api/agentic/transcripts",
87
+ relayUrl: "ws://app.test/agentic",
88
+ refreshMs: 1_000_000, // effectively disable the self-scheduling poll; we dispose() at the end.
89
+ };
90
+
91
+ test("the rendered transcript region sits directly beneath the Workers — supply table", async () => {
92
+ const restore = installEnv(fetchStub());
93
+ try {
94
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
95
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
96
+ const shell = document.querySelector(".cockpit-shell");
97
+ const order = [...(shell?.children ?? [])].map((c: { className: string }) => c.className);
98
+ assertEquals(order, ["cockpit-supply-region", "cockpit-terminal", "cockpit-past-region"]);
99
+ handle.dispose();
100
+ } finally {
101
+ restore();
102
+ }
103
+ });
104
+
105
+ test("live drill renders the transcript — a nwfTranscriptEvent chunk is never surfaced verbatim", async () => {
106
+ const restore = installEnv(fetchStub());
107
+ try {
108
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
109
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
110
+ handle.drill("job:live");
111
+ const socket = StubWebSocket.instances[0];
112
+ assert(socket !== undefined, "a relay socket was opened for the drill");
113
+ socket.fireOpen();
114
+ socket.deliver({ lane: "control", family: "relay", seq: 0, payload: { op: "subscribed", stream: "job:live", gap: false, nextOffset: 0 } });
115
+ socket.deliver({
116
+ lane: "bulk",
117
+ family: "relay",
118
+ seq: 1,
119
+ payload: { stream: "job:live", offset: 0, chunk: envChunk("message", { role: "assistant", text: "hello from the agent" }) },
120
+ });
121
+
122
+ const host = document.querySelector('[data-terminal="host"]');
123
+ const rendered = host?.querySelector(".cockpit-transcript-derived");
124
+ assert(rendered != null, "the derived transcript is rendered into the terminal host");
125
+ assert((host?.textContent ?? "").includes("hello from the agent"), "the message text is rendered");
126
+ assert(!(host?.textContent ?? "").includes(TRANSCRIPT_EVENT_MARKER), "the raw nwfTranscriptEvent marker is never shown");
127
+ assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "live");
128
+ handle.dispose();
129
+ } finally {
130
+ restore();
131
+ }
132
+ });
133
+
134
+ test("replay renders a past session's transcript — never a raw nwfTranscriptEvent dump", async () => {
135
+ const replay = {
136
+ stream: "job:past",
137
+ from: 0,
138
+ gap: false,
139
+ nextOffset: 3,
140
+ entries: [
141
+ { offset: 0, chunk: envChunk("message", { role: "user", text: "kick off" }) },
142
+ { offset: 1, chunk: envChunk("tool-call", { name: "grep", callId: "c1" }) },
143
+ { offset: 2, chunk: envChunk("tool-result", { callId: "c1", ok: true, content: "match" }) },
144
+ ],
145
+ };
146
+ const restore = installEnv(fetchStub(replay));
147
+ try {
148
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
149
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
150
+ await handle.replay("job:past");
151
+
152
+ const host = document.querySelector('[data-terminal="host"]');
153
+ assert(host?.querySelector(".cockpit-transcript-derived") != null, "the derived transcript is rendered on replay");
154
+ assert((host?.textContent ?? "").includes("kick off"), "the message text is rendered");
155
+ assert(host?.querySelector('[data-tool="grep"]') != null, "the tool card is rendered");
156
+ assert(!(host?.textContent ?? "").includes(TRANSCRIPT_EVENT_MARKER), "the raw nwfTranscriptEvent marker is never shown");
157
+ assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "replay");
158
+ handle.dispose();
159
+ } finally {
160
+ restore();
161
+ }
162
+ });
@@ -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.