@nanobpm/nano-workforce 0.200.8 → 0.201.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 +16 -0
- package/SPEC.md +35 -0
- package/app/deliveryGraph.test.ts +10 -0
- package/app/deliveryGraphCompiler.test.ts +354 -10
- package/app/deliveryGraphCompiler.ts +296 -40
- package/app/deliveryRunner.ts +14 -5
- package/app/implementEscalationReason.test.ts +216 -0
- package/app/implementEscalationReason.ts +213 -0
- package/app/implementReconcile.test.ts +168 -11
- package/app/implementReconcile.ts +112 -18
- package/app/pullRequestReadModel.test.ts +1 -1
- package/app/roundChurn.test.ts +388 -0
- package/app/roundChurn.ts +265 -0
- package/app/roundProgress.test.ts +224 -1
- package/app/service.test.ts +7 -0
- package/app/service.ts +12 -0
- package/db/migrations/117_pull_requests_churn_escalated_round.sql +12 -0
- package/db/migrations/118_pull_requests_read_model_churn_escalated_round.sql +55 -0
- package/docs/agent-guide.md +10 -7
- package/e2e/delivery-graph.e2e.ts +570 -1
- package/e2e/feature-run.e2e.ts +8 -1
- package/e2e/plan-fanout.e2e.ts +75 -2
- package/package.json +1 -1
- package/resources/processes/implement-cell.bpmn +42 -17
- package/resources/processes/plan-fanout.bpmn +236 -211
- package/resources/prompts/feature.md +16 -1
- package/workers/progress-check/worker.ts +98 -1
- package/workers/readiness-probe/worker.test.ts +72 -1
- package/workers/readiness-probe/worker.ts +39 -0
- package/workers/reconcile-implement/worker.test.ts +31 -3
- package/workers/reconcile-implement/worker.ts +5 -0
- package/workers/record-feature-escalation/worker.test.ts +58 -3
- package/workers/record-feature-escalation/worker.ts +24 -10
|
@@ -144,6 +144,12 @@ const DELEGATE_TASK_TYPE: Record<Exclude<DeliveryNode["kind"], "agent" | "human"
|
|
|
144
144
|
* truth so the runner never hardcodes the literal it substitutes. */
|
|
145
145
|
export const DELIVERY_GRAPH_PROCESS_ID = "delivery-graph";
|
|
146
146
|
|
|
147
|
+
/** The run-level node SLA fallback (`PT1H`) — the SINGLE SOURCE OF TRUTH shared by the compiler's
|
|
148
|
+
* bounded-timeout ioMapping and the runner's `DEFAULTS.nodeTimeout` (PR #876 review). A node released
|
|
149
|
+
* with no per-node and no run-level timeout falls back to THIS one constant, so the two code paths can
|
|
150
|
+
* never drift to two different "default SLA" values (derivation-over-duplication). */
|
|
151
|
+
export const DELIVERY_NODE_DEFAULT_TIMEOUT = "PT1H";
|
|
152
|
+
|
|
147
153
|
/** The BPMN element id a `human` node's inlined user task carries. One user task per human node (the
|
|
148
154
|
* compiled one-shot inlines each), so the id is per-node (`delivery-human-task__<element>`) — the
|
|
149
155
|
* `isDeliveryHumanElement` convention (single source of truth in `deliveryHuman.ts`) is what keeps it
|
|
@@ -169,6 +175,33 @@ function contractEscalationTaskElement(element: string): string {
|
|
|
169
175
|
return `${DELIVERY_HUMAN_ELEMENT}__${element}__contract`;
|
|
170
176
|
}
|
|
171
177
|
|
|
178
|
+
/** A distinctive infix stamped into the compiler-generated resume-validity flag variable ({@link
|
|
179
|
+
* resumeValidVar}) to visually mark it as an internal engine variable rather than a user fact. It is
|
|
180
|
+
* NOT a reserved fact-name namespace — reserving it in the public `DeliveryFact.name` space would
|
|
181
|
+
* silently reject previously valid names (e.g. `my__flag__fact`) that openapi still advertises as legal
|
|
182
|
+
* and that durable library/proposal rows may already carry (PR #876 review). Collision-freedom is
|
|
183
|
+
* instead guaranteed structurally at the single bind site (see {@link escalationTaskLines}), so this
|
|
184
|
+
* infix is only a readability aid. */
|
|
185
|
+
const FLAG_VAR_INFIX = "__flag__";
|
|
186
|
+
|
|
187
|
+
/** The internal FEEL variable the resume-validation gateway routes on (PR #876 review). Derived from
|
|
188
|
+
* the escalation task's compiler-generated element id (sanitised to a FEEL-safe identifier) — NOT from
|
|
189
|
+
* the user fact-name space — so it can never collide with a user-declared emit fact bound into the same
|
|
190
|
+
* escalation subprocess scope (an `agent`/`connector` emit binds under its own `fact.name`). This
|
|
191
|
+
* removes the need to RESERVE a user-visible fact name (`resumeValid`) and the recompilation break that
|
|
192
|
+
* reserving it would impose on durable rows that already carry a fact of that name.
|
|
193
|
+
*
|
|
194
|
+
* The sanitised element id is still a legal FACT-NAME string (`^[A-Za-z_][A-Za-z0-9_]*$`), so a node
|
|
195
|
+
* could in principle declare an emit NAMED EXACTLY this flag — and for a single-emit `agent`/`connector`
|
|
196
|
+
* node that fact's emit-source var IS its own name ({@link factSourceVar}), which would map the
|
|
197
|
+
* recovered fact value AND this boolean flag onto the ONE variable. Rather than RESERVE part of the
|
|
198
|
+
* public fact-name space to forbid that (which would break previously valid names — PR #876 review),
|
|
199
|
+
* the single bind site in {@link escalationTaskLines} makes the generated flag name collision-free
|
|
200
|
+
* against this node's actual emit-source target by construction (a deterministic unused suffix). */
|
|
201
|
+
export function resumeValidVar(esc: string): string {
|
|
202
|
+
return `${esc.replace(/[^A-Za-z0-9_]/g, "_")}${FLAG_VAR_INFIX}resumeValid`;
|
|
203
|
+
}
|
|
204
|
+
|
|
172
205
|
/** The self-reported completion statuses an `agent` node's job may return that count as a TERMINAL
|
|
173
206
|
* SUCCESS and are allowed to route their result onward (issue #731). Everything else — the pathological
|
|
174
207
|
* `in_progress` an agent that delegated/returned-before-finishing reports (instance 10746), a `blocked`/
|
|
@@ -1707,12 +1740,22 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
|
|
|
1707
1740
|
const outputs: { source: string; target: string }[] = [];
|
|
1708
1741
|
const cfg = (field: string): string => `=nodeInputs.${el}.${field}`;
|
|
1709
1742
|
const guarded = (src: string): string => `=if (is defined(${src})) then ${src} else null`;
|
|
1743
|
+
// Null-safe bounded-timeout source (issue #872): the node's boundary timer is `=nodeTimeout`, so a
|
|
1744
|
+
// null `nodeInputs.<el>.timeout` seeds a null duration and the engine fires the timer IMMEDIATELY
|
|
1745
|
+
// (a ~0.5s escalation that looks like an instant SLA breach). Fall back to the run-level
|
|
1746
|
+
// `runNodeTimeout` (seeded by the runner), then to the shared `DELIVERY_NODE_DEFAULT_TIMEOUT`
|
|
1747
|
+
// constant (`PT1H`) — the SAME default the runner seeds, so the two never drift (PR #876 review) —
|
|
1748
|
+
// so a node released with no per-node timeout always gets a real SLA instead of a zero-length timer.
|
|
1749
|
+
const timeoutSource = (): string => {
|
|
1750
|
+
const perNode = cfg("timeout").slice(1);
|
|
1751
|
+
return `=if (is defined(${perNode}) and ${perNode} != null) then ${perNode} else (if (is defined(runNodeTimeout) and runNodeTimeout != null) then runNodeTimeout else ${feelStr(DELIVERY_NODE_DEFAULT_TIMEOUT)})`;
|
|
1752
|
+
};
|
|
1710
1753
|
|
|
1711
1754
|
switch (node.kind) {
|
|
1712
1755
|
case "agent":
|
|
1713
1756
|
inputs.push({ source: cfg("jobType"), target: "jobType" });
|
|
1714
1757
|
inputs.push({ source: cfg("appendPrompt"), target: "appendPrompt" });
|
|
1715
|
-
inputs.push({ source:
|
|
1758
|
+
inputs.push({ source: timeoutSource(), target: "nodeTimeout" });
|
|
1716
1759
|
// Stage 0 transcript correlation (#543): seed the transcript URL base so the completing fleet
|
|
1717
1760
|
// worker can append its own jobKey-scoped stream and emit `transcriptUrl` (below). `transcriptUrlBase`
|
|
1718
1761
|
// is a top-level launch variable (deliveryRunner) — guarded so a hand-seeded instance without it
|
|
@@ -1726,8 +1769,16 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
|
|
|
1726
1769
|
// OBSERVED value via FEEL `context put`, so the canonical `agent → connector[converge-merge] →
|
|
1727
1770
|
// wait[pr, merged]` shape polls the PR the agent opened with NO hardcoded literal. A plain literal
|
|
1728
1771
|
// target (a real `owner/repo#N` is never `<node>.<fact>`-shaped) can't match a bound ref, so it
|
|
1729
|
-
// passes through unchanged. Guarded (`is defined`) so an as-yet-unobserved fact
|
|
1730
|
-
//
|
|
1772
|
+
// passes through unchanged. Guarded (`is defined`) so an as-yet-unobserved fact binds NULL rather
|
|
1773
|
+
// than raising a FEEL error.
|
|
1774
|
+
//
|
|
1775
|
+
// The else-branch writes NULL — NOT the authored fact-ref literal (issue #872, PR #876 review).
|
|
1776
|
+
// The compiler is the ONLY component that knows whether the target was actually bound; leaving the
|
|
1777
|
+
// `<node>.<fact>` reference in place forced the readiness-probe worker to re-derive "unresolved"
|
|
1778
|
+
// from a SYNTAX test (`isFactRefTarget`), which cannot tell an unresolved fact-ref from a VALID
|
|
1779
|
+
// dotted literal command target (e.g. `check.sh`) and so parked that gate forever. Writing null
|
|
1780
|
+
// here is the fail-closed provenance: the worker guards null/blank ONLY, and a dotted literal
|
|
1781
|
+
// always reaches its probe verbatim.
|
|
1731
1782
|
// TRIM the target before the bind match — `parseProbe` (`readiness.ts`) trims `wait.target` before
|
|
1732
1783
|
// the worker keys on it, and the display/digest render the trimmed form (`p.target.trim()`,
|
|
1733
1784
|
// deliveryGraphCompiler.ts:1476). Matching the RAW `node.wait.target` here let a padded fact
|
|
@@ -1739,7 +1790,7 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
|
|
|
1739
1790
|
const varName = `${boundTarget.producerElement}_${boundTarget.fact}`;
|
|
1740
1791
|
const probeRef = cfg("probe").slice(1);
|
|
1741
1792
|
inputs.push({
|
|
1742
|
-
source: `=context put(${probeRef}, "target", if (is defined(${varName})) then ${varName} else
|
|
1793
|
+
source: `=context put(${probeRef}, "target", if (is defined(${varName}) and ${varName} != null) then ${varName} else null)`,
|
|
1743
1794
|
target: "probe",
|
|
1744
1795
|
});
|
|
1745
1796
|
} else {
|
|
@@ -1767,7 +1818,7 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
|
|
|
1767
1818
|
inputs.push({ source: cfg("target"), target: "target" });
|
|
1768
1819
|
inputs.push({ source: cfg("dedupeKey"), target: "dedupeKey" });
|
|
1769
1820
|
inputs.push({ source: cfg("payload"), target: "payload" });
|
|
1770
|
-
inputs.push({ source:
|
|
1821
|
+
inputs.push({ source: timeoutSource(), target: "nodeTimeout" });
|
|
1771
1822
|
break;
|
|
1772
1823
|
default:
|
|
1773
1824
|
return assertNever(node, "ioMappingLines");
|
|
@@ -1820,19 +1871,33 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>, displ
|
|
|
1820
1871
|
// as a required data dependency. A broken producer (returns `in_progress`, or omits a required
|
|
1821
1872
|
// emit) escalates AT this node instead of threading an incomplete result onward.
|
|
1822
1873
|
const contractGate = { requiredEmits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
|
|
1874
|
+
// The subProcess timeout `__esc` twin resumes with the SAME required emits the contract gate
|
|
1875
|
+
// uses — the facts a downstream node binds, which a null (lost) result poisons (#872).
|
|
1876
|
+
const timeoutResume = { kind: node.kind, emits: contractGate.requiredEmits };
|
|
1877
|
+
// The source var of EVERY declared emit (required + non-required), so the resume-valid flag on
|
|
1878
|
+
// this node's escalations dodges all of them — not only the resumed required emit (PR #876 review).
|
|
1879
|
+
const allEmitTargets = normaliseEmits(node).map((f) => factSourceVar(node.kind, f));
|
|
1823
1880
|
// The executable `<zeebe:taskDefinition type=…>` MUST carry the raw `jobType` verbatim for worker
|
|
1824
1881
|
// routing (validateDeliveryGraph rejects a URL-shaped/credential-bearing jobType, so it can never
|
|
1825
1882
|
// be a leak here), but the `descriptor` is embedded by `serviceBodyLines` into the operator-visible
|
|
1826
1883
|
// timeout / producer-contract escalation FEEL `prompt` — so it takes the SAME display redaction
|
|
1827
1884
|
// `nodeDisplay` applies, never the raw value (issue #778 review — thread deliveryGraphCompiler.ts:1606).
|
|
1828
|
-
return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName);
|
|
1885
|
+
return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], redactConnectorValue(node.agent.jobType), contractGate, agentRepoSpecHeaderLines(node), displayName, timeoutResume, allEmitTargets);
|
|
1829
1886
|
}
|
|
1830
|
-
case "connector":
|
|
1887
|
+
case "connector": {
|
|
1831
1888
|
// TRIM the target before building the escalation descriptor — `nodeDisplay` and the connector
|
|
1832
1889
|
// worker both key on the trimmed value, so an untrimmed `" converge-merge "` would fork the
|
|
1833
1890
|
// `semanticBpmn`/digest from the trimmed-equivalent graph that dispatches identically (issue #778
|
|
1834
1891
|
// review — thread deliveryGraphCompiler.ts:1741).
|
|
1835
|
-
|
|
1892
|
+
// A connector has no producer-contract gate, but its timeout `__esc` is still made resumable with
|
|
1893
|
+
// the facts a downstream node binds — so a human/agent who unsticks a timed-out connector can
|
|
1894
|
+
// supply the lost emit value rather than thread a null onward (#872).
|
|
1895
|
+
const connectorResume = { kind: node.kind, emits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
|
|
1896
|
+
// Every declared emit's source var, so the flag dodges a non-required (routing/unconsumed) emit
|
|
1897
|
+
// named exactly the flag, not only the resumed required one (PR #876 review).
|
|
1898
|
+
const allEmitTargets = normaliseEmits(node).map((f) => factSourceVar(node.kind, f));
|
|
1899
|
+
return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${redactConnectorValue(node.connector.target.trim())}`, undefined, [], displayName, connectorResume, allEmitTargets);
|
|
1900
|
+
}
|
|
1836
1901
|
case "wait":
|
|
1837
1902
|
return waitBodyLines(el, node, displayName);
|
|
1838
1903
|
case "human":
|
|
@@ -1917,6 +1982,20 @@ function serviceBodyLines(
|
|
|
1917
1982
|
contractGate?: { requiredEmits: readonly DeliveryFact[] },
|
|
1918
1983
|
taskHeaders: readonly string[] = [],
|
|
1919
1984
|
taskName: string = nodeId,
|
|
1985
|
+
// The node's REQUIRED emits (the facts a downstream node binds as a data dependency), used to make
|
|
1986
|
+
// the `__esc` TIMEOUT escalation RESUMABLE (issue #872): when a timed-out node is unstuck by a
|
|
1987
|
+
// human/agent supplying `value`, that value must bind the node's declared emit exactly as the
|
|
1988
|
+
// agent's own result would — otherwise the subProcess publishes `<el>_<fact> = null` and the
|
|
1989
|
+
// downstream reference resolves to null (the production failure: a completed `__esc` returned
|
|
1990
|
+
// `ok:true` yet `n3_pr` stayed null). Empty for a node with no required emit (the escalation stays
|
|
1991
|
+
// an inert "click done" acknowledgement, `emitMode = "none"`).
|
|
1992
|
+
timeoutResume?: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[] },
|
|
1993
|
+
// The source var ({@link factSourceVar}) of EVERY declared emit of this node — required AND
|
|
1994
|
+
// non-required — so the resume-valid flag on BOTH the timeout `__esc` and the producer-contract
|
|
1995
|
+
// `__contract` escalations is grown collision-free against all of them, not only the resumed required
|
|
1996
|
+
// emit (PR #876 review). Both escalations live in this node's subProcess scope, which republishes
|
|
1997
|
+
// every declared emit, so both must dodge the same full target set.
|
|
1998
|
+
reservedFlagTargets: readonly string[] = [],
|
|
1920
1999
|
): string[] {
|
|
1921
2000
|
const esc = escalationTaskElement(el);
|
|
1922
2001
|
const taskExt = [
|
|
@@ -1937,7 +2016,16 @@ function serviceBodyLines(
|
|
|
1937
2016
|
"nodeTimeout",
|
|
1938
2017
|
"; in-flight work may already exist — check for a draft PR or partial state before retrying or reassigning.",
|
|
1939
2018
|
),
|
|
1940
|
-
{
|
|
2019
|
+
{
|
|
2020
|
+
// Bind the operator-supplied `value` onto the node's emit-source var so a resumed timeout
|
|
2021
|
+
// escalation publishes the SAME `<el>_<fact>` shape a normally-completing node does (#872) —
|
|
2022
|
+
// VALIDATED by the post-escalation gate (`validTarget`), so an omitted/malformed value re-parks
|
|
2023
|
+
// instead of threading null downstream (PR #876 review). Only when the node owes a required emit
|
|
2024
|
+
// — a no-emit node keeps its inert acknowledgement form.
|
|
2025
|
+
...(timeoutResume !== undefined && timeoutResume.emits.length > 0 ? { resume: timeoutResume, validTarget: `${el}_end` } : {}),
|
|
2026
|
+
displayName: taskName,
|
|
2027
|
+
reservedTargets: reservedFlagTargets,
|
|
2028
|
+
},
|
|
1941
2029
|
);
|
|
1942
2030
|
const head = [
|
|
1943
2031
|
` <bpmn:startEvent id="${el}_start"><bpmn:outgoing>${el}_i0</bpmn:outgoing></bpmn:startEvent>`,
|
|
@@ -1953,20 +2041,30 @@ function serviceBodyLines(
|
|
|
1953
2041
|
...timeoutEscalation,
|
|
1954
2042
|
];
|
|
1955
2043
|
|
|
2044
|
+
// When the timeout escalation is RESUMABLE (it grew a validation gate), its return flow `${el}_i3`
|
|
2045
|
+
// routes to the GATE (not straight to the end), and the gate's valid branch flows to the end — so a
|
|
2046
|
+
// value-less/invalid resume can never bypass the gate (PR #876 review).
|
|
2047
|
+
const timeoutResumed = timeoutResume !== undefined && timeoutResume.emits.length > 0;
|
|
2048
|
+
const timeoutReturnTarget = timeoutResumed ? `${esc}Vg` : `${el}_end`;
|
|
2049
|
+
|
|
1956
2050
|
if (contractGate === undefined) {
|
|
1957
2051
|
return [
|
|
1958
2052
|
...head,
|
|
1959
|
-
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming
|
|
2053
|
+
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming>${timeoutResumed ? `<bpmn:incoming>${esc}Vok</bpmn:incoming>` : `<bpmn:incoming>${el}_i3</bpmn:incoming>`}</bpmn:endEvent>`,
|
|
1960
2054
|
flow(`${el}_i0`, `${el}_start`, `${el}_task`),
|
|
1961
2055
|
flow(`${el}_i1`, `${el}_task`, `${el}_end`),
|
|
1962
2056
|
flow(`${el}_i2`, `${el}_be`, esc),
|
|
1963
|
-
flow(`${el}_i3`, esc,
|
|
2057
|
+
flow(`${el}_i3`, esc, timeoutReturnTarget),
|
|
1964
2058
|
];
|
|
1965
2059
|
}
|
|
1966
2060
|
|
|
1967
|
-
// Producer-contract gate (issue #731): task → gate → (proceed | contract-escalation) → end.
|
|
2061
|
+
// Producer-contract gate (issue #731): task → gate → (proceed | contract-escalation) → end. When the
|
|
2062
|
+
// contract escalation is RESUMABLE (it grew a validation gate), its return flow `${el}_g2` routes to
|
|
2063
|
+
// the GATE (not straight to the end), and the gate's valid branch flows to the end (PR #876 review).
|
|
1968
2064
|
const contractEsc = contractEscalationTaskElement(el);
|
|
1969
2065
|
const emits = contractGate.requiredEmits;
|
|
2066
|
+
const contractResumed = emits.length > 0;
|
|
2067
|
+
const contractReturnTarget = contractResumed ? `${contractEsc}Vg` : `${el}_end`;
|
|
1970
2068
|
const proceedCondition = agentContractProceedCondition(emits);
|
|
1971
2069
|
const contractEscalation = escalationTaskLines(
|
|
1972
2070
|
contractEsc,
|
|
@@ -1976,8 +2074,10 @@ function serviceBodyLines(
|
|
|
1976
2074
|
agentContractContextFeel(nodeId, descriptor, emits),
|
|
1977
2075
|
// Resumable when the producer owes a required emit: a human/agent supplies the missing fact, which
|
|
1978
2076
|
// the subProcess output ioMapping then publishes as `<el>_<fact>` (agent emit source = fact name),
|
|
1979
|
-
// so the downstream consumer late-binds a real value instead of the null that poisoned it (#731)
|
|
1980
|
-
|
|
2077
|
+
// so the downstream consumer late-binds a real value instead of the null that poisoned it (#731) —
|
|
2078
|
+
// VALIDATED by the post-escalation gate (`validTarget`) so an omitted/malformed value re-parks
|
|
2079
|
+
// instead of threading null downstream (PR #876 review).
|
|
2080
|
+
{ ...(contractResumed ? { resume: { kind: "agent" as const, emits }, validTarget: `${el}_end` } : {}), displayName: taskName, reservedTargets: reservedFlagTargets },
|
|
1981
2081
|
);
|
|
1982
2082
|
return [
|
|
1983
2083
|
...head,
|
|
@@ -1987,14 +2087,14 @@ function serviceBodyLines(
|
|
|
1987
2087
|
` <bpmn:outgoing>${el}_g1</bpmn:outgoing>`,
|
|
1988
2088
|
" </bpmn:exclusiveGateway>",
|
|
1989
2089
|
...contractEscalation,
|
|
1990
|
-
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_g0</bpmn:incoming
|
|
2090
|
+
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_g0</bpmn:incoming>${timeoutResumed ? `<bpmn:incoming>${esc}Vok</bpmn:incoming>` : `<bpmn:incoming>${el}_i3</bpmn:incoming>`}${contractResumed ? `<bpmn:incoming>${contractEsc}Vok</bpmn:incoming>` : `<bpmn:incoming>${el}_g2</bpmn:incoming>`}</bpmn:endEvent>`,
|
|
1991
2091
|
flow(`${el}_i0`, `${el}_start`, `${el}_task`),
|
|
1992
2092
|
flow(`${el}_i1`, `${el}_task`, `${el}_gate`),
|
|
1993
2093
|
` <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>`,
|
|
1994
2094
|
` <bpmn:sequenceFlow id="${el}_g1" name="contract broken" sourceRef="${el}_gate" targetRef="${contractEsc}" />`,
|
|
1995
|
-
flow(`${el}_g2`, contractEsc,
|
|
2095
|
+
flow(`${el}_g2`, contractEsc, contractReturnTarget),
|
|
1996
2096
|
flow(`${el}_i2`, `${el}_be`, esc),
|
|
1997
|
-
flow(`${el}_i3`, esc,
|
|
2097
|
+
flow(`${el}_i3`, esc, timeoutReturnTarget),
|
|
1998
2098
|
];
|
|
1999
2099
|
}
|
|
2000
2100
|
|
|
@@ -2019,6 +2119,9 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
|
|
|
2019
2119
|
// topology diverging from the requested (and digested) behaviour (issue #778 review — thread
|
|
2020
2120
|
// deliveryGraphCompiler.ts:2012).
|
|
2021
2121
|
const continueOnTimeout = trimmedOrEmpty(node.wait?.onTimeout) === "continue";
|
|
2122
|
+
// When the escalation is RESUMABLE (it grew a validation gate), its return flow `_i5` routes to the
|
|
2123
|
+
// GATE (not straight to the end) and the gate's valid branch flows to the end (PR #876 review).
|
|
2124
|
+
const waitResumed = !continueOnTimeout && emits.length > 0;
|
|
2022
2125
|
// Defect A: read-only probe diagnostics seeded onto the escalation task so the operator/agent can
|
|
2023
2126
|
// tell a genuine "not published yet" from a transient false-negative — the probe's last detail, the
|
|
2024
2127
|
// resolved target/match, and a compact summary of the candidate releases the probe observed.
|
|
@@ -2104,19 +2207,23 @@ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>
|
|
|
2104
2207
|
[`${el}_i4`],
|
|
2105
2208
|
`${el}_i5`,
|
|
2106
2209
|
waitEscalationContextFeel(nodeId),
|
|
2107
|
-
|
|
2210
|
+
// A resumed wait-gate escalation is VALIDATED by the post-escalation gate (`validTarget`) so
|
|
2211
|
+
// an omitted/malformed operator value re-parks instead of threading null onto the emit the
|
|
2212
|
+
// downstream consumer binds (PR #876 review).
|
|
2213
|
+
{ resume: { kind: node.kind, emits }, diagnosticInputs, displayName, reservedTargets: emits.map((f) => factSourceVar(node.kind, f)), ...(emits.length > 0 ? { validTarget: `${el}_end` } : {}) },
|
|
2108
2214
|
)),
|
|
2109
2215
|
// On `continue`, the not-ready-at-boundary branch (`_i4`) proceeds straight to the node end (no
|
|
2110
2216
|
// human stop, no `_i5` escalation-return flow); on `escalate` it parks on the escalation task,
|
|
2111
|
-
// which returns via `_i5`.
|
|
2112
|
-
|
|
2217
|
+
// which returns via `_i5`. A RESUMABLE escalation routes `_i5` to its validation gate (not straight
|
|
2218
|
+
// to the end), so a value-less/invalid resume can never bypass the gate (PR #876 review).
|
|
2219
|
+
` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming>${continueOnTimeout ? `<bpmn:incoming>${el}_i4</bpmn:incoming>` : waitResumed ? `<bpmn:incoming>${esc}Vok</bpmn:incoming>` : `<bpmn:incoming>${el}_i5</bpmn:incoming>`}<bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
|
|
2113
2220
|
flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
|
|
2114
2221
|
flow(`${el}_i1`, `${el}_probeLoop`, `${el}_end`),
|
|
2115
2222
|
flow(`${el}_i2`, `${el}_be`, `${el}_lastAttempt`),
|
|
2116
2223
|
flow(`${el}_i6`, `${el}_lastAttempt`, `${el}_lastGw`),
|
|
2117
2224
|
` <bpmn:sequenceFlow id="${el}_i7" name="ready" sourceRef="${el}_lastGw" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
|
|
2118
2225
|
` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_lastGw" targetRef="${continueOnTimeout ? `${el}_end` : esc}" />`,
|
|
2119
|
-
...(continueOnTimeout ? [] : [flow(`${el}_i5`, esc, `${el}_end`)]),
|
|
2226
|
+
...(continueOnTimeout ? [] : [flow(`${el}_i5`, esc, waitResumed ? `${esc}Vg` : `${el}_end`)]),
|
|
2120
2227
|
];
|
|
2121
2228
|
}
|
|
2122
2229
|
|
|
@@ -2187,9 +2294,19 @@ function escalationTaskLines(
|
|
|
2187
2294
|
resume?: { kind: DeliveryNode["kind"]; emits: readonly DeliveryFact[] };
|
|
2188
2295
|
diagnosticInputs?: readonly { source: string; target: string }[];
|
|
2189
2296
|
displayName?: string;
|
|
2297
|
+
/** The element the resume-validation gate's VALID branch flows to (the node end). Required iff
|
|
2298
|
+
* `resume` carries emits — a resumable escalation never routes straight to its end. */
|
|
2299
|
+
validTarget?: string;
|
|
2300
|
+
/** The emit-source var ({@link factSourceVar}) of EVERY declared emit of this node — required AND
|
|
2301
|
+
* non-required (a routing-only `when`-guard emit, or a declared-but-unconsumed one) — that shares
|
|
2302
|
+
* this escalation's flat subprocess scope. The resumed required emits are reserved regardless; this
|
|
2303
|
+
* reserves the REST so the generated resume-valid flag is grown collision-free against ALL of them,
|
|
2304
|
+
* never only the single resumed required emit (PR #876 review). */
|
|
2305
|
+
reservedTargets?: readonly string[];
|
|
2190
2306
|
},
|
|
2191
2307
|
): string[] {
|
|
2192
|
-
const
|
|
2308
|
+
const resume = opts?.resume;
|
|
2309
|
+
const emits = resume?.emits ?? [];
|
|
2193
2310
|
const emitMode = emits.length > 0 ? "typed" : "none";
|
|
2194
2311
|
const emitLabel = emits.map((e) => `${e.name} (${e.type})`).join(", ");
|
|
2195
2312
|
const inputs: string[] = [
|
|
@@ -2203,30 +2320,70 @@ function escalationTaskLines(
|
|
|
2203
2320
|
for (const di of opts?.diagnosticInputs ?? []) {
|
|
2204
2321
|
inputs.push(` <zeebe:input ${attr("source", di.source)} target="${di.target}" />`);
|
|
2205
2322
|
}
|
|
2206
|
-
// Defect B:
|
|
2207
|
-
//
|
|
2323
|
+
// Defect B + PR #876 review: the operator's captured typed value is VALIDATED on this task's OWN
|
|
2324
|
+
// output mapping (reading the form's `value` field), which the pinned engine evaluates reliably —
|
|
2325
|
+
// unlike a downstream gateway's ioMapping, whose inputs/outputs are NOT visible downstream (verified
|
|
2326
|
+
// engine-native). The required emit's emit-source var is bound from `value` ONLY when it is present
|
|
2327
|
+
// and type-valid (`resumeValueCondition`), else null; a validity boolean flags the resume.
|
|
2328
|
+
// The post-escalation gateway then routes on the SIMPLE `<resumeValidVar> = true` (a complex FEEL
|
|
2329
|
+
// condition on the gateway mis-evaluates — verified), looping an invalid resume back onto a fresh
|
|
2330
|
+
// escalation task (fail closed) so it can never thread null/garbage onto the emit the subProcess
|
|
2331
|
+
// output ioMapping republishes downstream. The generic escalation form (`GENERIC_HUMAN_FORM`)
|
|
2332
|
+
// captures the operator's answer in a single `value` field — it has NO `resolvedArtifact` field — so
|
|
2333
|
+
// a single emit resumes from `value`, validated per its fact type (artifact→resolvedArtifact,
|
|
2334
|
+
// version→detail, …). Sourcing an artifact from a `resolvedArtifact` form field the form never sets
|
|
2335
|
+
// would publish null and make an artifact wait-node escalation non-resumable via the UI.
|
|
2336
|
+
//
|
|
2337
|
+
// MULTI-EMIT (PR #876 review): the generic form's SINGLE `value` cannot supply a distinct value per
|
|
2338
|
+
// fact, so a node owing more than one required emit CANNOT be resumed from it. Copying the one
|
|
2339
|
+
// `value` into every emit-source var would release duplicate/garbage facts downstream; instead we
|
|
2340
|
+
// FAIL CLOSED — bind nothing and hard-set the validity flag to `false`, so the post-escalation gate
|
|
2341
|
+
// always loops back (the operator re-parks) and no single value is ever threaded onto several
|
|
2342
|
+
// distinct emits. (Per-fact resume would need a per-fact form, which the generic form does not have.)
|
|
2208
2343
|
const outputs: string[] = [];
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2344
|
+
// The resume-valid flag shares this escalation's flat engine scope with EVERY declared emit's
|
|
2345
|
+
// source var — not only the single resumed required emit. For a multi-emit node nothing is bound by
|
|
2346
|
+
// the gate, and for `wait`/`human` kinds `factSourceVar` is a fixed internal name; but for an
|
|
2347
|
+
// `agent`/`connector` a declared emit's source var IS its own fact name, which the user controls.
|
|
2348
|
+
// That emit need NOT be the resumed required one: a routing-only `when`-guard emit, or a declared-
|
|
2349
|
+
// but-unconsumed emit, is absent from `resume.emits` yet is STILL republished by the subProcess
|
|
2350
|
+
// output mapping from its own source var (see the `normaliseEmits(node)` output loop). So a node can
|
|
2351
|
+
// legally declare such an emit named EXACTLY the generated flag; left colliding, the escalation would
|
|
2352
|
+
// write its boolean validity flag into that emit's var and the subProcess would publish `true`/`false`
|
|
2353
|
+
// for it downstream (potentially selecting a guarded branch — PR #876 review). Reserve the source var
|
|
2354
|
+
// of every declared emit (callers thread them via `reservedTargets`) AND the resumed required emits,
|
|
2355
|
+
// then grow the flag with a deterministic unused `_` suffix until it differs from ALL of them —
|
|
2356
|
+
// closing the whole collision class structurally WITHOUT reserving any part of the public fact-name
|
|
2357
|
+
// space. `_` keeps it a legal FEEL identifier; the loop terminates because each step lengthens the
|
|
2358
|
+
// flag past every fixed-length reserved target.
|
|
2359
|
+
const reservedTargets = new Set<string>(opts?.reservedTargets ?? []);
|
|
2360
|
+
if (resume !== undefined) for (const f of emits) reservedTargets.add(factSourceVar(resume.kind, f));
|
|
2361
|
+
let flagVar = resume !== undefined && emits.length > 0 ? resumeValidVar(esc) : "";
|
|
2362
|
+
while (flagVar !== "" && reservedTargets.has(flagVar)) flagVar = `${flagVar}_`;
|
|
2363
|
+
if (resume !== undefined && emits.length === 1) {
|
|
2364
|
+
const fact = emits[0];
|
|
2365
|
+
const target = factSourceVar(resume.kind, fact);
|
|
2366
|
+
outputs.push(
|
|
2367
|
+
` <zeebe:output ${attr("source", `=if ${resumeValueCondition(fact, "value")} then value else null`)} target="${target}" />`,
|
|
2368
|
+
);
|
|
2369
|
+
outputs.push(
|
|
2370
|
+
// The validity flag the post-escalation gateway routes on — an internal, compiler-generated
|
|
2371
|
+
// variable name ({@link resumeValidVar}), made collision-free against the emit-source var above
|
|
2372
|
+
// by construction, so it can neither collide with nor be shadowed by a declared emit.
|
|
2373
|
+
` <zeebe:output ${attr("source", `=if ${resumeValueCondition(fact, "value")} then true else false`)} target="${flagVar}" />`,
|
|
2374
|
+
);
|
|
2375
|
+
} else if (resume !== undefined && emits.length > 1) {
|
|
2376
|
+
outputs.push(
|
|
2377
|
+
// Fail closed: more than one required emit cannot be resumed from the generic form's single
|
|
2378
|
+
// `value`, so the resume is never valid and the gate always re-parks (see the comment above).
|
|
2379
|
+
` <zeebe:output ${attr("source", "=false")} target="${flagVar}" />`,
|
|
2380
|
+
);
|
|
2224
2381
|
}
|
|
2225
2382
|
// The escalation user task shows the node's DESCRIPTIVE display name (issue #778 review) so a
|
|
2226
2383
|
// timed-out / contract-broken node is legible in the explorer/inbox instead of an opaque bare id;
|
|
2227
2384
|
// `nodeId` is still threaded as the `nodeId` input above for runtime correlation.
|
|
2228
2385
|
const escLabel = trimmedOrEmpty(opts?.displayName) || nodeId;
|
|
2229
|
-
|
|
2386
|
+
const task = [
|
|
2230
2387
|
` <bpmn:userTask id="${esc}" name="Escalate: ${escapeXml(escLabel)}">`,
|
|
2231
2388
|
" <bpmn:extensionElements>",
|
|
2232
2389
|
` <zeebe:formDefinition formId="${GENERIC_HUMAN_FORM}" />`,
|
|
@@ -2241,6 +2398,105 @@ function escalationTaskLines(
|
|
|
2241
2398
|
` <bpmn:outgoing>${outgoing}</bpmn:outgoing>`,
|
|
2242
2399
|
" </bpmn:userTask>",
|
|
2243
2400
|
];
|
|
2401
|
+
if (resume === undefined || emits.length === 0) return task;
|
|
2402
|
+
|
|
2403
|
+
// Resume-validation gate (PR #876 review): a resumable escalation no longer routes straight to the
|
|
2404
|
+
// node end. It flows into an exclusive gateway whose VALID branch proceeds to the node end and whose
|
|
2405
|
+
// DEFAULT (invalid) branch loops back onto the escalation task — so a blank/malformed `value`
|
|
2406
|
+
// re-parks with an explanation instead of releasing a null/invalid fact downstream (fail closed on
|
|
2407
|
+
// the default). The gateway is a PLAIN exclusive gateway (NO ioMapping — a gateway's ioMapping is not
|
|
2408
|
+
// visible downstream in the pinned engine, verified): the per-emit validation already ran on the
|
|
2409
|
+
// escalation task's OWN output mapping (binding the emit-source var + the validity flag), so
|
|
2410
|
+
// the gateway only routes on the simple `<resumeValidVar> = true` boolean. For a multi-emit node the
|
|
2411
|
+
// flag is hard-`false` (unresumable via the single-value form — see the outputs comment above), so
|
|
2412
|
+
// the gate always takes its default (invalid) branch and re-parks.
|
|
2413
|
+
const validTarget = opts?.validTarget;
|
|
2414
|
+
if (validTarget === undefined) {
|
|
2415
|
+
throw new Error(`escalationTaskLines(${esc}): a resumable escalation requires opts.validTarget (the node end its valid resume flows to)`);
|
|
2416
|
+
}
|
|
2417
|
+
const vg = `${esc}Vg`;
|
|
2418
|
+
const vok = `${esc}Vok`;
|
|
2419
|
+
const vbad = `${esc}Vbad`;
|
|
2420
|
+
return [
|
|
2421
|
+
...task,
|
|
2422
|
+
` <bpmn:exclusiveGateway id="${vg}" name="resume value valid?" default="${vbad}">`,
|
|
2423
|
+
` <bpmn:incoming>${outgoing}</bpmn:incoming>`,
|
|
2424
|
+
` <bpmn:outgoing>${vok}</bpmn:outgoing>`,
|
|
2425
|
+
` <bpmn:outgoing>${vbad}</bpmn:outgoing>`,
|
|
2426
|
+
" </bpmn:exclusiveGateway>",
|
|
2427
|
+
` <bpmn:sequenceFlow id="${vok}" name="valid" sourceRef="${vg}" targetRef="${validTarget}"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=${flagVar} = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
|
|
2428
|
+
` <bpmn:sequenceFlow id="${vbad}" name="invalid" sourceRef="${vg}" targetRef="${esc}" />`,
|
|
2429
|
+
];
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
/** The per-required-emit FEEL condition an escalation resume value must satisfy to bind its
|
|
2433
|
+
* emit-source var (PR #876 review) — the runtime mirror of `coerceFactValue` (app/deliveryHuman.ts),
|
|
2434
|
+
* re-expressed in FEEL so the compiled model validates WITHOUT a worker round-trip. A missing/null
|
|
2435
|
+
* value fails every branch (fail closed). Type-aware: a `pr` must match the `owner/repo#N` shape, a
|
|
2436
|
+
* `version`/`artifact` the version grammar, a `number`/`boolean` coerce, a `url` carry a scheme, a
|
|
2437
|
+
* `string` be non-blank.
|
|
2438
|
+
*
|
|
2439
|
+
* The pinned WASM FEEL evaluator's null semantics drive the exact shape (each verified engine-native):
|
|
2440
|
+
* the type-test builtins `is string(x)` / `is number(x)` / `is boolean(x)` are UNSUPPORTED — they raise
|
|
2441
|
+
* "cannot call a null value" (an ioMapping incident) rather than returning a boolean — so they are
|
|
2442
|
+
* NEVER used. Instead every value is funnelled through `string(resumeValue)` (null-safe) and validated
|
|
2443
|
+
* by `matches(...)`/`trim(...)`, which ARE null-safe (a non-match yields false, never an error).
|
|
2444
|
+
* `is defined(x)` returns NULL (not false) for an absent variable, so the presence guard is anchored
|
|
2445
|
+
* `= true` (null-safe). The caller binds `if cond then <value> else null` on the escalation task's OWN
|
|
2446
|
+
* output mapping (verified: a valid value binds it, an invalid/absent one binds null — fail closed).
|
|
2447
|
+
*
|
|
2448
|
+
* CANONICAL-GRAMMAR ALIGNMENT (PR #876 round-4 "Previously missed"): the regexes below must accept
|
|
2449
|
+
* exactly what the canonical contracts accept, or a timed-out node cannot resume with a value that
|
|
2450
|
+
* works on its normal completion path. `artifact` mirrors `coerceFactValue`'s `pkg@version` split on
|
|
2451
|
+
* the LAST `@` — so a SCOPED package (`@nanobpm/urban@0.54.0`) is accepted: the name segment is
|
|
2452
|
+
* `[^@\s]+` (no `@`/space) preceded by an OPTIONAL leading scope `@`. `pr` mirrors `parsePr`
|
|
2453
|
+
* (app/prParse.ts), which accepts BOTH the `owner/repo#N` shorthand AND a canonical GitHub PR URL
|
|
2454
|
+
* (`https://github.com/owner/repo/pull/N`, optional scheme/`www.`, optional `/files`/`?query`/
|
|
2455
|
+
* `#fragment` suffix) — so the `pr` branch is the disjunction of the two anchored grammars. parsePr's
|
|
2456
|
+
* URL regex carries the `/i` flag, so the `pr` branch folds the tested value through `lower case(...)`
|
|
2457
|
+
* (an engine-native, null-safe builtin) before matching its lower-case literals — otherwise a mixed-
|
|
2458
|
+
* case host/scheme the normal completion path accepts (`https://GitHub.com/...`, `HTTPS://...`) would be
|
|
2459
|
+
* rejected here, re-parking a legitimate resume (fail-closed on a value the canonical path takes). Only
|
|
2460
|
+
* the accept/reject test folds; the bound `value` stays verbatim.
|
|
2461
|
+
*
|
|
2462
|
+
* KNOWN FAIL-CLOSED DIVERGENCES (PR #876 round-5 escalation — ACCEPTED tradeoff, do NOT redesign the
|
|
2463
|
+
* completion door): these FEEL regexes are a deliberate in-engine APPROXIMATION of the canonical
|
|
2464
|
+
* `coerceFactValue`/`new URL()`/`Number()` parsers, which are host-side and not FEEL-expressible. A few
|
|
2465
|
+
* exotic-but-canonically-valid inputs are therefore rejected HERE (re-parked) even though the normal
|
|
2466
|
+
* completion path accepts them — safe (fail-CLOSED, never fail-open), and the escalation form's `value`
|
|
2467
|
+
* is a textfield so no type is erased. The known cases, accepted as rare enough not to warrant a
|
|
2468
|
+
* worker/host round-trip:
|
|
2469
|
+
* • `number`: scientific/hex/octal/binary literals (`1e3`, `0x10`, `.5`) — `Number()` parses them,
|
|
2470
|
+
* the `^-?\d+(\.\d+)?$` regex does not.
|
|
2471
|
+
* • `url`: schemes WHATWG `URL` accepts without `://` (`mailto:user@example.com`, `urn:…`) — the
|
|
2472
|
+
* `scheme://` guard requires the authority form.
|
|
2473
|
+
* • `artifact`: a multi-`@` name whose non-final `@` is NOT a scope prefix — the single last-`@`
|
|
2474
|
+
* split differs from edge cases of the canonical parser.
|
|
2475
|
+
* If these ever become common, the fix is to route the resume through the canonical host-side coercer
|
|
2476
|
+
* (a completion-door redesign) rather than widening these regexes toward fail-OPEN. */
|
|
2477
|
+
function resumeValueCondition(fact: DeliveryFact, v: string): string {
|
|
2478
|
+
const present = `((is defined(${v})) = true and (${v} != null))`;
|
|
2479
|
+
const s = `string(${v})`;
|
|
2480
|
+
switch (fact.type) {
|
|
2481
|
+
case "string":
|
|
2482
|
+
return `${present} and trim(${s}) != ""`;
|
|
2483
|
+
case "number":
|
|
2484
|
+
return `${present} and (matches(trim(${s}), "^-?\\\\d+(\\\\.\\\\d+)?$") = true)`;
|
|
2485
|
+
case "boolean":
|
|
2486
|
+
return `${present} and (trim(${s}) = "true" or trim(${s}) = "false")`;
|
|
2487
|
+
case "version":
|
|
2488
|
+
return `${present} and (matches(trim(${s}), "^v?\\\\d[\\\\w.+-]*$") = true)`;
|
|
2489
|
+
case "artifact":
|
|
2490
|
+
return `${present} and (matches(trim(${s}), "^@?[^@\\\\s]+@v?\\\\d[\\\\w.+-]*$") = true)`;
|
|
2491
|
+
case "url":
|
|
2492
|
+
return `${present} and (matches(trim(${s}), "^[A-Za-z][A-Za-z0-9+.-]*://") = true)`;
|
|
2493
|
+
case "pr":
|
|
2494
|
+
// Case-INSENSITIVE like the canonical parsePr (`/i`): fold the tested value to lower case so the
|
|
2495
|
+
// lower-case host/scheme literals below match a mixed-case input the normal path accepts.
|
|
2496
|
+
return `${present} and (matches(lower case(trim(${s})), "^(([^/#]+/[^/#]+)#(\\\\d+)|((https?://)?(www\\\\.)?github\\\\.com/[^/]+/[^/]+/pull/\\\\d+([/?#].*)?))$") = true)`;
|
|
2497
|
+
default:
|
|
2498
|
+
return assertNever(fact.type, "resumeValueCondition");
|
|
2499
|
+
}
|
|
2244
2500
|
}
|
|
2245
2501
|
|
|
2246
2502
|
/** Build the FEEL context line seeded onto an escalation task's read-only prompt field (issue #499).
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generate
|
|
|
20
20
|
import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
|
|
21
21
|
import { AGENT_SLA_TIMEOUT } from "./agentSla.ts";
|
|
22
22
|
import { isPlausibleBranchName } from "./baseBranch.ts";
|
|
23
|
-
import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID, redactFreeText } from "./deliveryGraphCompiler.ts";
|
|
23
|
+
import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID, DELIVERY_NODE_DEFAULT_TIMEOUT, redactFreeText } from "./deliveryGraphCompiler.ts";
|
|
24
24
|
import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
|
|
25
25
|
import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, isResolvableRepo, RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
|
|
26
26
|
import { isoDuration } from "./reviewWait.ts";
|
|
@@ -97,7 +97,7 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
|
|
100
|
-
nodeTimeout:
|
|
100
|
+
nodeTimeout: DELIVERY_NODE_DEFAULT_TIMEOUT,
|
|
101
101
|
probeTimeout: "PT30M",
|
|
102
102
|
probePollEvery: msToIsoDuration(DEFAULT_EVERY_MS),
|
|
103
103
|
escalationSlaTimeout: "P1D",
|
|
@@ -127,6 +127,11 @@ export interface PreparedDeliveryGraph {
|
|
|
127
127
|
* `runDeliveryGraph` seeds the SAME value as a run-root process variable — the reconciliation
|
|
128
128
|
* handle a stale-claim relaunch matches a still-running original instance on (issue #852). */
|
|
129
129
|
runKey: string;
|
|
130
|
+
/** The resolved run-level node SLA (`isoDuration(options.nodeTimeout)`), surfaced so `runDeliveryGraph`
|
|
131
|
+
* seeds it as the run-root `runNodeTimeout` variable — the fallback a node's bounded-timeout
|
|
132
|
+
* ioMapping reads when its per-node `nodeInputs.<el>.timeout` is null, so a node released with no
|
|
133
|
+
* per-node timeout gets a real SLA instead of a zero-length timer that fires instantly (issue #872). */
|
|
134
|
+
runNodeTimeout: string;
|
|
130
135
|
}
|
|
131
136
|
|
|
132
137
|
export type PrepareDeliveryResult =
|
|
@@ -198,7 +203,7 @@ export async function prepareDeliveryGraph(
|
|
|
198
203
|
if (element === undefined) continue; // unreachable — resolved covers every node — but keep total.
|
|
199
204
|
nodeInputs[element] = buildNodeInput(node, { runKey, element, ...timeouts, requiredEmits: requiredEmitsByNodeId.get(node.id) ?? EMPTY_REQUIRED_EMITS });
|
|
200
205
|
}
|
|
201
|
-
return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs, runKey } };
|
|
206
|
+
return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs, runKey, runNodeTimeout: timeouts.nodeTimeout } };
|
|
202
207
|
}
|
|
203
208
|
|
|
204
209
|
/** Deploy + start a compiled graph as a running engine-native instance. Idempotent at the DEFINITION
|
|
@@ -212,7 +217,7 @@ export async function runDeliveryGraph(
|
|
|
212
217
|
): Promise<RunDeliveryResult> {
|
|
213
218
|
const prep = await prepareDeliveryGraph(graph, options);
|
|
214
219
|
if (!prep.ok) return prep;
|
|
215
|
-
const { processDefinitionId, bpmn, nodeInputs, runKey } = prep.prepared;
|
|
220
|
+
const { processDefinitionId, bpmn, nodeInputs, runKey, runNodeTimeout } = prep.prepared;
|
|
216
221
|
|
|
217
222
|
await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
|
|
218
223
|
// Per-node repository isolation (#739): the `io.nanobpm.agentTask.repository` envelope is now seeded
|
|
@@ -235,6 +240,10 @@ export async function runDeliveryGraph(
|
|
|
235
240
|
variables: {
|
|
236
241
|
nodeInputs,
|
|
237
242
|
runKey,
|
|
243
|
+
// Run-level node SLA fallback (issue #872): the bounded-timeout ioMapping reads this when a node's
|
|
244
|
+
// per-node `nodeInputs.<el>.timeout` is null, so a node released without a per-node timeout gets a
|
|
245
|
+
// real SLA instead of a null `=nodeTimeout` that fires the boundary timer instantly.
|
|
246
|
+
runNodeTimeout,
|
|
238
247
|
// Stage 0 transcript correlation (#543): the transcript-endpoint base every agent node's
|
|
239
248
|
// completing worker appends its jobKey-scoped stream to, to emit `transcriptUrl` (see the agent
|
|
240
249
|
// node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
|
|
@@ -250,7 +259,7 @@ export async function runDeliveryGraph(
|
|
|
250
259
|
// downstream consumers expect a string — coerce (codebase-wide `String(...)` pattern, e.g. app/plan.ts).
|
|
251
260
|
return {
|
|
252
261
|
ok: true,
|
|
253
|
-
handle: { processDefinitionId, bpmn, nodeInputs, runKey, processInstanceKey: String(processInstanceKey) },
|
|
262
|
+
handle: { processDefinitionId, bpmn, nodeInputs, runKey, runNodeTimeout, processInstanceKey: String(processInstanceKey) },
|
|
254
263
|
};
|
|
255
264
|
}
|
|
256
265
|
|