@nanobpm/nano-workforce 0.179.1 → 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 +12 -0
- package/app/deliveryGraphCompiler.test.ts +101 -2
- package/app/deliveryGraphCompiler.ts +164 -21
- package/e2e/agent-answerable.e2e.ts +1 -2
- package/e2e/convergence-escalation.e2e.ts +2 -3
- package/e2e/delivery-graph.e2e.ts +61 -0
- package/e2e/feature-run.e2e.ts +4 -5
- package/e2e/retire-escalation-subsystem.e2e.ts +2 -3
- package/package.json +1 -1
- package/e2e/support/engine-client.ts +0 -33
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
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
|
+
|
|
7
|
+
## [0.179.2](https://github.com/nanobpm/nano-workforce/compare/v0.179.1...v0.179.2) (2026-09-04)
|
|
8
|
+
|
|
9
|
+
### Code Refactoring
|
|
10
|
+
|
|
11
|
+
* drop redundant testkit engine-client shim in e2e ([#734](https://github.com/nanobpm/nano-workforce/issues/734)) ([8ea7ccd](https://github.com/nanobpm/nano-workforce/commit/8ea7ccd5c38a2b0e363cac74b126dd86bee0f0b1)), closes [#336](https://github.com/nanobpm/nano-workforce/issues/336)
|
|
12
|
+
|
|
1
13
|
## [0.179.1](https://github.com/nanobpm/nano-workforce/compare/v0.179.0...v0.179.1) (2026-09-04)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
|
@@ -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
|
];
|
|
@@ -26,7 +26,6 @@ import { fileURLToPath } from "node:url";
|
|
|
26
26
|
import type { EngineJob } from "@nanobpm/urban/runtime";
|
|
27
27
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
28
28
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
29
|
-
import { asEngineClient } from "./support/engine-client.ts";
|
|
30
29
|
import {
|
|
31
30
|
completeEscalationAsAgent,
|
|
32
31
|
latestCompletion,
|
|
@@ -142,7 +141,7 @@ describe("agent-answerable escalations (U6 — same form, agent completer, attri
|
|
|
142
141
|
|
|
143
142
|
// Complete AS AN AGENT through the host-side completer — the same typed `{resolution, answer}`
|
|
144
143
|
// a human submits through the inbox, only the caller differs.
|
|
145
|
-
const r = await completeEscalationAsAgent(app.db,
|
|
144
|
+
const r = await completeEscalationAsAgent(app.db, app.engine, {
|
|
146
145
|
userTaskKey: task.userTaskKey,
|
|
147
146
|
agentId: "senior:answer-bot",
|
|
148
147
|
variables: { resolution: "answer", answer: "use v2" },
|
|
@@ -29,7 +29,6 @@ import { after, before, describe, test } from "node:test";
|
|
|
29
29
|
import { fileURLToPath } from "node:url";
|
|
30
30
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
31
31
|
import { pollUserTasks } from "../app/service.ts";
|
|
32
|
-
import { asEngineClient } from "./support/engine-client.ts";
|
|
33
32
|
|
|
34
33
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
35
34
|
const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u4-"));
|
|
@@ -159,7 +158,7 @@ describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
|
|
|
159
158
|
// denormalised `open_escalation_*` pointer is written or read. That read model is projected by
|
|
160
159
|
// `pollUserTasks` (part of the imperative main.ts poll loop the testkit does not run), so drive it
|
|
161
160
|
// explicitly (mirrors feature-run.e2e.ts) to project the parked `wait-answer` task first.
|
|
162
|
-
await pollUserTasks(app.db,
|
|
161
|
+
await pollUserTasks(app.db, app.engine);
|
|
163
162
|
const status = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
|
|
164
163
|
assert.equal(status.status, 200, "the status endpoint responds");
|
|
165
164
|
const statusRow = status.body.prs.find((p) => p.prKey === prKey);
|
|
@@ -198,7 +197,7 @@ describe("nano-workforce PR review-loop escalation (U4 userTask)", () => {
|
|
|
198
197
|
// reconciles away, so no open escalation lingers on the status endpoint once answered + resumed.
|
|
199
198
|
// Re-run the projection (as the durable poller would) so the closed task's read-model row is
|
|
200
199
|
// deleted before re-reading /status.
|
|
201
|
-
await pollUserTasks(app.db,
|
|
200
|
+
await pollUserTasks(app.db, app.engine);
|
|
202
201
|
const afterStatus = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
|
|
203
202
|
const afterRow = afterStatus.body.prs.find((p) => p.prKey === prKey);
|
|
204
203
|
if (afterRow) {
|
|
@@ -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/e2e/feature-run.e2e.ts
CHANGED
|
@@ -23,7 +23,6 @@ import { fileURLToPath } from "node:url";
|
|
|
23
23
|
import type { EngineJob } from "@nanobpm/urban/runtime";
|
|
24
24
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
25
25
|
import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
|
|
26
|
-
import { asEngineClient } from "./support/engine-client.ts";
|
|
27
26
|
import { pollUserTasks } from "../app/service.ts";
|
|
28
27
|
|
|
29
28
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -208,7 +207,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
208
207
|
// read-model (issue #332 dropped the denormalised `feature_runs.blocked_user_task_key` pointer),
|
|
209
208
|
// so `pollUserTasks` projects the parked `feature-blocked` task onto `user_tasks` by reading the
|
|
210
209
|
// engine directly — no per-run column write.
|
|
211
|
-
await pollUserTasks(app.db,
|
|
210
|
+
await pollUserTasks(app.db, app.engine);
|
|
212
211
|
const inboxRow = await app.db
|
|
213
212
|
.table<{ user_task_key: string; element_id: string }>("user_tasks", "user_task_key")
|
|
214
213
|
.findOne({ user_task_key: task!.userTaskKey });
|
|
@@ -233,7 +232,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
233
232
|
|
|
234
233
|
// A further poll pass is an idempotent no-op — the task is completed, so the read-model row is
|
|
235
234
|
// reconciled away and a terminal run is not a candidate.
|
|
236
|
-
await pollUserTasks(app.db,
|
|
235
|
+
await pollUserTasks(app.db, app.engine);
|
|
237
236
|
assert.equal((await featureRow(app, featureKey)).status, "blocked");
|
|
238
237
|
},
|
|
239
238
|
);
|
|
@@ -385,7 +384,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
385
384
|
|
|
386
385
|
// The poller projects the parked task onto the Tasks inbox `user_tasks` read-model by reading
|
|
387
386
|
// the engine directly, sourcing the question from the `feature_escalations` audit log.
|
|
388
|
-
await pollUserTasks(app.db,
|
|
387
|
+
await pollUserTasks(app.db, app.engine);
|
|
389
388
|
const inboxRow = await app.db
|
|
390
389
|
.table<{ user_task_key: string; element_id: string; question: string | null }>("user_tasks", "user_task_key")
|
|
391
390
|
.findOne({ user_task_key: task!.userTaskKey });
|
|
@@ -414,7 +413,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
414
413
|
assert.equal(settled.status, "opened", "the resumed run opened its PR");
|
|
415
414
|
|
|
416
415
|
// A further poll pass reconciles the completed task's read-model row away.
|
|
417
|
-
await pollUserTasks(app.db,
|
|
416
|
+
await pollUserTasks(app.db, app.engine);
|
|
418
417
|
const gone = await app.db
|
|
419
418
|
.table<{ user_task_key: string }>("user_tasks", "user_task_key")
|
|
420
419
|
.findOne({ user_task_key: task!.userTaskKey });
|
|
@@ -26,7 +26,6 @@ import { after, before, describe, test } from "node:test";
|
|
|
26
26
|
import { fileURLToPath } from "node:url";
|
|
27
27
|
import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
|
|
28
28
|
import { pollUserTasks } from "../app/service.ts";
|
|
29
|
-
import { asEngineClient } from "./support/engine-client.ts";
|
|
30
29
|
|
|
31
30
|
const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
32
31
|
const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u7-"));
|
|
@@ -237,7 +236,7 @@ describe("retire escalation subsystem (U7 — destructive contract phase)", () =
|
|
|
237
236
|
// written. That read model is projected by `pollUserTasks` (part of the imperative main.ts poll
|
|
238
237
|
// loop the testkit does not run), so drive it explicitly (mirrors feature-run.e2e.ts) to project
|
|
239
238
|
// the parked `wait-answer` task before reading /status.
|
|
240
|
-
await pollUserTasks(app.db,
|
|
239
|
+
await pollUserTasks(app.db, app.engine);
|
|
241
240
|
const status = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
|
|
242
241
|
const statusRow = status.body.prs.find((p) => p.prKey === prKey);
|
|
243
242
|
assert.equal(statusRow?.status, "escalated", "the PR reads as escalated");
|
|
@@ -265,7 +264,7 @@ describe("retire escalation subsystem (U7 — destructive contract phase)", () =
|
|
|
265
264
|
// No addressed escalation lingers — the completed task's `user_tasks` row reconciles away. Re-run
|
|
266
265
|
// the projection (as the durable poller would) so the closed task's read-model row is deleted
|
|
267
266
|
// before re-reading /status.
|
|
268
|
-
await pollUserTasks(app.db,
|
|
267
|
+
await pollUserTasks(app.db, app.engine);
|
|
269
268
|
const afterStatus = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
|
|
270
269
|
const afterRow = afterStatus.body.prs.find((p) => p.prKey === prKey);
|
|
271
270
|
if (afterRow) {
|
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",
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import type { EngineClient } from "@nanobpm/urban";
|
|
2
|
-
import type { TestApp } from "@nanobpm/urban-testkit";
|
|
3
|
-
|
|
4
|
-
// `@nanobpm/urban-testkit`'s `WasmEngineClient` has historically lagged urban's `EngineClient`
|
|
5
|
-
// interface: a method lands on the real seam a release or two before the testkit fake grows it. Both
|
|
6
|
-
// guards below defensively COMPLETE the contract for any method the *installed* testkit hasn't
|
|
7
|
-
// implemented yet — each is an idempotent `if (typeof … !== "function")`, so it no-ops the moment a
|
|
8
|
-
// testkit release ships the real method (no version pins to drift). Scoped to the test harness;
|
|
9
|
-
// production adapters (`SdkEngineClient`) implement both for real.
|
|
10
|
-
//
|
|
11
|
-
// - `getForm` (urban 0.49.0, ADR 0062): the original instance of this lag. Now implemented by the
|
|
12
|
-
// currently pinned testkit, so this guard is a no-op there; kept as defence against version skew.
|
|
13
|
-
// These hermetic flows drive completion directly and never resolve a form schema, so the fallback
|
|
14
|
-
// returns `null` — the documented "no matching form" path.
|
|
15
|
-
// - `openUserTasks` (issue #294 moved the pollers onto it): the CURRENT gap — the pinned testkit has
|
|
16
|
-
// `searchUserTasks` but not the open-task-scoped `openUserTasks`, so a poller call throws
|
|
17
|
-
// `not a function` (swallowed by the poller's try/catch) and the read-model denormalisation
|
|
18
|
-
// silently no-ops. Polyfill it as `searchUserTasks({ state: "CREATED" })` — byte-for-byte what
|
|
19
|
-
// urban's real `SdkEngineClient.openUserTasks` does, so the two cannot drift. Fix: nano-workforce
|
|
20
|
-
// #309; categorical (testkit) fix upstream in nanobpm/nano-ide#341.
|
|
21
|
-
export function asEngineClient(engine: TestApp["engine"]): EngineClient {
|
|
22
|
-
const e = engine as unknown as EngineClient & {
|
|
23
|
-
getForm?: EngineClient["getForm"];
|
|
24
|
-
openUserTasks?: EngineClient["openUserTasks"];
|
|
25
|
-
};
|
|
26
|
-
if (typeof e.getForm !== "function") {
|
|
27
|
-
e.getForm = async () => null;
|
|
28
|
-
}
|
|
29
|
-
if (typeof e.openUserTasks !== "function") {
|
|
30
|
-
e.openUserTasks = (filter) => e.searchUserTasks({ ...filter, state: "CREATED" });
|
|
31
|
-
}
|
|
32
|
-
return e;
|
|
33
|
-
}
|