@nanobpm/nano-workforce 0.111.1 → 0.113.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.
@@ -0,0 +1,371 @@
1
+ // nano-workforce — the `human` delivery-graph node (ADR 0005, slice S3). A `human` node promotes
2
+ // ADR 0002's user-task+form machinery from an *exception* (something broke) to a *scheduled node* (a
3
+ // planned "now do X" stop that BLOCKS its dependents, is answerable by a human OR an agent — ADR 0046
4
+ // — is SLA-bounded so it nags and cannot silently wedge the graph, and can EMIT a typed fact its form
5
+ // captures which late-binds downstream). It is the emit-side of #263's emit-vs-poll dual: a *human*
6
+ // emitter is the same shape as an automated one (the `capability`/`pr` probe's `resolvedArtifact`
7
+ // bind), which is what unifies human and automated steps in one graph.
8
+ //
9
+ // This module owns the PURE, side-effect-free machinery the compiler (S1, author-time) and the
10
+ // runner (S4, runtime) reuse — it never itself creates a user task or completes one. Execution stays
11
+ // engine-native (Decision 2): the human node's body is the deployed `delivery-human` `bpmn:userTask`
12
+ // (`resources/processes/delivery-human.bpmn`, an SLA-bounded scheduled user task), completion routes
13
+ // through the ONE canonical completer (`completeEscalationAsAgent`/`completeEscalationAsHuman`,
14
+ // `app/agentCompletion.ts`) because `DELIVERY_HUMAN_ELEMENT` is registered in
15
+ // `ESCALATION_TASK_ELEMENTS`, and the emitted fact threads downstream via the #289 brief-appender /
16
+ // bound-output pattern (`renderResolvedDepsBrief` / the `caps-resolved` → `appendPrompt` recipe).
17
+ //
18
+ // The three pure concerns:
19
+ // 1. FORM RESOLUTION — specific-else-generic (Decision 4): an explicit `human.formKey` on the node,
20
+ // else a form SELECTED by node category (derived from the node's typed emits), else a GENERIC
21
+ // fallback form that STILL captures a typed emitted fact — so every human node can emit even with
22
+ // no bespoke form. A runtime agent-form-router is a GATED exception: it fires ONLY when a node
23
+ // activates with no statically resolvable form (multiple heterogeneous emits, no explicit/bespoke
24
+ // form), never in the common path — the same "deterministic default, agent judgment as the escape
25
+ // hatch" grain as the capability probe's empirical verifier.
26
+ // 2. TYPED EMIT — validate + coerce the completed form output against the node's declared `emits[]`
27
+ // (Decision 3/4 — binds are validated, not stringly), producing the typed facts the node hands
28
+ // forward. A "click done" node declares no emits — the degenerate no-emit case.
29
+ // 3. LATE-BINDING — render the emitted facts into a downstream node's brief (`renderHumanEmitBrief`,
30
+ // mirroring `renderResolvedDepsBrief`) and into a qualified bind map keyed `<nodeId>.<fact>`
31
+ // (`humanEmitBind`, mirroring the probe `bind`) so a downstream `capability`/`npm`/`pr` edge
32
+ // binds and pins exactly the value the human handed forward.
33
+ import type { DeliveryFact, DeliveryNodeHuman } from "../nano-generated/api-io.d.ts";
34
+ import { DELIVERY_FACT_TYPES, type DeliveryFactType, FACT_NAME_MAX_LENGTH, FACT_NAME_PATTERN } from "./deliveryGraph.ts";
35
+
36
+ /** The single BPMN `bpmn:userTask` element id every `human` delivery-graph node schedules its work
37
+ * as (`resources/processes/delivery-human.bpmn`). One reusable engine-native body, instantiated once
38
+ * per human node (mirroring the one `readiness-gate` process instantiated per `wait` node). It is
39
+ * registered in `ESCALATION_TASK_ELEMENTS` (`app/agentCompletion.ts`) so a human OR an agent
40
+ * (ADR 0046) can complete it through the ONE canonical `complete-user-task` / `agent-complete` door,
41
+ * and in `USER_TASK_KIND_LABELS` (`app/userTasks.ts`) so it surfaces on the Tasks inbox. */
42
+ export const DELIVERY_HUMAN_ELEMENT = "delivery-human-task";
43
+
44
+ /** The GENERIC fallback form (Decision 4, step 3): captures ONE typed value into the node's single
45
+ * declared emitted fact, so a human node with no explicit/category form can STILL emit downstream. */
46
+ export const GENERIC_HUMAN_FORM = "delivery-human-generic";
47
+
48
+ /** The "click done" category form: a degenerate no-emit acknowledgement ("now do X" → done). */
49
+ export const HUMAN_ACK_FORM = "delivery-human-ack";
50
+
51
+ /** The manual-publish category form: captures a `resolvedArtifact` (`pkg@version`) — the motivating
52
+ * case where a human hands a just-published version forward to a downstream `capability`/`npm`/`pr`
53
+ * edge. */
54
+ export const HUMAN_PUBLISH_FORM = "delivery-human-publish";
55
+
56
+ /** A human node's derived CATEGORY — a coarse classification of what the node emits, used to SELECT a
57
+ * bespoke form (Decision 4, step 2) without the author naming one. Derived from the node's typed
58
+ * `emits[]` so the same typed-fact declaration that drives late-binding also drives form selection —
59
+ * one source of truth, no second author-facing knob (the frozen S0 `human` config carries only
60
+ * `formKey`/`prompt`). `null` ⇒ no bespoke category form applies (fall through to generic/router). */
61
+ export type HumanNodeCategory = "ack" | "publish";
62
+
63
+ /** Category → its bespoke form. Kept as the single source of truth for "which form a category selects"
64
+ * so {@link resolveHumanForm} and any preview/compile step agree. */
65
+ export const HUMAN_CATEGORY_FORMS: Readonly<Record<HumanNodeCategory, string>> = {
66
+ ack: HUMAN_ACK_FORM,
67
+ publish: HUMAN_PUBLISH_FORM,
68
+ };
69
+
70
+ /** How a human node's form was resolved. `explicit` — the node named a `human.formKey`; `category` —
71
+ * a form was selected by the node's derived category; `generic` — the typed-emit-capturing fallback;
72
+ * `agent-router` — NOTHING statically resolved, so a runtime agent must pick/assemble one (the gated
73
+ * exception). */
74
+ export type HumanFormSource = "explicit" | "category" | "generic" | "agent-router";
75
+
76
+ /** The resolved form for a human node (Decision 4). `formKey` is the `.form` to attach — `null` ONLY
77
+ * for `agent-router`, where the runtime supplies it. `category` records the derived category when one
78
+ * applied. `reason` is a human-readable one-liner for the compiled preview. */
79
+ export interface HumanFormResolution {
80
+ readonly source: HumanFormSource;
81
+ readonly formKey: string | null;
82
+ readonly category: HumanNodeCategory | null;
83
+ readonly emits: readonly DeliveryFact[];
84
+ readonly reason: string;
85
+ }
86
+
87
+ /** A typed fact a completed human node hands forward — its declared `name`/`type` plus the validated,
88
+ * canonically-serialised `value` (a string, like the probe `bind`, so it threads uniformly into
89
+ * prompts, messages, and qualified bind maps). */
90
+ export interface BoundFact {
91
+ readonly name: string;
92
+ readonly type: DeliveryFactType;
93
+ readonly value: string;
94
+ }
95
+
96
+ /** The outcome of binding a completed form's output to a node's declared emits: the typed `facts` in
97
+ * declaration order plus any path-qualified `errors` (a missing/ill-typed declared fact). An empty
98
+ * `emits` yields `{ facts: [], errors: [] }` — the degenerate no-emit "click done" case. */
99
+ export interface HumanEmitResult {
100
+ readonly facts: readonly BoundFact[];
101
+ readonly errors: readonly string[];
102
+ }
103
+
104
+ /** Narrow an untyped value to a plain object so its fields can be read defensively. */
105
+ function isRecord(value: unknown): value is Record<string, unknown> {
106
+ return typeof value === "object" && value !== null && !Array.isArray(value);
107
+ }
108
+
109
+ /** True when `value` is one of the closed set of delivery fact types (a type guard, so the pure
110
+ * form/emit logic narrows without an `as` assertion). */
111
+ function isDeliveryFactType(value: unknown): value is DeliveryFactType {
112
+ return typeof value === "string" && DELIVERY_FACT_TYPES.some((t) => t === value);
113
+ }
114
+
115
+ /** Normalise a human node's `emits[]` to the well-formed, typed declarations, dropping anything the
116
+ * S0 validator would already have rejected (a non-record entry, a blank name, a name that is not a
117
+ * dot-free identifier within the 128-char cap, a duplicate name, or an unknown type) so the pure
118
+ * form/emit logic never trips on a malformed declaration — the graph is validated upstream by
119
+ * `validateDeliveryGraph`, this is defence in depth. Fact-name pattern/length are the SAME canonical
120
+ * constants the validator enforces (`FACT_NAME_PATTERN`/`FACT_NAME_MAX_LENGTH`), so a dotted /
121
+ * over-long / duplicate name can't slip through here and make `<nodeId>.<fact>` binding ambiguous. */
122
+ export function normalizeEmits(node: Pick<DeliveryNodeHuman, "emits">): DeliveryFact[] {
123
+ const raw = node.emits;
124
+ if (!Array.isArray(raw)) return [];
125
+ const facts: DeliveryFact[] = [];
126
+ const seen = new Set<string>();
127
+ for (const entry of raw) {
128
+ if (!isRecord(entry)) continue;
129
+ const name = entry.name;
130
+ const type = entry.type;
131
+ if (typeof name !== "string" || name.length === 0) continue;
132
+ if (name.length > FACT_NAME_MAX_LENGTH || !FACT_NAME_PATTERN.test(name)) continue;
133
+ if (seen.has(name)) continue;
134
+ if (!isDeliveryFactType(type)) continue;
135
+ seen.add(name);
136
+ facts.push({ name, type, ...(typeof entry.description === "string" ? { description: entry.description } : {}) });
137
+ }
138
+ return facts;
139
+ }
140
+
141
+ /** Derive a human node's CATEGORY from its typed emits (Decision 4, step 2), or `null` when no bespoke
142
+ * category form applies:
143
+ * - `ack` — the node emits NOTHING (a "click done" acknowledgement).
144
+ * - `publish` — the node emits EXACTLY ONE `artifact` fact (a `pkg@version` handle — the manual-
145
+ * publish-hands-a-version-forward case), which the bespoke publish form captures as a
146
+ * `resolvedArtifact`. A lone `version` (a BARE version, e.g. `1.4.0`) does NOT map here: the
147
+ * publish form captures a `pkg@version` into `resolvedArtifact`, which fails `version` coercion in
148
+ * {@link bindHumanEmits} — so a single `version` falls through to the generic single-value form
149
+ * (which captures a bare `value`, validated against the `version` type).
150
+ * - `null` — a single non-artifact scalar/url/version fact (the generic fallback captures it), OR
151
+ * two-or-more facts (no bespoke or generic single-value form can hold them → the agent-router
152
+ * territory).
153
+ * Kept coarse ON PURPOSE: bespoke category forms are a deterministic convenience, not an open
154
+ * taxonomy; anything they don't cover falls through to the generic fallback or the gated router. */
155
+ export function deriveHumanCategory(emits: readonly DeliveryFact[]): HumanNodeCategory | null {
156
+ if (emits.length === 0) return "ack";
157
+ if (emits.length === 1 && emits[0].type === "artifact") return "publish";
158
+ return null;
159
+ }
160
+
161
+ /** Resolve which form a human node uses, specific-else-generic (ADR 0005 Decision 4). Preference:
162
+ * 1. `explicit` — the node carries a non-blank `human.formKey`.
163
+ * 2. `category` — the node's derived category selects a bespoke form ({@link deriveHumanCategory}).
164
+ * 3. `generic` — the node emits ≤1 fact, captured by the generic typed-emit fallback form.
165
+ * 4. `agent-router` — NOTHING statically resolved (≥2 heterogeneous emits, no explicit/bespoke
166
+ * form): a runtime agent must pick/assemble a form. This is the GATED escape
167
+ * hatch — it fires ONLY here, never in the common path.
168
+ * Pure and total (never throws): the graph is shape/semantic-validated upstream, and malformed emits
169
+ * are normalised away, so this always returns a resolution. Author-time callers (the S1 compiler)
170
+ * attach `formKey` deterministically so the resolved form is visible in the preview; only the
171
+ * `agent-router` case defers to runtime. */
172
+ export function resolveHumanForm(node: Pick<DeliveryNodeHuman, "emits" | "human">): HumanFormResolution {
173
+ const emits = normalizeEmits(node);
174
+ const explicit = node.human?.formKey;
175
+ if (typeof explicit === "string" && explicit.trim().length > 0) {
176
+ return {
177
+ source: "explicit",
178
+ formKey: explicit.trim(),
179
+ category: null,
180
+ emits,
181
+ reason: `explicit form "${explicit.trim()}" attached on the node`,
182
+ };
183
+ }
184
+ const category = deriveHumanCategory(emits);
185
+ if (category !== null) {
186
+ return {
187
+ source: "category",
188
+ formKey: HUMAN_CATEGORY_FORMS[category],
189
+ category,
190
+ emits,
191
+ reason:
192
+ category === "ack"
193
+ ? "no emitted fact — the generic acknowledgement (click-done) form"
194
+ : `emits a single ${emits[0].type} fact — the manual-publish form (captures a resolvedArtifact)`,
195
+ };
196
+ }
197
+ if (emits.length <= 1) {
198
+ return {
199
+ source: "generic",
200
+ formKey: GENERIC_HUMAN_FORM,
201
+ category: null,
202
+ reason: `emits a single ${emits[0].type} fact — the generic typed-emit fallback form`,
203
+ emits,
204
+ };
205
+ }
206
+ return {
207
+ source: "agent-router",
208
+ formKey: null,
209
+ category: null,
210
+ emits,
211
+ reason:
212
+ `emits ${emits.length} typed facts and carries no explicit/bespoke form — no static form can ` +
213
+ "capture them, so a runtime agent-form-router must assemble one (the gated exception)",
214
+ };
215
+ }
216
+
217
+ /** True when a human node resolves to the gated runtime agent-form-router — i.e. nothing statically
218
+ * resolved. A thin predicate over {@link resolveHumanForm} so callers can branch without re-deriving. */
219
+ export function needsAgentFormRouter(node: Pick<DeliveryNodeHuman, "emits" | "human">): boolean {
220
+ return resolveHumanForm(node).source === "agent-router";
221
+ }
222
+
223
+ /** The canonical form-field keys a bespoke/generic form captures its single typed value under, tried
224
+ * (in addition to the fact's own name) when binding a single-fact node's output. The generic form
225
+ * captures `value`; the publish form captures `resolvedArtifact`. */
226
+ const CANONICAL_VALUE_KEYS = ["resolvedArtifact", "value"] as const;
227
+
228
+ /** The ONE accepted shape of a bare version string (an optional `v` then a digit-led `[\w.+-]` run,
229
+ * e.g. `1.4.0`, `v2.0.0-rc.1`). The single source of truth shared by the `version` fact type AND the
230
+ * version segment of an `artifact` handle — so a `pkg@version` artifact validates its version the
231
+ * same way a bare `version` does, with no second notion of "valid version" to drift. */
232
+ const VERSION_PATTERN = /^v?\d[\w.+-]*$/;
233
+
234
+ /** Coerce + validate one raw form value against a declared fact type, returning the canonical string
235
+ * serialisation or an error message. Typed so a bind is validated, not stringly (Decision 3/4): an
236
+ * `artifact` must be `pkg@version` (with a well-formed version segment), a `version` a bare version, a
237
+ * `url` a parseable location, a `number` finite, a `boolean` a real boolean. */
238
+ function coerceFactValue(type: DeliveryFactType, raw: unknown): { value: string } | { error: string } {
239
+ switch (type) {
240
+ case "string": {
241
+ if (typeof raw !== "string" || raw.trim() === "") return { error: "expected a non-empty string" };
242
+ // Return the trimmed value, mirroring the other string-like types (version/artifact/url) so the
243
+ // canonical serialisation does not depend on incidental surrounding whitespace.
244
+ return { value: raw.trim() };
245
+ }
246
+ case "number": {
247
+ const n = typeof raw === "number" ? raw : typeof raw === "string" && raw.trim() !== "" ? Number(raw) : Number.NaN;
248
+ if (!Number.isFinite(n)) return { error: "expected a finite number" };
249
+ return { value: String(n) };
250
+ }
251
+ case "boolean": {
252
+ if (typeof raw === "boolean") return { value: String(raw) };
253
+ // Trim before validating, mirroring the other string-like types (version/artifact/url) so a
254
+ // boolean captured via the generic textfield (e.g. " true ") is not needlessly brittle.
255
+ const trimmed = typeof raw === "string" ? raw.trim() : raw;
256
+ if (trimmed === "true" || trimmed === "false") return { value: trimmed };
257
+ return { error: "expected a boolean (true/false)" };
258
+ }
259
+ case "version": {
260
+ if (typeof raw !== "string" || !VERSION_PATTERN.test(raw.trim())) {
261
+ return { error: "expected a version string (e.g. 1.4.0)" };
262
+ }
263
+ return { value: raw.trim() };
264
+ }
265
+ case "artifact": {
266
+ if (typeof raw !== "string") return { error: "expected a pkg@version artifact handle" };
267
+ const at = raw.trim().lastIndexOf("@");
268
+ const name = raw.trim().slice(0, at);
269
+ const version = raw.trim().slice(at + 1);
270
+ if (at <= 0 || name.length === 0 || version.length === 0 || !VERSION_PATTERN.test(version)) {
271
+ return { error: "expected a pkg@version artifact handle (e.g. @nanobpm/urban@0.54.0)" };
272
+ }
273
+ return { value: raw.trim() };
274
+ }
275
+ case "url": {
276
+ if (typeof raw !== "string" || raw.trim() === "") return { error: "expected a URL" };
277
+ try {
278
+ new URL(raw.trim());
279
+ } catch {
280
+ return { error: "expected a valid URL (with a scheme)" };
281
+ }
282
+ return { value: raw.trim() };
283
+ }
284
+ default:
285
+ return { error: `unknown fact type "${type}"` };
286
+ }
287
+ }
288
+
289
+ /** Bind a completed human node's form `output` to its declared `emits[]` (Decision 3/4). For each
290
+ * declared fact, read its value from the output — preferring the fact's own `name` key, then, for a
291
+ * single-fact node, the canonical capture keys the generic/publish forms use (`resolvedArtifact` /
292
+ * `value`) — and validate/coerce it against the fact's type. Returns the typed facts in declaration
293
+ * order plus one error per missing/ill-typed declared fact. A node with no declared emits yields no
294
+ * facts and no errors (the degenerate "click done" case) regardless of what the form captured.
295
+ *
296
+ * This is the typed-emit contract the runner (S4) enforces on completion before threading the facts
297
+ * downstream via {@link humanEmitBind} / {@link renderHumanEmitBrief} — the emit-side of #263. */
298
+ export function bindHumanEmits(
299
+ emits: readonly DeliveryFact[],
300
+ output: Record<string, unknown> | null | undefined,
301
+ ): HumanEmitResult {
302
+ const out = isRecord(output) ? output : {};
303
+ const facts: BoundFact[] = [];
304
+ const errors: string[] = [];
305
+ const single = emits.length === 1;
306
+ for (const fact of emits) {
307
+ let raw = out[fact.name];
308
+ if (raw === undefined && single) {
309
+ for (const key of CANONICAL_VALUE_KEYS) {
310
+ if (out[key] !== undefined) {
311
+ raw = out[key];
312
+ break;
313
+ }
314
+ }
315
+ }
316
+ if (raw === undefined || raw === null) {
317
+ errors.push(`emits.${fact.name}: no value captured for the declared ${fact.type} fact`);
318
+ continue;
319
+ }
320
+ const coerced = coerceFactValue(fact.type, raw);
321
+ if ("error" in coerced) {
322
+ errors.push(`emits.${fact.name}: ${coerced.error}`);
323
+ continue;
324
+ }
325
+ facts.push({ name: fact.name, type: fact.type, value: coerced.value });
326
+ }
327
+ return { facts, errors };
328
+ }
329
+
330
+ /** Build the qualified bind map a human node's emitted facts publish downstream, keyed exactly as a
331
+ * delivery edge references them — `<nodeId>.<fact>` (mirroring the probe `bind: Record<string,string>`
332
+ * and the `capability` kind's `resolvedArtifact`). A downstream edge `from: "<nodeId>.<fact>"` binds
333
+ * and PINS the human-handed value from this map. Empty for a no-emit node. */
334
+ export function humanEmitBind(nodeId: string, facts: readonly BoundFact[]): Record<string, string> {
335
+ const bind: Record<string, string> = {};
336
+ for (const fact of facts) bind[`${nodeId}.${fact.name}`] = fact.value;
337
+ return bind;
338
+ }
339
+
340
+ /** Neutralise a human-submitted value before it is embedded in a Markdown inline-code span in the
341
+ * brief. A raw backtick would terminate the span (corrupting the brief and letting free-form form
342
+ * text inject unintended prompt content into the downstream agent's prompt), and a newline would
343
+ * break the span; collapse both. Display-only — the authoritative value published downstream via
344
+ * {@link humanEmitBind} is untouched, so the pinned contract is unaffected. */
345
+ function inlineCodeSafe(value: string): string {
346
+ return value.replace(/`/g, "'").replace(/\r?\n/g, " ");
347
+ }
348
+
349
+ /** Render the "human-emitted facts" brief appended to a downstream node's prompt once a human node
350
+ * completes and hands its typed facts forward (#289 §3), mirroring `renderResolvedDepsBrief`. It pins
351
+ * each `name → value` the human declared + captured so a downstream agent/edge consumes EXACTLY that
352
+ * value — no re-derivation, no human re-entry. Returns "" for a no-emit node so callers concatenate
353
+ * unconditionally (the same `if X = null then "" else X` FEEL convention as the other briefs). */
354
+ export function renderHumanEmitBrief(facts: readonly BoundFact[]): string {
355
+ if (facts.length === 0) return "";
356
+ const lines = [
357
+ "",
358
+ "",
359
+ "---",
360
+ "",
361
+ "**Human-emitted facts (authoritative — a scheduled human step handed these forward):**",
362
+ "",
363
+ "A `human` node upstream captured and emitted the typed values below. Consume/pin exactly these —",
364
+ "do NOT re-derive, float, or re-request them:",
365
+ "",
366
+ ];
367
+ for (const fact of facts) lines.push(`- \`${fact.name}\` (${fact.type}) → \`${inlineCodeSafe(fact.value)}\``);
368
+ lines.push("");
369
+ lines.push("These are the contract the human handed forward; a different value is a different run.");
370
+ return lines.join("\n");
371
+ }
@@ -80,7 +80,7 @@ test("merge-protocol: only a `ui` land method is decision-required; the rest are
80
80
  }
81
81
  });
82
82
 
83
- // --- task (plan-fanout w_gw "escalated?") ---
83
+ // --- task (plan-fanout w_gw "clean terminal?" — the implement-stage escalation net, #360) ---
84
84
 
85
85
  test("task: status=escalated with an answerable question is decision-required; blank is none", () => {
86
86
  assertEquals(classifyEscalation({ kind: "task", question: "which approach?" }), "decision-required");
@@ -39,8 +39,10 @@ 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` "escalated?" gateway — an implementation agent reported
43
- // `status = "escalated"` with a question.
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.
44
46
  | "task";
45
47
 
46
48
  /** Everything the classifier may need from any raise site. Each field is consumed only by the
@@ -0,0 +1,40 @@
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.
10
+ //
11
+ // Pure text assertions over the committed BPMN (no engine), matching the repo's model-guard style.
12
+ import { readFileSync } from "node:fs";
13
+ import { test } from "node:test";
14
+ import { assert, assertStringIncludes } from "#test-assert";
15
+
16
+ const bpmn = readFileSync("resources/processes/plan-fanout.bpmn", "utf8");
17
+ const flat = bpmn.replace(/\s+/g, " ");
18
+
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
+
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
+ });
27
+
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");
34
+ });
35
+
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");
40
+ });
@@ -131,6 +131,36 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
131
131
  assertEquals(byKey["ut-pr"].subject_title, "Resolve the reviews");
132
132
  });
133
133
 
134
+ test("pollUserTasks: projects a feature-escalation that lands on a plan-fanout plan instance (issue #358)", async () => {
135
+ // plan-fanout embeds each wave slice as a multi-instance `implement` subprocess, so a slice that
136
+ // escalates parks on the `feature-escalation` user task on the PLAN-ROOT process instance — never on
137
+ // a standalone `feature_runs` instance. The feature scan above only walks `feature_runs`, so before
138
+ // #358 the plan scan's hardcoded {plan-review, trial-merge} whitelist silently dropped it and the
139
+ // escalation was invisible in the Tasks inbox (the instance-19153 orphan). The plan scan must project
140
+ // EVERY open user-task element in the canonical registry, keyed to the epic (plan) subject, sourcing
141
+ // the question from the `feature_escalations` audit log the escalate arm writes (keyed by plan_key).
142
+ const { data, stores } = memData({
143
+ plans: [
144
+ { plan_key: "o/r#64", status: "dispatched", process_key: "pp-64", issue_url: "https://github.com/o/r/issues/64", title: "Learn BPMN scaffold" },
145
+ ],
146
+ feature_escalations: [
147
+ { id: 1, feature_key: "o/r#64", question: "the agent returned no machine-readable result — enrol the PR?", created_at: "2025-01-01T00:00:00.000Z", job_key: "j1" },
148
+ ],
149
+ });
150
+ const engine = fakeEngine({ "pp-64": [{ userTaskKey: "ut-embedded-feat", elementId: "feature-escalation" }] });
151
+
152
+ await pollUserTasks(data, engine);
153
+
154
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
155
+ assertEquals(Object.keys(byKey), ["ut-embedded-feat"]);
156
+ assertEquals(byKey["ut-embedded-feat"].element_id, "feature-escalation");
157
+ assertEquals(byKey["ut-embedded-feat"].kind_label, "Feature escalation");
158
+ assertEquals(byKey["ut-embedded-feat"].subject_type, "plan");
159
+ assertEquals(byKey["ut-embedded-feat"].subject_key, "o/r#64");
160
+ assertEquals(byKey["ut-embedded-feat"].subject_title, "Learn BPMN scaffold");
161
+ assertEquals(byKey["ut-embedded-feat"].question, "the agent returned no machine-readable result — enrol the PR?");
162
+ });
163
+
134
164
  test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into user_tasks as \"PR merge\"", async () => {
135
165
  // During the merge phase a PR's process_key points at its merge-loop instance; the merge escalation
136
166
  // parks on a native `wait-merge-answer` userTask (#256) and writes the SAME `escalations` row the
@@ -328,3 +358,181 @@ test("pollUserTasks: an instance whose only task is COMPLETED surfaces no row",
328
358
 
329
359
  assertEquals(stores.user_tasks ?? [], []);
330
360
  });
361
+
362
+ // ── Engine-first sweep (issue #358) ────────────────────────────────────────────────────────────────
363
+ // When the raw-REST surface is available (production always supplies it), the projection's source of
364
+ // truth for WHICH escalations are open is the ENGINE, not the tracked subject set: every open escalation
365
+ // the engine reports is surfaced — even on an instance NO tracked subject row references (an
366
+ // orphaned/untracked instance, the reported 19153 case) — enriched by a subject row when one exists and
367
+ // by a per-kind fallback when it does not. These drive the sweep over a stubbed Camunda-8
368
+ // `/v2/user-tasks/search`, the raw surface that (unlike the typed `openUserTasks` seam) carries each
369
+ // task's `processInstanceKey`.
370
+
371
+ /** A single task as the raw Camunda-8 `/v2/user-tasks/search` reports it — carries `processInstanceKey`
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 };
374
+
375
+ /** Stub `globalThis.fetch` so `pollUserTasks`' engine-first sweep reads its open tasks from `tasks`.
376
+ * Honours the `page.from`/`page.limit` pagination the sweep drives, and 404s any other path so a stray
377
+ * call is loud. Returns a restore fn. */
378
+ function stubUserTaskSearch(tasks: RawTask[]): () => void {
379
+ const orig = globalThis.fetch;
380
+ // biome-ignore lint/suspicious/noExplicitAny: minimal fetch double for the raw-REST search surface
381
+ globalThis.fetch = (async (url: string | URL, init?: any) => {
382
+ const u = String(url);
383
+ if (!u.endsWith("/user-tasks/search")) return new Response("not found", { status: 404 });
384
+ const body = JSON.parse(init?.body ?? "{}");
385
+ const from: number = body?.page?.from ?? 0;
386
+ const limit: number = body?.page?.limit ?? 100;
387
+ return new Response(JSON.stringify({ items: tasks.slice(from, from + limit) }), {
388
+ status: 200,
389
+ headers: { "content-type": "application/json" },
390
+ });
391
+ }) as typeof fetch;
392
+ return () => {
393
+ globalThis.fetch = orig;
394
+ };
395
+ }
396
+
397
+ const REST = { restAddress: "http://engine.test/v2" };
398
+
399
+ test("pollUserTasks (engine-first): surfaces an escalation on an UNTRACKED/orphaned instance — the 19153 case (issue #358)", async () => {
400
+ // No `feature_runs`/`plans`/`pull_requests` row references instance 19153, yet the engine reports its
401
+ // `feature-escalation` (key 27337) open. Before #358 the subject-tracking-gated scan dropped it and the
402
+ // operator could never see nor answer it. The engine-first sweep surfaces it, keyed to a stable
403
+ // non-blank fallback subject (the instance) so the row renders and stays answerable.
404
+ const { data, stores } = memData({});
405
+ const restore = stubUserTaskSearch([
406
+ { userTaskKey: "27337", elementId: "feature-escalation", processInstanceKey: "19153", state: "CREATED" },
407
+ ]);
408
+ try {
409
+ await pollUserTasks(data, fakeEngine({}), REST);
410
+ } finally {
411
+ restore();
412
+ }
413
+
414
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
415
+ assertEquals(Object.keys(byKey), ["27337"]);
416
+ assertEquals(byKey["27337"].element_id, "feature-escalation");
417
+ assertEquals(byKey["27337"].kind_label, "Feature escalation");
418
+ assertEquals(byKey["27337"].subject_type, "feature");
419
+ assertEquals(byKey["27337"].subject_key, "19153"); // fallback to the instance — non-blank so it renders
420
+ assertEquals(byKey["27337"].subject_title, "19153");
421
+ assertEquals(byKey["27337"].question, null); // no tracked audit source for an orphan → null, still listed
422
+ });
423
+
424
+ test("pollUserTasks (engine-first): orphaned plan-review and PR-wait escalations are surfaced too (issue #358)", async () => {
425
+ // Same failure class across aggregates: a `plan-review-decision` with no `plans` row and a `wait-answer`
426
+ // with no `pull_requests` row are each surfaced, bucketed to the aggregate their kind implies.
427
+ const { data, stores } = memData({});
428
+ const restore = stubUserTaskSearch([
429
+ { userTaskKey: "ut-orphan-plan", elementId: "plan-review-decision", processInstanceKey: "pi-1", state: "CREATED" },
430
+ { userTaskKey: "ut-orphan-pr", elementId: "wait-answer", processInstanceKey: "pi-2", state: "CREATED" },
431
+ ]);
432
+ try {
433
+ await pollUserTasks(data, fakeEngine({}), REST);
434
+ } finally {
435
+ restore();
436
+ }
437
+
438
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
439
+ assertEquals(Object.keys(byKey).sort(), ["ut-orphan-plan", "ut-orphan-pr"]);
440
+ assertEquals(byKey["ut-orphan-plan"].subject_type, "plan");
441
+ assertEquals(byKey["ut-orphan-plan"].subject_key, "pi-1");
442
+ assertEquals(byKey["ut-orphan-pr"].subject_type, "pr");
443
+ assertEquals(byKey["ut-orphan-pr"].kind_label, "PR review");
444
+ });
445
+
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
+ // from it exactly as the per-subject scan produced — the sweep maps by `processInstanceKey`.
449
+ const { data, stores } = memData({
450
+ feature_runs: [
451
+ { 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 },
452
+ ],
453
+ feature_escalations: [
454
+ { id: 1, feature_key: "o/r#10", question: "which framework?", created_at: "2025-01-01T00:00:00.000Z", job_key: "j1" },
455
+ ],
456
+ plans: [
457
+ { plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: "https://github.com/o/r/issues/20", title: "Broaden the epic scope" },
458
+ ],
459
+ plan_reviews: [
460
+ { plan_key: "o/r#20", epoch: 0, round: 1, approved: 0, findings: "scope too broad", created_at: "2025-01-02T00:00:00.000Z" },
461
+ ],
462
+ });
463
+ const restore = stubUserTaskSearch([
464
+ { userTaskKey: "ut-feat", elementId: "feature-escalation", processInstanceKey: "fp-10", state: "CREATED" },
465
+ { userTaskKey: "ut-plan", elementId: "plan-review-decision", processInstanceKey: "pp-20", state: "CREATED" },
466
+ ]);
467
+ try {
468
+ await pollUserTasks(data, fakeEngine({}), REST);
469
+ } finally {
470
+ restore();
471
+ }
472
+
473
+ const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
474
+ assertEquals(Object.keys(byKey).sort(), ["ut-feat", "ut-plan"]);
475
+ assertEquals(byKey["ut-feat"].subject_key, "o/r#10");
476
+ assertEquals(byKey["ut-feat"].subject_title, "Add the framework selector");
477
+ assertEquals(byKey["ut-feat"].question, "which framework?");
478
+ assertEquals(byKey["ut-plan"].subject_title, "Broaden the epic scope");
479
+ assertEquals(byKey["ut-plan"].question, "scope too broad");
480
+ });
481
+
482
+ test("pollUserTasks (engine-first): never leaks a non-escalation element nor a non-CREATED task", async () => {
483
+ // The `USER_TASK_KIND_LABELS` gate keeps an arbitrary internal user task out of the inbox, and the
484
+ // defensive state re-filter drops a lagging COMPLETED/CANCELED read (a dead affordance, #294) even if
485
+ // the wire `state` filter is ignored.
486
+ const { data, stores } = memData({});
487
+ const restore = stubUserTaskSearch([
488
+ { userTaskKey: "ut-internal", elementId: "some-internal-task", processInstanceKey: "pi-9", state: "CREATED" },
489
+ { userTaskKey: "ut-done", elementId: "feature-escalation", processInstanceKey: "pi-8", state: "COMPLETED" },
490
+ { userTaskKey: "ut-live", elementId: "feature-escalation", processInstanceKey: "pi-7", state: "CREATED" },
491
+ ]);
492
+ try {
493
+ await pollUserTasks(data, fakeEngine({}), REST);
494
+ } finally {
495
+ restore();
496
+ }
497
+
498
+ const keys = (stores.user_tasks ?? []).map((r) => r.user_task_key);
499
+ assertEquals(keys, ["ut-live"]);
500
+ });
501
+
502
+ test("pollUserTasks (engine-first): an answered task (no longer open) is deleted on the next pass", async () => {
503
+ // Feed the engine-derived desired set to the unchanged reconcile: a persisted row whose task the engine
504
+ // no longer reports open is deleted, so `showCount` tracks live work — identical to the scan path.
505
+ const { data, stores } = memData({
506
+ user_tasks: [
507
+ { user_task_key: "ut-gone", element_id: "wait-answer", kind_label: "PR review", subject_type: "pr", subject_key: "o/r#30", subject_url: null, question: null, process_key: "rp-30", created_at: "2025-01-01T00:00:00.000Z", updated_at: "2025-01-01T00:00:00.000Z" },
508
+ ],
509
+ });
510
+ const restore = stubUserTaskSearch([]); // engine reports nothing open
511
+ try {
512
+ await pollUserTasks(data, fakeEngine({}), REST);
513
+ } finally {
514
+ restore();
515
+ }
516
+
517
+ assertEquals(stores.user_tasks, []);
518
+ });
519
+
520
+ test("pollUserTasks (engine-first): pages through a large open set (no first-page truncation)", async () => {
521
+ // Open escalations are normally few, but the sweep must page defensively so a large set is not silently
522
+ // truncated to the first page. 150 open escalations across a 100-item page size → all 150 projected.
523
+ const { data, stores } = memData({});
524
+ const tasks: RawTask[] = Array.from({ length: 150 }, (_, i) => ({
525
+ userTaskKey: `ut-${i}`,
526
+ elementId: "feature-escalation",
527
+ processInstanceKey: `pi-${i}`,
528
+ state: "CREATED",
529
+ }));
530
+ const restore = stubUserTaskSearch(tasks);
531
+ try {
532
+ await pollUserTasks(data, fakeEngine({}), REST);
533
+ } finally {
534
+ restore();
535
+ }
536
+
537
+ assertEquals((stores.user_tasks ?? []).length, 150);
538
+ });