@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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Drift guard for the Tasks-inbox closed sets (issue #674 — fix the class, not the bug).
|
|
2
|
+
//
|
|
3
|
+
// Root cause of #674: the readiness/preflight escalation user tasks (`readiness-escalation-pf`,
|
|
4
|
+
// `readiness-escalation`) were deployed BPMN `<bpmn:userTask>`s with a linked `.form`, but their
|
|
5
|
+
// element ids were never added to the app-tier closed sets that gate surfacing (`USER_TASK_KIND_LABELS`,
|
|
6
|
+
// app/userTasks.ts) and completion (`HUMAN_COMPLETABLE_ELEMENTS` / `ESCALATION_FORM_BY_ELEMENT`,
|
|
7
|
+
// app/agentCompletion.ts). The poller's leak guard (`userTaskKindLabel(id) === undefined`) silently
|
|
8
|
+
// DROPPED every such task, so a parked run went invisible in the Tasks surface AND uncompletable
|
|
9
|
+
// through the one canonical `complete-user-task` door.
|
|
10
|
+
//
|
|
11
|
+
// This test makes that drift structurally impossible: it enumerates EVERY human `<bpmn:userTask>`
|
|
12
|
+
// (bearing a `<zeebe:userTask />`) in the deployed processes (`resources/processes/*.bpmn`) and asserts
|
|
13
|
+
// each element id is a KNOWN Tasks-inbox kind, and — where it declares a static `zeebe:formDefinition`
|
|
14
|
+
// form and is completable — that the completer's form contract resolves to the SAME `.form` the BPMN
|
|
15
|
+
// declares. So a future BPMN user task can never again vanish from the inbox by being absent from the
|
|
16
|
+
// hand-maintained closed set: the closed set's completeness is DERIVED from the deployed BPMN, not
|
|
17
|
+
// asserted blind.
|
|
18
|
+
//
|
|
19
|
+
// On `main` (before #674's registration) this test FAILS on `readiness-escalation-pf` /
|
|
20
|
+
// `readiness-escalation`; it passes once both are registered.
|
|
21
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
22
|
+
import { test } from "node:test";
|
|
23
|
+
import { assert, assertEquals } from "#test-assert";
|
|
24
|
+
import { escalationFormId, HUMAN_COMPLETABLE_ELEMENTS } from "./agentCompletion.ts";
|
|
25
|
+
import { isDeliveryHumanElement } from "./deliveryHuman.ts";
|
|
26
|
+
import { userTaskKindLabel } from "./userTasks.ts";
|
|
27
|
+
|
|
28
|
+
const PROCESS_DIR = "resources/processes";
|
|
29
|
+
|
|
30
|
+
/** Demo/fixture processes that are deployed for the engine-spine e2e (`e2e/user-task-spine.e2e.ts`)
|
|
31
|
+
* but are NOT part of the workforce escalation surface — their user tasks are deliberately not
|
|
32
|
+
* Tasks-inbox kinds. Kept as a tiny, explicitly-documented file allowlist (not a per-id one) so a new
|
|
33
|
+
* REAL escalation process is still fully guarded. */
|
|
34
|
+
const DEMO_PROCESS_FILES: ReadonlySet<string> = new Set(["spine-demo.bpmn"]);
|
|
35
|
+
|
|
36
|
+
interface DeployedUserTask {
|
|
37
|
+
file: string;
|
|
38
|
+
elementId: string;
|
|
39
|
+
/** The `zeebe:formDefinition formId`, when the task declares a static form. */
|
|
40
|
+
formId: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Parse every human `<bpmn:userTask>` (one bearing a `<zeebe:userTask />`, i.e. a native user task an
|
|
44
|
+
* operator answers — not a job-worker task) out of the deployed process BPMN. Text parsing, matching
|
|
45
|
+
* the repo's lightweight model-guard style (convergenceEscalationGuard.test.ts et al.). */
|
|
46
|
+
function deployedHumanUserTasks(): DeployedUserTask[] {
|
|
47
|
+
const out: DeployedUserTask[] = [];
|
|
48
|
+
for (const file of readdirSync(PROCESS_DIR).filter((f) => f.endsWith(".bpmn"))) {
|
|
49
|
+
if (DEMO_PROCESS_FILES.has(file)) continue;
|
|
50
|
+
const xml = readFileSync(`${PROCESS_DIR}/${file}`, "utf8");
|
|
51
|
+
for (const m of xml.matchAll(/<bpmn:userTask\b[^>]*\bid="([^"]+)"([\s\S]*?)<\/bpmn:userTask>/g)) {
|
|
52
|
+
const [, elementId, body] = m;
|
|
53
|
+
if (!/<zeebe:userTask\b/.test(body)) continue; // not a native human user task (no <zeebe:userTask/>)
|
|
54
|
+
const form = body.match(/<zeebe:formDefinition\b[^>]*\bformId="([^"]+)"/);
|
|
55
|
+
out.push({ file, elementId, formId: form ? form[1] : null });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test("drift guard: every deployed human user task is a known Tasks-inbox kind (issue #674)", () => {
|
|
62
|
+
const tasks = deployedHumanUserTasks();
|
|
63
|
+
// Sanity: the sweep actually found the deployed escalations (guards against a parser that silently
|
|
64
|
+
// matches nothing and vacuously passes).
|
|
65
|
+
assert(tasks.length > 0, `expected the process sweep to find the deployed user tasks, got ${tasks.length}`);
|
|
66
|
+
assert(
|
|
67
|
+
tasks.some((t) => t.elementId === "readiness-escalation-pf"),
|
|
68
|
+
"expected readiness-escalation-pf among the deployed user tasks",
|
|
69
|
+
);
|
|
70
|
+
assert(
|
|
71
|
+
tasks.some((t) => t.elementId === "readiness-escalation"),
|
|
72
|
+
"expected readiness-escalation among the deployed user tasks",
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const unsurfaced = tasks.filter((t) => userTaskKindLabel(t.elementId) === undefined);
|
|
76
|
+
assertEquals(
|
|
77
|
+
unsurfaced.map((t) => `${t.file}:${t.elementId}`),
|
|
78
|
+
[],
|
|
79
|
+
"deployed user task(s) are not registered in USER_TASK_KIND_LABELS — pollUserTasks' leak guard would drop them from the Tasks inbox (issue #674)",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("drift guard: every deployed fixed-form user task is human-completable through the canonical door (issue #674)", () => {
|
|
84
|
+
// The delivery-graph `human` node renders DIFFERENT forms per node (variable form, resolved at
|
|
85
|
+
// activation), so it is intentionally absent from the static `ESCALATION_FORM_BY_ELEMENT` contract —
|
|
86
|
+
// exclude it from the fixed-form assertion (it is still asserted to be a KNOWN kind above).
|
|
87
|
+
const fixedForm = deployedHumanUserTasks().filter((t) => t.formId && !isDeliveryHumanElement(t.elementId));
|
|
88
|
+
|
|
89
|
+
const notCompletable = fixedForm.filter((t) => !HUMAN_COMPLETABLE_ELEMENTS.has(t.elementId));
|
|
90
|
+
assertEquals(
|
|
91
|
+
notCompletable.map((t) => `${t.file}:${t.elementId}`),
|
|
92
|
+
[],
|
|
93
|
+
"deployed fixed-form user task(s) are not in HUMAN_COMPLETABLE_ELEMENTS — the canonical complete-user-task door would reject them",
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const formMismatch = fixedForm.filter((t) => escalationFormId(t.elementId) !== t.formId);
|
|
97
|
+
assertEquals(
|
|
98
|
+
formMismatch.map((t) => `${t.file}:${t.elementId} bpmn=${t.formId} app=${escalationFormId(t.elementId) ?? "undefined"}`),
|
|
99
|
+
[],
|
|
100
|
+
"deployed user task(s) map to a different .form contract in ESCALATION_FORM_BY_ELEMENT than the BPMN declares",
|
|
101
|
+
);
|
|
102
|
+
});
|
package/app/userTasks.ts
CHANGED
|
@@ -72,6 +72,24 @@ export const ACP_PERMISSION_ELEMENT = "acp-permission";
|
|
|
72
72
|
* escalation is filtered out by the unknown-kind guard and silently vanishes from the Tasks inbox. */
|
|
73
73
|
export const HUMAN_ESCALATION_ELEMENT = "escalation";
|
|
74
74
|
|
|
75
|
+
/** The readiness/preflight escalation user task parked when a LEADING readiness/capability PREFLIGHT
|
|
76
|
+
* times out before its `ReadinessProbe` went green — a human decision (acknowledge/proceed vs abandon).
|
|
77
|
+
* This is the `pf_*` embedded-subprocess variant that appears both in `feature.bpmn` (the feature-run
|
|
78
|
+
* readiness preflight) and `plan-fanout.bpmn` (the producer-capability preflight). It parks on the
|
|
79
|
+
* run's OWN engine instance (an embedded subprocess, not a callActivity child), renders the deployed
|
|
80
|
+
* `readiness-escalation` `.form`, and is HUMAN-only: an agent must NOT auto-answer a readiness gate
|
|
81
|
+
* (that would silently defeat the very "is upstream actually ready?" decision the gate exists to make),
|
|
82
|
+
* so it lives in `HUMAN_COMPLETABLE_ELEMENTS`, never in `ESCALATION_TASK_ELEMENTS`. Without this the
|
|
83
|
+
* task's kind is `undefined`, the poller's leak guard drops it, and the run wedges invisibly + unanswerably
|
|
84
|
+
* (issue #674). */
|
|
85
|
+
export const READINESS_ESCALATION_PF_ELEMENT = "readiness-escalation-pf";
|
|
86
|
+
|
|
87
|
+
/** The readiness/wait-gate escalation user task (`readiness-gate.bpmn`, `wait-gate.bpmn`) — the same
|
|
88
|
+
* human readiness decision on the standalone inter-epic wait-gate cell rather than the inline preflight.
|
|
89
|
+
* Same `readiness-escalation` `.form`, same HUMAN-only policy as `READINESS_ESCALATION_PF_ELEMENT`
|
|
90
|
+
* (issue #674). */
|
|
91
|
+
export const READINESS_ESCALATION_ELEMENT = "readiness-escalation";
|
|
92
|
+
|
|
75
93
|
/** One row per currently-open native user-task escalation, denormalised for the Tasks page. Keyed on
|
|
76
94
|
* the completable `user_task_key` (a task is open at most once). Present iff the engine reports the
|
|
77
95
|
* task open; `pollUserTasks` deletes it once the task is gone. */
|
|
@@ -186,6 +204,8 @@ export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
|
|
|
186
204
|
[PR_WAIT_ANSWER_ELEMENT]: "PR review",
|
|
187
205
|
[PR_WAIT_MERGE_ANSWER_ELEMENT]: "PR merge",
|
|
188
206
|
[CONFORMANCE_ESCALATION_ELEMENT]: "Conformance review",
|
|
207
|
+
[READINESS_ESCALATION_PF_ELEMENT]: "Upstream readiness stalled",
|
|
208
|
+
[READINESS_ESCALATION_ELEMENT]: "Readiness escalation",
|
|
189
209
|
[DELIVERY_HUMAN_ELEMENT]: "Delivery: human step",
|
|
190
210
|
[ACP_PERMISSION_ELEMENT]: "Agent permission",
|
|
191
211
|
};
|
|
@@ -342,8 +362,21 @@ export function latestTrialMergeQuestion(audits: readonly TrialMergeAuditRow[]):
|
|
|
342
362
|
return latest?.summary ?? null;
|
|
343
363
|
}
|
|
344
364
|
|
|
345
|
-
/**
|
|
346
|
-
*
|
|
365
|
+
/** Pure: the "what is the run waiting on" question line for a readiness/preflight escalation
|
|
366
|
+
* (`readiness-escalation-pf` / `readiness-escalation`, issue #674) — the unresolved probe/capability
|
|
367
|
+
* the leading readiness gate stalled on. The wait-gate projection already renders that clause on the
|
|
368
|
+
* subject row: a plan gated behind a producer capability carries it on `plans.wait_gate_label`
|
|
369
|
+
* ("waiting on <clause> · re-checks …", app/waitGate.ts), and a feature preflight carries its rollup
|
|
370
|
+
* on `feature_runs.delivery_label`. Prefer whichever the subject supplies; fall back to a static
|
|
371
|
+
* readiness-stalled line so the Tasks grid never renders a blank question for a parked gate.
|
|
372
|
+
* Always returns a non-empty string — the static fallback guarantees a question is never blank. */
|
|
373
|
+
export function readinessEscalationQuestion(label?: string | null): string {
|
|
374
|
+
const t = typeof label === "string" ? label.trim() : "";
|
|
375
|
+
return (
|
|
376
|
+
t ||
|
|
377
|
+
"The readiness preflight timed out before its ReadinessProbe went green. Proceed against the (now-published) upstream, or abandon the gate."
|
|
378
|
+
);
|
|
379
|
+
}
|
|
347
380
|
export interface PrEscalationRow {
|
|
348
381
|
id: number;
|
|
349
382
|
pr_key: string;
|
|
@@ -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
|
|
|
@@ -108,7 +108,10 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
async function openTask(app: TestApp, processKey: string, elementId: string): Promise<InboxTask> {
|
|
111
|
-
|
|
111
|
+
// Root-scoped: the implement-stage escalation now parks on a `human-escalation` grandchild the
|
|
112
|
+
// shared `implement-cell` spawns (ADR 0006 S4); plan-level tasks (plan-review, trial-merge) still
|
|
113
|
+
// park on the root instance and remain discoverable under a root-scoped search (superset).
|
|
114
|
+
const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
|
|
112
115
|
const match = tasks.find((t) => t.elementId === elementId);
|
|
113
116
|
assert.ok(match, `expected an open ${elementId} user task (open: ${tasks.map((t) => t.elementId).join(", ")})`);
|
|
114
117
|
return match!;
|
|
@@ -118,7 +121,7 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
|
|
|
118
121
|
* filter) and NOT by a bogus group — proving `zeebe:assignmentDefinition candidateGroups` took. */
|
|
119
122
|
async function assertAssignmentFilterable(app: TestApp, processKey: string, elementId: string) {
|
|
120
123
|
const byOperators = await app.engine.searchUserTasks({
|
|
121
|
-
|
|
124
|
+
rootProcessInstanceKey: processKey,
|
|
122
125
|
candidateGroup: "operators",
|
|
123
126
|
});
|
|
124
127
|
assert.ok(
|
|
@@ -126,7 +129,7 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
|
|
|
126
129
|
`${elementId} is filterable by the operators candidate group`,
|
|
127
130
|
);
|
|
128
131
|
const byBogus = await app.engine.searchUserTasks({
|
|
129
|
-
|
|
132
|
+
rootProcessInstanceKey: processKey,
|
|
130
133
|
candidateGroup: "nobody-here",
|
|
131
134
|
});
|
|
132
135
|
assert.ok(
|
|
@@ -150,20 +153,21 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
|
|
|
150
153
|
}),
|
|
151
154
|
},
|
|
152
155
|
async ({ app, processKey }) => {
|
|
153
|
-
await openTask(app, processKey, "
|
|
154
|
-
await assertAssignmentFilterable(app, processKey, "
|
|
156
|
+
await openTask(app, processKey, "escalation");
|
|
157
|
+
await assertAssignmentFilterable(app, processKey, "escalation");
|
|
155
158
|
|
|
156
|
-
// Never answer — let the SLA elapse. The interrupting boundary
|
|
157
|
-
//
|
|
159
|
+
// Never answer — let the SLA elapse. The interrupting boundary (on the human-escalation cell's
|
|
160
|
+
// `escalation` task) cancels the parked task and routes to its SLA auto-abandon end; the cell
|
|
161
|
+
// then takes its abandon default (the safe auto-abandon of the slice).
|
|
158
162
|
await advancePastTimer(app, PAST_SLA_MS);
|
|
159
163
|
|
|
160
164
|
const flows = takenFlows(app);
|
|
161
165
|
assert.ok(
|
|
162
|
-
flows.includes("
|
|
163
|
-
`the SLA boundary auto-abandoned to the
|
|
166
|
+
flows.includes("be_he_sla->he_end_sla"),
|
|
167
|
+
`the SLA boundary auto-abandoned to the human-escalation cell's SLA end (flows: ${flows.join(", ")})`,
|
|
164
168
|
);
|
|
165
169
|
assert.ok(
|
|
166
|
-
!flows.includes("
|
|
170
|
+
!flows.includes("ic_gw_answer->record-implementing"),
|
|
167
171
|
"the human answer loop was NOT taken",
|
|
168
172
|
);
|
|
169
173
|
},
|
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -106,7 +106,11 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
async function openTask(app: TestApp, processKey: string, elementId: string): Promise<InboxTask> {
|
|
109
|
-
|
|
109
|
+
// Root-scoped: the implement-stage escalation now parks on a `human-escalation` grandchild the
|
|
110
|
+
// shared `implement-cell` spawns (ADR 0006 S4); plan-level tasks (plan-review, empty-plan,
|
|
111
|
+
// trial-merge, the caps-timeout `feature-escalation`) still park on the root and remain
|
|
112
|
+
// discoverable under a root-scoped search (a superset of the parent's own tasks).
|
|
113
|
+
const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: processKey });
|
|
110
114
|
const match = tasks.find((t) => t.elementId === elementId);
|
|
111
115
|
assert.ok(match, `expected an open ${elementId} user task (open: ${tasks.map((t) => t.elementId).join(", ")})`);
|
|
112
116
|
return match!;
|
|
@@ -131,20 +135,20 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
131
135
|
},
|
|
132
136
|
},
|
|
133
137
|
async ({ app, processKey }) => {
|
|
134
|
-
const task = await openTask(app, processKey, "
|
|
135
|
-
assert.ok(task.userTaskKey, "the
|
|
138
|
+
const task = await openTask(app, processKey, "escalation");
|
|
139
|
+
assert.ok(task.userTaskKey, "the human-escalation cell task carries a completable userTaskKey");
|
|
136
140
|
|
|
137
|
-
// Answer it: the typed resolution loops the
|
|
141
|
+
// Answer it: the typed resolution loops the cell back to re-dispatch the SAME task.
|
|
138
142
|
await app.engine.completeUserTask(task.userTaskKey, { resolution: "answer", answer: "use v2" });
|
|
139
143
|
await app.settle();
|
|
140
144
|
|
|
141
145
|
const flows = takenFlows(app);
|
|
142
146
|
assert.ok(
|
|
143
|
-
flows.includes("
|
|
144
|
-
`answer routed back to implement-task (flows: ${flows.join(", ")})`,
|
|
147
|
+
flows.includes("ic_gw_answer->record-implementing") && flows.includes("record-implementing->implement-task"),
|
|
148
|
+
`answer routed back to implement-task through the cell reset (flows: ${flows.join(", ")})`,
|
|
145
149
|
);
|
|
146
150
|
assert.ok(
|
|
147
|
-
!flows.includes("
|
|
151
|
+
!flows.includes("ic_gw_answer->ic_end"),
|
|
148
152
|
"the abandon (default) flow was NOT taken",
|
|
149
153
|
);
|
|
150
154
|
},
|
|
@@ -163,17 +167,17 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
163
167
|
}),
|
|
164
168
|
},
|
|
165
169
|
async ({ app, processKey }) => {
|
|
166
|
-
const task = await openTask(app, processKey, "
|
|
170
|
+
const task = await openTask(app, processKey, "escalation");
|
|
167
171
|
await app.engine.completeUserTask(task.userTaskKey, { resolution: "abandon" });
|
|
168
172
|
await app.settle();
|
|
169
173
|
|
|
170
174
|
const flows = takenFlows(app);
|
|
171
175
|
assert.ok(
|
|
172
|
-
flows.includes("
|
|
173
|
-
`abandon routed to the task-done end (flows: ${flows.join(", ")})`,
|
|
176
|
+
flows.includes("ic_gw_answer->ic_end"),
|
|
177
|
+
`abandon routed to the cell's task-done end (flows: ${flows.join(", ")})`,
|
|
174
178
|
);
|
|
175
179
|
assert.ok(
|
|
176
|
-
!flows.includes("
|
|
180
|
+
!flows.includes("ic_gw_answer->record-implementing"),
|
|
177
181
|
"the answer loop was NOT taken",
|
|
178
182
|
);
|
|
179
183
|
},
|
|
@@ -454,7 +458,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
454
458
|
`task with needs routed through the barrier gateway to wait-caps-resolved (flows: ${parked.join(", ")})`,
|
|
455
459
|
);
|
|
456
460
|
assert.ok(
|
|
457
|
-
!parked.includes("w_gw_needs->implement-
|
|
461
|
+
!parked.includes("w_gw_needs->implement-cell-call"),
|
|
458
462
|
"the no-needs shortcut was NOT taken for a task that declares needs",
|
|
459
463
|
);
|
|
460
464
|
assert.equal(featureBrief, undefined, "the agent has not been dispatched while parked at the barrier");
|
|
@@ -471,8 +475,8 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
471
475
|
|
|
472
476
|
const flows = takenFlows(app);
|
|
473
477
|
assert.ok(
|
|
474
|
-
flows.includes("wait-caps-resolved->implement-task"),
|
|
475
|
-
`caps-resolved released the barrier into implement
|
|
478
|
+
flows.includes("wait-caps-resolved->implement-cell-call") && flows.includes("Start->implement-task"),
|
|
479
|
+
`caps-resolved released the barrier into the implement cell (flows: ${flows.join(", ")})`,
|
|
476
480
|
);
|
|
477
481
|
assert.equal(
|
|
478
482
|
featureBrief,
|
|
@@ -534,15 +538,16 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
534
538
|
`the caps bound escalated the parked task to the operator (flows: ${flows.join(", ")})`,
|
|
535
539
|
);
|
|
536
540
|
assert.ok(
|
|
537
|
-
!flows.includes("wait-caps-resolved->implement-
|
|
541
|
+
!flows.includes("wait-caps-resolved->implement-cell-call"),
|
|
538
542
|
"the resolve arm was withdrawn — the token did not also proceed as if resolved",
|
|
539
543
|
);
|
|
540
544
|
assert.equal(featureRan, false, "the agent was NOT dispatched — the unresolved task escalated instead");
|
|
541
545
|
|
|
542
|
-
// The escalation is a genuine, operable operator decision point: answering it
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
+
// The escalation is a genuine, operable operator decision point: answering it releases the
|
|
547
|
+
// caps barrier into the implement cell (the operator having unblocked/decided), proving this is
|
|
548
|
+
// a real bounded wait + operator escalation, not a dead end. This is the plan-fanout's OWN
|
|
549
|
+
// caps-timeout `feature-escalation` task (the fan-out-specific barrier is surrounding
|
|
550
|
+
// orchestration, retained per ADR 0006 S4), distinct from the implement cell's `escalation`.
|
|
546
551
|
const task = await openTask(app, processKey, "feature-escalation");
|
|
547
552
|
assert.ok(task.userTaskKey, "the caps-timeout escalation carries a completable userTaskKey");
|
|
548
553
|
await app.engine.completeUserTask(task.userTaskKey, { resolution: "answer", answer: "shipped it manually" });
|
|
@@ -550,8 +555,8 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
550
555
|
|
|
551
556
|
const answered = takenFlows(app);
|
|
552
557
|
assert.ok(
|
|
553
|
-
answered.includes("w_gw_answer->implement-task"),
|
|
554
|
-
`answering the caps escalation
|
|
558
|
+
answered.includes("w_gw_answer->implement-cell-call") && answered.includes("Start->implement-task"),
|
|
559
|
+
`answering the caps escalation released the barrier into the implement cell (flows: ${answered.join(", ")})`,
|
|
555
560
|
);
|
|
556
561
|
assert.equal(featureRan, true, "the agent was dispatched once the operator answered the caps escalation");
|
|
557
562
|
},
|
package/nano.app.json
CHANGED
|
@@ -172,10 +172,6 @@
|
|
|
172
172
|
"taskType": "pr.record-wave",
|
|
173
173
|
"handler": "workers/record-wave/worker.ts"
|
|
174
174
|
},
|
|
175
|
-
{
|
|
176
|
-
"taskType": "pr.record-wave-escalation",
|
|
177
|
-
"handler": "workers/record-wave-escalation/worker.ts"
|
|
178
|
-
},
|
|
179
175
|
{
|
|
180
176
|
"taskType": "pr.record-feature",
|
|
181
177
|
"handler": "workers/record-feature/worker.ts"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.171.
|
|
3
|
+
"version": "0.171.3",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|
|
@@ -63,12 +63,12 @@
|
|
|
63
63
|
},
|
|
64
64
|
"dependencies": {
|
|
65
65
|
"@nanobpm/agentic": "^0.10.0",
|
|
66
|
-
"@nanobpm/urban": "^0.
|
|
66
|
+
"@nanobpm/urban": "^0.90.0",
|
|
67
67
|
"bpmn-auto-layout": "^2.0.0-alpha.2"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
70
|
"@biomejs/biome": "^2.4.11",
|
|
71
|
-
"@nanobpm/urban-testkit": "^1.0
|
|
71
|
+
"@nanobpm/urban-testkit": "^1.2.0",
|
|
72
72
|
"@nanobpm/workflow": "^0.14.0",
|
|
73
73
|
"@semantic-release/changelog": "^7.0.0",
|
|
74
74
|
"@semantic-release/git": "^11.0.0",
|