@nanobpm/nano-workforce 0.179.2 → 0.181.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,15 @@
1
+ ## [0.181.0](https://github.com/nanobpm/nano-workforce/compare/v0.180.0...v0.181.0) (2026-09-04)
2
+
3
+ ### Features
4
+
5
+ * delivery-graph MCP DX — interleaved sequence gates, paginated guide sections, stage-supersede visibility ([#741](https://github.com/nanobpm/nano-workforce/issues/741)) ([6f10e8a](https://github.com/nanobpm/nano-workforce/commit/6f10e8a25721645af4e5f932f2cbaa9ad6025b70)), closes [#740](https://github.com/nanobpm/nano-workforce/issues/740) [#460](https://github.com/nanobpm/nano-workforce/issues/460) [#740](https://github.com/nanobpm/nano-workforce/issues/740)
6
+
7
+ ## [0.180.0](https://github.com/nanobpm/nano-workforce/compare/v0.179.2...v0.180.0) (2026-09-04)
8
+
9
+ ### Features
10
+
11
+ * **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)
12
+
1
13
  ## [0.179.2](https://github.com/nanobpm/nano-workforce/compare/v0.179.1...v0.179.2) (2026-09-04)
2
14
 
3
15
  ### Code Refactoring
@@ -10,9 +10,11 @@ import { test } from "node:test";
10
10
  import { assert, assertEquals } from "#test-assert";
11
11
  import {
12
12
  GUIDE_SECTIONS,
13
+ GUIDE_SECTION_PAGE_DEFAULT,
13
14
  guideToc,
14
15
  renderAgentGuide,
15
16
  renderGuideSection,
17
+ renderGuideSectionChunk,
16
18
  splitGuideSections,
17
19
  } from "./agentGuide.ts";
18
20
 
@@ -105,6 +107,91 @@ test("an unknown section id resolves to undefined (the op turns that into a 400)
105
107
  assertEquals(renderGuideSection("does-not-exist", "https://x/app/api"), undefined);
106
108
  });
107
109
 
110
+ // ── Paginated section retrieval (issue #740) ────────────────────────────────────────────────────
111
+
112
+ test("renderGuideSectionChunk: paging through a section reassembles it exactly, chunk by chunk", () => {
113
+ const base = "https://x/app/api";
114
+ const full = renderGuideSection("delivery-graphs", base)!;
115
+ const totalChars = Array.from(full).length;
116
+ const PAGE = 4000;
117
+ let start = 0;
118
+ let assembled = "";
119
+ let pages = 0;
120
+ let lastTotal = -1;
121
+ // Follow the nextStart cursor until it is null — the classic pagination loop.
122
+ for (;;) {
123
+ const chunk = renderGuideSectionChunk("delivery-graphs", base, start, PAGE)!;
124
+ assert(chunk, "a known section must page");
125
+ assert(Array.from(chunk.instructions).length <= PAGE, "a page must be bounded by the window");
126
+ assertEquals(chunk.start, start);
127
+ assertEquals(chunk.totalLength, totalChars);
128
+ if (lastTotal !== -1) assertEquals(chunk.totalLength, lastTotal);
129
+ lastTotal = chunk.totalLength;
130
+ assembled += chunk.instructions;
131
+ pages++;
132
+ if (chunk.nextStart === null) break;
133
+ start = chunk.nextStart;
134
+ assert(pages < 100, "pagination must terminate");
135
+ }
136
+ assert(pages > 1, "a large section must span multiple pages at this window");
137
+ assertEquals(assembled, full, "the reassembled pages must equal the whole section");
138
+ });
139
+
140
+ test("renderGuideSectionChunk: a window past the end returns an empty last page with nextStart null", () => {
141
+ const base = "https://x/app/api";
142
+ const full = renderGuideSection("orient", base)!;
143
+ const total = Array.from(full).length;
144
+ const chunk = renderGuideSectionChunk("orient", base, total + 500, 100)!;
145
+ assertEquals(chunk.start, total);
146
+ assertEquals(chunk.length, 0);
147
+ assertEquals(chunk.instructions, "");
148
+ assertEquals(chunk.nextStart, null);
149
+ });
150
+
151
+ test("renderGuideSectionChunk: the first page of a fitting section equals the whole section (nextStart null)", () => {
152
+ const base = "https://x/app/api";
153
+ const full = renderGuideSection("orient", base)!;
154
+ const chunk = renderGuideSectionChunk("orient", base, 0, GUIDE_SECTION_PAGE_DEFAULT)!;
155
+ // `orient` fits well under the default page, so one page carries it whole with no continuation.
156
+ assertEquals(chunk.instructions, full);
157
+ assertEquals(chunk.nextStart, null);
158
+ });
159
+
160
+ test("renderGuideSectionChunk: a zero-length window on a non-empty section terminates (nextStart null, no loop)", () => {
161
+ const base = "https://x/app/api";
162
+ const full = renderGuideSection("delivery-graphs", base)!;
163
+ assert(Array.from(full).length > 0, "precondition: the section has content");
164
+ // A caller (or a direct invoker bypassing the op's `length >= 1` validation) that asks for a
165
+ // zero-length window gets an empty page — but `nextStart` MUST be null so a "page until nextStart
166
+ // is null" loop cannot spin forever on a cursor that never advances.
167
+ const chunk = renderGuideSectionChunk("delivery-graphs", base, 0, 0)!;
168
+ assertEquals(chunk.start, 0);
169
+ assertEquals(chunk.length, 0);
170
+ assertEquals(chunk.instructions, "");
171
+ assertEquals(chunk.nextStart, null);
172
+ });
173
+
174
+ test("renderGuideSectionChunk: an unknown section id is undefined (the op turns that into a 400)", () => {
175
+ assertEquals(renderGuideSectionChunk("does-not-exist", "https://x/app/api", 0, 100), undefined);
176
+ });
177
+
178
+ test("renderGuideSectionChunk: chunk boundaries never split a multi-byte character", () => {
179
+ const base = "https://x/app/api";
180
+ const full = renderGuideSection("delivery-graphs", base)!;
181
+ // Page at every 1-char window across a stretch that contains the guide's arrows/emoji; each
182
+ // reassembled result must round-trip losslessly (no U+FFFD replacement from a split code point).
183
+ let assembled = "";
184
+ let start = 0;
185
+ for (let i = 0; i < 200 && start < Array.from(full).length; i++) {
186
+ const chunk = renderGuideSectionChunk("delivery-graphs", base, start, 7)!;
187
+ assembled += chunk.instructions;
188
+ if (chunk.nextStart === null) break;
189
+ start = chunk.nextStart;
190
+ }
191
+ assert(!assembled.includes("\uFFFD"), "no replacement characters — code points were never split");
192
+ assertEquals(assembled, Array.from(full).slice(0, Array.from(assembled).length).join(""));
193
+ });
194
+
108
195
  test("no content regression: each section body is a verbatim slice of the raw guide", () => {
109
196
  // Rendering keys examples to an instance; the UN-substituted section bodies must be exact
110
197
  // substrings of the authored doc, so the addressable surface never rewrites guide content.
package/app/agentGuide.ts CHANGED
@@ -174,3 +174,64 @@ export function renderGuideSection(id: string, apiBase: string): string | undefi
174
174
  const base = apiBase.replace(/\/+$/, "");
175
175
  return section.body.replaceAll("__BASE__", base).replaceAll("__ENGINE__", resolveEngineBase());
176
176
  }
177
+
178
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
179
+ // Paginated section retrieval (issue #740).
180
+ //
181
+ // The addressable guide exists to avoid the ~43KB monolith, but a single section can ITSELF overflow
182
+ // a typical MCP tool-result limit — `delivery-graphs` alone renders to ~25KB, the one section an
183
+ // author most needs. So a section is additionally retrievable in BOUNDED CHUNKS via a `start`/`length`
184
+ // window with a `nextStart` continuation cursor. Offsets are UNICODE CHARACTER (code-point) offsets —
185
+ // NOT raw byte offsets — so a chunk boundary never splits a multi-byte character (the arrows/emoji in
186
+ // the guide) into mojibake. A plain `renderGuideSection` (no window) is untouched, so existing
187
+ // `section=<id>` calls that already fit stay byte-for-byte identical.
188
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
189
+
190
+ /** The default window size (characters) when a caller engages pagination without an explicit
191
+ * `length` — comfortably under a typical MCP tool-result limit so a single page never overflows. */
192
+ export const GUIDE_SECTION_PAGE_DEFAULT = 12_000;
193
+
194
+ /** One page of a section's markdown: the `instructions` slice plus the cursor state so a caller can
195
+ * page through with `nextStart` until it is `null`. Offsets/lengths are CHARACTER counts. */
196
+ export interface GuideSectionChunk {
197
+ readonly instructions: string;
198
+ /** The (clamped) character offset this page starts at. */
199
+ readonly start: number;
200
+ /** The number of characters actually returned in this page. */
201
+ readonly length: number;
202
+ /** The total number of characters in the fully-rendered section. */
203
+ readonly totalLength: number;
204
+ /** The character offset to pass as `start` for the next page, or `null` when this is the last page. */
205
+ readonly nextStart: number | null;
206
+ }
207
+
208
+ /** Render a bounded WINDOW of a single section's markdown for a given app control-API base, with
209
+ * `__BASE__`/`__ENGINE__` substituted exactly as {@link renderGuideSection} does. `start` and
210
+ * `length` are CHARACTER offsets (clamped to the section bounds; a non-positive `length` yields an
211
+ * empty page). Returns `undefined` for an unknown id (the caller turns that into a 400). */
212
+ export function renderGuideSectionChunk(
213
+ id: string,
214
+ apiBase: string,
215
+ start: number,
216
+ length: number,
217
+ ): GuideSectionChunk | undefined {
218
+ const full = renderGuideSection(id, apiBase);
219
+ if (full === undefined) return undefined;
220
+ const chars = Array.from(full);
221
+ const total = chars.length;
222
+ const from = Math.min(Math.max(0, Math.trunc(start)), total);
223
+ const take = Math.max(0, Math.trunc(length));
224
+ const slice = chars.slice(from, from + take);
225
+ const end = from + slice.length;
226
+ return {
227
+ instructions: slice.join(""),
228
+ start: from,
229
+ length: slice.length,
230
+ totalLength: total,
231
+ // Only advance when this page actually consumed characters. A zero-length window (`length <= 0`)
232
+ // returns an empty page that made NO progress, so it must terminate the cursor (`null`) rather
233
+ // than hand back a `nextStart` equal to `start` — a non-advancing cursor would loop a caller
234
+ // that pages until `nextStart` is null forever.
235
+ nextStart: slice.length > 0 && end < total ? end : null,
236
+ };
237
+ }
@@ -164,7 +164,7 @@ const GRAPH_NAME_MAX_LENGTH = 255;
164
164
  * like the library import/save doors' `graphJson`, where the schema never touches the parsed value)
165
165
  * would otherwise let an oversized-but-compilable graph reach the layout/compiler and be persisted —
166
166
  * both violating the declared contract and exposing the import path to avoidable CPU/memory growth. */
167
- const GRAPH_MAX_NODES = 256;
167
+ export const GRAPH_MAX_NODES = 256;
168
168
  const GRAPH_MAX_EDGES = 1024;
169
169
  const NODE_MAX_EMITS = 32;
170
170
 
@@ -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
  ];