@nanobpm/nano-workforce 0.171.1 → 0.171.2
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/agentCompletion.test.ts +32 -0
- package/app/agentCompletion.ts +15 -1
- package/app/pollUserTasks.test.ts +63 -0
- package/app/service.ts +18 -2
- package/app/userTaskInboxDriftGuard.test.ts +102 -0
- package/app/userTasks.ts +35 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.171.2](https://github.com/nanobpm/nano-workforce/compare/v0.171.1...v0.171.2) (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* 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)
|
|
6
|
+
|
|
1
7
|
## [0.171.1](https://github.com/nanobpm/nano-workforce/compare/v0.171.0...v0.171.1) (2026-08-31)
|
|
2
8
|
|
|
3
9
|
### 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`
|
|
@@ -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
|
};
|
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.171.
|
|
3
|
+
"version": "0.171.2",
|
|
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",
|