@nanobpm/nano-workforce 0.171.2 → 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 +6 -0
- 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/service.ts +44 -25
- 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,9 @@
|
|
|
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
|
+
|
|
1
7
|
## [0.171.2](https://github.com/nanobpm/nano-workforce/compare/v0.171.1...v0.171.2) (2026-08-31)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
|
@@ -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
|
});
|
package/app/service.ts
CHANGED
|
@@ -2782,22 +2782,36 @@ export async function pollUserTasks(
|
|
|
2782
2782
|
await project(t.elementId, t.userTaskKey, t.processInstanceKey, t.rootProcessInstanceKey, t.formKey);
|
|
2783
2783
|
}
|
|
2784
2784
|
} else {
|
|
2785
|
-
// Reduced-capability fallback (no raw-REST surface): typed-seam per-active-subject scan
|
|
2786
|
-
//
|
|
2787
|
-
//
|
|
2788
|
-
//
|
|
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.
|
|
2789
2794
|
const seen = new Set<string>();
|
|
2790
2795
|
const scanInstance = async (processKey: string | null | undefined) => {
|
|
2791
2796
|
if (!processKey || seen.has(processKey)) return;
|
|
2792
2797
|
seen.add(processKey);
|
|
2793
|
-
let
|
|
2798
|
+
let direct: { userTaskKey: string; elementId?: string; formKey?: string }[];
|
|
2799
|
+
let hierarchy: { userTaskKey: string; elementId?: string; processInstanceKey?: string; rootProcessInstanceKey?: string; formKey?: string }[];
|
|
2794
2800
|
try {
|
|
2795
|
-
|
|
2801
|
+
[direct, hierarchy] = await Promise.all([
|
|
2802
|
+
engine.openUserTasks({ processInstanceKey: processKey }),
|
|
2803
|
+
engine.openUserTasks({ rootProcessInstanceKey: processKey }),
|
|
2804
|
+
]);
|
|
2796
2805
|
} catch (err) {
|
|
2797
2806
|
console.error(`[poller] user tasks (${processKey}): ${err}`);
|
|
2798
2807
|
return;
|
|
2799
2808
|
}
|
|
2800
|
-
|
|
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 ?? "");
|
|
2801
2815
|
};
|
|
2802
2816
|
for (const status of FEATURE_ACTIVE_STATUSES) for (const run of await featureRuns(data).find({ status })) await scanInstance(run.process_key);
|
|
2803
2817
|
for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
|
|
@@ -2867,27 +2881,32 @@ export async function pollUserTasks(
|
|
|
2867
2881
|
// element inside a DESCENDANT instance that the parent's `openUserTasks` never reports. Confirming
|
|
2868
2882
|
// the parent alone would read "no escalation open" and wrongly flip a genuinely-parked child-cell run
|
|
2869
2883
|
// back to `running` whenever THIS pass's sweep missed it (truncated/unavailable, so it is absent from
|
|
2870
|
-
// `desired`).
|
|
2871
|
-
//
|
|
2872
|
-
//
|
|
2873
|
-
//
|
|
2874
|
-
|
|
2875
|
-
|
|
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).
|
|
2876
2890
|
let stillParked = false;
|
|
2877
2891
|
let queryErrored = false;
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
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);
|
|
2890
2906
|
}
|
|
2907
|
+
} catch (err) {
|
|
2908
|
+
console.error(`[poller] escalated-run self-heal (${run.feature_key} @ ${run.process_key}): ${err}`);
|
|
2909
|
+
queryErrored = true;
|
|
2891
2910
|
}
|
|
2892
2911
|
if (queryErrored || stillParked) continue;
|
|
2893
2912
|
await featureRuns(data).update(run.feature_key, { status: "running", updated_at: at });
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// End-to-end proof for agent-answerable escalations (epic #156, slice U6; ADR 0046). Boots the whole
|
|
2
2
|
// app against the WASM engine and drives the REAL plan-fanout.bpmn to the implementation-phase task
|
|
3
|
-
// escalation — the native `
|
|
3
|
+
// escalation — the native `escalation` userTask (formId `feature-escalation`) on the `human-escalation`
|
|
4
|
+
// grandchild the shared implement-cell spawns, which the human path (U2) completes — then
|
|
4
5
|
// completes it through the HOST-SIDE AGENT COMPLETER (`completeEscalationAsAgent`) instead of a raw
|
|
5
6
|
// human completion. It proves the three things U6 promises:
|
|
6
7
|
//
|
|
7
8
|
// 1. an AGENT assignee completing the SAME `.form` resumes the process with typed vars IDENTICAL to
|
|
8
9
|
// a human completion — asserted on the cumulative taken sequence flows: `{resolution:"answer"}`
|
|
9
|
-
// routes `
|
|
10
|
-
// completion would take the abandon default);
|
|
10
|
+
// routes `ic_gw_answer -> record-implementing -> implement-task`, exactly as the U2 human test
|
|
11
|
+
// asserts (an empty/wrong completion would take the abandon default);
|
|
11
12
|
// 2. attribution is recorded — the `task_completions` ledger row is actor_kind=agent + the agent id
|
|
12
13
|
// + the submitted variables;
|
|
13
14
|
// 3. the completion is reversible — a human reverts it, and the ledger records who + when.
|
|
@@ -111,7 +112,9 @@ describe("agent-answerable escalations (U6 — same form, agent completer, attri
|
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
async function openTask(app: TestApp, processKey: string, elementId: string): Promise<InboxTask> {
|
|
114
|
-
|
|
115
|
+
// Root-scoped: after the ADR 0006 S4 composition the escalation user task parks on a
|
|
116
|
+
// `human-escalation` grandchild instance the shared `implement-cell` spawns, not on the parent.
|
|
117
|
+
const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
|
|
115
118
|
const match = tasks.find((t) => t.elementId === elementId);
|
|
116
119
|
assert.ok(match, `expected an open ${elementId} user task (open: ${tasks.map((t) => t.elementId).join(", ")})`);
|
|
117
120
|
return match!;
|
|
@@ -134,8 +137,8 @@ describe("agent-answerable escalations (U6 — same form, agent completer, attri
|
|
|
134
137
|
},
|
|
135
138
|
},
|
|
136
139
|
async ({ app, processKey }) => {
|
|
137
|
-
const task = await openTask(app, processKey, "
|
|
138
|
-
assert.ok(task.userTaskKey, "the
|
|
140
|
+
const task = await openTask(app, processKey, "escalation");
|
|
141
|
+
assert.ok(task.userTaskKey, "the human-escalation cell task carries a completable userTaskKey");
|
|
139
142
|
|
|
140
143
|
// Complete AS AN AGENT through the host-side completer — the same typed `{resolution, answer}`
|
|
141
144
|
// a human submits through the inbox, only the caller differs.
|
|
@@ -145,18 +148,18 @@ describe("agent-answerable escalations (U6 — same form, agent completer, attri
|
|
|
145
148
|
variables: { resolution: "answer", answer: "use v2" },
|
|
146
149
|
});
|
|
147
150
|
assert.equal(r.ok, true, "the agent completer accepted the escalation completion");
|
|
148
|
-
assert.equal(r.elementId, "
|
|
151
|
+
assert.equal(r.elementId, "escalation");
|
|
149
152
|
await app.settle();
|
|
150
153
|
|
|
151
154
|
// IDENTICAL resume to the human path (mirrors U2's human test): the typed resolution loops
|
|
152
|
-
// the
|
|
155
|
+
// the cell back to re-dispatch the SAME task through its implementing-reset — NOT the abandon default.
|
|
153
156
|
const flows = takenFlows(app);
|
|
154
157
|
assert.ok(
|
|
155
|
-
flows.includes("
|
|
156
|
-
`agent answer routed back to implement-task (flows: ${flows.join(", ")})`,
|
|
158
|
+
flows.includes("ic_gw_answer->record-implementing") && flows.includes("record-implementing->implement-task"),
|
|
159
|
+
`agent answer routed back to implement-task through the cell reset (flows: ${flows.join(", ")})`,
|
|
157
160
|
);
|
|
158
161
|
assert.ok(
|
|
159
|
-
!flows.includes("
|
|
162
|
+
!flows.includes("ic_gw_answer->ic_end"),
|
|
160
163
|
"the abandon (default) flow was NOT taken",
|
|
161
164
|
);
|
|
162
165
|
|
|
@@ -168,7 +171,7 @@ describe("agent-answerable escalations (U6 — same form, agent completer, attri
|
|
|
168
171
|
const row = completions[0];
|
|
169
172
|
assert.equal(row.actor_kind, "agent");
|
|
170
173
|
assert.equal(row.actor_id, "senior:answer-bot");
|
|
171
|
-
assert.equal(row.element_id, "
|
|
174
|
+
assert.equal(row.element_id, "escalation");
|
|
172
175
|
assert.deepEqual(JSON.parse(row.variables_json), { resolution: "answer", answer: "use v2" });
|
|
173
176
|
assert.equal(row.reversible, 1, "an agent completion is reversible");
|
|
174
177
|
assert.equal(row.reverted, 0);
|
|
@@ -158,8 +158,9 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
158
158
|
);
|
|
159
159
|
assert.ok(
|
|
160
160
|
flows.includes("ensure-base-branch->record-feature-implementing") &&
|
|
161
|
-
flows.includes("record-feature-implementing->implement
|
|
162
|
-
|
|
161
|
+
flows.includes("record-feature-implementing->implement") &&
|
|
162
|
+
flows.includes("Start->implement-task"),
|
|
163
|
+
`the run reaches the implement agent (via the implement cell) only after the gate (flows: ${flows.join(", ")})`,
|
|
163
164
|
);
|
|
164
165
|
// A green probe never escalates.
|
|
165
166
|
const tasks = await app.engine.searchUserTasks({ processInstanceKey });
|
|
@@ -190,8 +191,9 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
|
|
|
190
191
|
);
|
|
191
192
|
assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "an ungated feature never enters the preflight");
|
|
192
193
|
assert.ok(
|
|
193
|
-
flows.includes("record-feature-implementing->implement
|
|
194
|
-
|
|
194
|
+
flows.includes("record-feature-implementing->implement") &&
|
|
195
|
+
flows.includes("Start->implement-task"),
|
|
196
|
+
"an ungated feature reaches the implement agent (via the implement cell)",
|
|
195
197
|
);
|
|
196
198
|
} finally {
|
|
197
199
|
await app.stop();
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// • raise-only — the agent opens a PR, `converge` is off → the run ends at `opened`, no hand-off;
|
|
4
4
|
// • raise + converge — with `converge` on, the opened PR is enrolled into the convergence loop
|
|
5
5
|
// (a `pull_requests` row appears) and the feature_run lands `converging`;
|
|
6
|
-
// • escalate + resume — the agent escalates, a
|
|
7
|
-
// run, and answering re-dispatches the
|
|
6
|
+
// • escalate + resume — the agent escalates, the shared implement-cell spawns a `human-escalation`
|
|
7
|
+
// grandchild whose native `escalation` user task parks the run, and answering re-dispatches the
|
|
8
|
+
// SAME implement task inside the cell (mirrors the epic slice).
|
|
8
9
|
//
|
|
9
10
|
// The gateway assertions are the falsifiable core: the WASM engine folds a completed instance's
|
|
10
11
|
// variables away, so we assert on the cumulative taken sequence flows (an empty/wrong result takes a
|
|
@@ -236,23 +237,26 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
236
237
|
},
|
|
237
238
|
{ baseBranch: "epic/e2e" },
|
|
238
239
|
async ({ app, processKey }) => {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
// The escalation user task now lives on a `human-escalation` grandchild instance the shared
|
|
241
|
+
// `implement-cell` spawns (ADR 0006 S4), so it is found root-scoped, by the cell's `escalation`
|
|
242
|
+
// element — not `feature-escalation` on the parent (that inline task no longer exists).
|
|
243
|
+
const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
|
|
244
|
+
const task = tasks.find((t) => t.elementId === "escalation") as InboxTask | undefined;
|
|
245
|
+
assert.ok(task?.userTaskKey, "the human-escalation cell parked a completable native user task on a child instance");
|
|
242
246
|
|
|
243
247
|
await app.engine.completeUserTask(task!.userTaskKey, { resolution: "answer", answer: "use v2" });
|
|
244
248
|
await app.settle();
|
|
245
249
|
|
|
246
250
|
const flows = takenFlows(app);
|
|
247
251
|
assert.ok(
|
|
248
|
-
flows.includes("
|
|
249
|
-
`answer re-dispatched through the implementing-reset task (flows: ${flows.join(", ")})`,
|
|
252
|
+
flows.includes("ic_gw_answer->record-implementing"),
|
|
253
|
+
`answer re-dispatched through the cell's implementing-reset task (flows: ${flows.join(", ")})`,
|
|
250
254
|
);
|
|
251
255
|
assert.ok(
|
|
252
|
-
flows.includes("record-
|
|
253
|
-
`the reset task re-enters the same implement task (flows: ${flows.join(", ")})`,
|
|
256
|
+
flows.includes("record-implementing->implement-task"),
|
|
257
|
+
`the reset task re-enters the same implement task inside the cell (flows: ${flows.join(", ")})`,
|
|
254
258
|
);
|
|
255
|
-
assert.ok(!flows.includes("
|
|
259
|
+
assert.ok(!flows.includes("ic_gw_answer->ic_end"), "the abandon (default) flow was NOT taken");
|
|
256
260
|
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
257
261
|
},
|
|
258
262
|
);
|
|
@@ -289,9 +293,9 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
289
293
|
assert.equal(parked.status, "escalated", "the run parks at escalated while awaiting the answer");
|
|
290
294
|
assert.ok(parked.process_key, "the parked run carries its engine process-instance key");
|
|
291
295
|
|
|
292
|
-
const tasks = await app.engine.searchUserTasks({
|
|
293
|
-
const task = tasks.find((t) => t.elementId === "
|
|
294
|
-
assert.ok(task?.userTaskKey, "the
|
|
296
|
+
const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: parked.process_key! });
|
|
297
|
+
const task = tasks.find((t) => t.elementId === "escalation") as InboxTask | undefined;
|
|
298
|
+
assert.ok(task?.userTaskKey, "the human-escalation cell parked a completable native user task on a child instance");
|
|
295
299
|
|
|
296
300
|
await app.engine.completeUserTask(task!.userTaskKey, { resolution: "answer", answer: "use v2" });
|
|
297
301
|
await app.settle();
|
|
@@ -315,19 +319,24 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
315
319
|
},
|
|
316
320
|
{ baseBranch: "epic/e2e" },
|
|
317
321
|
async ({ app, processKey }) => {
|
|
318
|
-
const tasks = await app.engine.searchUserTasks({
|
|
319
|
-
const task = tasks.find((t) => t.elementId === "
|
|
320
|
-
assert.ok(task?.userTaskKey, "the
|
|
322
|
+
const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
|
|
323
|
+
const task = tasks.find((t) => t.elementId === "escalation") as InboxTask | undefined;
|
|
324
|
+
assert.ok(task?.userTaskKey, "the human-escalation cell parked a completable native user task on a child instance");
|
|
321
325
|
|
|
322
326
|
await app.engine.completeUserTask(task!.userTaskKey, { resolution: "abandon" });
|
|
323
327
|
await app.settle();
|
|
324
328
|
|
|
325
329
|
const flows = takenFlows(app);
|
|
326
330
|
assert.ok(
|
|
327
|
-
flows.includes("
|
|
328
|
-
`abandon
|
|
331
|
+
flows.includes("ic_gw_answer->ic_end"),
|
|
332
|
+
`abandon took the cell's default abandon flow to the task-done end (flows: ${flows.join(", ")})`,
|
|
333
|
+
);
|
|
334
|
+
assert.ok(!flows.includes("ic_gw_answer->record-implementing"), "the answer loop was NOT taken");
|
|
335
|
+
// The abandoned cell completes and the parent routes to its terminal recorder exactly once.
|
|
336
|
+
assert.ok(
|
|
337
|
+
flows.includes("implement->record-feature"),
|
|
338
|
+
`the completed cell routes the parent to record-feature (flows: ${flows.join(", ")})`,
|
|
329
339
|
);
|
|
330
|
-
assert.ok(!flows.includes("w_gw_answer->record-feature-implementing"), "the answer loop was NOT taken");
|
|
331
340
|
},
|
|
332
341
|
);
|
|
333
342
|
});
|
|
@@ -345,16 +354,19 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
345
354
|
},
|
|
346
355
|
{ baseBranch: "epic/e2e" },
|
|
347
356
|
async ({ app, featureKey, processKey }) => {
|
|
348
|
-
// The `record-
|
|
357
|
+
// The cell's `record-escalation` service task runs on the escalated arm (before the human task),
|
|
349
358
|
// so the row already carries the flipped status when the run parks, and the agent's question is
|
|
350
359
|
// recorded in the `feature_escalations` audit log the poller reads (issue #332 dropped the
|
|
351
360
|
// denormalised `feature_runs.escalation_question` column).
|
|
352
361
|
const parked = await featureRow(app, featureKey);
|
|
353
362
|
assert.equal(parked.status, "escalated", "the escalated status is surfaced on the read model");
|
|
354
363
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
364
|
+
// The escalation parks on a `human-escalation` grandchild instance (ADR 0006 S4), so it is found
|
|
365
|
+
// root-scoped, by the cell's `escalation` element — the reduced-path poller correlates that
|
|
366
|
+
// grandchild task back to the feature subject via its rootProcessInstanceKey (real child execution).
|
|
367
|
+
const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
|
|
368
|
+
const task = tasks.find((t) => t.elementId === "escalation") as InboxTask | undefined;
|
|
369
|
+
assert.ok(task?.userTaskKey, "the human-escalation cell parked a completable native user task on a child instance");
|
|
358
370
|
|
|
359
371
|
// The poller projects the parked task onto the Tasks inbox `user_tasks` read-model by reading
|
|
360
372
|
// the engine directly, sourcing the question from the `feature_escalations` audit log.
|
|
@@ -376,9 +388,9 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
376
388
|
|
|
377
389
|
const flows = takenFlows(app);
|
|
378
390
|
assert.ok(
|
|
379
|
-
flows.includes("
|
|
380
|
-
flows.includes("record-
|
|
381
|
-
`the answer re-dispatched the same implement task through the reset (flows: ${flows.join(", ")})`,
|
|
391
|
+
flows.includes("ic_gw_answer->record-implementing") &&
|
|
392
|
+
flows.includes("record-implementing->implement-task"),
|
|
393
|
+
`the answer re-dispatched the same implement task through the cell's reset (flows: ${flows.join(", ")})`,
|
|
382
394
|
);
|
|
383
395
|
assert.equal(calls, 2, "the implementation agent was re-dispatched exactly once after the answer");
|
|
384
396
|
|