@nanobpm/nano-workforce 0.200.8 → 0.201.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/SPEC.md +35 -0
  3. package/app/deliveryGraph.test.ts +10 -0
  4. package/app/deliveryGraphCompiler.test.ts +354 -10
  5. package/app/deliveryGraphCompiler.ts +296 -40
  6. package/app/deliveryRunner.ts +14 -5
  7. package/app/implementEscalationReason.test.ts +216 -0
  8. package/app/implementEscalationReason.ts +213 -0
  9. package/app/implementReconcile.test.ts +168 -11
  10. package/app/implementReconcile.ts +112 -18
  11. package/app/pullRequestReadModel.test.ts +1 -1
  12. package/app/roundChurn.test.ts +388 -0
  13. package/app/roundChurn.ts +265 -0
  14. package/app/roundProgress.test.ts +224 -1
  15. package/app/service.test.ts +7 -0
  16. package/app/service.ts +12 -0
  17. package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
  18. package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
  19. package/docs/agent-guide.md +10 -7
  20. package/e2e/delivery-graph.e2e.ts +570 -1
  21. package/e2e/feature-run.e2e.ts +8 -1
  22. package/e2e/plan-fanout.e2e.ts +75 -2
  23. package/package.json +1 -1
  24. package/resources/processes/implement-cell.bpmn +42 -17
  25. package/resources/processes/plan-fanout.bpmn +236 -211
  26. package/resources/prompts/feature.md +16 -1
  27. package/workers/progress-check/worker.ts +98 -1
  28. package/workers/readiness-probe/worker.test.ts +72 -1
  29. package/workers/readiness-probe/worker.ts +39 -0
  30. package/workers/reconcile-implement/worker.test.ts +31 -3
  31. package/workers/reconcile-implement/worker.ts +5 -0
  32. package/workers/record-feature-escalation/worker.test.ts +58 -3
  33. package/workers/record-feature-escalation/worker.ts +24 -10
@@ -144,6 +144,12 @@ const DELEGATE_TASK_TYPE: Record<Exclude<DeliveryNode["kind"], "agent" | "human"
144
144
  * truth so the runner never hardcodes the literal it substitutes. */
145
145
  export const DELIVERY_GRAPH_PROCESS_ID = "delivery-graph";
146
146
 
147
+ /** The run-level node SLA fallback (`PT1H`) — the SINGLE SOURCE OF TRUTH shared by the compiler's
148
+ * bounded-timeout ioMapping and the runner's `DEFAULTS.nodeTimeout` (PR #876 review). A node released
149
+ * with no per-node and no run-level timeout falls back to THIS one constant, so the two code paths can
150
+ * never drift to two different "default SLA" values (derivation-over-duplication). */
151
+ export const DELIVERY_NODE_DEFAULT_TIMEOUT = "PT1H";
152
+
147
153
  /** The BPMN element id a `human` node's inlined user task carries. One user task per human node (the
148
154
  * compiled one-shot inlines each), so the id is per-node (`delivery-human-task__<element>`) — the
149
155
  * `isDeliveryHumanElement` convention (single source of truth in `deliveryHuman.ts`) is what keeps it
@@ -169,6 +175,33 @@ function contractEscalationTaskElement(element: string): string {
169
175
  return `${DELIVERY_HUMAN_ELEMENT}__${element}__contract`;
170
176
  }
171
177
 
178
+ /** A distinctive infix stamped into the compiler-generated resume-validity flag variable ({@link
179
+ * resumeValidVar}) to visually mark it as an internal engine variable rather than a user fact. It is
180
+ * NOT a reserved fact-name namespace — reserving it in the public `DeliveryFact.name` space would
181
+ * silently reject previously valid names (e.g. `my__flag__fact`) that openapi still advertises as legal
182
+ * and that durable library/proposal rows may already carry (PR #876 review). Collision-freedom is
183
+ * instead guaranteed structurally at the single bind site (see {@link escalationTaskLines}), so this
184
+ * infix is only a readability aid. */
185
+ const FLAG_VAR_INFIX = "__flag__";
186
+
187
+ /** The internal FEEL variable the resume-validation gateway routes on (PR #876 review). Derived from
188
+ * the escalation task's compiler-generated element id (sanitised to a FEEL-safe identifier) — NOT from
189
+ * the user fact-name space — so it can never collide with a user-declared emit fact bound into the same
190
+ * escalation subprocess scope (an `agent`/`connector` emit binds under its own `fact.name`). This
191
+ * removes the need to RESERVE a user-visible fact name (`resumeValid`) and the recompilation break that
192
+ * reserving it would impose on durable rows that already carry a fact of that name.
193
+ *
194
+ * The sanitised element id is still a legal FACT-NAME string (`^[A-Za-z_][A-Za-z0-9_]*$`), so a node
195
+ * could in principle declare an emit NAMED EXACTLY this flag — and for a single-emit `agent`/`connector`
196
+ * node that fact's emit-source var IS its own name ({@link factSourceVar}), which would map the
197
+ * recovered fact value AND this boolean flag onto the ONE variable. Rather than RESERVE part of the
198
+ * public fact-name space to forbid that (which would break previously valid names — PR #876 review),
199
+ * the single bind site in {@link escalationTaskLines} makes the generated flag name collision-free
200
+ * against this node's actual emit-source target by construction (a deterministic unused suffix). */
201
+ export function resumeValidVar(esc: string): string {
202
+ return `${esc.replace(/[^A-Za-z0-9_]/g, "_")}${FLAG_VAR_INFIX}resumeValid`;
203
+ }
204
+
172
205
  /** The self-reported completion statuses an `agent` node's job may return that count as a TERMINAL
173
206
  * SUCCESS and are allowed to route their result onward (issue #731). Everything else — the pathological
174
207
  * `in_progress` an agent that delegated/returned-before-finishing reports (instance 10746), a `blocked`/
@@ -1707,12 +1740,22 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
1707
1740
  const outputs: { source: string; target: string }[] = [];
1708
1741
  const cfg = (field: string): string => `=nodeInputs.${el}.${field}`;
1709
1742
  const guarded = (src: string): string => `=if (is defined(${src})) then ${src} else null`;
1743
+ // Null-safe bounded-timeout source (issue #872): the node's boundary timer is `=nodeTimeout`, so a
1744
+ // null `nodeInputs.<el>.timeout` seeds a null duration and the engine fires the timer IMMEDIATELY
1745
+ // (a ~0.5s escalation that looks like an instant SLA breach). Fall back to the run-level
1746
+ // `runNodeTimeout` (seeded by the runner), then to the shared `DELIVERY_NODE_DEFAULT_TIMEOUT`
1747
+ // constant (`PT1H`) — the SAME default the runner seeds, so the two never drift (PR #876 review) —
1748
+ // so a node released with no per-node timeout always gets a real SLA instead of a zero-length timer.
1749
+ const timeoutSource = (): string => {
1750
+ const perNode = cfg("timeout").slice(1);
1751
+ return `=if (is defined(${perNode}) and ${perNode} != null) then ${perNode} else (if (is defined(runNodeTimeout) and runNodeTimeout != null) then runNodeTimeout else ${feelStr(DELIVERY_NODE_DEFAULT_TIMEOUT)})`;
1752
+ };
1710
1753
 
1711
1754
  switch (node.kind) {
1712
1755
  case "agent":
1713
1756
  inputs.push({ source: cfg("jobType"), target: "jobType" });
1714
1757
  inputs.push({ source: cfg("appendPrompt"), target: "appendPrompt" });
1715
- inputs.push({ source: cfg("timeout"), target: "nodeTimeout" });
1758
+ inputs.push({ source: timeoutSource(), target: "nodeTimeout" });
1716
1759
  // Stage 0 transcript correlation (#543): seed the transcript URL base so the completing fleet
1717
1760
  // worker can append its own jobKey-scoped stream and emit `transcriptUrl` (below). `transcriptUrlBase`
1718
1761
  // is a top-level launch variable (deliveryRunner) — guarded so a hand-seeded instance without it
@@ -1726,8 +1769,16 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
1726
1769
  // OBSERVED value via FEEL `context put`, so the canonical `agent → connector[converge-merge] →
1727
1770
  // wait[pr, merged]` shape polls the PR the agent opened with NO hardcoded literal. A plain literal
1728
1771
  // target (a real `owner/repo#N` is never `<node>.<fact>`-shaped) can't match a bound ref, so it
1729
- // passes through unchanged. Guarded (`is defined`) so an as-yet-unobserved fact keeps the
1730
- // authored value rather than raising a FEEL error.
1772
+ // passes through unchanged. Guarded (`is defined`) so an as-yet-unobserved fact binds NULL rather
1773
+ // than raising a FEEL error.
1774
+ //
1775
+ // The else-branch writes NULL — NOT the authored fact-ref literal (issue #872, PR #876 review).
1776
+ // The compiler is the ONLY component that knows whether the target was actually bound; leaving the
1777
+ // `<node>.<fact>` reference in place forced the readiness-probe worker to re-derive "unresolved"
1778
+ // from a SYNTAX test (`isFactRefTarget`), which cannot tell an unresolved fact-ref from a VALID
1779
+ // dotted literal command target (e.g. `check.sh`) and so parked that gate forever. Writing null
1780
+ // here is the fail-closed provenance: the worker guards null/blank ONLY, and a dotted literal
1781
+ // always reaches its probe verbatim.
1731
1782
  // TRIM the target before the bind match — `parseProbe` (`readiness.ts`) trims `wait.target` before
1732
1783
  // the worker keys on it, and the display/digest render the trimmed form (`p.target.trim()`,
1733
1784
  // deliveryGraphCompiler.ts:1476). Matching the RAW `node.wait.target` here let a padded fact
@@ -1739,7 +1790,7 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
1739
1790
  const varName = `${boundTarget.producerElement}_${boundTarget.fact}`;
1740
1791
  const probeRef = cfg("probe").slice(1);
1741
1792
  inputs.push({
1742
- source: `=context put(${probeRef}, "target", if (is defined(${varName})) then ${varName} else ${probeRef}.target)`,
1793
+ source: `=context put(${probeRef}, "target", if (is defined(${varName}) and ${varName} != null) then ${varName} else null)`,
1743
1794
  target: "probe",
1744
1795
  });
1745
1796
  } else {
@@ -1767,7 +1818,7 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
1767
1818
  inputs.push({ source: cfg("target"), target: "target" });
1768
1819
  inputs.push({ source: cfg("dedupeKey"), target: "dedupeKey" });
1769
1820
  inputs.push({ source: cfg("payload"), target: "payload" });
1770
- inputs.push({ source: cfg("timeout"), target: "nodeTimeout" });
1821
+ inputs.push({ source: timeoutSource(), target: "nodeTimeout" });
1771
1822
  break;
1772
1823
  default:
1773
1824
  return assertNever(node, "ioMappingLines");
@@ -1820,19 +1871,33 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>, displ
1820
1871
  // as a required data dependency. A broken producer (returns `in_progress`, or omits a required
1821
1872
  // emit) escalates AT this node instead of threading an incomplete result onward.
1822
1873
  const contractGate = { requiredEmits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
1874
+ // The subProcess timeout `__esc` twin resumes with the SAME required emits the contract gate
1875
+ // uses — the facts a downstream node binds, which a null (lost) result poisons (#872).
1876
+ const timeoutResume = { kind: node.kind, emits: contractGate.requiredEmits };
1877
+ // The source var of EVERY declared emit (required + non-required), so the resume-valid flag on
1878
+ // this node's escalations dodges all of them — not only the resumed required emit (PR #876 review).
1879
+ const allEmitTargets = normaliseEmits(node).map((f) => factSourceVar(node.kind, f));
1823
1880
  // The executable `<zeebe:taskDefinition type=…>` MUST carry the raw `jobType` verbatim for worker
1824
1881
  // routing (validateDeliveryGraph rejects a URL-shaped/credential-bearing jobType, so it can never
1825
1882
  // be a leak here), but the `descriptor` is embedded by `serviceBodyLines` into the operator-visible
1826
1883
  // timeout / producer-contract escalation FEEL `prompt` — so it takes the SAME display redaction
1827
1884
  // `nodeDisplay` applies, never the raw value (issue #778 review — thread deliveryGraphCompiler.ts:1606).
1828
- return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName);
1885
+ return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName, timeoutResume, allEmitTargets);
1829
1886
  }
1830
- case "connector":
1887
+ case "connector": {
1831
1888
  // TRIM the target before building the escalation descriptor — `nodeDisplay` and the connector
1832
1889
  // worker both key on the trimmed value, so an untrimmed `" converge-merge "` would fork the
1833
1890
  // `semanticBpmn`/digest from the trimmed-equivalent graph that dispatches identically (issue #778
1834
1891
  // review — thread deliveryGraphCompiler.ts:1741).
1835
- return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${redactConnectorValue(node.connector.target.trim())}`, undefined, [], displayName);
1892
+ // A connector has no producer-contract gate, but its timeout `__esc` is still made resumable with
1893
+ // the facts a downstream node binds — so a human/agent who unsticks a timed-out connector can
1894
+ // supply the lost emit value rather than thread a null onward (#872).
1895
+ const connectorResume = { kind: node.kind, emits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
1896
+ // Every declared emit's source var, so the flag dodges a non-required (routing/unconsumed) emit
1897
+ // named exactly the flag, not only the resumed required one (PR #876 review).
1898
+ const allEmitTargets = normaliseEmits(node).map((f) => factSourceVar(node.kind, f));
1899
+ return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${redactConnectorValue(node.connector.target.trim())}`, undefined, [], displayName, connectorResume, allEmitTargets);
1900
+ }
1836
1901
  case "wait":
1837
1902
  return waitBodyLines(el, node, displayName);
1838
1903
  case "human":
@@ -1917,6 +1982,20 @@ function serviceBodyLines(
1917
1982
  contractGate?: { requiredEmits: readonly DeliveryFact[] },
1918
1983
  taskHeaders: readonly string[] = [],
1919
1984
  taskName: string = nodeId,
1985
+ // The node's REQUIRED emits (the facts a downstream node binds as a data dependency), used to make
1986
+ // the `__esc` TIMEOUT escalation RESUMABLE (issue #872): when a timed-out node is unstuck by a
1987
+ // human/agent supplying `value`, that value must bind the node's declared emit exactly as the
1988
+ // agent's own result would — otherwise the subProcess publishes `<el>_<fact> = null` and the
1989
+ // downstream reference resolves to null (the production failure: a completed `__esc` returned
1990
+ // `ok:true` yet `n3_pr` stayed null). Empty for a node with no required emit (the escalation stays
1991
+ // an inert "click done" acknowledgement, `emitMode = "none"`).
1992
+ timeoutResume?: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[] },
1993
+ // The source var ({@link factSourceVar}) of EVERY declared emit of this node — required AND
1994
+ // non-required — so the resume-valid flag on BOTH the timeout `__esc` and the producer-contract
1995
+ // `__contract` escalations is grown collision-free against all of them, not only the resumed required
1996
+ // emit (PR #876 review). Both escalations live in this node's subProcess scope, which republishes
1997
+ // every declared emit, so both must dodge the same full target set.
1998
+ reservedFlagTargets: readonly string[] = [],
1920
1999
  ): string[] {
1921
2000
  const esc = escalationTaskElement(el);
1922
2001
  const taskExt = [
@@ -1937,7 +2016,16 @@ function serviceBodyLines(
1937
2016
  "nodeTimeout",
1938
2017
  "; in-flight work may already exist — check for a draft PR or partial state before retrying or reassigning.",
1939
2018
  ),
1940
- { displayName: taskName },
2019
+ {
2020
+ // Bind the operator-supplied `value` onto the node's emit-source var so a resumed timeout
2021
+ // escalation publishes the SAME `<el>_<fact>` shape a normally-completing node does (#872) —
2022
+ // VALIDATED by the post-escalation gate (`validTarget`), so an omitted/malformed value re-parks
2023
+ // instead of threading null downstream (PR #876 review). Only when the node owes a required emit
2024
+ // — a no-emit node keeps its inert acknowledgement form.
2025
+ ...(timeoutResume !== undefined && timeoutResume.emits.length > 0 ? { resume: timeoutResume, validTarget: `${el}_end` } : {}),
2026
+ displayName: taskName,
2027
+ reservedTargets: reservedFlagTargets,
2028
+ },
1941
2029
  );
1942
2030
  const head = [
1943
2031
  ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
@@ -1953,20 +2041,30 @@ function serviceBodyLines(
1953
2041
  ...timeoutEscalation,
1954
2042
  ];
1955
2043
 
2044
+ // When the timeout escalation is RESUMABLE (it grew a validation gate), its return flow `${el}_i3`
2045
+ // routes to the GATE (not straight to the end), and the gate's valid branch flows to the end — so a
2046
+ // value-less/invalid resume can never bypass the gate (PR #876 review).
2047
+ const timeoutResumed = timeoutResume !== undefined && timeoutResume.emits.length > 0;
2048
+ const timeoutReturnTarget = timeoutResumed ? `${esc}Vg` : `${el}_end`;
2049
+
1956
2050
  if (contractGate === undefined) {
1957
2051
  return [
1958
2052
  ...head,
1959
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i3</bpmn:incoming></bpmn:endEvent>`,
2053
+ ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming>${timeoutResumed ? `<bpmn:incoming>${esc}Vok</bpmn:incoming>` : `<bpmn:incoming>${el}_i3</bpmn:incoming>`}</bpmn:endEvent>`,
1960
2054
  flow(`${el}_i0`, `${el}_start`, `${el}_task`),
1961
2055
  flow(`${el}_i1`, `${el}_task`, `${el}_end`),
1962
2056
  flow(`${el}_i2`, `${el}_be`, esc),
1963
- flow(`${el}_i3`, esc, `${el}_end`),
2057
+ flow(`${el}_i3`, esc, timeoutReturnTarget),
1964
2058
  ];
1965
2059
  }
1966
2060
 
1967
- // Producer-contract gate (issue #731): task → gate → (proceed | contract-escalation) → end.
2061
+ // Producer-contract gate (issue #731): task → gate → (proceed | contract-escalation) → end. When the
2062
+ // contract escalation is RESUMABLE (it grew a validation gate), its return flow `${el}_g2` routes to
2063
+ // the GATE (not straight to the end), and the gate's valid branch flows to the end (PR #876 review).
1968
2064
  const contractEsc = contractEscalationTaskElement(el);
1969
2065
  const emits = contractGate.requiredEmits;
2066
+ const contractResumed = emits.length > 0;
2067
+ const contractReturnTarget = contractResumed ? `${contractEsc}Vg` : `${el}_end`;
1970
2068
  const proceedCondition = agentContractProceedCondition(emits);
1971
2069
  const contractEscalation = escalationTaskLines(
1972
2070
  contractEsc,
@@ -1976,8 +2074,10 @@ function serviceBodyLines(
1976
2074
  agentContractContextFeel(nodeId, descriptor, emits),
1977
2075
  // Resumable when the producer owes a required emit: a human/agent supplies the missing fact, which
1978
2076
  // the subProcess output ioMapping then publishes as `<el>_<fact>` (agent emit source = fact name),
1979
- // so the downstream consumer late-binds a real value instead of the null that poisoned it (#731).
1980
- { ...(emits.length > 0 ? { resume: { kind: "agent" as const, emits } } : {}), displayName: taskName },
2077
+ // so the downstream consumer late-binds a real value instead of the null that poisoned it (#731) —
2078
+ // VALIDATED by the post-escalation gate (`validTarget`) so an omitted/malformed value re-parks
2079
+ // instead of threading null downstream (PR #876 review).
2080
+ { ...(contractResumed ? { resume: { kind: "agent" as const, emits }, validTarget: `${el}_end` } : {}), displayName: taskName, reservedTargets: reservedFlagTargets },
1981
2081
  );
1982
2082
  return [
1983
2083
  ...head,
@@ -1987,14 +2087,14 @@ function serviceBodyLines(
1987
2087
  ` <bpmn:outgoing>${el}_g1</bpmn:outgoing>`,
1988
2088
  " </bpmn:exclusiveGateway>",
1989
2089
  ...contractEscalation,
1990
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_g0</bpmn:incoming><bpmn:incoming>${el}_i3</bpmn:incoming><bpmn:incoming>${el}_g2</bpmn:incoming></bpmn:endEvent>`,
2090
+ ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_g0</bpmn:incoming>${timeoutResumed ? `<bpmn:incoming>${esc}Vok</bpmn:incoming>` : `<bpmn:incoming>${el}_i3</bpmn:incoming>`}${contractResumed ? `<bpmn:incoming>${contractEsc}Vok</bpmn:incoming>` : `<bpmn:incoming>${el}_g2</bpmn:incoming>`}</bpmn:endEvent>`,
1991
2091
  flow(`${el}_i0`, `${el}_start`, `${el}_task`),
1992
2092
  flow(`${el}_i1`, `${el}_task`, `${el}_gate`),
1993
2093
  ` <bpmn:sequenceFlow id="${el}_g0" name="contract met" sourceRef="${el}_gate" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">${proceedCondition}</bpmn:conditionExpression></bpmn:sequenceFlow>`,
1994
2094
  ` <bpmn:sequenceFlow id="${el}_g1" name="contract broken" sourceRef="${el}_gate" targetRef="${contractEsc}" />`,
1995
- flow(`${el}_g2`, contractEsc, `${el}_end`),
2095
+ flow(`${el}_g2`, contractEsc, contractReturnTarget),
1996
2096
  flow(`${el}_i2`, `${el}_be`, esc),
1997
- flow(`${el}_i3`, esc, `${el}_end`),
2097
+ flow(`${el}_i3`, esc, timeoutReturnTarget),
1998
2098
  ];
1999
2099
  }
2000
2100
 
@@ -2019,6 +2119,9 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
2019
2119
  // topology diverging from the requested (and digested) behaviour (issue #778 review — thread
2020
2120
  // deliveryGraphCompiler.ts:2012).
2021
2121
  const continueOnTimeout = trimmedOrEmpty(node.wait?.onTimeout) === "continue";
2122
+ // When the escalation is RESUMABLE (it grew a validation gate), its return flow `_i5` routes to the
2123
+ // GATE (not straight to the end) and the gate's valid branch flows to the end (PR #876 review).
2124
+ const waitResumed = !continueOnTimeout && emits.length > 0;
2022
2125
  // Defect A: read-only probe diagnostics seeded onto the escalation task so the operator/agent can
2023
2126
  // tell a genuine "not published yet" from a transient false-negative — the probe's last detail, the
2024
2127
  // resolved target/match, and a compact summary of the candidate releases the probe observed.
@@ -2104,19 +2207,23 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
2104
2207
  [`${el}_i4`],
2105
2208
  `${el}_i5`,
2106
2209
  waitEscalationContextFeel(nodeId),
2107
- { resume: { kind: node.kind, emits }, diagnosticInputs, displayName },
2210
+ // A resumed wait-gate escalation is VALIDATED by the post-escalation gate (`validTarget`) so
2211
+ // an omitted/malformed operator value re-parks instead of threading null onto the emit the
2212
+ // downstream consumer binds (PR #876 review).
2213
+ { resume: { kind: node.kind, emits }, diagnosticInputs, displayName, reservedTargets: emits.map((f) => factSourceVar(node.kind, f)), ...(emits.length > 0 ? { validTarget: `${el}_end` } : {}) },
2108
2214
  )),
2109
2215
  // On `continue`, the not-ready-at-boundary branch (`_i4`) proceeds straight to the node end (no
2110
2216
  // human stop, no `_i5` escalation-return flow); on `escalate` it parks on the escalation task,
2111
- // which returns via `_i5`.
2112
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming>${continueOnTimeout ? `<bpmn:incoming>${el}_i4</bpmn:incoming>` : `<bpmn:incoming>${el}_i5</bpmn:incoming>`}<bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
2217
+ // which returns via `_i5`. A RESUMABLE escalation routes `_i5` to its validation gate (not straight
2218
+ // to the end), so a value-less/invalid resume can never bypass the gate (PR #876 review).
2219
+ ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming>${continueOnTimeout ? `<bpmn:incoming>${el}_i4</bpmn:incoming>` : waitResumed ? `<bpmn:incoming>${esc}Vok</bpmn:incoming>` : `<bpmn:incoming>${el}_i5</bpmn:incoming>`}<bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
2113
2220
  flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
2114
2221
  flow(`${el}_i1`, `${el}_probeLoop`, `${el}_end`),
2115
2222
  flow(`${el}_i2`, `${el}_be`, `${el}_lastAttempt`),
2116
2223
  flow(`${el}_i6`, `${el}_lastAttempt`, `${el}_lastGw`),
2117
2224
  ` <bpmn:sequenceFlow id="${el}_i7" name="ready" sourceRef="${el}_lastGw" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
2118
2225
  ` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_lastGw" targetRef="${continueOnTimeout ? `${el}_end` : esc}" />`,
2119
- ...(continueOnTimeout ? [] : [flow(`${el}_i5`, esc, `${el}_end`)]),
2226
+ ...(continueOnTimeout ? [] : [flow(`${el}_i5`, esc, waitResumed ? `${esc}Vg` : `${el}_end`)]),
2120
2227
  ];
2121
2228
  }
2122
2229
 
@@ -2187,9 +2294,19 @@ function escalationTaskLines(
2187
2294
  resume?: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[] };
2188
2295
  diagnosticInputs?: readonly { source: string; target: string }[];
2189
2296
  displayName?: string;
2297
+ /** The element the resume-validation gate's VALID branch flows to (the node end). Required iff
2298
+ * `resume` carries emits — a resumable escalation never routes straight to its end. */
2299
+ validTarget?: string;
2300
+ /** The emit-source var ({@link factSourceVar}) of EVERY declared emit of this node — required AND
2301
+ * non-required (a routing-only `when`-guard emit, or a declared-but-unconsumed one) — that shares
2302
+ * this escalation's flat subprocess scope. The resumed required emits are reserved regardless; this
2303
+ * reserves the REST so the generated resume-valid flag is grown collision-free against ALL of them,
2304
+ * never only the single resumed required emit (PR #876 review). */
2305
+ reservedTargets?: readonly string[];
2190
2306
  },
2191
2307
  ): string[] {
2192
- const emits = opts?.resume?.emits ?? [];
2308
+ const resume = opts?.resume;
2309
+ const emits = resume?.emits ?? [];
2193
2310
  const emitMode = emits.length > 0 ? "typed" : "none";
2194
2311
  const emitLabel = emits.map((e) => `${e.name} (${e.type})`).join(", ");
2195
2312
  const inputs: string[] = [
@@ -2203,30 +2320,70 @@ function escalationTaskLines(
2203
2320
  for (const di of opts?.diagnosticInputs ?? []) {
2204
2321
  inputs.push(` <zeebe:input ${attr("source", di.source)} target="${di.target}" />`);
2205
2322
  }
2206
- // Defect B: map the operator's captured typed value onto the node's emit-source var, so the
2207
- // subProcess output ioMapping publishes the SAME `<el>_<fact>` shape a normally-completing node does.
2323
+ // Defect B + PR #876 review: the operator's captured typed value is VALIDATED on this task's OWN
2324
+ // output mapping (reading the form's `value` field), which the pinned engine evaluates reliably —
2325
+ // unlike a downstream gateway's ioMapping, whose inputs/outputs are NOT visible downstream (verified
2326
+ // engine-native). The required emit's emit-source var is bound from `value` ONLY when it is present
2327
+ // and type-valid (`resumeValueCondition`), else null; a validity boolean flags the resume.
2328
+ // The post-escalation gateway then routes on the SIMPLE `<resumeValidVar> = true` (a complex FEEL
2329
+ // condition on the gateway mis-evaluates — verified), looping an invalid resume back onto a fresh
2330
+ // escalation task (fail closed) so it can never thread null/garbage onto the emit the subProcess
2331
+ // output ioMapping republishes downstream. The generic escalation form (`GENERIC_HUMAN_FORM`)
2332
+ // captures the operator's answer in a single `value` field — it has NO `resolvedArtifact` field — so
2333
+ // a single emit resumes from `value`, validated per its fact type (artifact→resolvedArtifact,
2334
+ // version→detail, …). Sourcing an artifact from a `resolvedArtifact` form field the form never sets
2335
+ // would publish null and make an artifact wait-node escalation non-resumable via the UI.
2336
+ //
2337
+ // MULTI-EMIT (PR #876 review): the generic form's SINGLE `value` cannot supply a distinct value per
2338
+ // fact, so a node owing more than one required emit CANNOT be resumed from it. Copying the one
2339
+ // `value` into every emit-source var would release duplicate/garbage facts downstream; instead we
2340
+ // FAIL CLOSED — bind nothing and hard-set the validity flag to `false`, so the post-escalation gate
2341
+ // always loops back (the operator re-parks) and no single value is ever threaded onto several
2342
+ // distinct emits. (Per-fact resume would need a per-fact form, which the generic form does not have.)
2208
2343
  const outputs: string[] = [];
2209
- if (opts?.resume) {
2210
- const seen = new Set<string>();
2211
- for (const fact of emits) {
2212
- const target = factSourceVar(opts.resume.kind, fact);
2213
- if (seen.has(target)) continue;
2214
- seen.add(target);
2215
- // The generic escalation form (`GENERIC_HUMAN_FORM`) captures the operator's answer in a single
2216
- // `value` field — it has NO `resolvedArtifact` field — so every emit type resumes from `value`,
2217
- // mapped onto that fact's emit-source var (artifact→resolvedArtifact, version→detail, …). Sourcing
2218
- // an artifact from a `resolvedArtifact` form field the form never sets would publish null and make
2219
- // an artifact wait-node escalation non-resumable via the UI.
2220
- outputs.push(
2221
- ` <zeebe:output ${attr("source", `=if (is defined(value)) then value else null`)} target="${target}" />`,
2222
- );
2223
- }
2344
+ // The resume-valid flag shares this escalation's flat engine scope with EVERY declared emit's
2345
+ // source var — not only the single resumed required emit. For a multi-emit node nothing is bound by
2346
+ // the gate, and for `wait`/`human` kinds `factSourceVar` is a fixed internal name; but for an
2347
+ // `agent`/`connector` a declared emit's source var IS its own fact name, which the user controls.
2348
+ // That emit need NOT be the resumed required one: a routing-only `when`-guard emit, or a declared-
2349
+ // but-unconsumed emit, is absent from `resume.emits` yet is STILL republished by the subProcess
2350
+ // output mapping from its own source var (see the `normaliseEmits(node)` output loop). So a node can
2351
+ // legally declare such an emit named EXACTLY the generated flag; left colliding, the escalation would
2352
+ // write its boolean validity flag into that emit's var and the subProcess would publish `true`/`false`
2353
+ // for it downstream (potentially selecting a guarded branch — PR #876 review). Reserve the source var
2354
+ // of every declared emit (callers thread them via `reservedTargets`) AND the resumed required emits,
2355
+ // then grow the flag with a deterministic unused `_` suffix until it differs from ALL of them —
2356
+ // closing the whole collision class structurally WITHOUT reserving any part of the public fact-name
2357
+ // space. `_` keeps it a legal FEEL identifier; the loop terminates because each step lengthens the
2358
+ // flag past every fixed-length reserved target.
2359
+ const reservedTargets = new Set<string>(opts?.reservedTargets ?? []);
2360
+ if (resume !== undefined) for (const f of emits) reservedTargets.add(factSourceVar(resume.kind, f));
2361
+ let flagVar = resume !== undefined && emits.length > 0 ? resumeValidVar(esc) : "";
2362
+ while (flagVar !== "" && reservedTargets.has(flagVar)) flagVar = `${flagVar}_`;
2363
+ if (resume !== undefined && emits.length === 1) {
2364
+ const fact = emits[0];
2365
+ const target = factSourceVar(resume.kind, fact);
2366
+ outputs.push(
2367
+ ` <zeebe:output ${attr("source", `=if ${resumeValueCondition(fact, "value")} then value else null`)} target="${target}" />`,
2368
+ );
2369
+ outputs.push(
2370
+ // The validity flag the post-escalation gateway routes on — an internal, compiler-generated
2371
+ // variable name ({@link resumeValidVar}), made collision-free against the emit-source var above
2372
+ // by construction, so it can neither collide with nor be shadowed by a declared emit.
2373
+ ` <zeebe:output ${attr("source", `=if ${resumeValueCondition(fact, "value")} then true else false`)} target="${flagVar}" />`,
2374
+ );
2375
+ } else if (resume !== undefined && emits.length > 1) {
2376
+ outputs.push(
2377
+ // Fail closed: more than one required emit cannot be resumed from the generic form's single
2378
+ // `value`, so the resume is never valid and the gate always re-parks (see the comment above).
2379
+ ` <zeebe:output ${attr("source", "=false")} target="${flagVar}" />`,
2380
+ );
2224
2381
  }
2225
2382
  // The escalation user task shows the node's DESCRIPTIVE display name (issue #778 review) so a
2226
2383
  // timed-out / contract-broken node is legible in the explorer/inbox instead of an opaque bare id;
2227
2384
  // `nodeId` is still threaded as the `nodeId` input above for runtime correlation.
2228
2385
  const escLabel = trimmedOrEmpty(opts?.displayName) || nodeId;
2229
- return [
2386
+ const task = [
2230
2387
  ` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(escLabel)}">`,
2231
2388
  " <bpmn:extensionElements>",
2232
2389
  ` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
@@ -2241,6 +2398,105 @@ function escalationTaskLines(
2241
2398
  ` <bpmn:outgoing>${outgoing}</bpmn:outgoing>`,
2242
2399
  " </bpmn:userTask>",
2243
2400
  ];
2401
+ if (resume === undefined || emits.length === 0) return task;
2402
+
2403
+ // Resume-validation gate (PR #876 review): a resumable escalation no longer routes straight to the
2404
+ // node end. It flows into an exclusive gateway whose VALID branch proceeds to the node end and whose
2405
+ // DEFAULT (invalid) branch loops back onto the escalation task — so a blank/malformed `value`
2406
+ // re-parks with an explanation instead of releasing a null/invalid fact downstream (fail closed on
2407
+ // the default). The gateway is a PLAIN exclusive gateway (NO ioMapping — a gateway's ioMapping is not
2408
+ // visible downstream in the pinned engine, verified): the per-emit validation already ran on the
2409
+ // escalation task's OWN output mapping (binding the emit-source var + the validity flag), so
2410
+ // the gateway only routes on the simple `<resumeValidVar> = true` boolean. For a multi-emit node the
2411
+ // flag is hard-`false` (unresumable via the single-value form — see the outputs comment above), so
2412
+ // the gate always takes its default (invalid) branch and re-parks.
2413
+ const validTarget = opts?.validTarget;
2414
+ if (validTarget === undefined) {
2415
+ throw new Error(`escalationTaskLines(${esc}): a resumable escalation requires opts.validTarget (the node end its valid resume flows to)`);
2416
+ }
2417
+ const vg = `${esc}Vg`;
2418
+ const vok = `${esc}Vok`;
2419
+ const vbad = `${esc}Vbad`;
2420
+ return [
2421
+ ...task,
2422
+ ` <bpmn:exclusiveGateway id="${vg}" name="resume value valid?" default="${vbad}">`,
2423
+ ` <bpmn:incoming>${outgoing}</bpmn:incoming>`,
2424
+ ` <bpmn:outgoing>${vok}</bpmn:outgoing>`,
2425
+ ` <bpmn:outgoing>${vbad}</bpmn:outgoing>`,
2426
+ " </bpmn:exclusiveGateway>",
2427
+ ` <bpmn:sequenceFlow id="${vok}" name="valid" sourceRef="${vg}" targetRef="${validTarget}"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${flagVar} = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
2428
+ ` <bpmn:sequenceFlow id="${vbad}" name="invalid" sourceRef="${vg}" targetRef="${esc}" />`,
2429
+ ];
2430
+ }
2431
+
2432
+ /** The per-required-emit FEEL condition an escalation resume value must satisfy to bind its
2433
+ * emit-source var (PR #876 review) — the runtime mirror of `coerceFactValue` (app/deliveryHuman.ts),
2434
+ * re-expressed in FEEL so the compiled model validates WITHOUT a worker round-trip. A missing/null
2435
+ * value fails every branch (fail closed). Type-aware: a `pr` must match the `owner/repo#N` shape, a
2436
+ * `version`/`artifact` the version grammar, a `number`/`boolean` coerce, a `url` carry a scheme, a
2437
+ * `string` be non-blank.
2438
+ *
2439
+ * The pinned WASM FEEL evaluator's null semantics drive the exact shape (each verified engine-native):
2440
+ * the type-test builtins `is string(x)` / `is number(x)` / `is boolean(x)` are UNSUPPORTED — they raise
2441
+ * "cannot call a null value" (an ioMapping incident) rather than returning a boolean — so they are
2442
+ * NEVER used. Instead every value is funnelled through `string(resumeValue)` (null-safe) and validated
2443
+ * by `matches(...)`/`trim(...)`, which ARE null-safe (a non-match yields false, never an error).
2444
+ * `is defined(x)` returns NULL (not false) for an absent variable, so the presence guard is anchored
2445
+ * `= true` (null-safe). The caller binds `if cond then <value> else null` on the escalation task's OWN
2446
+ * output mapping (verified: a valid value binds it, an invalid/absent one binds null — fail closed).
2447
+ *
2448
+ * CANONICAL-GRAMMAR ALIGNMENT (PR #876 round-4 "Previously missed"): the regexes below must accept
2449
+ * exactly what the canonical contracts accept, or a timed-out node cannot resume with a value that
2450
+ * works on its normal completion path. `artifact` mirrors `coerceFactValue`'s `pkg@version` split on
2451
+ * the LAST `@` — so a SCOPED package (`@nanobpm/urban@0.54.0`) is accepted: the name segment is
2452
+ * `[^@\s]+` (no `@`/space) preceded by an OPTIONAL leading scope `@`. `pr` mirrors `parsePr`
2453
+ * (app/prParse.ts), which accepts BOTH the `owner/repo#N` shorthand AND a canonical GitHub PR URL
2454
+ * (`https://github.com/owner/repo/pull/N`, optional scheme/`www.`, optional `/files`/`?query`/
2455
+ * `#fragment` suffix) — so the `pr` branch is the disjunction of the two anchored grammars. parsePr's
2456
+ * URL regex carries the `/i` flag, so the `pr` branch folds the tested value through `lower case(...)`
2457
+ * (an engine-native, null-safe builtin) before matching its lower-case literals — otherwise a mixed-
2458
+ * case host/scheme the normal completion path accepts (`https://GitHub.com/...`, `HTTPS://...`) would be
2459
+ * rejected here, re-parking a legitimate resume (fail-closed on a value the canonical path takes). Only
2460
+ * the accept/reject test folds; the bound `value` stays verbatim.
2461
+ *
2462
+ * KNOWN FAIL-CLOSED DIVERGENCES (PR #876 round-5 escalation — ACCEPTED tradeoff, do NOT redesign the
2463
+ * completion door): these FEEL regexes are a deliberate in-engine APPROXIMATION of the canonical
2464
+ * `coerceFactValue`/`new URL()`/`Number()` parsers, which are host-side and not FEEL-expressible. A few
2465
+ * exotic-but-canonically-valid inputs are therefore rejected HERE (re-parked) even though the normal
2466
+ * completion path accepts them — safe (fail-CLOSED, never fail-open), and the escalation form's `value`
2467
+ * is a textfield so no type is erased. The known cases, accepted as rare enough not to warrant a
2468
+ * worker/host round-trip:
2469
+ * • `number`: scientific/hex/octal/binary literals (`1e3`, `0x10`, `.5`) — `Number()` parses them,
2470
+ * the `^-?\d+(\.\d+)?$` regex does not.
2471
+ * • `url`: schemes WHATWG `URL` accepts without `://` (`mailto:user@example.com`, `urn:…`) — the
2472
+ * `scheme://` guard requires the authority form.
2473
+ * • `artifact`: a multi-`@` name whose non-final `@` is NOT a scope prefix — the single last-`@`
2474
+ * split differs from edge cases of the canonical parser.
2475
+ * If these ever become common, the fix is to route the resume through the canonical host-side coercer
2476
+ * (a completion-door redesign) rather than widening these regexes toward fail-OPEN. */
2477
+ function resumeValueCondition(fact: DeliveryFact, v: string): string {
2478
+ const present = `((is defined(${v})) = true and (${v} != null))`;
2479
+ const s = `string(${v})`;
2480
+ switch (fact.type) {
2481
+ case "string":
2482
+ return `${present} and trim(${s}) != ""`;
2483
+ case "number":
2484
+ return `${present} and (matches(trim(${s}), "^-?\\\\d+(\\\\.\\\\d+)?$") = true)`;
2485
+ case "boolean":
2486
+ return `${present} and (trim(${s}) = "true" or trim(${s}) = "false")`;
2487
+ case "version":
2488
+ return `${present} and (matches(trim(${s}), "^v?\\\\d[\\\\w.+-]*$") = true)`;
2489
+ case "artifact":
2490
+ return `${present} and (matches(trim(${s}), "^@?[^@\\\\s]+@v?\\\\d[\\\\w.+-]*$") = true)`;
2491
+ case "url":
2492
+ return `${present} and (matches(trim(${s}), "^[A-Za-z][A-Za-z0-9+.-]*://") = true)`;
2493
+ case "pr":
2494
+ // Case-INSENSITIVE like the canonical parsePr (`/i`): fold the tested value to lower case so the
2495
+ // lower-case host/scheme literals below match a mixed-case input the normal path accepts.
2496
+ return `${present} and (matches(lower case(trim(${s})), "^(([^/#]+/[^/#]+)#(\\\\d+)|((https?://)?(www\\\\.)?github\\\\.com/[^/]+/[^/]+/pull/\\\\d+([/?#].*)?))$") = true)`;
2497
+ default:
2498
+ return assertNever(fact.type, "resumeValueCondition");
2499
+ }
2244
2500
  }
2245
2501
 
2246
2502
  /** Build the FEEL context line seeded onto an escalation task's read-only prompt field (issue #499).
@@ -20,7 +20,7 @@ import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generate
20
20
  import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
21
21
  import { AGENT_SLA_TIMEOUT } from "./agentSla.ts";
22
22
  import { isPlausibleBranchName } from "./baseBranch.ts";
23
- import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID, redactFreeText } from "./deliveryGraphCompiler.ts";
23
+ import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID, DELIVERY_NODE_DEFAULT_TIMEOUT, redactFreeText } from "./deliveryGraphCompiler.ts";
24
24
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
25
25
  import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, isResolvableRepo, RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
26
26
  import { isoDuration } from "./reviewWait.ts";
@@ -97,7 +97,7 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
97
97
  }
98
98
 
99
99
  const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
100
- nodeTimeout: "PT1H",
100
+ nodeTimeout: DELIVERY_NODE_DEFAULT_TIMEOUT,
101
101
  probeTimeout: "PT30M",
102
102
  probePollEvery: msToIsoDuration(DEFAULT_EVERY_MS),
103
103
  escalationSlaTimeout: "P1D",
@@ -127,6 +127,11 @@ export interface PreparedDeliveryGraph {
127
127
  * `runDeliveryGraph` seeds the SAME value as a run-root process variable — the reconciliation
128
128
  * handle a stale-claim relaunch matches a still-running original instance on (issue #852). */
129
129
  runKey: string;
130
+ /** The resolved run-level node SLA (`isoDuration(options.nodeTimeout)`), surfaced so `runDeliveryGraph`
131
+ * seeds it as the run-root `runNodeTimeout` variable — the fallback a node's bounded-timeout
132
+ * ioMapping reads when its per-node `nodeInputs.<el>.timeout` is null, so a node released with no
133
+ * per-node timeout gets a real SLA instead of a zero-length timer that fires instantly (issue #872). */
134
+ runNodeTimeout: string;
130
135
  }
131
136
 
132
137
  export type PrepareDeliveryResult =
@@ -198,7 +203,7 @@ export async function prepareDeliveryGraph(
198
203
  if (element === undefined) continue; // unreachable — resolved covers every node — but keep total.
199
204
  nodeInputs[element] = buildNodeInput(node, { runKey, element, ...timeouts, requiredEmits: requiredEmitsByNodeId.get(node.id) ?? EMPTY_REQUIRED_EMITS });
200
205
  }
201
- return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs, runKey } };
206
+ return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs, runKey, runNodeTimeout: timeouts.nodeTimeout } };
202
207
  }
203
208
 
204
209
  /** Deploy + start a compiled graph as a running engine-native instance. Idempotent at the DEFINITION
@@ -212,7 +217,7 @@ export async function runDeliveryGraph(
212
217
  ): Promise<RunDeliveryResult> {
213
218
  const prep = await prepareDeliveryGraph(graph, options);
214
219
  if (!prep.ok) return prep;
215
- const { processDefinitionId, bpmn, nodeInputs, runKey } = prep.prepared;
220
+ const { processDefinitionId, bpmn, nodeInputs, runKey, runNodeTimeout } = prep.prepared;
216
221
 
217
222
  await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
218
223
  // Per-node repository isolation (#739): the `io.nanobpm.agentTask.repository` envelope is now seeded
@@ -235,6 +240,10 @@ export async function runDeliveryGraph(
235
240
  variables: {
236
241
  nodeInputs,
237
242
  runKey,
243
+ // Run-level node SLA fallback (issue #872): the bounded-timeout ioMapping reads this when a node's
244
+ // per-node `nodeInputs.<el>.timeout` is null, so a node released without a per-node timeout gets a
245
+ // real SLA instead of a null `=nodeTimeout` that fires the boundary timer instantly.
246
+ runNodeTimeout,
238
247
  // Stage 0 transcript correlation (#543): the transcript-endpoint base every agent node's
239
248
  // completing worker appends its jobKey-scoped stream to, to emit `transcriptUrl` (see the agent
240
249
  // node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
@@ -250,7 +259,7 @@ export async function runDeliveryGraph(
250
259
  // downstream consumers expect a string — coerce (codebase-wide `String(...)` pattern, e.g. app/plan.ts).
251
260
  return {
252
261
  ok: true,
253
- handle: { processDefinitionId, bpmn, nodeInputs, runKey, processInstanceKey: String(processInstanceKey) },
262
+ handle: { processDefinitionId, bpmn, nodeInputs, runKey, runNodeTimeout, processInstanceKey: String(processInstanceKey) },
254
263
  };
255
264
  }
256
265