@nanobpm/nano-workforce 0.179.2 → 0.180.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.180.0](https://github.com/nanobpm/nano-workforce/compare/v0.179.2...v0.180.0) (2026-09-04)
2
+
3
+ ### Features
4
+
5
+ * **delivery-graph:** gate agent-node completion on producer status + required emits ([#735](https://github.com/nanobpm/nano-workforce/issues/735)) ([a765e93](https://github.com/nanobpm/nano-workforce/commit/a765e937dc16f3b8f9dd1e1dedb489be697435e0)), closes [#731](https://github.com/nanobpm/nano-workforce/issues/731) [#731](https://github.com/nanobpm/nano-workforce/issues/731) [#731](https://github.com/nanobpm/nano-workforce/issues/731)
6
+
1
7
  ## [0.179.2](https://github.com/nanobpm/nano-workforce/compare/v0.179.1...v0.179.2) (2026-09-04)
2
8
 
3
9
  ### Code Refactoring
@@ -30,12 +30,13 @@ async function compileFail(graph: unknown) {
30
30
  }
31
31
 
32
32
  /** The sub-process element id of the PLANNED human user task in a compiled graph — i.e. the
33
- * delivery-human-task element that is NOT a bounded node's __esc timeout twin. Returns "" if none. */
33
+ * delivery-human-task element that is NOT a bounded node's escalation twin (`__esc` timeout or
34
+ * `__contract` producer-gate, issue #731). Returns "" if none. */
34
35
  function humanTaskSubEl(bpmn: string): string {
35
36
  const parts = bpmn.split('<bpmn:userTask id="delivery-human-task__');
36
37
  for (let k = 1; k < parts.length; k++) {
37
38
  const id = parts[k].slice(0, parts[k].indexOf('"'));
38
- if (!id.endsWith("__esc")) return id;
39
+ if (!id.endsWith("__esc") && !id.endsWith("__contract")) return id;
39
40
  }
40
41
  return "";
41
42
  }
@@ -293,6 +294,15 @@ function escBlockForNode(bpmn: string, nodeId: string): string {
293
294
  return bpmn.slice(start, bpmn.indexOf("</bpmn:userTask>", start));
294
295
  }
295
296
 
297
+ /** Slice a compiled BPMN to a node's escalation user task body by twin suffix (`esc` timeout or
298
+ * `contract` producer-gate, issue #731). */
299
+ function escBlockForNodeSuffix(bpmn: string, nodeId: string, suffix: "esc" | "contract"): string {
300
+ const esc = `delivery-human-task__${elementForNode(bpmn, nodeId)}__${suffix}`;
301
+ const start = bpmn.indexOf(`<bpmn:userTask id="${esc}"`);
302
+ assert(start !== -1, `escalation task ${esc} for node ${nodeId} exists`);
303
+ return bpmn.slice(start, bpmn.indexOf("</bpmn:userTask>", start));
304
+ }
305
+
296
306
  test("#514 Defect A: a capability wait-gate escalation surfaces the probe's last detail, target/match, and observed releases so it is self-diagnosing", async () => {
297
307
  const r = await compileOk(CAP_GATE);
298
308
  const esc = escBlockForNode(r.bpmn, "n2");
@@ -775,3 +785,92 @@ test("a wait node's onTimeout: fail is rejected at compile with a path-qualified
775
785
  assert(hit, `expected a path-qualified onTimeout error, got ${JSON.stringify(errors)}`);
776
786
  assert(hit?.message.includes("#978"), `the error names the blocking engine issue, got ${hit?.message}`);
777
787
  });
788
+
789
+ // Issue #731 — the producer-contract gate. An `agent` node's job completing is NOT the node
790
+ // succeeding: a producer that returns a non-terminal `status` (the instance-10746 `in_progress`) or
791
+ // omits a declared emit consumed downstream as a required data dependency must escalate AT the
792
+ // producer, not thread a null/incomplete result into a consumer two nodes downstream. The routing-only
793
+ // emit (referenced only by an edge `when` guard) stays optional — omit ⇒ default branch.
794
+
795
+ // The canonical `agent → connector[converge-merge]` shape: `open` opens the PR and emits `pr`, which
796
+ // `land` binds as its connector `payload.pr` (a required DATA dependency, threaded on the fact edge).
797
+ const PRODUCER_GATE = {
798
+ name: "producer gate",
799
+ nodes: [
800
+ { id: "open", kind: "agent", agent: { jobType: "senior:feature" }, emits: [{ name: "pr", type: "pr" }] },
801
+ { id: "land", kind: "connector", connector: { target: "converge-merge", payload: { pr: "open.pr" }, dedupeKey: "land-1" } },
802
+ ],
803
+ edges: [{ from: "open.pr", to: "land" }],
804
+ };
805
+
806
+ test("#731 producer status gate: an agent node inserts a post-completion contract gate that escalates a non-terminal status AT the producer", async () => {
807
+ const r = await compileOk(PRODUCER_GATE);
808
+ const el = elementForNode(r.bpmn, "open");
809
+ // The agent body is no longer `task → end`: the task feeds an exclusive `_gate` whose default routes
810
+ // a broken producer to a SECOND (contract) escalation task distinct from the `__esc` timeout twin.
811
+ assert(
812
+ r.bpmn.includes(`<bpmn:exclusiveGateway id="${el}_gate" name="producer contract met?" default="${el}_g1">`),
813
+ "the agent task feeds a producer-contract exclusive gate",
814
+ );
815
+ assert(r.bpmn.includes(`<bpmn:sequenceFlow id="${el}_i1" sourceRef="${el}_task" targetRef="${el}_gate" />`), "the task flows into the gate, not straight to end");
816
+ assert(r.bpmn.includes(`<bpmn:userTask id="delivery-human-task__${el}__contract"`), "a producer-contract escalation task exists, distinct from the __esc timeout twin");
817
+ assert(
818
+ r.bpmn.includes(`<bpmn:sequenceFlow id="${el}_g1" name="contract broken" sourceRef="${el}_gate" targetRef="delivery-human-task__${el}__contract" />`),
819
+ "the gate's default (contract-broken) flow parks the producer on its contract escalation",
820
+ );
821
+ // The success flow proceeds only on a terminal-success status (or an absent/null status); an
822
+ // `in_progress`/`blocked`/`failed` self-report falls through to the default → escalation.
823
+ const g0 = r.bpmn.match(new RegExp(`<bpmn:sequenceFlow id="${el}_g0"[^>]*>(.*?)</bpmn:sequenceFlow>`, "s"));
824
+ assert(g0, "the contract-met success flow exists");
825
+ assert(g0![1].includes('list contains(["done", "opened", "skipped"], status)'), "the success flow gates on the terminal-success status allowlist");
826
+ assert(g0![1].includes("not(is defined(status)) or status = null"), "an absent/null status is not itself the failure mode — it still proceeds");
827
+ // The contract escalation's read-only context names the node and its reported status (#731).
828
+ const esc = escBlockForNodeSuffix(r.bpmn, "open", "contract");
829
+ assert(esc.includes("did not satisfy its producer contract"), "the contract escalation explains WHY it parked");
830
+ assert(esc.includes("Reported status="), "the context surfaces the actual reported status");
831
+ });
832
+
833
+ test("#731 required-emit gate: a producer's declared emit consumed as a required data dependency adds a non-null gate clause and a resumable escalation NAMING the fact", async () => {
834
+ const r = await compileOk(PRODUCER_GATE);
835
+ const el = elementForNode(r.bpmn, "open");
836
+ const g0 = r.bpmn.match(new RegExp(`<bpmn:sequenceFlow id="${el}_g0"[^>]*>(.*?)</bpmn:sequenceFlow>`, "s"));
837
+ assert(g0, "the contract-met success flow exists");
838
+ // `pr` is threaded to `land`'s connector payload as a required data dependency — so the gate proceeds
839
+ // only when it is actually populated non-null (a null `pr`, as in instance 10746, escalates here).
840
+ assert(g0![1].includes("(is defined(pr) and pr != null)"), "a required-emit non-null clause gates the success flow on the populated fact");
841
+ const esc = escBlockForNodeSuffix(r.bpmn, "open", "contract");
842
+ assert(esc.includes("Required emit &apos;pr&apos;"), "the escalation NAMES the required fact that was not emitted");
843
+ // Resumable (#514 Defect-B mirror): a human/agent supplies the missing fact, mapped onto the agent
844
+ // emit source var (fact name), so the subProcess output ioMapping republishes `<el>_pr` non-null.
845
+ assert(esc.includes('="typed"') && esc.includes('target="emitMode"'), "the contract escalation PRESENTS its value field (resumable)");
846
+ assert(/source="=if \(is defined\(value\)\) then value else null" target="pr"/.test(esc), "the operator's value maps onto the required emit's source var");
847
+ });
848
+
849
+ test("#731 routing-only emits stay optional: a fact referenced ONLY by an edge `when` guard is NOT gated as a required emit (omit ⇒ default branch)", async () => {
850
+ // `classify` emits `result` used ONLY for guarded routing (`when`/`equals` + a default) — never
851
+ // threaded as a fact-qualified `from` data dependency. The producer gate must NOT require it non-null.
852
+ const graph = {
853
+ name: "routing only",
854
+ nodes: [
855
+ { id: "classify", kind: "agent", agent: { jobType: "senior:feature" }, emits: [{ name: "result", type: "string" }] },
856
+ { id: "migrate", kind: "connector", connector: { target: "npm:install", dedupeKey: "m-1" } },
857
+ { id: "release", kind: "connector", connector: { target: "npm:publish", dedupeKey: "r-1" } },
858
+ ],
859
+ edges: [
860
+ { from: "classify", to: "migrate", when: "classify.result", equals: "breaking" },
861
+ { from: "classify", to: "release", default: true },
862
+ ],
863
+ };
864
+ const r = await compileOk(graph);
865
+ const el = elementForNode(r.bpmn, "classify");
866
+ const g0 = r.bpmn.match(new RegExp(`<bpmn:sequenceFlow id="${el}_g0"[^>]*>(.*?)</bpmn:sequenceFlow>`, "s"));
867
+ assert(g0, "the contract-met success flow exists");
868
+ // The status gate is still present, but there is NO `result` non-null clause — routing stays optional.
869
+ assert(g0![1].includes("list contains"), "the status gate is still present for the agent node");
870
+ assert(!g0![1].includes("result"), `a routing-only emit is NOT gated as a required data dependency, got: ${g0![1]}`);
871
+ // The contract escalation for a status-only gate is inert (no emit resume field).
872
+ const esc = escBlockForNodeSuffix(r.bpmn, "classify", "contract");
873
+ assert(esc.includes('="none"') && esc.includes('target="emitMode"'), "a status-only contract escalation keeps its emit field hidden");
874
+ // The guarded split that routes `result` downstream is untouched (default branch preserved).
875
+ assert(r.bpmn.includes('=classify_result = "breaking"') || r.bpmn.includes(`${el}_result = "breaking"`), "the routing guard on the emitted fact is preserved");
876
+ });
@@ -90,6 +90,26 @@ function escalationTaskElement(element: string): string {
90
90
  return `${DELIVERY_HUMAN_ELEMENT}__${element}__esc`;
91
91
  }
92
92
 
93
+ /** The BPMN element id an `agent` node's PRODUCER-CONTRACT escalation user task carries (issue #731) —
94
+ * distinct from the `__esc` timeout twin so a node can carry both a bounded-timeout escalation AND a
95
+ * post-completion contract-gate escalation without an id collision. Same human-completable convention
96
+ * (`delivery-human-task__…` → recognised by `isDeliveryHumanElement`, routed onto the Tasks inbox), so
97
+ * a producer that finishes without doing its job escalates AT that node and is answerable by a human
98
+ * OR an agent. */
99
+ function contractEscalationTaskElement(element: string): string {
100
+ return `${DELIVERY_HUMAN_ELEMENT}__${element}__contract`;
101
+ }
102
+
103
+ /** The self-reported completion statuses an `agent` node's job may return that count as a TERMINAL
104
+ * SUCCESS and are allowed to route their result onward (issue #731). Everything else — the pathological
105
+ * `in_progress` an agent that delegated/returned-before-finishing reports (instance 10746), a `blocked`/
106
+ * `failed`/`escalated` give-up, or any unrecognised free-formed status — fails the producer status gate
107
+ * and escalates AT the node instead of threading an incomplete result into a downstream consumer. An
108
+ * ABSENT/null status passes the gate (a status-less completion — an older fleet worker or a bare test
109
+ * stub — is not itself the failure mode; the required-emit gate still catches a missing data fact).
110
+ * Sorted for the compiler's byte-identical-output determinism. */
111
+ const AGENT_TERMINAL_SUCCESS_STATUSES: readonly string[] = ["done", "opened", "skipped"];
112
+
93
113
  /** A never-reached exhaustiveness guard: `compileNode`'s `switch` covers every allowlisted kind, so
94
114
  * the closed union narrows to `never` here. If a future kind is added to the vocabulary without a
95
115
  * compiler arm, `tsc` flags this call — the compile-time half of the trust bound. */
@@ -532,7 +552,24 @@ export async function compileDeliveryGraphSemantic(
532
552
  list.sort((a, b) => byCodeUnit(a.producerElement, b.producerElement) || byCodeUnit(a.fact, b.fact));
533
553
  }
534
554
 
535
- const semanticBpmn = renderBpmn(typed, wirings, numberedFlows, startForkGateway, endJoinGateway, boundInputsByElement);
555
+ // Producer-side required-emit gate (issue #731): the set of a producer's declared emit names that are
556
+ // consumed as a REQUIRED DATA DEPENDENCY downstream — i.e. threaded on a FACT-QUALIFIED edge
557
+ // (`from: "<node>.<fact>"`) into a consumer's connector `payload`/probe `target`. This is the SAME
558
+ // `<producerElement>_<fact>` wiring `boundInputsByElement` derives, keyed by the PRODUCER element so a
559
+ // node can gate its own completion on populating every fact a sibling depends on. A ROUTING emit
560
+ // (referenced only by an edge `when` guard, never as a fact-qualified `from`) is deliberately absent
561
+ // here — those stay optional (omit ⇒ default branch). Grouped by producer element; only set
562
+ // membership is ever queried downstream, so the sets carry no ordering guarantee.
563
+ const requiredEmitsByElement = new Map<string, Set<string>>();
564
+ for (const edge of resolvedEdges) {
565
+ if (edge.fromFact === undefined) continue;
566
+ const producerEl = mustGet(elementById, edge.fromNode);
567
+ const set = requiredEmitsByElement.get(producerEl) ?? new Set<string>();
568
+ set.add(edge.fromFact);
569
+ requiredEmitsByElement.set(producerEl, set);
570
+ }
571
+
572
+ const semanticBpmn = renderBpmn(typed, wirings, numberedFlows, startForkGateway, endJoinGateway, boundInputsByElement, requiredEmitsByElement);
536
573
  const diagram = renderMermaid(typed, wirings, resolvedEdges, elementById);
537
574
  const resolved = buildResolved(typed, wirings, resolvedEdges, producersById);
538
575
  const humanNodes = buildHumanNodes(nodes);
@@ -698,6 +735,11 @@ function buildSideEffects(nodes: readonly DeliveryNode[]): DeliverySideEffect[]
698
735
  return effects;
699
736
  }
700
737
 
738
+ /** Shared empty required-emits set for nodes with no required emits — reused instead of allocating a
739
+ * fresh `new Set()` per such node while rendering. Safe because `requiredEmits` is only ever read
740
+ * (`ReadonlySet`). */
741
+ const EMPTY_REQUIRED_EMITS: ReadonlySet<string> = new Set<string>();
742
+
701
743
  /** Render the compiled one-shot BPMN process definition (compile-to-native). Deterministic — element
702
744
  * order is fixed (start, gateways, nodes sorted, end) and every id is positional. */
703
745
  function renderBpmn(
@@ -707,6 +749,7 @@ function renderBpmn(
707
749
  startForkGateway: string | undefined,
708
750
  endJoinGateway: string | undefined,
709
751
  boundInputsByElement: ReadonlyMap<string, BoundInput[]>,
752
+ requiredEmitsByElement: ReadonlyMap<string, ReadonlySet<string>>,
710
753
  ): string {
711
754
  // Precompute incoming/outgoing flow-id maps once (single pass over flows) so BPMN rendering stays
712
755
  // linear in the number of flows instead of O(elements * flows) from repeated full-array filtering.
@@ -779,7 +822,15 @@ function renderBpmn(
779
822
  if (w.joinGateway) {
780
823
  lines.push(...gateway(w.joinGateway, w.joinExclusive, `join into ${w.node.id}`));
781
824
  }
782
- lines.push(renderNodeElement(w, incoming(w.element), outgoing(w.element), boundInputsByElement.get(w.element) ?? []));
825
+ lines.push(
826
+ renderNodeElement(
827
+ w,
828
+ incoming(w.element),
829
+ outgoing(w.element),
830
+ boundInputsByElement.get(w.element) ?? [],
831
+ requiredEmitsByElement.get(w.element) ?? EMPTY_REQUIRED_EMITS,
832
+ ),
833
+ );
783
834
  if (w.forkGateway) {
784
835
  lines.push(...gateway(w.forkGateway, w.forkExclusive, `fan out of ${w.node.id}`));
785
836
  }
@@ -835,6 +886,7 @@ function renderNodeElement(
835
886
  incoming: readonly string[],
836
887
  outgoing: readonly string[],
837
888
  boundInputs: readonly BoundInput[],
889
+ requiredEmits: ReadonlySet<string>,
838
890
  ): string {
839
891
  const el = w.element;
840
892
  const name = escapeXml(`${w.node.kind}: ${w.node.id}`);
@@ -843,7 +895,7 @@ function renderNodeElement(
843
895
  ...outgoing.map((id) => ` <bpmn:outgoing>${id}</bpmn:outgoing>`),
844
896
  ];
845
897
  const io = ioMappingLines(w, boundInputs);
846
- const inner = innerBodyLines(w);
898
+ const inner = innerBodyLines(w, requiredEmits);
847
899
  const lines = [
848
900
  ` <bpmn:subProcess id="${el}" name="${name}">`,
849
901
  ...flowRefs,
@@ -965,12 +1017,18 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
965
1017
  * the S3 scheduled user-task + generic form + SLA. Each is a single-entry / single-exit subgraph with
966
1018
  * a bounded timeout that escalates onto a human-completable user task (or, for `human`, records an
967
1019
  * escalated outcome). */
968
- function innerBodyLines(w: NodeWiring): string[] {
1020
+ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): string[] {
969
1021
  const el = w.element;
970
1022
  const node = w.node;
971
1023
  switch (node.kind) {
972
- case "agent":
973
- return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType);
1024
+ case "agent": {
1025
+ // Issue #731: an `agent` node gates its own completion on a producer contract — a terminal-success
1026
+ // self-reported `status` AND a non-null value for every declared emit a downstream consumer binds
1027
+ // as a required data dependency. A broken producer (returns `in_progress`, or omits a required
1028
+ // emit) escalates AT this node instead of threading an incomplete result onward.
1029
+ const contractGate = { requiredEmits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
1030
+ return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType, contractGate);
1031
+ }
974
1032
  case "connector":
975
1033
  return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${node.connector.target}`);
976
1034
  case "wait":
@@ -986,12 +1044,57 @@ function innerBodyLines(w: NodeWiring): string[] {
986
1044
  * escalates the stalled node onto a human-completable user task. `taskDefAttr` is the pre-rendered
987
1045
  * `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines; `descriptor`
988
1046
  * names the stalled work (job type / connector target) for the escalation task's context line (#499). */
1047
+ /** The FEEL boolean an `agent` node's producer-contract gate (issue #731) evaluates on its `_gate`
1048
+ * exclusive split's SUCCESS flow: the completion proceeds onward only when the self-reported `status`
1049
+ * is a terminal success (or absent/null) AND every required-data-dependency emit is populated non-null.
1050
+ * Reads the job's returned variables from the subProcess scope (the emit source var for an agent fact
1051
+ * is the fact's own name — see {@link factSourceVar}). When it is false the split's DEFAULT flow routes
1052
+ * to the contract-escalation task instead. */
1053
+ function agentContractProceedCondition(requiredEmits: readonly DeliveryFact[]): string {
1054
+ const statusList = `[${AGENT_TERMINAL_SUCCESS_STATUSES.map((s) => feelStr(s)).join(", ")}]`;
1055
+ const statusOk = `(not(is defined(status)) or status = null or list contains(${statusList}, status))`;
1056
+ const emitClauses = requiredEmits.map((f) => `(is defined(${f.name}) and ${f.name} != null)`);
1057
+ return `=${[statusOk, ...emitClauses].join(" and ")}`;
1058
+ }
1059
+
1060
+ /** The read-only context line seeded onto an `agent` node's producer-contract escalation (issue #731),
1061
+ * so the human/agent unsticking it sees WHY it parked — the node, its job type, the actual reported
1062
+ * status, and, per required emit, whether it arrived. Turns the instance-10746 failure (a silent null
1063
+ * thread + two mis-attributed CONSUMER incidents) into one correctly-attributed PRODUCER escalation. */
1064
+ function agentContractContextFeel(nodeId: string, descriptor: string, requiredEmits: readonly DeliveryFact[]): string {
1065
+ const statuses = AGENT_TERMINAL_SUCCESS_STATUSES.join("/");
1066
+ const head = feelStr(
1067
+ `Node ${nodeId} (${descriptor}) completed but did not satisfy its producer contract — a producer must ` +
1068
+ `self-report a terminal-success status (${statuses}) and populate every emit a downstream node requires ` +
1069
+ "before its result routes onward. Reported status=",
1070
+ );
1071
+ let feel = `=${head} + (if (is defined(status) and status != null) then string(status) else "(none)") + "."`;
1072
+ for (const f of requiredEmits) {
1073
+ const present = `(is defined(${f.name}) and ${f.name} != null)`;
1074
+ feel += ` + " Required emit '${f.name}': " + (if ${present} then "present" else "MISSING (null)") + "."`;
1075
+ }
1076
+ return feel;
1077
+ }
1078
+
1079
+ /** `agent`/`connector` body: `start → serviceTask → end`, with a bounded `=nodeTimeout` boundary that
1080
+ * escalates the stalled node onto a human-completable user task. `taskDefAttr` is the pre-rendered
1081
+ * `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines; `descriptor`
1082
+ * names the stalled work (job type / connector target) for the escalation task's context line (#499).
1083
+ *
1084
+ * `contractGate` (agent only, issue #731) inserts a PRODUCER post-condition between the task and the
1085
+ * end: an exclusive split whose SUCCESS flow ({@link agentContractProceedCondition}) proceeds only on a
1086
+ * terminal-success `status` AND non-null required emits, and whose DEFAULT flow parks a broken producer
1087
+ * on a SECOND (contract) escalation task — distinct from the `__esc` timeout twin. That escalation is
1088
+ * RESUMABLE with the node's required emits (a human/agent supplies the missing fact, which the
1089
+ * subProcess output mapping then publishes as `<el>_<fact>`), mirroring the #514 Defect-B wait resume.
1090
+ * Omitted for a `connector` (no self-reported status contract), whose body stays `task → end`. */
989
1091
  function serviceBodyLines(
990
1092
  el: string,
991
1093
  nodeId: string,
992
1094
  taskDefAttr: string,
993
1095
  taskProps: readonly string[],
994
1096
  descriptor: string,
1097
+ contractGate?: { requiredEmits: readonly DeliveryFact[] },
995
1098
  ): string[] {
996
1099
  const esc = escalationTaskElement(el);
997
1100
  const taskExt =
@@ -1009,7 +1112,19 @@ function serviceBodyLines(
1009
1112
  ` <zeebe:taskDefinition ${taskDefAttr} />`,
1010
1113
  " </bpmn:extensionElements>",
1011
1114
  ];
1012
- return [
1115
+ const timeoutEscalation = escalationTaskLines(
1116
+ esc,
1117
+ nodeId,
1118
+ [`${el}_i2`],
1119
+ `${el}_i3`,
1120
+ escalationContextFeel(
1121
+ nodeId,
1122
+ descriptor,
1123
+ "nodeTimeout",
1124
+ "; in-flight work may already exist — check for a draft PR or partial state before retrying or reassigning.",
1125
+ ),
1126
+ );
1127
+ const head = [
1013
1128
  ` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
1014
1129
  ` <bpmn:serviceTask id="${el}_task" name="${escapeXml(nodeId)}">`,
1015
1130
  ...taskExt,
@@ -1020,21 +1135,49 @@ function serviceBodyLines(
1020
1135
  ` <bpmn:outgoing>${el}_i2</bpmn:outgoing>`,
1021
1136
  ` <bpmn:timerEventDefinition id="${el}_ted"><bpmn:timeDuration xsi:type="bpmn:tFormalExpression">=nodeTimeout</bpmn:timeDuration></bpmn:timerEventDefinition>`,
1022
1137
  " </bpmn:boundaryEvent>",
1023
- ...escalationTaskLines(
1024
- esc,
1025
- nodeId,
1026
- [`${el}_i2`],
1027
- `${el}_i3`,
1028
- escalationContextFeel(
1029
- nodeId,
1030
- descriptor,
1031
- "nodeTimeout",
1032
- "; in-flight work may already exist — check for a draft PR or partial state before retrying or reassigning.",
1033
- ),
1034
- ),
1035
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i3</bpmn:incoming></bpmn:endEvent>`,
1138
+ ...timeoutEscalation,
1139
+ ];
1140
+
1141
+ if (contractGate === undefined) {
1142
+ return [
1143
+ ...head,
1144
+ ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i3</bpmn:incoming></bpmn:endEvent>`,
1145
+ flow(`${el}_i0`, `${el}_start`, `${el}_task`),
1146
+ flow(`${el}_i1`, `${el}_task`, `${el}_end`),
1147
+ flow(`${el}_i2`, `${el}_be`, esc),
1148
+ flow(`${el}_i3`, esc, `${el}_end`),
1149
+ ];
1150
+ }
1151
+
1152
+ // Producer-contract gate (issue #731): task → gate → (proceed | contract-escalation) → end.
1153
+ const contractEsc = contractEscalationTaskElement(el);
1154
+ const emits = contractGate.requiredEmits;
1155
+ const proceedCondition = agentContractProceedCondition(emits);
1156
+ const contractEscalation = escalationTaskLines(
1157
+ contractEsc,
1158
+ nodeId,
1159
+ [`${el}_g1`],
1160
+ `${el}_g2`,
1161
+ agentContractContextFeel(nodeId, descriptor, emits),
1162
+ // Resumable when the producer owes a required emit: a human/agent supplies the missing fact, which
1163
+ // the subProcess output ioMapping then publishes as `<el>_<fact>` (agent emit source = fact name),
1164
+ // so the downstream consumer late-binds a real value instead of the null that poisoned it (#731).
1165
+ emits.length > 0 ? { resume: { kind: "agent" as const, emits } } : undefined,
1166
+ );
1167
+ return [
1168
+ ...head,
1169
+ ` <bpmn:exclusiveGateway id="${el}_gate" name="producer contract met?" default="${el}_g1">`,
1170
+ ` <bpmn:incoming>${el}_i1</bpmn:incoming>`,
1171
+ ` <bpmn:outgoing>${el}_g0</bpmn:outgoing>`,
1172
+ ` <bpmn:outgoing>${el}_g1</bpmn:outgoing>`,
1173
+ " </bpmn:exclusiveGateway>",
1174
+ ...contractEscalation,
1175
+ ` <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>`,
1036
1176
  flow(`${el}_i0`, `${el}_start`, `${el}_task`),
1037
- flow(`${el}_i1`, `${el}_task`, `${el}_end`),
1177
+ flow(`${el}_i1`, `${el}_task`, `${el}_gate`),
1178
+ ` <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>`,
1179
+ ` <bpmn:sequenceFlow id="${el}_g1" name="contract broken" sourceRef="${el}_gate" targetRef="${contractEsc}" />`,
1180
+ flow(`${el}_g2`, contractEsc, `${el}_end`),
1038
1181
  flow(`${el}_i2`, `${el}_be`, esc),
1039
1182
  flow(`${el}_i3`, esc, `${el}_end`),
1040
1183
  ];
@@ -185,6 +185,67 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
185
185
  assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the late-bound wait resolved and the graph reached End");
186
186
  });
187
187
 
188
+ test("#731 producer contract gate: an agent that completes with status=in_progress and a null required emit escalates AT the producer, does NOT thread null downstream, and resumes", async () => {
189
+ const app = track(await boot(freshDir()));
190
+
191
+ // The instance-10746 failure mode: the agent's job COMPLETES, but it broke its node contract —
192
+ // it self-reports `status: "in_progress"` and never opened the PR, so its required `pr` emit is
193
+ // null. Before #731 this threaded `open_pr = null` through to the connector, which then failed with
194
+ // a mis-attributed CONSUMER incident. The producer gate must instead park THIS node.
195
+ let agentFired = 0;
196
+ await app.engine.registerWorker("senior:demo", async () => {
197
+ agentFired++;
198
+ return { status: "in_progress", summary: "delegated to a background agent; PR not opened." };
199
+ });
200
+ let connectorFired = 0;
201
+ await app.engine.registerWorker(
202
+ "pr.delivery-connector",
203
+ async (job) => {
204
+ connectorFired++;
205
+ const vars = job.variables as Record<string, unknown>;
206
+ const { target, payload, boundFacts } = readConnectorInput(vars as Parameters<typeof readConnectorInput>[0]);
207
+ const dedupeKey = connectorDedupeKey({
208
+ dedupeKey: (vars.dedupeKey as string | null | undefined) ?? null,
209
+ processInstanceKey: job.processInstanceKey ?? null,
210
+ elementId: job.elementId ?? null,
211
+ });
212
+ return await dispatchConnector(app.db, { dedupeKey: dedupeKey ?? "x", target, payload, boundFacts }, new Date().toISOString());
213
+ },
214
+ { fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
215
+ );
216
+
217
+ const graph: DeliveryGraph = {
218
+ name: "e2e producer gate",
219
+ nodes: [
220
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: "pr", type: "pr" }] },
221
+ { id: "land", kind: "connector", connector: { target: "slack", payload: { pr: "open.pr" }, dedupeKey: "land-731" } },
222
+ ],
223
+ edges: [{ from: "open.pr", to: "land" }],
224
+ };
225
+
226
+ const run = await runDeliveryGraph(app.engine, graph, { escalationSlaTimeout: "PT1H", repoless: true });
227
+ assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
228
+ await app.settle();
229
+
230
+ // The agent job fired and COMPLETED — but the node did NOT succeed: it parked on its producer
231
+ // contract escalation, the graph never reached End, and the downstream connector never fired on null.
232
+ assert.equal(agentFired, 1, "the agent node's job fired and completed");
233
+ assert.ok(!takenFlows(app).some((f) => f.endsWith("->End")), "the broken producer did NOT thread its result to End");
234
+ assert.equal(connectorFired, 0, "the downstream connector never fired on a null required emit");
235
+ const open = await app.engine.searchUserTasks({ state: "CREATED" });
236
+ const contract = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && t.elementId?.endsWith("__contract"));
237
+ assert.ok(contract, `the producer escalates AT its node on its __contract task, got ${JSON.stringify(open.map((t) => t.elementId))}`);
238
+
239
+ // Resumable (the issue's manual unblock): a human/agent supplies the eventually-created PR on the
240
+ // contract task; the subProcess output mapping republishes `open_pr` non-null and the connector runs.
241
+ await app.engine.completeUserTask(contract.userTaskKey, { value: "owner/repo#42", humanOutcome: "completed" });
242
+ await app.settle();
243
+ assert.equal(connectorFired, 1, "resuming the contract escalation with the missing PR unblocks the downstream connector");
244
+ const rows = await deliveryConnectorDispatches(app.db).find({ dedupe_key: "land-731" });
245
+ assert.equal(rows.length, 1, "the connector fired exactly once after resume");
246
+ assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the resumed producer's result reaches End");
247
+ });
248
+
188
249
  test("resume never double-fires: an at-least-once redelivery of the connector dedupes", async () => {
189
250
  const app = track(await boot(freshDir()));
190
251
  // The connector fired once above's-style; here prove the idempotency directly against the ledger a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.179.2",
3
+ "version": "0.180.0",
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",