@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 +6 -0
- package/app/deliveryGraphCompiler.test.ts +101 -2
- package/app/deliveryGraphCompiler.ts +164 -21
- package/e2e/delivery-graph.e2e.ts +61 -0
- package/package.json +1 -1
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
|
|
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 'pr'"), "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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
),
|
|
1034
|
-
|
|
1035
|
-
|
|
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}
|
|
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.
|
|
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",
|