@nanobpm/nano-workforce 0.201.0 → 0.201.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.
@@ -32,6 +32,7 @@
32
32
  // binds and pins exactly the value the human handed forward.
33
33
  import type { DeliveryFact, DeliveryNodeHuman } from "../nano-generated/api-io.d.ts";
34
34
  import { DELIVERY_FACT_TYPES, type DeliveryFactType, FACT_NAME_MAX_LENGTH, FACT_NAME_PATTERN } from "./deliveryGraph.ts";
35
+ import { redactFreeText } from "./redactText.ts";
35
36
 
36
37
  /** The single BPMN `bpmn:userTask` element id every `human` delivery-graph node schedules its work
37
38
  * as (`resources/processes/delivery-human.bpmn`). One reusable engine-native body, instantiated once
@@ -54,6 +55,77 @@ export function isDeliveryHumanElement(elementId: string): boolean {
54
55
  return elementId === DELIVERY_HUMAN_ELEMENT || elementId.startsWith(`${DELIVERY_HUMAN_ELEMENT}__`);
55
56
  }
56
57
 
58
+ /** The id suffix the S4 compiler appends for a service node's bounded-timeout escalation TWIN user task
59
+ * (`delivery-human-task__<el>__esc`). */
60
+ export const DELIVERY_ESCALATION_TWIN_SUFFIX = "__esc";
61
+
62
+ /** The id suffix for an `agent` node's producer-contract escalation TWIN (`…__contract`). */
63
+ export const DELIVERY_CONTRACT_TWIN_SUFFIX = "__contract";
64
+
65
+ /** The KIND marker the S4 compiler appends to a `wait` gate's bounded-timeout escalation twin
66
+ * (`delivery-human-task__<el>__esc__wait`). A wait-gate escalation has NO retry semantics — its
67
+ * resolution is "supply the awaited value and continue", never "re-run the probe loop" — so the
68
+ * compiler renders the select-less {@link GENERIC_HUMAN_FORM} for it, NOT the retry-capable
69
+ * {@link ESCALATION_FORM} a service-node `__esc`/`__contract` twin renders. The bare `__esc` suffix is
70
+ * shared by both kinds, so a form inferred from the suffix ALONE misclassifies the wait gate as
71
+ * retryable (the #461 Tasks-inbox `form_key` fallback then renders a bogus "Retry this step" that the
72
+ * wait process silently ignores). Stamping the kind into the id lets {@link escalationFormId} DERIVE
73
+ * the compiled form from the id — no separate node-kind registry to drift (PR #863 review,
74
+ * app/agentCompletion.ts:186). */
75
+ export const DELIVERY_WAIT_ESCALATION_KIND_MARKER = "__wait";
76
+
77
+ /** The full id suffix of a `wait` gate's bounded-timeout escalation twin (`__esc` + the `__wait` kind
78
+ * marker). */
79
+ export const DELIVERY_WAIT_ESCALATION_TWIN_SUFFIX = `${DELIVERY_ESCALATION_TWIN_SUFFIX}${DELIVERY_WAIT_ESCALATION_KIND_MARKER}`;
80
+
81
+ /** True for a delivery-graph SERVICE-node escalation TWIN user task — the bounded-timeout `__esc` twin
82
+ * or an agent's producer-contract `__contract` twin — as opposed to a plain per-node human task
83
+ * (`delivery-human-task__<el>`). Unlike that per-node human task, which renders a DIFFERENT form per
84
+ * node (and so has no single static completion contract — left unvalidated, `ESCALATION_FORM_BY_ELEMENT`
85
+ * deliberately omits it), a twin ALWAYS renders a FIXED escalation form — the retry-capable
86
+ * {@link ESCALATION_FORM} (a strict superset of {@link GENERIC_HUMAN_FORM}: same `value`, an optional
87
+ * note field (`escalationNote` on the escalation form, `note` on the generic human form), PLUS
88
+ * the `decision` select) or the select-less generic form — so its completion variables CAN be validated
89
+ * against `ESCALATION_FORM`'s contract (no required fields, so the generic-form twins never false-reject;
90
+ * only a present-but-invalid `decision` is rejected). Matched on the convention suffix — single source of
91
+ * truth with the compiler's `escalationTaskElement`/`contractEscalationTaskElement` builders — requiring
92
+ * a NON-EMPTY `<el>` between prefix and suffix so the bare per-node base of a node literally named
93
+ * `esc`/`contract` (`delivery-human-task__esc`) is not misread as a twin. (A node id MAY itself end in
94
+ * `__esc` — `NODE_ID_PATTERN` allows it — so the twin of node `foo` and the base of a node named
95
+ * `foo__esc` collide on one id; validating that rare base against the superset escalation form is
96
+ * harmless, since the form has no required fields and only constrains a `decision` such a base never
97
+ * sets to an out-of-range value through its own select-less form.) */
98
+ export function isDeliveryEscalationTwin(elementId: string): boolean {
99
+ const prefix = `${DELIVERY_HUMAN_ELEMENT}__`;
100
+ if (!elementId.startsWith(prefix)) return false;
101
+ for (const suffix of [DELIVERY_WAIT_ESCALATION_TWIN_SUFFIX, DELIVERY_ESCALATION_TWIN_SUFFIX, DELIVERY_CONTRACT_TWIN_SUFFIX]) {
102
+ if (elementId.endsWith(suffix) && elementId.length > prefix.length + suffix.length) return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ /** True for a `wait` gate's bounded-timeout escalation twin specifically (`…__esc__wait`) — the twin
108
+ * kind that renders the select-less {@link GENERIC_HUMAN_FORM} (no retry path), as opposed to a
109
+ * service-node `__esc`/`__contract` twin, which renders the retry-capable {@link ESCALATION_FORM}.
110
+ * This is the discriminator {@link escalationFormId} uses to DERIVE the compiled form from the id. */
111
+ export function isDeliveryWaitEscalationTwin(elementId: string): boolean {
112
+ const prefix = `${DELIVERY_HUMAN_ELEMENT}__`;
113
+ return (
114
+ elementId.startsWith(prefix) &&
115
+ elementId.endsWith(DELIVERY_WAIT_ESCALATION_TWIN_SUFFIX) &&
116
+ elementId.length > prefix.length + DELIVERY_WAIT_ESCALATION_TWIN_SUFFIX.length
117
+ );
118
+ }
119
+
120
+ /** Strip the trailing escalation-twin suffix (`__esc`, the wait-gate `__esc__wait`, or `__contract`)
121
+ * to recover the base per-node element id (`delivery-human-task__<el>`). The `__esc__wait` kind marker
122
+ * MUST be stripped before the bare `__esc` (it ends in `__esc`, so stripping `__esc` first would leave
123
+ * a dangling `__wait`). A non-twin id is returned unchanged. Single source of truth for the strip so
124
+ * every "resolve the twin to its base node" lookup agrees on the suffix set. */
125
+ export function stripDeliveryEscalationTwinSuffix(elementId: string): string {
126
+ return elementId.replace(/__(esc__wait|esc|contract)$/, "");
127
+ }
128
+
57
129
  /** The read-model "Decision context" for a parked delivery-graph `human` node (issue #772). The Tasks
58
130
  * surface renders the deployed `.form` against EMPTY data (it seeds no task-local variables), so the
59
131
  * node's instruction cannot reach the operator through the form's readonly `prompt` field — it must
@@ -76,11 +148,45 @@ export function isDeliveryHumanElement(elementId: string): boolean {
76
148
  export function deliveryHumanContextQuestion(
77
149
  humanLabels: Record<string, string> | undefined,
78
150
  elementId: string,
151
+ livePrompt?: string | null,
79
152
  ): string {
80
153
  const labels = humanLabels ?? {};
81
- const base = elementId.replace(/__esc$/, "");
154
+ const base = stripDeliveryEscalationTwinSuffix(elementId);
82
155
  const label = (labels[elementId] ?? labels[base] ?? "").trim();
83
- return label || "A scheduled delivery-graph step is waiting to be completed.";
156
+ return label || (livePrompt ?? "").trim() || "A scheduled delivery-graph step is waiting to be completed.";
157
+ }
158
+
159
+ /** Whether a parked delivery task needs its LIVE engine prompt for the "Decision context": an
160
+ * `__esc`/`__contract` escalation twin of a bounded agent/wait/connector node has no stamped
161
+ * `human_labels` entry, so without the compiler's per-task `prompt` (node, reported status, missing
162
+ * emits, the agent's own report/question/transcript, and how to resolve) the operator saw only the
163
+ * static fallback — the "no actionable information" escalations of instance 171774. A real human
164
+ * node (or its `__esc` twin) keeps its authored instruction label. */
165
+ export function needsLiveDeliveryPrompt(humanLabels: Record<string, string> | undefined, elementId: string): boolean {
166
+ if (!/__(esc__wait|esc|contract)$/.test(elementId)) return false;
167
+ const labels = humanLabels ?? {};
168
+ const base = stripDeliveryEscalationTwinSuffix(elementId);
169
+ return !(labels[elementId] ?? labels[base] ?? "").trim();
170
+ }
171
+
172
+ /** Decode a `prompt` variable as the engine's variable search reports it (a JSON-encoded value) into
173
+ * plain text; `null` for an absent, non-string, or truncated preview (never show a clipped prompt as
174
+ * if it were whole — the caller falls back).
175
+ *
176
+ * The decoded text is REDACTED ({@link redactFreeText}) before it is returned: the escalation prompt
177
+ * embeds agent-controlled `summary`/`question`/`error`/`transcriptUrl` (the compiler's report
178
+ * sentence), and the caller persists the result as the operator-facing `user_tasks.question`. An
179
+ * agent report can carry a token-bearing URL or a credential copied into its report, so this path
180
+ * redacts exactly like the static `human_labels` instruction (which `buildHumanLabels` runs through
181
+ * `redactFreeText`, issue #778) — never store a live prompt raw (issue #863 review). */
182
+ export function decodeLivePrompt(row: { value: string; isTruncated?: boolean } | undefined): string | null {
183
+ if (!row || row.isTruncated) return null;
184
+ try {
185
+ const v: unknown = JSON.parse(row.value);
186
+ return typeof v === "string" && v.trim() ? redactFreeText(v.trim()) : null;
187
+ } catch {
188
+ return null;
189
+ }
84
190
  }
85
191
 
86
192
  /** The clickable link for a parked delivery-graph `human` node's Tasks-inbox row (issue #813): the
@@ -96,7 +202,7 @@ export function deliveryHumanContextUrl(
96
202
  elementId: string,
97
203
  ): string | null {
98
204
  const labels = humanLabels ?? {};
99
- const base = elementId.replace(/__esc$/, "");
205
+ const base = stripDeliveryEscalationTwinSuffix(elementId);
100
206
  const instruction = labels[elementId] ?? labels[base] ?? "";
101
207
  const url = firstHttpUrl(instruction);
102
208
  // The stored `human_labels` are display-REDACTED (`redactFreeText` in `buildHumanLabels`): a URL that
@@ -179,6 +285,12 @@ export function firstHttpUrl(text: string | undefined | null): string | null {
179
285
  * declared emitted fact, so a human node with no explicit/category form can STILL emit downstream. */
180
286
  export const GENERIC_HUMAN_FORM = "delivery-human-generic";
181
287
 
288
+ /** The bounded service-node ESCALATION form (retry-node resolution): like the generic form plus the
289
+ * `decision` Resolution select (Continue / Retry this step). Kept SEPARATE from {@link GENERIC_HUMAN_FORM}
290
+ * — that form is shared with plain scheduled human steps and wait-gate escalations, which have no
291
+ * retry semantics and never read `decision`, so the select must not render there. */
292
+ export const ESCALATION_FORM = "delivery-escalation";
293
+
182
294
  /** The "click done" category form: a degenerate no-emit acknowledgement ("now do X" → done). */
183
295
  export const HUMAN_ACK_FORM = "delivery-human-ack";
184
296
 
@@ -238,12 +238,12 @@ test("producer contract required-emit subset (#761): a routing-only emit is NOT
238
238
  const graph: DeliveryGraph = {
239
239
  name: "routing-only producer",
240
240
  nodes: [
241
- { id: "classify", kind: "agent", agent: { jobType: "senior:feature", prompt: "classify it" }, emits: [{ name: "decision", type: "string" }] },
241
+ { id: "classify", kind: "agent", agent: { jobType: "senior:feature", prompt: "classify it" }, emits: [{ name: "verdict", type: "string" }] },
242
242
  { id: "migrate", kind: "connector", connector: { target: "npm:install", dedupeKey: "m-1" } },
243
243
  { id: "release", kind: "connector", connector: { target: "npm:publish", dedupeKey: "r-1" } },
244
244
  ],
245
245
  edges: [
246
- { from: "classify", to: "migrate", when: "classify.decision", equals: "breaking" },
246
+ { from: "classify", to: "migrate", when: "classify.verdict", equals: "breaking" },
247
247
  { from: "classify", to: "release", default: true },
248
248
  ],
249
249
  };
@@ -257,7 +257,7 @@ test("producer contract required-emit subset (#761): a routing-only emit is NOT
257
257
  assert(!prompt.includes("populate each of these top-level fields"), "no required-emit sentence is rendered when every emit is routing-only");
258
258
  // The classifier-emit contract STILL tells the agent to return the routing fact (and omit if undecidable).
259
259
  assert(prompt.includes("Classifier emit contract"), "the classifier-emit contract is present");
260
- assert(prompt.includes("`decision`"), "the routing fact is still named by the classifier-emit contract");
260
+ assert(prompt.includes("`verdict`"), "the routing fact is still named by the classifier-emit contract");
261
261
  });
262
262
 
263
263
 
@@ -0,0 +1,88 @@
1
+ // Free-form-prose URL-credential redaction (`redactFreeText`) lives in this LOW-LEVEL helper module so
2
+ // both the delivery-graph compiler (`deliveryGraphCompiler.ts`, which embeds redacted prompts into the
3
+ // generated BPMN display name/documentation) AND `deliveryHuman.ts` (which redacts a decoded live
4
+ // prompt / human labels) can import it WITHOUT a cycle. Previously `redactFreeText` was defined in the
5
+ // compiler and `deliveryHuman.ts` imported the compiler solely for it — a direct `compiler ⇄ human`
6
+ // import cycle that reversed `deliveryHuman.ts`'s documented pure-helper layering and made module init
7
+ // depend on ESM cycle ordering (PR #863 review — thread deliveryGraphCompiler.ts / deliveryHuman.ts).
8
+ // This module depends only on the character-class strip (`deliveryGraph.ts`) and the canonical URL
9
+ // redactors (`readiness.ts`), both strictly lower layers, so nothing here can import back.
10
+
11
+ import { stripXmlInvalidChars } from "./deliveryGraph.ts";
12
+ import { redactEmbeddedCredential, redactString } from "./readiness.ts";
13
+
14
+ /** Redact credential-bearing pieces of any URL embedded in FREE-FORM prose (a node's authored
15
+ * `prompt`), IN PLACE. Unlike {@link redactString} — tuned for a single opaque target string, where it
16
+ * truncates from the first `?`/`#` to end-of-string — this finds each URL-shaped token *within*
17
+ * surrounding prose (`scheme://…` or a scheme-relative `//…`) and strips only that token's
18
+ * `user:pass@` userinfo and `?query`/`#fragment` via `redactString`, leaving the prose (and ordinary
19
+ * punctuation such as a `?` ending a sentence) intact. Applied to the DISPLAY name/documentation only —
20
+ * a prompt is up to 20 000 chars of arbitrary text that can embed a bearer token or private URL, and
21
+ * the deployed `<bpmn:documentation>`/name is visible to modeler/explorer readers; the RAW prompt still
22
+ * reaches the runtime job input (`appendPrompt`/`prompt`) unmodified (issue #778 review). Deterministic
23
+ * and total. */
24
+ export function redactFreeText(value: string): string {
25
+ // Strip XML-invalid display characters BEFORE tokenizing/redacting so every scan runs on the exact
26
+ // string the renderer emits. Otherwise a control char embedded in a URL (`//us\x0Ber:pass@…`) breaks
27
+ // the `//[^\s]+` token match, escapes redaction, then reconstructs the credential once `escapeXml`/
28
+ // `stripXmlInvalidChars` drops the control at render time (issue #778 review — same class as the
29
+ // connector/probe strip-before-classify fix).
30
+ const cleaned = stripXmlInvalidChars(value);
31
+ // Embedded credential userinfo: DERIVE from the ONE canonical redactor {@link redactEmbeddedCredential}
32
+ // (`EMBEDDED_CREDENTIAL_SRC` = `//[^/?#]*@`) rather than a bespoke belt heuristic. Its `[^/?#]` class
33
+ // spans spaces/TABs/newlines up to the LAST `@` before a `/`/`?`/`#`, so EVERY `//<userinfo>@` shape
34
+ // collapses uniformly to `//***@` — a colon-prefix `//user:pass@`, a colon-LESS bearer separated from
35
+ // its `@host` by a word or space (`//token part@host`, `//token @host`), a userinfo split by a raw
36
+ // CR/LF/TAB, and a malformed multi-`@` authority (`//user:pass@ss@host`) — with NO second "what is a
37
+ // credential" implementation that can drift from the canonical redactor/validator (`hasEmbeddedCredential`)
38
+ // (issue #783 review — thread deliveryGraphCompiler.ts:1140; the earlier bespoke colon-less bridge
39
+ // stopped on the first non-whitespace char after the space and leaked `//token part@host`). A `//…@`
40
+ // span is UNAMBIGUOUSLY a credential wherever it sits, so redacting a prose `//word …@host` too is the
41
+ // SAFE direction: the RAW prompt still reaches the runtime job input unmodified — only the operator-
42
+ // visible display doc loses the span. The `[^/?#]` bound also keeps the userinfo from swallowing a `?`
43
+ // marker (`//host?token=secret@tail` has no userinfo — its `@` rides the query), leaving that tail to
44
+ // the query/fragment belt below.
45
+ const credStripped = redactEmbeddedCredential(cleaned);
46
+ // Query/fragment across a whitespace BREAK: the primary `//[^\s]+` token below stops at the break, so a
47
+ // `//host/?\nTOKEN=secret` would leave the value visible in the XML-preserved doc. The linear belt walks
48
+ // each SPACE-bounded `//`-run (crossing an embedded CR/LF/TAB the primary token stopped at) and
49
+ // CONSERVATIVELY redacts its `?query`/`#fragment` tail through the span's space boundary — a continuation
50
+ // past the break is indistinguishable from a split value, so we never keep the far side (issue #778
51
+ // review — thread deliveryGraphCompiler.ts:1115).
52
+ const belted = redactQueryFragmentSpans(credStripped);
53
+ return belted.replace(/(?:[a-z][a-z0-9+.-]*:)?\/\/[^\s]+/gi, (m) => redactString(m));
54
+ }
55
+
56
+ /** Linear (backtracking-free) newline-aware belt companion to {@link redactFreeText}'s primary
57
+ * whitespace-bounded pass. Each `//`-run is bounded by a literal SPACE (0x20) — so a single span may
58
+ * cross a CR/LF/TAB *inside* the URL that the primary `//[^\s]+` token stopped at. Credential userinfo is
59
+ * already collapsed to `//***@` by the canonical {@link redactEmbeddedCredential} before this runs, so the
60
+ * belt's SOLE remaining job is a `?query`/`#fragment` tail: it redacts CONSERVATIVELY from the first
61
+ * `?`/`#` marker through the span's space boundary — a continuation past an embedded break is
62
+ * indistinguishable from a split value, so the far side is never kept. A span with no `?`/`#` (ordinary
63
+ * prose — a `//comment` reference, an already-collapsed `//***@host`) is returned untouched. The scan is a
64
+ * single left-to-right walk using `indexOf`/`charCodeAt` over spans bounded by the next SPACE, with no
65
+ * regex backtracking, so a 20 000-char adversarial prompt cannot trigger catastrophic backtracking (issue
66
+ * #778 review). Deterministic and total. */
67
+ function redactQueryFragmentSpans(text: string): string {
68
+ let out = "";
69
+ let i = 0;
70
+ for (;;) {
71
+ const start = text.indexOf("//", i);
72
+ if (start < 0) return out + text.slice(i);
73
+ out += text.slice(i, start);
74
+ let end = start;
75
+ while (end < text.length && text.charCodeAt(end) !== 0x20 /* SPACE */) end++;
76
+ const span = text.slice(start, end);
77
+ const qMark = span.indexOf("?");
78
+ const hMark = span.indexOf("#");
79
+ const qi = qMark < 0 ? hMark : hMark < 0 ? qMark : Math.min(qMark, hMark);
80
+ // CONSERVATIVE: keep everything up to and including the `?`/`#` marker, then collapse the whole tail
81
+ // (any continuation past an embedded CR/LF/TAB) to `***`. A value/prose resuming after the break is
82
+ // INDISTINGUISHABLE from a split-credential continuation, so we never keep the post-break side; the RAW
83
+ // prompt still reaches the runtime job input unmodified (issue #778 review — thread
84
+ // deliveryGraphCompiler.ts:1115).
85
+ out += qi < 0 ? span : `${span.slice(0, qi + 1)}***`;
86
+ i = end;
87
+ }
88
+ }
package/app/service.ts CHANGED
@@ -34,7 +34,7 @@ import { deriveDelivery, EPIC_LIVE_STATUSES, TERMINAL_STATUSES } from "./deliver
34
34
  import { DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
35
35
  import { sweepExpiredProposals } from "./deliveryGraphProposals.ts";
36
36
  import { deliveryGraphRuns, deriveDeliveryPhase, parseHumanLabels, reconcileOriginalInstanceBeforeRelaunch, reconcileStaleLaunchClaim } from "./deliveryGraphRun.ts";
37
- import { deliveryHumanContextQuestion, deliveryHumanContextUrl, isDeliveryHumanElement } from "./deliveryHuman.ts";
37
+ import { decodeLivePrompt, deliveryHumanContextQuestion, deliveryHumanContextUrl, isDeliveryHumanElement, needsLiveDeliveryPrompt } from "./deliveryHuman.ts";
38
38
  import { fleetSupportsDurableResume } from "./durableResume.ts";
39
39
  import { deriveEpicPhaseLive, deriveTerminalEpicPhase } from "./epicPhase.ts";
40
40
  import { deriveFeatureCompletion, deriveFeatureDelivery, FEATURE_BLOCKED_ELEMENT, FEATURE_ESCALATION_ELEMENT, FEATURE_RUN_STATUSES, type FeatureRunStatus, featureEscalations, featureRuns, featureRunsTracking, foldCompletedFeatureRun } from "./feature.ts";
@@ -3316,7 +3316,21 @@ export async function pollUserTasks(
3316
3316
  // null), so this is the only link a delivery human task can offer.
3317
3317
  let deliverySubjectUrl: string | null = null;
3318
3318
  if (question === null && isDeliveryHumanElement(elementId)) {
3319
- question = deliveryHumanContextQuestion(subj?.humanLabels, elementId);
3319
+ let livePrompt: string | null = null;
3320
+ if (needsLiveDeliveryPrompt(subj?.humanLabels, elementId)) {
3321
+ // An agent/wait/connector escalation twin: read the task's OWN compiler-rendered `prompt`, local
3322
+ // to the user task's element instance (fail-open to the static fallback on any read error).
3323
+ try {
3324
+ const [ei] = await engine.searchElementInstances({ processInstanceKey, elementId, state: "ACTIVE" });
3325
+ if (ei) {
3326
+ const [row] = await engine.searchVariables({ scopeKey: String(ei.elementInstanceKey), name: "prompt" });
3327
+ livePrompt = decodeLivePrompt(row);
3328
+ }
3329
+ } catch (err) {
3330
+ console.error(`[poller] delivery escalation prompt (${processInstanceKey}/${elementId}): ${err}`);
3331
+ }
3332
+ }
3333
+ question = deliveryHumanContextQuestion(subj?.humanLabels, elementId, livePrompt);
3320
3334
  deliverySubjectUrl = deliveryHumanContextUrl(subj?.humanLabels, elementId);
3321
3335
  }
3322
3336
  return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? deliverySubjectUrl, question, processKey: processInstanceKey, formKey: resolvedFormKey };
@@ -21,6 +21,7 @@ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
21
21
  import { deliveryGraphProposals } from "../app/deliveryGraphProposals.ts";
22
22
  import { deliveryGraphRuns } from "../app/deliveryGraphRun.ts";
23
23
  import { pollDeliveryGraphPhase } from "../app/service.ts";
24
+ import { isDeliveryEscalationTwin } from "../app/deliveryHuman.ts";
24
25
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
25
26
 
26
27
  const APP_ROOT = resolve(import.meta.dirname, "..");
@@ -129,7 +130,7 @@ describe("delivery-graph dispatch — agent compiles→stages, operator dispatch
129
130
 
130
131
  // ── Completion: complete the human stop → the instance ends → the poller reconciles to done ───
131
132
  const open = await app.engine.searchUserTasks({ state: "CREATED" });
132
- const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !t.elementId?.endsWith("__esc"));
133
+ const human = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && !isDeliveryEscalationTwin(t.elementId));
133
134
  assert.ok(human, `a human user task is open, got ${JSON.stringify(open.map((t) => t.elementId))}`);
134
135
  await app.engine.completeUserTask(human.userTaskKey, { humanOutcome: "completed" });
135
136
  await app.settle();