@nanobpm/nano-workforce 0.171.1 → 0.171.3
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/agentCompletion.test.ts +32 -0
- package/app/agentCompletion.ts +15 -1
- package/app/deliveryDispatch.test.ts +6 -2
- package/app/escalationTaxonomy.ts +5 -4
- package/app/fineGrainedCells.test.ts +93 -0
- package/app/planFanoutCleanTerminal.test.ts +29 -26
- package/app/planFanoutPreflight.test.ts +11 -4
- package/app/pollUserTasks.test.ts +63 -0
- package/app/service.ts +62 -27
- package/app/userTaskInboxDriftGuard.test.ts +102 -0
- package/app/userTasks.ts +35 -2
- package/e2e/agent-answerable.e2e.ts +15 -12
- package/e2e/feature-preflight.e2e.ts +6 -4
- package/e2e/feature-run.e2e.ts +38 -26
- package/e2e/plan-fanout-sla.e2e.ts +14 -10
- package/e2e/plan-fanout.e2e.ts +26 -21
- package/nano.app.json +0 -4
- package/package.json +3 -3
- package/resources/processes/feature.bpmn +55 -175
- package/resources/processes/implement-cell.bpmn +78 -22
- package/resources/processes/plan-fanout.bpmn +245 -296
- package/workers/record-feature-escalation/worker.test.ts +74 -25
- package/workers/record-feature-escalation/worker.ts +79 -32
- package/workers/record-feature-implementing/worker.test.ts +31 -1
- package/workers/record-feature-implementing/worker.ts +36 -12
- package/workers/record-wave-escalation/worker.test.ts +0 -95
- package/workers/record-wave-escalation/worker.ts +0 -69
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.171.3](https://github.com/nanobpm/nano-workforce/compare/v0.171.2...v0.171.3) (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### Code Refactoring
|
|
4
|
+
|
|
5
|
+
* compose implement → implement-cell (feature + plan-fanout) ([#646](https://github.com/nanobpm/nano-workforce/issues/646)) ([#656](https://github.com/nanobpm/nano-workforce/issues/656)) ([caca54e](https://github.com/nanobpm/nano-workforce/commit/caca54e42caf0a1db83a8f15092c7c834e05e81f)), closes [#642](https://github.com/nanobpm/nano-workforce/issues/642) [#360](https://github.com/nanobpm/nano-workforce/issues/360) [#642](https://github.com/nanobpm/nano-workforce/issues/642) [#642](https://github.com/nanobpm/nano-workforce/issues/642)
|
|
6
|
+
|
|
7
|
+
## [0.171.2](https://github.com/nanobpm/nano-workforce/compare/v0.171.1...v0.171.2) (2026-08-31)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* surface + complete readiness/preflight escalations in the Tasks inbox ([#675](https://github.com/nanobpm/nano-workforce/issues/675)) ([9d874e8](https://github.com/nanobpm/nano-workforce/commit/9d874e889f3155b7887bb48032e39c5f78e33ce3)), closes [#674](https://github.com/nanobpm/nano-workforce/issues/674)
|
|
12
|
+
|
|
1
13
|
## [0.171.1](https://github.com/nanobpm/nano-workforce/compare/v0.171.0...v0.171.1) (2026-08-31)
|
|
2
14
|
|
|
3
15
|
### Code Refactoring
|
|
@@ -286,6 +286,38 @@ test("empty-plan-escalation is HUMAN-completable but NOT agent-completable (issu
|
|
|
286
286
|
assertEquals(completed[0].variables, { directive: "revise", notes: "look again" });
|
|
287
287
|
});
|
|
288
288
|
|
|
289
|
+
test("readiness-escalation(-pf) is HUMAN-completable but NOT agent-completable (issue #674)", async () => {
|
|
290
|
+
// A readiness/preflight gate adjudicates whether upstream is ACTUALLY ready (proceed) or the gate
|
|
291
|
+
// should be abandoned. Like feature-blocked/conformance/empty-plan it is a HUMAN operator decision —
|
|
292
|
+
// an agent must never auto-answer it, or the fleet would silently defeat the very readiness gate the
|
|
293
|
+
// task exists to enforce. So both ids stay OUTSIDE `ESCALATION_TASK_ELEMENTS` (agent-refused) but are
|
|
294
|
+
// retired by the HUMAN completer via the one canonical `complete-user-task` door.
|
|
295
|
+
for (const elementId of ["readiness-escalation-pf", "readiness-escalation"] as const) {
|
|
296
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
297
|
+
const data = memData(stores);
|
|
298
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-r", elementId }]);
|
|
299
|
+
|
|
300
|
+
const asAgent = await completeEscalationAsAgent(data, engine, {
|
|
301
|
+
userTaskKey: "ut-r",
|
|
302
|
+
agentId: "bot",
|
|
303
|
+
variables: { resolution: "acknowledge" },
|
|
304
|
+
});
|
|
305
|
+
assertEquals(asAgent.ok, false, `the agent completer refuses ${elementId}`);
|
|
306
|
+
assertEquals(asAgent.reason, "not a completable task");
|
|
307
|
+
assertEquals(completed.length, 0);
|
|
308
|
+
|
|
309
|
+
const asHuman = await completeEscalationAsHuman(data, engine, {
|
|
310
|
+
userTaskKey: "ut-r",
|
|
311
|
+
operatorId: "alice",
|
|
312
|
+
variables: { resolution: "abandon", answer: "upstream never published" },
|
|
313
|
+
});
|
|
314
|
+
assertEquals(asHuman.ok, true, `the human completer retires ${elementId}`);
|
|
315
|
+
assertEquals(asHuman.elementId, elementId);
|
|
316
|
+
assertEquals(completed.length, 1);
|
|
317
|
+
assertEquals(completed[0].variables, { resolution: "abandon", answer: "upstream never published" });
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
289
321
|
test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
|
|
290
322
|
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
291
323
|
const data = memData(stores);
|
package/app/agentCompletion.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { readFileSync } from "node:fs";
|
|
|
24
24
|
import type { DataLayer, EngineClient } from "@nanobpm/urban";
|
|
25
25
|
import { CONFORMANCE_ESCALATION_ELEMENT } from "./conformance.ts";
|
|
26
26
|
import { DELIVERY_HUMAN_ELEMENT, isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
27
|
-
import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT } from "./userTasks.ts";
|
|
27
|
+
import { ACP_PERMISSION_ELEMENT, EMPTY_PLAN_ELEMENT, READINESS_ESCALATION_ELEMENT, READINESS_ESCALATION_PF_ELEMENT } from "./userTasks.ts";
|
|
28
28
|
|
|
29
29
|
const now = () => new Date().toISOString();
|
|
30
30
|
|
|
@@ -101,6 +101,16 @@ export const CONFORMANCE_ESCALATION_TASK_ELEMENT = CONFORMANCE_ESCALATION_ELEMEN
|
|
|
101
101
|
* Re-exported from the canonical `EMPTY_PLAN_ELEMENT` (app/userTasks.ts) — one source of truth. */
|
|
102
102
|
export const EMPTY_PLAN_TASK_ELEMENT = EMPTY_PLAN_ELEMENT;
|
|
103
103
|
|
|
104
|
+
/** The readiness/preflight escalation user-task element ids (`readiness-escalation-pf` in feature.bpmn's
|
|
105
|
+
* readiness preflight + plan-fanout.bpmn's producer-capability preflight; `readiness-escalation` in
|
|
106
|
+
* readiness-gate.bpmn / wait-gate.bpmn). Like `feature-blocked`, `conformance-escalation` and
|
|
107
|
+
* `empty-plan-escalation` these are HUMAN-only decisions — an agent must never auto-answer a readiness
|
|
108
|
+
* gate (that would silently defeat the "is upstream actually ready?" adjudication the gate exists for),
|
|
109
|
+
* so they live OUTSIDE `ESCALATION_TASK_ELEMENTS` and only the HUMAN completer accepts them (issue
|
|
110
|
+
* #674). Re-exported from the canonical constants in app/userTasks.ts — one source of truth. */
|
|
111
|
+
export const READINESS_ESCALATION_PF_TASK_ELEMENT = READINESS_ESCALATION_PF_ELEMENT;
|
|
112
|
+
export const READINESS_ESCALATION_TASK_ELEMENT = READINESS_ESCALATION_ELEMENT;
|
|
113
|
+
|
|
104
114
|
/** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
|
|
105
115
|
* `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
|
|
106
116
|
* and `conformance-escalation` acknowledgements, PLUS the advisory ACP permission prompt
|
|
@@ -115,6 +125,8 @@ export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
|
|
|
115
125
|
FEATURE_BLOCKED_TASK_ELEMENT,
|
|
116
126
|
CONFORMANCE_ESCALATION_TASK_ELEMENT,
|
|
117
127
|
EMPTY_PLAN_TASK_ELEMENT,
|
|
128
|
+
READINESS_ESCALATION_PF_TASK_ELEMENT,
|
|
129
|
+
READINESS_ESCALATION_TASK_ELEMENT,
|
|
118
130
|
ACP_PERMISSION_ELEMENT,
|
|
119
131
|
]);
|
|
120
132
|
|
|
@@ -131,6 +143,8 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
|
131
143
|
"feature-blocked": "feature-blocked",
|
|
132
144
|
[EMPTY_PLAN_TASK_ELEMENT]: "empty-plan-escalation",
|
|
133
145
|
[CONFORMANCE_ESCALATION_TASK_ELEMENT]: "conformance-escalation",
|
|
146
|
+
[READINESS_ESCALATION_PF_TASK_ELEMENT]: "readiness-escalation",
|
|
147
|
+
[READINESS_ESCALATION_TASK_ELEMENT]: "readiness-escalation",
|
|
134
148
|
// NOTE: the delivery-graph `human` node (`DELIVERY_HUMAN_ELEMENT`, ADR 0005 S3) is intentionally
|
|
135
149
|
// ABSENT here. Unlike the fixed-form escalations above, ONE `delivery-human-task` element is DESIGNED
|
|
136
150
|
// to render DIFFERENT forms per node (explicit → category → generic → agent-router, `app/deliveryHuman.ts`
|
|
@@ -56,10 +56,14 @@ function manifestBindings(): Binding[] {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// The deployed process models — scanned for their prompt-bearing agent tasks, the real dispatch
|
|
59
|
-
// corpus the door's verbs must already exist in.
|
|
59
|
+
// corpus the door's verbs must already exist in. Includes the shared S4 atomic cells (ADR 0006): the
|
|
60
|
+
// implement/escalation and trial-merge agent tasks now live in `implement-cell` / `merge-cell`, which
|
|
61
|
+
// `feature.bpmn` and the `plan-fanout` MI body compose by `callActivity`.
|
|
60
62
|
const MODEL_FILES = [
|
|
61
63
|
"feature.bpmn",
|
|
62
64
|
"plan-fanout.bpmn",
|
|
65
|
+
"implement-cell.bpmn",
|
|
66
|
+
"merge-cell.bpmn",
|
|
63
67
|
"convergence-loop.bpmn",
|
|
64
68
|
"merge-loop.bpmn",
|
|
65
69
|
"retro.bpmn",
|
|
@@ -85,7 +89,7 @@ test("VERB PARITY: every mapped dispatch verb is a real senior:* agent task in t
|
|
|
85
89
|
});
|
|
86
90
|
|
|
87
91
|
test("VERB PARITY: the implementation kinds keep the pre-collapse senior:feature target", () => {
|
|
88
|
-
//
|
|
92
|
+
// implement-cell.bpmn's agent task (composed by feature.bpmn and the plan-fanout MI body) dispatches
|
|
89
93
|
// senior:feature today — the door preserves that for the single-issue implementation kinds.
|
|
90
94
|
assertEquals(dispatchJobTypeForKind("feature"), "senior:feature");
|
|
91
95
|
assertEquals(dispatchJobTypeForKind("plan-task"), "senior:feature");
|
|
@@ -39,10 +39,11 @@ export type EscalationKind =
|
|
|
39
39
|
| "dead-end-base"
|
|
40
40
|
// `mergeProtocol` (app/mergeProtocol.ts) — the repo's declared land method.
|
|
41
41
|
| "merge-protocol"
|
|
42
|
-
//
|
|
43
|
-
// Any non-clean-terminal slice outcome routes through the
|
|
44
|
-
// classifies with this kind: the agent's own answerable
|
|
45
|
-
// readable result (or a blank-question `escalated`) is
|
|
42
|
+
// The shared `implement-cell`'s `ic_gw` "clean terminal?" gateway — the implement-stage escalation
|
|
43
|
+
// net (issue #360). Any non-clean-terminal slice outcome routes through the cell's unified
|
|
44
|
+
// `record-feature-escalation` worker, which classifies with this kind: the agent's own answerable
|
|
45
|
+
// question passes through, and a no-machine-readable result (or a blank-question `escalated`) is
|
|
46
|
+
// synthesised into an answerable one.
|
|
46
47
|
| "task";
|
|
47
48
|
|
|
48
49
|
/** Everything the classifier may need from any raise site. Each field is consumed only by the
|
|
@@ -99,3 +99,96 @@ test("feature.bpmn composes its converge step via callActivity to converge-cell
|
|
|
99
99
|
);
|
|
100
100
|
}
|
|
101
101
|
});
|
|
102
|
+
|
|
103
|
+
test("feature.bpmn composes its implement step via callActivity to implement-cell — no inlined implement serviceTask", () => {
|
|
104
|
+
// The implement seam (deferred out of #632, tracked in #646) composes the shared `implement-cell`
|
|
105
|
+
// via a `callActivity` with an explicit `zeebe:ioMapping`, rather than an inlined `senior:feature`
|
|
106
|
+
// serviceTask plus a per-caller escalation loop. The atomic cell owns its own escalation retry
|
|
107
|
+
// (through the shared `human-escalation` cell), so the parent no longer carries the
|
|
108
|
+
// `record-feature-escalation` / `feature-escalation` / answer-loop gateways.
|
|
109
|
+
const xml = flat("feature");
|
|
110
|
+
assert(
|
|
111
|
+
/<bpmn:callActivity\b[^>]*\bid="implement"[\s\S]*?<zeebe:calledElement\b[^>]*\bprocessId="implement-cell"/.test(xml),
|
|
112
|
+
"feature.bpmn must compose implement as a callActivity to implement-cell",
|
|
113
|
+
);
|
|
114
|
+
assert(
|
|
115
|
+
!/<bpmn:serviceTask\b[^>]*\bid="implement-task"/.test(xml),
|
|
116
|
+
"feature.bpmn must not keep an inlined implement-task serviceTask once the cell is composed",
|
|
117
|
+
);
|
|
118
|
+
// The inlined escalation loop is relocated into the atomic cell — the parent path must not keep it.
|
|
119
|
+
assert(
|
|
120
|
+
!/<bpmn:serviceTask\b[^>]*\bid="record-feature-escalation"/.test(xml),
|
|
121
|
+
"feature.bpmn must not keep the inlined record-feature-escalation serviceTask once the cell owns escalation",
|
|
122
|
+
);
|
|
123
|
+
assert(
|
|
124
|
+
!/<bpmn:userTask\b[^>]*\bid="feature-escalation"/.test(xml),
|
|
125
|
+
"feature.bpmn must not keep the inlined feature-escalation userTask once the cell owns escalation",
|
|
126
|
+
);
|
|
127
|
+
// Pin the explicit `zeebe:ioMapping` on the implement callActivity block alone (no `propagateAll*`).
|
|
128
|
+
const implementBlock =
|
|
129
|
+
xml.match(/<bpmn:callActivity\b[^>]*\bid="implement"[\s\S]*?<\/bpmn:callActivity>/)?.[0] ?? "";
|
|
130
|
+
assert(
|
|
131
|
+
/<zeebe:ioMapping>[\s\S]*?<\/zeebe:ioMapping>/.test(implementBlock),
|
|
132
|
+
"the implement callActivity must carry an explicit zeebe:ioMapping",
|
|
133
|
+
);
|
|
134
|
+
assert(
|
|
135
|
+
/<zeebe:input\b[^>]*\btarget="task"/.test(implementBlock),
|
|
136
|
+
"the implement callActivity ioMapping must map the task slice into the child scope",
|
|
137
|
+
);
|
|
138
|
+
for (const field of ["status", "summary", "pr"]) {
|
|
139
|
+
assert(
|
|
140
|
+
new RegExp(`<zeebe:output\\b[^>]*\\btarget="${field}"`).test(implementBlock),
|
|
141
|
+
`the implement callActivity ioMapping must map the child result field ${field} back into the parent scope`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("plan-fanout.bpmn composes its per-wave implement step via a callActivity to implement-cell", () => {
|
|
147
|
+
// Per ADR 0006 §2 the composition "replaces the inlined segments, not the surrounding orchestration":
|
|
148
|
+
// the multi-instance `implement` subProcess (one token per wave task) keeps its fan-out-specific
|
|
149
|
+
// capability barrier (`caps-prepare` / `wait-caps`) as surrounding orchestration, and its inlined
|
|
150
|
+
// implement/escalation SEGMENT (the per-wave `senior:feature` serviceTask, the `w_gw` "clean
|
|
151
|
+
// terminal?" gateway, and the `record-wave-escalation` recorder) collapses into a `callActivity` to
|
|
152
|
+
// the shared `implement-cell` — the atomic cell now owns the agent loop and its escalation through the
|
|
153
|
+
// shared `human-escalation` cell ("a wave IS a callActivity", #464/#646). The MI stays on the
|
|
154
|
+
// subProcess (one token per wave); the cell is invoked once per token.
|
|
155
|
+
const xml = flat("plan-fanout");
|
|
156
|
+
// The MI element is the `implement` subProcess (one token per wave), preserved as the caps-barrier
|
|
157
|
+
// host; assert its multi-instance loop over `waveTasks`.
|
|
158
|
+
const implementSub =
|
|
159
|
+
xml.match(/<bpmn:subProcess\b[^>]*\bid="implement"[\s\S]*?<\/bpmn:subProcess>/)?.[0] ?? "";
|
|
160
|
+
assert(implementSub.length > 0, "plan-fanout.bpmn must keep the per-wave implement subProcess (the caps-barrier host)");
|
|
161
|
+
assert(
|
|
162
|
+
/<bpmn:multiInstanceLoopCharacteristics>[\s\S]*?inputCollection="=waveTasks"/.test(implementSub),
|
|
163
|
+
"the implement subProcess must be multi-instance — one token per wave task",
|
|
164
|
+
);
|
|
165
|
+
// The caps barrier stays in the wave (fan-out-specific, not part of the atomic cell).
|
|
166
|
+
assert(
|
|
167
|
+
/<zeebe:taskDefinition\b[^>]*\btype="pr.caps-prepare"/.test(implementSub),
|
|
168
|
+
"the capability barrier (caps-prepare) stays in the wave as surrounding orchestration",
|
|
169
|
+
);
|
|
170
|
+
// The implement/escalation segment is replaced by a callActivity to the shared implement-cell.
|
|
171
|
+
const cellCall =
|
|
172
|
+
implementSub.match(/<bpmn:callActivity\b[^>]*\bid="implement-cell-call"[\s\S]*?<\/bpmn:callActivity>/)?.[0] ?? "";
|
|
173
|
+
assert(
|
|
174
|
+
/<zeebe:calledElement\b[^>]*\bprocessId="implement-cell"/.test(cellCall),
|
|
175
|
+
"plan-fanout.bpmn must compose the per-wave implement as a callActivity to implement-cell",
|
|
176
|
+
);
|
|
177
|
+
assert(
|
|
178
|
+
/<zeebe:ioMapping>[\s\S]*?<\/zeebe:ioMapping>/.test(cellCall),
|
|
179
|
+
"the implement-cell callActivity must carry an explicit zeebe:ioMapping (no propagateAll*)",
|
|
180
|
+
);
|
|
181
|
+
assert(
|
|
182
|
+
/<zeebe:input\b[^>]*\btarget="task"/.test(cellCall) && /<zeebe:input\b[^>]*\btarget="subjectKey"/.test(cellCall),
|
|
183
|
+
"the implement-cell callActivity must map the wave task and the plan subject key into the child scope",
|
|
184
|
+
);
|
|
185
|
+
// The inlined per-wave agent loop + escalation recorder no longer sit on the fan-out path.
|
|
186
|
+
assert(
|
|
187
|
+
!/<bpmn:serviceTask\b[^>]*\bid="implement-task"/.test(implementSub),
|
|
188
|
+
"plan-fanout.bpmn must not keep an inlined implement-task serviceTask once the cell is composed",
|
|
189
|
+
);
|
|
190
|
+
assert(
|
|
191
|
+
!/type="pr.record-wave-escalation"/.test(xml),
|
|
192
|
+
"plan-fanout.bpmn must not keep the inlined record-wave-escalation recorder (folded into the cell's recorder)",
|
|
193
|
+
);
|
|
194
|
+
});
|
|
@@ -1,40 +1,43 @@
|
|
|
1
|
-
// Structural guard for the
|
|
2
|
-
// escalation net (#358/#360)
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
1
|
+
// Structural guard for the implement cell's "clean terminal?" gateway (ic_gw) — the implement-stage
|
|
2
|
+
// escalation net (#358/#360), now owned by the shared `implement-cell` (ADR 0006 S4). Before the S4
|
|
3
|
+
// composition this gateway lived inline as `plan-fanout.bpmn`'s `w_gw` and `feature.bpmn`'s equivalent;
|
|
4
|
+
// composing the atomic cell relocated it (and its `record-escalation` recorder + `human-escalation`
|
|
5
|
+
// loop) into `implement-cell.bpmn`, which BOTH `feature.bpmn` and the `plan-fanout` MI body compose by
|
|
6
|
+
// `callActivity`. The invariant is unchanged: a slice with NO clean terminal status escalates to a
|
|
7
|
+
// human. The no-result case (the agent completes with `status` missing/undefined) is EXACTLY what must
|
|
8
|
+
// escalate, so the gateway must not depend on a `not(...)` negation that FEEL leaves `null` for a
|
|
9
|
+
// missing `status` (a null condition takes NO flow and would fall through to the default). We eliminate
|
|
10
|
+
// that failure mode categorically: ESCALATE is the DEFAULT flow and DONE is gated on the closed set of
|
|
11
|
+
// clean terminal statuses — so anything that is not a recognised clean terminal (including a
|
|
12
|
+
// missing/undefined status) escalates, regardless of how the engine evaluates equality against null.
|
|
10
13
|
//
|
|
11
14
|
// Pure text assertions over the committed BPMN (no engine), matching the repo's model-guard style.
|
|
12
15
|
import { readFileSync } from "node:fs";
|
|
13
16
|
import { test } from "node:test";
|
|
14
17
|
import { assert, assertStringIncludes } from "#test-assert";
|
|
15
18
|
|
|
16
|
-
const bpmn = readFileSync("resources/processes/
|
|
19
|
+
const bpmn = readFileSync("resources/processes/implement-cell.bpmn", "utf8");
|
|
17
20
|
const flat = bpmn.replace(/\s+/g, " ");
|
|
18
21
|
|
|
19
|
-
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
+
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="ic_gw"[^>]*>/)?.[0] ?? "";
|
|
23
|
+
const icDone = flat.match(/<bpmn:sequenceFlow\b[^>]*\bid="ic_done"[^>]*\/>|<bpmn:sequenceFlow\b[^>]*\bid="ic_done"[\s\S]*?<\/bpmn:sequenceFlow>/)?.[0] ?? "";
|
|
24
|
+
const icEscalate = flat.match(/<bpmn:sequenceFlow\b[^>]*\bid="ic_escalate"[^>]*\/>|<bpmn:sequenceFlow\b[^>]*\bid="ic_escalate"[\s\S]*?<\/bpmn:sequenceFlow>/)?.[0] ?? "";
|
|
22
25
|
|
|
23
|
-
test("
|
|
24
|
-
assert(gw, "
|
|
25
|
-
assertStringIncludes(gw, 'default="
|
|
26
|
+
test("ic_gw: ESCALATE is the default flow, so a missing/undefined status can never fall through to done", () => {
|
|
27
|
+
assert(gw, "ic_gw gateway must exist");
|
|
28
|
+
assertStringIncludes(gw, 'default="ic_escalate"', "escalate must be the default — the no-result case escalates, never silently completes");
|
|
26
29
|
});
|
|
27
30
|
|
|
28
|
-
test("
|
|
29
|
-
assert(
|
|
30
|
-
assertStringIncludes(
|
|
31
|
-
assertStringIncludes(
|
|
32
|
-
assertStringIncludes(
|
|
33
|
-
assertStringIncludes(
|
|
31
|
+
test("ic_gw: DONE is gated on the closed set of clean terminal statuses (not a fragile not(...) negation)", () => {
|
|
32
|
+
assert(icDone, "ic_done flow must exist");
|
|
33
|
+
assertStringIncludes(icDone, "conditionExpression", "the done flow must be conditional, not the default");
|
|
34
|
+
assertStringIncludes(icDone, 'status = "opened"', "done requires a recognised clean terminal status");
|
|
35
|
+
assertStringIncludes(icDone, 'status = "blocked"', "done requires a recognised clean terminal status");
|
|
36
|
+
assertStringIncludes(icDone, 'status = "skipped"', "done requires a recognised clean terminal status");
|
|
34
37
|
});
|
|
35
38
|
|
|
36
|
-
test("
|
|
37
|
-
assert(
|
|
38
|
-
assert(!
|
|
39
|
-
assert(!
|
|
39
|
+
test("ic_gw: the escalate flow carries no condition — it is the unconditional default sink", () => {
|
|
40
|
+
assert(icEscalate, "ic_escalate flow must exist");
|
|
41
|
+
assert(!icEscalate.includes("conditionExpression"), "escalate is the default flow and must carry no condition");
|
|
42
|
+
assert(!icEscalate.includes("not("), "escalate must not depend on a not(...) negation that FEEL leaves null for a missing status");
|
|
40
43
|
});
|
|
@@ -66,8 +66,15 @@ test("a never-green producer escalates (bounded) without wedging: probe timeout
|
|
|
66
66
|
assert(hasFlow("be_pf_sla", "pf_end"), "an elapsed escalation SLA settles the preflight instead of wedging");
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
test("the bound resolvedArtifacts version rides the implement
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
test("the bound resolvedArtifacts version rides the implement cell's appendPrompt", () => {
|
|
70
|
+
// The per-wave implement/escalation segment is composed into `implement-cell` (ADR 0006 S4), so the
|
|
71
|
+
// bound `pkg@version`s ride the MI `implement-cell` callActivity's ioMapping into the cell, whose
|
|
72
|
+
// `implement-task` threads them into the slice prompt's appendPrompt.
|
|
73
|
+
const call = flat.match(/<bpmn:callActivity\b[^>]*\bid="implement-cell-call"[\s\S]*?<\/bpmn:callActivity>/);
|
|
74
|
+
assert(call, "the implement-cell callActivity must exist");
|
|
75
|
+
assertStringIncludes(call![0], "resolvedArtifacts", "the bound pkg@version is threaded into the cell's slice prompt");
|
|
76
|
+
const cell = readFileSync("resources/processes/implement-cell.bpmn", "utf8").replace(/\s+/g, " ");
|
|
77
|
+
const task = cell.match(/<bpmn:serviceTask\b[^>]*\bid="implement-task"[\s\S]*?<\/bpmn:serviceTask>/);
|
|
78
|
+
assert(task, "implement-cell must own the implement-task");
|
|
79
|
+
assertStringIncludes(task![0], "resolvedArtifacts", "the cell's implement-task appendPrompt consumes the bound pkg@version");
|
|
73
80
|
});
|
|
@@ -161,6 +161,69 @@ test("pollUserTasks: projects a feature-escalation that lands on a plan-fanout p
|
|
|
161
161
|
assertEquals(byKey["ut-embedded-feat"].question, "the agent returned no machine-readable result — enrol the PR?");
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
+
test("pollUserTasks: projects a readiness-escalation-pf preflight task on a feature run (issue #674)", async () => {
|
|
165
|
+
// The leading readiness preflight (feature.bpmn `pf_*` embedded subprocess) parks on the run's OWN
|
|
166
|
+
// engine instance when it times out before its ReadinessProbe goes green. Before #674 the element id
|
|
167
|
+
// was absent from USER_TASK_KIND_LABELS, so `contextFor`'s leak guard dropped it and the parked run
|
|
168
|
+
// was invisible + uncompletable in the Tasks surface. It must now project as an "Upstream readiness
|
|
169
|
+
// stalled" row on the feature subject, carrying the run's wait rollup as the question.
|
|
170
|
+
const { data, stores } = memData({
|
|
171
|
+
feature_runs: [
|
|
172
|
+
{
|
|
173
|
+
feature_key: "o/r#674",
|
|
174
|
+
status: "running",
|
|
175
|
+
process_key: "fp-674",
|
|
176
|
+
issue_url: "https://github.com/o/r/issues/674",
|
|
177
|
+
title: "Ship the widget",
|
|
178
|
+
delivery_label: "waiting on @scope/upstream@1.2.0 · re-checks every 30s",
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
const engine = fakeEngine({ "fp-674": [{ userTaskKey: "ut-readiness-pf", elementId: "readiness-escalation-pf", formKey: "26" }] });
|
|
183
|
+
|
|
184
|
+
await pollUserTasks(data, engine);
|
|
185
|
+
|
|
186
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
187
|
+
assertEquals(Object.keys(byKey), ["ut-readiness-pf"]);
|
|
188
|
+
assertEquals(byKey["ut-readiness-pf"].element_id, "readiness-escalation-pf");
|
|
189
|
+
assertEquals(byKey["ut-readiness-pf"].kind_label, "Upstream readiness stalled");
|
|
190
|
+
assertEquals(byKey["ut-readiness-pf"].subject_type, "feature");
|
|
191
|
+
assertEquals(byKey["ut-readiness-pf"].subject_key, "o/r#674");
|
|
192
|
+
assertEquals(byKey["ut-readiness-pf"].subject_title, "Ship the widget");
|
|
193
|
+
assertEquals(byKey["ut-readiness-pf"].question, "waiting on @scope/upstream@1.2.0 · re-checks every 30s");
|
|
194
|
+
assertEquals(byKey["ut-readiness-pf"].form_key, "26");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("pollUserTasks: projects a readiness-escalation wait-gate task on a plan, with the wait-gate label as question (issue #674)", async () => {
|
|
198
|
+
// The standalone inter-epic wait-gate cell (readiness-gate.bpmn / wait-gate.bpmn) parks a dependent
|
|
199
|
+
// epic on `readiness-escalation` when its bounded capability wait elapses. It surfaces on the plan
|
|
200
|
+
// subject; the question derivation leans on the wait-gate projection (`plans.wait_gate_label`).
|
|
201
|
+
const { data, stores } = memData({
|
|
202
|
+
plans: [
|
|
203
|
+
{
|
|
204
|
+
plan_key: "o/r#700",
|
|
205
|
+
status: "dispatched",
|
|
206
|
+
process_key: "pp-700",
|
|
207
|
+
issue_url: "https://github.com/o/r/issues/700",
|
|
208
|
+
title: "Dependent epic",
|
|
209
|
+
wait_gate: "escalated",
|
|
210
|
+
wait_gate_label: "escalated · still waiting on @scope/producer@2.0.0 after 24h",
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
});
|
|
214
|
+
const engine = fakeEngine({ "pp-700": [{ userTaskKey: "ut-readiness", elementId: "readiness-escalation" }] });
|
|
215
|
+
|
|
216
|
+
await pollUserTasks(data, engine);
|
|
217
|
+
|
|
218
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
219
|
+
assertEquals(Object.keys(byKey), ["ut-readiness"]);
|
|
220
|
+
assertEquals(byKey["ut-readiness"].element_id, "readiness-escalation");
|
|
221
|
+
assertEquals(byKey["ut-readiness"].kind_label, "Readiness escalation");
|
|
222
|
+
assertEquals(byKey["ut-readiness"].subject_type, "plan");
|
|
223
|
+
assertEquals(byKey["ut-readiness"].subject_key, "o/r#700");
|
|
224
|
+
assertEquals(byKey["ut-readiness"].question, "escalated · still waiting on @scope/producer@2.0.0 after 24h");
|
|
225
|
+
});
|
|
226
|
+
|
|
164
227
|
test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into user_tasks as \"PR merge\"", async () => {
|
|
165
228
|
// During the merge phase a PR's process_key points at its merge-loop instance; the merge escalation
|
|
166
229
|
// parks on a native `wait-merge-answer` userTask (#256) and writes the SAME `escalations` row the
|
package/app/service.ts
CHANGED
|
@@ -99,6 +99,9 @@ import {
|
|
|
99
99
|
PR_WAIT_ANSWER_ELEMENT,
|
|
100
100
|
PR_WAIT_MERGE_ANSWER_ELEMENT,
|
|
101
101
|
prEscalations,
|
|
102
|
+
READINESS_ESCALATION_ELEMENT,
|
|
103
|
+
READINESS_ESCALATION_PF_ELEMENT,
|
|
104
|
+
readinessEscalationQuestion,
|
|
102
105
|
reconcileUserTasks,
|
|
103
106
|
TRIAL_MERGE_ELEMENT,
|
|
104
107
|
toOpenEscalation,
|
|
@@ -2652,15 +2655,19 @@ export async function pollUserTasks(
|
|
|
2652
2655
|
url?: string | null;
|
|
2653
2656
|
deliveryLabel?: string | null;
|
|
2654
2657
|
conformanceSummary?: string | null;
|
|
2658
|
+
/** The subject's at-a-glance "waiting on <capability> · …" rollup, denormalised for the readiness
|
|
2659
|
+
* escalation question (issue #674): the wait-gate projection on `plans.wait_gate_label`, or the
|
|
2660
|
+
* feature run's `delivery_label` for the inline preflight. */
|
|
2661
|
+
waitGateLabel?: string | null;
|
|
2655
2662
|
}
|
|
2656
2663
|
const subjectByInstance = new Map<string, Subject>();
|
|
2657
2664
|
for (const run of await featureRuns(data).all()) {
|
|
2658
2665
|
if (run.process_key) {
|
|
2659
|
-
subjectByInstance.set(run.process_key, { type: "feature", key: run.feature_key, title: run.title, url: run.issue_url, deliveryLabel: run.delivery_label });
|
|
2666
|
+
subjectByInstance.set(run.process_key, { type: "feature", key: run.feature_key, title: run.title, url: run.issue_url, deliveryLabel: run.delivery_label, waitGateLabel: run.delivery_label });
|
|
2660
2667
|
}
|
|
2661
2668
|
}
|
|
2662
2669
|
for (const plan of await plans(data).all()) {
|
|
2663
|
-
if (plan.process_key) subjectByInstance.set(plan.process_key, { type: "plan", key: plan.plan_key, title: plan.title, url: plan.issue_url });
|
|
2670
|
+
if (plan.process_key) subjectByInstance.set(plan.process_key, { type: "plan", key: plan.plan_key, title: plan.title, url: plan.issue_url, waitGateLabel: plan.wait_gate_label });
|
|
2664
2671
|
}
|
|
2665
2672
|
for (const pr of await prs(data).all()) {
|
|
2666
2673
|
if (pr.process_key) subjectByInstance.set(pr.process_key, { type: "pr", key: pr.pr_key, title: pr.title, url: pr.url });
|
|
@@ -2687,6 +2694,8 @@ export async function pollUserTasks(
|
|
|
2687
2694
|
[PLAN_REVIEW_ELEMENT]: "plan",
|
|
2688
2695
|
[TRIAL_MERGE_ELEMENT]: "plan",
|
|
2689
2696
|
[CONFORMANCE_ESCALATION_ELEMENT]: "plan",
|
|
2697
|
+
[READINESS_ESCALATION_PF_ELEMENT]: "feature",
|
|
2698
|
+
[READINESS_ESCALATION_ELEMENT]: "plan",
|
|
2690
2699
|
[PR_WAIT_ANSWER_ELEMENT]: "pr",
|
|
2691
2700
|
[PR_WAIT_MERGE_ANSWER_ELEMENT]: "pr",
|
|
2692
2701
|
};
|
|
@@ -2744,6 +2753,13 @@ export async function pollUserTasks(
|
|
|
2744
2753
|
case CONFORMANCE_ESCALATION_ELEMENT:
|
|
2745
2754
|
question = conformanceEscalationQuestion(subj ? { summary: subj.conformanceSummary } : undefined);
|
|
2746
2755
|
break;
|
|
2756
|
+
case READINESS_ESCALATION_PF_ELEMENT:
|
|
2757
|
+
case READINESS_ESCALATION_ELEMENT:
|
|
2758
|
+
// The leading readiness/capability gate stalled: surface WHAT it is waiting on. The wait-gate
|
|
2759
|
+
// projection already rendered that clause on the subject row (`plans.wait_gate_label`, or the
|
|
2760
|
+
// feature preflight's `delivery_label`), with a static readiness-stalled fallback (#674).
|
|
2761
|
+
question = readinessEscalationQuestion(subj?.waitGateLabel ?? null);
|
|
2762
|
+
break;
|
|
2747
2763
|
}
|
|
2748
2764
|
return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey, formKey: resolvedFormKey };
|
|
2749
2765
|
};
|
|
@@ -2766,22 +2782,36 @@ export async function pollUserTasks(
|
|
|
2766
2782
|
await project(t.elementId, t.userTaskKey, t.processInstanceKey, t.rootProcessInstanceKey, t.formKey);
|
|
2767
2783
|
}
|
|
2768
2784
|
} else {
|
|
2769
|
-
// Reduced-capability fallback (no raw-REST surface): typed-seam per-active-subject scan
|
|
2770
|
-
//
|
|
2771
|
-
//
|
|
2772
|
-
//
|
|
2785
|
+
// Reduced-capability fallback (no raw-REST surface): typed-seam per-active-subject scan. The typed
|
|
2786
|
+
// seam now carries the parent/root keys (`@nanobpm/urban` ≥ 0.90 / `@nanobpm/engine-wasm` ≥ 0.8.6,
|
|
2787
|
+
// Magikcraft/nano-bpm#977), so — unlike the pre-#646 tracked-only path — a task parked inside a
|
|
2788
|
+
// callActivity CHILD instance (the shared `human-escalation`/`implement-cell` cells, ADR 0006 S4)
|
|
2789
|
+
// correlates back to this tracked subject too: each subject instance is scanned BOTH directly (its
|
|
2790
|
+
// own top-level escalations) AND across its whole callActivity hierarchy via the `rootProcessInstanceKey`
|
|
2791
|
+
// filter (its native-child cell escalations), so an `implement-cell` → `human-escalation` grandchild
|
|
2792
|
+
// escalation surfaces on this path exactly as it does on the raw-REST sweep (issue #633/#646). Deduped
|
|
2793
|
+
// by `userTaskKey` in `project`, so the direct + hierarchy passes never double-project a shared task.
|
|
2773
2794
|
const seen = new Set<string>();
|
|
2774
2795
|
const scanInstance = async (processKey: string | null | undefined) => {
|
|
2775
2796
|
if (!processKey || seen.has(processKey)) return;
|
|
2776
2797
|
seen.add(processKey);
|
|
2777
|
-
let
|
|
2798
|
+
let direct: { userTaskKey: string; elementId?: string; formKey?: string }[];
|
|
2799
|
+
let hierarchy: { userTaskKey: string; elementId?: string; processInstanceKey?: string; rootProcessInstanceKey?: string; formKey?: string }[];
|
|
2778
2800
|
try {
|
|
2779
|
-
|
|
2801
|
+
[direct, hierarchy] = await Promise.all([
|
|
2802
|
+
engine.openUserTasks({ processInstanceKey: processKey }),
|
|
2803
|
+
engine.openUserTasks({ rootProcessInstanceKey: processKey }),
|
|
2804
|
+
]);
|
|
2780
2805
|
} catch (err) {
|
|
2781
2806
|
console.error(`[poller] user tasks (${processKey}): ${err}`);
|
|
2782
2807
|
return;
|
|
2783
2808
|
}
|
|
2784
|
-
|
|
2809
|
+
// Direct tasks park on the subject's OWN instance, so their process/root instance IS `processKey`.
|
|
2810
|
+
for (const t of direct) await project(t.elementId, t.userTaskKey, processKey, processKey, t.formKey ?? "");
|
|
2811
|
+
// Hierarchy tasks carry their own (child) `processInstanceKey` and the shared `rootProcessInstanceKey`
|
|
2812
|
+
// (= this subject) the engine reports, so `contextFor` correlates the child-instance task to the
|
|
2813
|
+
// tracked subject via the root rather than stranding it as an orphan.
|
|
2814
|
+
for (const t of hierarchy) await project(t.elementId, t.userTaskKey, t.processInstanceKey ?? processKey, t.rootProcessInstanceKey ?? processKey, t.formKey ?? "");
|
|
2785
2815
|
};
|
|
2786
2816
|
for (const status of FEATURE_ACTIVE_STATUSES) for (const run of await featureRuns(data).find({ status })) await scanInstance(run.process_key);
|
|
2787
2817
|
for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
|
|
@@ -2851,27 +2881,32 @@ export async function pollUserTasks(
|
|
|
2851
2881
|
// element inside a DESCENDANT instance that the parent's `openUserTasks` never reports. Confirming
|
|
2852
2882
|
// the parent alone would read "no escalation open" and wrongly flip a genuinely-parked child-cell run
|
|
2853
2883
|
// back to `running` whenever THIS pass's sweep missed it (truncated/unavailable, so it is absent from
|
|
2854
|
-
// `desired`).
|
|
2855
|
-
//
|
|
2856
|
-
//
|
|
2857
|
-
//
|
|
2858
|
-
|
|
2859
|
-
|
|
2884
|
+
// `desired`). The typed seam now carries the parent/root keys (`@nanobpm/urban` ≥ 0.90, #646), so BOTH
|
|
2885
|
+
// surfaces walk the hierarchy: the raw-REST surface enumerates descendant instances, and the
|
|
2886
|
+
// reduced-capability seam (no `rest`) confirms via the `rootProcessInstanceKey` filter — a
|
|
2887
|
+
// root-scoped `openUserTasks` reports every open task across the run's whole tree in one query. Any
|
|
2888
|
+
// query error is negative evidence, not proof the run is unparked — skip the heal and leave the row
|
|
2889
|
+
// for a later pass (parity with before).
|
|
2860
2890
|
let stillParked = false;
|
|
2861
2891
|
let queryErrored = false;
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2892
|
+
try {
|
|
2893
|
+
if (rest) {
|
|
2894
|
+
const confirmInstances = [run.process_key, ...(await searchDescendantInstanceKeys(rest.base, rest.headers, run.process_key))];
|
|
2895
|
+
for (const instanceKey of confirmInstances) {
|
|
2896
|
+
const openTasks = await engine.openUserTasks({ processInstanceKey: instanceKey });
|
|
2897
|
+
if (openTasks.some((t) => t.elementId === FEATURE_ESCALATION_ELEMENT || t.elementId === HUMAN_ESCALATION_ELEMENT)) {
|
|
2898
|
+
stillParked = true;
|
|
2899
|
+
break;
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
} else {
|
|
2903
|
+
// Reduced path: one root-scoped query covers the parent AND every callActivity descendant.
|
|
2904
|
+
const openTasks = await engine.openUserTasks({ rootProcessInstanceKey: run.process_key });
|
|
2905
|
+
stillParked = openTasks.some((t) => t.elementId === FEATURE_ESCALATION_ELEMENT || t.elementId === HUMAN_ESCALATION_ELEMENT);
|
|
2874
2906
|
}
|
|
2907
|
+
} catch (err) {
|
|
2908
|
+
console.error(`[poller] escalated-run self-heal (${run.feature_key} @ ${run.process_key}): ${err}`);
|
|
2909
|
+
queryErrored = true;
|
|
2875
2910
|
}
|
|
2876
2911
|
if (queryErrored || stillParked) continue;
|
|
2877
2912
|
await featureRuns(data).update(run.feature_key, { status: "running", updated_at: at });
|