@nanobpm/nano-workforce 0.139.3 → 0.139.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.139.4](https://github.com/nanobpm/nano-workforce/compare/v0.139.3...v0.139.4) (2026-08-25)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **delivery-graph:** honor a wait node's per-node poll.timeoutMs + onTimeout ([#462](https://github.com/nanobpm/nano-workforce/issues/462)) ([#545](https://github.com/nanobpm/nano-workforce/issues/545)) ([f91d456](https://github.com/nanobpm/nano-workforce/commit/f91d4561736e0d7b4bbcaf55a7a4ff61a602b82d)), closes [Magikcraft/nano-bpm#978](https://github.com/Magikcraft/nano-bpm/issues/978)
6
+
1
7
  ## [0.139.3](https://github.com/nanobpm/nano-workforce/compare/v0.139.2...v0.139.3) (2026-08-25)
2
8
 
3
9
  ### Documentation
@@ -718,3 +718,16 @@ test("S7 single-target guarded fan-out is NOT an exclusive split — a node whos
718
718
  });
719
719
  assertEquals(errors, []);
720
720
  });
721
+
722
+ test("a wait node's onTimeout: fail is rejected (unsupported-on-timeout) while continue/escalate validate (#462)", () => {
723
+ const waitWith = (onTimeout: string) => ({
724
+ name: "onTimeout",
725
+ nodes: [{ id: "g", kind: "wait", wait: { kind: "pr", target: "acme/repo#1", match: { prState: "merged" }, onTimeout } }],
726
+ edges: [],
727
+ });
728
+ const err = hasCode(validateDeliveryGraph(waitWith("fail")), "unsupported-on-timeout");
729
+ assertEquals(err.path, "nodes[0].wait.onTimeout");
730
+ // continue + escalate are honored — they must NOT raise the unsupported-on-timeout error.
731
+ assertEquals(validateDeliveryGraph(waitWith("continue")), []);
732
+ assertEquals(validateDeliveryGraph(waitWith("escalate")), []);
733
+ });
@@ -83,7 +83,8 @@ export type DeliveryGraphErrorCode =
83
83
  | "mixed-fan-out"
84
84
  | "multiple-defaults"
85
85
  | "non-exhaustive-split"
86
- | "exclusive-merge-parity";
86
+ | "exclusive-merge-parity"
87
+ | "unsupported-on-timeout";
87
88
 
88
89
  /** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
89
90
  * input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
@@ -342,6 +343,21 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
342
343
  });
343
344
  }
344
345
  }
346
+ // A `wait` node's `onTimeout: fail` cannot be honored yet: the compiler would emit a terminate
347
+ // end on the not-ready-at-boundary path, but the engine treats terminate-end events as
348
+ // parsed-not-executed (Magikcraft/nano-bpm bpmn.rs), so `fail` would silently degrade to a plain
349
+ // end — the "declared knob silently ignored" defect class. Reject it loudly (path-qualified)
350
+ // until engine parity lands (Magikcraft/nano-bpm#978), rather than mis-compile it. `escalate`
351
+ // (default) and `continue` ARE honored.
352
+ if (kind === "wait" && config.onTimeout === "fail") {
353
+ errors.push({
354
+ path: `${path}.${configKey}.onTimeout`,
355
+ message:
356
+ "`onTimeout: fail` on a `wait` node is not yet supported (blocked on engine terminate-end " +
357
+ "execution, Magikcraft/nano-bpm#978); use `escalate` (default) or `continue`",
358
+ code: "unsupported-on-timeout",
359
+ });
360
+ }
345
361
  }
346
362
  } else if (rawNode.human !== undefined && !isRecord(rawNode.human)) {
347
363
  // `human` config is OPTIONAL (formKey/prompt both resolve to a generic fallback in S3), but
@@ -605,3 +605,45 @@ test("S7 compiler: a post-merge node with an extra always-firing producer joins
605
605
  assert(/<bpmn:parallelGateway id="gwj\d+"[^>]*name="join into finalize"/.test(r.bpmn), "finalize joins its always-firing producers on a parallel gateway");
606
606
  assert(!/<bpmn:exclusiveGateway id="gwm\d+"[^>]*name="join into finalize"/.test(r.bpmn), "finalize is NOT compiled as an exclusive merge");
607
607
  });
608
+
609
+ test("a wait node's onTimeout: continue proceeds past the gate with NO escalation task; escalate (default) keeps the human stop (#462)", async () => {
610
+ // AC (#462): `onTimeout: continue` routes the not-ready-at-boundary branch straight to the node end
611
+ // — no `__esc` escalation user task, no human stop — while the default (`escalate`) parks it on the
612
+ // escalation task. Two sibling wait nodes, one of each, isolate the difference.
613
+ const graph = {
614
+ name: "continue vs escalate",
615
+ nodes: [
616
+ { id: "soft", kind: "wait", wait: { kind: "pr", target: "acme/repo#1", match: { prState: "merged" }, onTimeout: "continue" } },
617
+ { id: "hard", kind: "wait", wait: { kind: "pr", target: "acme/repo#2", match: { prState: "merged" }, onTimeout: "escalate" } },
618
+ ],
619
+ edges: [{ from: "soft", to: "hard" }],
620
+ };
621
+ const r = await compileOk(graph);
622
+ const softEl = elementForNode(r.bpmn, "soft");
623
+ const hardEl = elementForNode(r.bpmn, "hard");
624
+ // continue: no escalation twin for `soft`, and its not-ready boundary flow lands on the node end.
625
+ assert(!r.bpmn.includes(`delivery-human-task__${softEl}__esc`), "continue emits no escalation user task");
626
+ assert(
627
+ r.bpmn.includes(`<bpmn:sequenceFlow id="${softEl}_i4" name="not ready" sourceRef="${softEl}_lastGw" targetRef="${softEl}_end" />`),
628
+ "continue routes the not-ready boundary branch to the node end",
629
+ );
630
+ // escalate: `hard` keeps its escalation twin and routes not-ready to it.
631
+ assert(r.bpmn.includes(`delivery-human-task__${hardEl}__esc`), "escalate keeps the escalation user task");
632
+ assert(
633
+ r.bpmn.includes(`<bpmn:sequenceFlow id="${hardEl}_i4" name="not ready" sourceRef="${hardEl}_lastGw" targetRef="delivery-human-task__${hardEl}__esc" />`),
634
+ "escalate routes the not-ready boundary branch to the escalation task",
635
+ );
636
+ });
637
+
638
+ test("a wait node's onTimeout: fail is rejected at compile with a path-qualified error (blocked on engine terminate-end, #462/#978)", async () => {
639
+ const errors = await compileFail({
640
+ name: "fail not yet supported",
641
+ nodes: [
642
+ { id: "g", kind: "wait", wait: { kind: "pr", target: "acme/repo#1", match: { prState: "merged" }, onTimeout: "fail" } },
643
+ ],
644
+ edges: [],
645
+ });
646
+ const hit = errors.find((e) => e.path === "nodes[0].wait.onTimeout");
647
+ assert(hit, `expected a path-qualified onTimeout error, got ${JSON.stringify(errors)}`);
648
+ assert(hit?.message.includes("#978"), `the error names the blocking engine issue, got ${hit?.message}`);
649
+ });
@@ -953,10 +953,16 @@ function serviceBodyLines(
953
953
  /** `wait` body: `start → pr.readiness-probe (poll) → ready? → end`, escalating on not-ready or on the
954
954
  * `=probeTimeout` engine bound. The probe polls its OWN target, so an unrelated upstream event can
955
955
  * never flip it to ready (#274/S2 concurrency-correctness); the `pr` kind (S2) binds `mergedSha`. */
956
- function waitBodyLines(el: string, node: DeliveryNode): string[] {
956
+ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>): string[] {
957
957
  const nodeId = node.id;
958
958
  const esc = escalationTaskElement(el);
959
959
  const emits = normaliseEmits(node);
960
+ // `onTimeout` routing (#462): `escalate` (default) parks the not-ready-at-boundary token on a
961
+ // human-completable escalation task; `continue` proceeds past the gate as not-ready WITHOUT a human
962
+ // stop (a documented sharp edge — the downstream side-effecting node then runs without the awaited
963
+ // fact). `fail` is rejected earlier at validation (blocked on engine terminate-end, #978), so it
964
+ // never reaches here.
965
+ const continueOnTimeout = node.wait?.onTimeout === "continue";
960
966
  // Defect A: read-only probe diagnostics seeded onto the escalation task so the operator/agent can
961
967
  // tell a genuine "not published yet" from a transient false-negative — the probe's last detail, the
962
968
  // resolved target/match, and a compact summary of the candidate releases the probe observed.
@@ -1033,22 +1039,27 @@ function waitBodyLines(el: string, node: DeliveryNode): string[] {
1033
1039
  ` <bpmn:outgoing>${el}_i7</bpmn:outgoing>`,
1034
1040
  ` <bpmn:outgoing>${el}_i4</bpmn:outgoing>`,
1035
1041
  " </bpmn:exclusiveGateway>",
1036
- ...escalationTaskLines(
1037
- esc,
1038
- nodeId,
1039
- [`${el}_i4`],
1040
- `${el}_i5`,
1041
- waitEscalationContextFeel(nodeId),
1042
- { resume: { kind: node.kind, emits }, diagnosticInputs },
1043
- ),
1044
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i5</bpmn:incoming><bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
1042
+ ...(continueOnTimeout
1043
+ ? []
1044
+ : escalationTaskLines(
1045
+ esc,
1046
+ nodeId,
1047
+ [`${el}_i4`],
1048
+ `${el}_i5`,
1049
+ waitEscalationContextFeel(nodeId),
1050
+ { resume: { kind: node.kind, emits }, diagnosticInputs },
1051
+ )),
1052
+ // On `continue`, the not-ready-at-boundary branch (`_i4`) proceeds straight to the node end (no
1053
+ // human stop, no `_i5` escalation-return flow); on `escalate` it parks on the escalation task,
1054
+ // which returns via `_i5`.
1055
+ ` <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>`,
1045
1056
  flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
1046
1057
  flow(`${el}_i1`, `${el}_probeLoop`, `${el}_end`),
1047
1058
  flow(`${el}_i2`, `${el}_be`, `${el}_lastAttempt`),
1048
1059
  flow(`${el}_i6`, `${el}_lastAttempt`, `${el}_lastGw`),
1049
1060
  ` <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>`,
1050
- ` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_lastGw" targetRef="${esc}" />`,
1051
- flow(`${el}_i5`, esc, `${el}_end`),
1061
+ ` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_lastGw" targetRef="${continueOnTimeout ? `${el}_end` : esc}" />`,
1062
+ ...(continueOnTimeout ? [] : [flow(`${el}_i5`, esc, `${el}_end`)]),
1052
1063
  ];
1053
1064
  }
1054
1065
 
@@ -258,6 +258,51 @@ test("a RUN-LEVEL timeout is normalized (lower-case → canonical) and a malform
258
258
  }
259
259
  });
260
260
 
261
+ test("a wait node's per-node poll.timeoutMs drives its escalation boundary while a sibling keeps the run/default (#462)", async () => {
262
+ // AC (#462): a `wait` node declaring `poll.timeoutMs` seeds nodeInputs.<el>.probeTimeout derived
263
+ // from that budget (the compiled `=probeTimeout` boundary), while a sibling wait WITHOUT a declared
264
+ // timeout keeps the run-level value. Mirrors the per-node `everyMs → probePollEvery` override that
265
+ // already exists — the escalation boundary is the one budget that was silently ignored, so a 7-day
266
+ // gate escalated at the 30-minute run default.
267
+ const graph: DeliveryGraph = {
268
+ name: "per-node wait timeout",
269
+ nodes: [
270
+ { id: "long-gate", kind: "wait", wait: { kind: "pr", target: "owner/repo#1", match: { prState: "merged" }, poll: { timeoutMs: 604_800_000 } } },
271
+ { id: "default-gate", kind: "wait", wait: { kind: "pr", target: "owner/repo#2", match: { prState: "merged" } } },
272
+ ],
273
+ edges: [{ from: "long-gate", to: "default-gate" }],
274
+ };
275
+ const p = await prepareOk(graph, { probeTimeout: "PT20M", runKey: "run-462" });
276
+ const waits = Object.values(p.nodeInputs).filter((v) => "gateKey" in v) as Array<{ gateKey: string; probeTimeout: string }>;
277
+ const byGate = (suffix: string) => waits.find((w) => w.gateKey.endsWith(suffix));
278
+ // Element ids are positional by sorted node id: default-gate → n0, long-gate → n1.
279
+ assertEquals(byGate(":n1")?.probeTimeout, "PT604800S"); // 7 days in seconds — the per-node budget wins
280
+ assertEquals(byGate(":n0")?.probeTimeout, "PT20M"); // sibling keeps the run-level value
281
+ });
282
+
283
+ test("an invalid per-node poll.timeoutMs/everyMs falls back to the run-level ctx.* override, not the built-in default (#462)", async () => {
284
+ // Guard against the JS-truthiness gap: a negative `poll.timeoutMs` (`-1`) is truthy, so a bare
285
+ // `probe.poll?.timeoutMs ? readinessTimeout(probe, {}) : ctx.probeTimeout` would route to
286
+ // `readinessTimeout(probe, {})`, which rejects `< 1` and — with `env: {}` — returns the built-in
287
+ // PT30M default, silently discarding the run/dispatch override. An invalid value must fall through
288
+ // to `ctx.*` (the run-level value) instead. Same for `everyMs`.
289
+ const graph: DeliveryGraph = {
290
+ name: "invalid per-node budget",
291
+ nodes: [
292
+ {
293
+ id: "bad-gate",
294
+ kind: "wait",
295
+ wait: { kind: "pr", target: "owner/repo#1", match: { prState: "merged" }, poll: { timeoutMs: -1, everyMs: -5 } },
296
+ },
297
+ ],
298
+ edges: [],
299
+ };
300
+ const p = await prepareOk(graph, { probeTimeout: "PT20M", probePollEvery: "PT42S", runKey: "run-462b" });
301
+ const wait = Object.values(p.nodeInputs).find((v) => "gateKey" in v) as { probeTimeout: string; probePollEvery: string } | undefined;
302
+ assertEquals(wait?.probeTimeout, "PT20M"); // run-level override, NOT the built-in PT30M default
303
+ assertEquals(wait?.probePollEvery, "PT42S"); // run-level cadence, NOT DEFAULT_EVERY_MS
304
+ });
305
+
261
306
  test("a malformed graph returns the S1 compile errors and prepares nothing", async () => {
262
307
  const r = await prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
263
308
  assert(!r.ok, "a dangling edge fails to prepare");
@@ -17,7 +17,7 @@ import { createHash, randomUUID } from "node:crypto";
17
17
  import type { EngineClient } from "@nanobpm/urban";
18
18
  import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
19
19
  import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
20
- import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery } from "./readiness.ts";
20
+ import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
21
21
  import { isoDuration } from "./reviewWait.ts";
22
22
 
23
23
  /** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
@@ -227,11 +227,25 @@ function buildNodeInput(
227
227
  }
228
228
  case "wait": {
229
229
  const probe = parseProbe(node.wait);
230
+ // Only a VALID, positive per-node budget overrides the run level. Match the `>= 1` predicate
231
+ // `readinessTimeout`/`readinessPollEvery` apply internally, rather than a bare JS-truthiness
232
+ // check on `poll.timeoutMs`/`everyMs`: a negative (`-1`) value is truthy, so a truthiness gate
233
+ // would route to `readinessTimeout(probe, {})`, which then rejects it (`< 1`) and — because
234
+ // `env` is `{}` — falls back to the *built-in* default (PT30M / DEFAULT_EVERY_MS), silently
235
+ // discarding the run/dispatch override in `ctx.*`. Gating on the same validity predicate here
236
+ // makes an invalid per-node value fall through to `ctx.probeTimeout`/`ctx.probePollEvery`.
237
+ const declaredTimeout = typeof probe.poll?.timeoutMs === "number" && probe.poll.timeoutMs >= 1;
238
+ const declaredEvery = typeof probe.poll?.everyMs === "number" && probe.poll.everyMs >= 1;
230
239
  return {
231
240
  gateKey: `${ctx.runKey}:${ctx.element}`,
232
241
  probe: node.wait,
233
- probeTimeout: ctx.probeTimeout,
234
- probePollEvery: probe.poll?.everyMs ? readinessPollEvery(probe, {}) : ctx.probePollEvery,
242
+ // Per-node escalation boundary (#462): a `wait` node's declared `poll.timeoutMs` drives its
243
+ // compiled `=probeTimeout` bound, mirroring the `everyMs → probePollEvery` override below —
244
+ // otherwise a node's poll budget is honored for the interval but silently ignored for the
245
+ // boundary (a 7-day gate escalated at the 30-minute run default). Falls back to the run-level
246
+ // `ctx.probeTimeout` (which itself honors the dispatch override / default) when undeclared.
247
+ probeTimeout: declaredTimeout ? readinessTimeout(probe, {}) : ctx.probeTimeout,
248
+ probePollEvery: declaredEvery ? readinessPollEvery(probe, {}) : ctx.probePollEvery,
235
249
  };
236
250
  }
237
251
  case "human":
package/openapi.yaml CHANGED
@@ -1430,7 +1430,14 @@ components:
1430
1430
  description: >-
1431
1431
  A `wait` node — a durable `ReadinessProbe` (ADR 0001 §2) watching an external fact. Reuses the
1432
1432
  existing `ReadinessProbe` shape verbatim (Decision 3 — never a second wait loop); the `pr`
1433
- merge-state kind is added to that shape by slice S2 and flows in here automatically.
1433
+ merge-state kind is added to that shape by slice S2 and flows in here automatically. The
1434
+ probe's `poll.timeoutMs` sets THIS node's escalation boundary (how long the gate waits before
1435
+ it acts on `onTimeout`), falling back to the run/default when absent (#462). `onTimeout`
1436
+ `escalate` (default) parks the elapsed gate on a human-completable task; `continue` proceeds
1437
+ past the gate as not-ready with NO human stop (a sharp edge — the downstream side-effecting
1438
+ node then runs without the awaited fact); `fail` is NOT yet supported on a delivery `wait`
1439
+ node (blocked on engine terminate-end execution, Magikcraft/nano-bpm#978) and is rejected at
1440
+ compile with a path-qualified error rather than silently degrading.
1434
1441
  allOf:
1435
1442
  - $ref: "#/components/schemas/DeliveryNodeCommon"
1436
1443
  - type: object
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.139.3",
3
+ "version": "0.139.4",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",