@nanobpm/nano-workforce 0.99.0 → 0.100.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/app/agentCompletion.test.ts +92 -6
- package/app/agentCompletion.ts +86 -58
- package/app/convergeGate.test.ts +8 -4
- package/app/convergenceEscalationGuard.test.ts +106 -0
- package/app/feature.ts +15 -166
- package/app/featureGateway.test.ts +6 -57
- package/app/persist-escalation.test.ts +32 -0
- package/app/pollUserTasks.test.ts +39 -13
- package/app/roundProgress.test.ts +7 -4
- package/app/service.ts +60 -132
- package/app/stage.test.ts +7 -53
- package/app/stage.ts +5 -37
- package/app/userTasks.test.ts +1 -1
- package/app/userTasks.ts +3 -3
- package/db/migrations/049_drop_feature_escalation_surface.sql +25 -0
- package/e2e/feature-run.e2e.ts +52 -41
- package/e2e/retire-escalation-subsystem.e2e.ts +26 -0
- package/openapi.yaml +7 -108
- package/operations/agentCompleteEscalation.ts +2 -2
- package/operations/completeUserTask.test.ts +25 -6
- package/operations/completeUserTask.ts +12 -10
- package/package.json +2 -2
- package/pages/feature.page.json +1 -37
- package/pages/overview.page.json +1 -39
- package/pages/tasks.page.json +78 -93
- package/resources/forms/feature-escalation.form +3 -0
- package/resources/processes/convergence-loop.bpmn +114 -118
- package/workers/persist-escalation/worker.ts +7 -4
- package/workers/record-blocked-ack/worker.test.ts +1 -4
- package/workers/record-blocked-ack/worker.ts +0 -5
- package/workers/record-feature/worker.ts +0 -6
- package/workers/record-feature-escalation/worker.test.ts +14 -27
- package/workers/record-feature-escalation/worker.ts +16 -22
- package/app/featureBlocked.test.ts +0 -182
- package/app/featureEscalation.test.ts +0 -235
- package/operations/acknowledgeBlocked.test.ts +0 -111
- package/operations/acknowledgeBlocked.ts +0 -62
- package/operations/answerFeatureEscalation.ts +0 -68
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# [0.100.0](https://github.com/nanobpm/nano-workforce/compare/v0.99.1...v0.100.0) (2026-08-19)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* retire interim feature-run escalation/blocked surface ([#339](https://github.com/nanobpm/nano-workforce/issues/339)) ([525d0f7](https://github.com/nanobpm/nano-workforce/commit/525d0f7a7f9661f640899e4bdf1b32052cff4ed9)), closes [#305](https://github.com/nanobpm/nano-workforce/issues/305) [#310](https://github.com/nanobpm/nano-workforce/issues/310) [#332](https://github.com/nanobpm/nano-workforce/issues/332) [#332](https://github.com/nanobpm/nano-workforce/issues/332)
|
|
7
|
+
|
|
8
|
+
## [0.99.1](https://github.com/nanobpm/nano-workforce/compare/v0.99.0...v0.99.1) (2026-08-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **convergence-loop:** route every escalation arm through gw-escalated ([#333](https://github.com/nanobpm/nano-workforce/issues/333)) ([#340](https://github.com/nanobpm/nano-workforce/issues/340)) ([0ad7a5c](https://github.com/nanobpm/nano-workforce/commit/0ad7a5c08a659a0ca26dc48def8c024d21a3a53a)), closes [#329](https://github.com/nanobpm/nano-workforce/issues/329)
|
|
14
|
+
|
|
1
15
|
# [0.99.0](https://github.com/nanobpm/nano-workforce/compare/v0.98.1...v0.99.0) (2026-08-19)
|
|
2
16
|
|
|
3
17
|
|
|
@@ -62,11 +62,15 @@ function memData(stores: Record<string, { rows: any[]; key: string }>) {
|
|
|
62
62
|
} as any;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
/** A stub engine recording every `completeUserTask`, with a seeded set of open user tasks.
|
|
65
|
+
/** A stub engine recording every `completeUserTask`, with a seeded set of open user tasks. The
|
|
66
|
+
* completers resolve tasks via `openUserTasks` (CREATED only), so the fixture exposes it; a legacy
|
|
67
|
+
* `searchUserTasks` (ANY state) is also present to prove the completer does NOT reach for it. */
|
|
66
68
|
function fakeEngine(openTasks: Array<{ userTaskKey: string; elementId?: string }>) {
|
|
67
69
|
const completed: Array<{ userTaskKey: string; variables?: Record<string, unknown> }> = [];
|
|
68
70
|
const engine = {
|
|
69
|
-
|
|
71
|
+
openUserTasks: (_filter?: Record<string, unknown>) => Promise.resolve(openTasks),
|
|
72
|
+
searchUserTasks: (_filter?: Record<string, unknown>) =>
|
|
73
|
+
Promise.reject(new Error("completer must resolve via openUserTasks, not searchUserTasks")),
|
|
70
74
|
completeUserTask: (userTaskKey: string, variables?: Record<string, unknown>) => {
|
|
71
75
|
completed.push({ userTaskKey, variables });
|
|
72
76
|
return Promise.resolve();
|
|
@@ -118,7 +122,7 @@ test("agent completer refuses a non-escalation user task (scoped to the migrated
|
|
|
118
122
|
});
|
|
119
123
|
|
|
120
124
|
assertEquals(r.ok, false);
|
|
121
|
-
assertEquals(r.reason, "not
|
|
125
|
+
assertEquals(r.reason, "not a completable task");
|
|
122
126
|
assertEquals(completed.length, 0, "a non-escalation task is never completed");
|
|
123
127
|
assertEquals(stores.task_completions.rows.length, 0, "and no attribution row is written");
|
|
124
128
|
});
|
|
@@ -137,10 +141,40 @@ test("agent completer is a no-op for an unknown userTaskKey", async () => {
|
|
|
137
141
|
});
|
|
138
142
|
|
|
139
143
|
assertEquals(r.ok, false);
|
|
140
|
-
assertEquals(r.reason, "no open
|
|
144
|
+
assertEquals(r.reason, "no open completable task");
|
|
141
145
|
assertEquals(completed.length, 0);
|
|
142
146
|
});
|
|
143
147
|
|
|
148
|
+
test("completer resolves only OPEN tasks — a completed/canceled task's key is a 404-style no-op, not a doomed re-completion", async () => {
|
|
149
|
+
// A looping instance keeps COMPLETED/CANCELED user tasks alongside the live one. If the completer
|
|
150
|
+
// matched by key against ANY-state tasks (`searchUserTasks`), a stale key would drive a re-completion
|
|
151
|
+
// that the engine rejects with a 5xx instead of the intended "no open task" no-op. The completer must
|
|
152
|
+
// query `openUserTasks` (CREATED only), so a key that exists only as a non-open task does not match.
|
|
153
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
154
|
+
const data = memData(stores);
|
|
155
|
+
const completed: Array<{ userTaskKey: string }> = [];
|
|
156
|
+
const engine = {
|
|
157
|
+
// No open tasks…
|
|
158
|
+
openUserTasks: () => Promise.resolve([]),
|
|
159
|
+
// …even though a completed task with this key exists in the full (any-state) search.
|
|
160
|
+
searchUserTasks: () => Promise.resolve([{ userTaskKey: "ut-done", elementId: "feature-escalation" }]),
|
|
161
|
+
completeUserTask: (userTaskKey: string) => {
|
|
162
|
+
completed.push({ userTaskKey });
|
|
163
|
+
return Promise.resolve();
|
|
164
|
+
},
|
|
165
|
+
} as any;
|
|
166
|
+
|
|
167
|
+
const r = await completeEscalationAsHuman(data, engine, {
|
|
168
|
+
userTaskKey: "ut-done",
|
|
169
|
+
operatorId: "alice",
|
|
170
|
+
variables: { resolution: "abandon" },
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
assertEquals(r.ok, false);
|
|
174
|
+
assertEquals(r.reason, "no open completable task");
|
|
175
|
+
assertEquals(completed.length, 0, "a non-open key never drives a doomed re-completion");
|
|
176
|
+
});
|
|
177
|
+
|
|
144
178
|
test("a HUMAN operator completes a feature escalation via the SAME attributed resume path (issue #210)", async () => {
|
|
145
179
|
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
146
180
|
const data = memData(stores);
|
|
@@ -166,6 +200,35 @@ test("a HUMAN operator completes a feature escalation via the SAME attributed re
|
|
|
166
200
|
assertEquals(row.reversible, 0, "a human completion is the authority (not reversible)");
|
|
167
201
|
});
|
|
168
202
|
|
|
203
|
+
test("feature-blocked is HUMAN-completable but NOT agent-completable (issue #332)", async () => {
|
|
204
|
+
// Issue #332 folded the bespoke `acknowledge-blocked` door onto the canonical human completer, so a
|
|
205
|
+
// HUMAN operator retires a blocked run through `completeEscalationAsHuman`. It stays OUTSIDE the agent
|
|
206
|
+
// surface (`ESCALATION_TASK_ELEMENTS`) — an agent must never acknowledge a blocked run on a human's
|
|
207
|
+
// behalf — so the agent completer refuses it.
|
|
208
|
+
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
209
|
+
const data = memData(stores);
|
|
210
|
+
const { engine, completed } = fakeEngine([{ userTaskKey: "ut-b", elementId: "feature-blocked" }]);
|
|
211
|
+
|
|
212
|
+
const asAgent = await completeEscalationAsAgent(data, engine, {
|
|
213
|
+
userTaskKey: "ut-b",
|
|
214
|
+
agentId: "bot",
|
|
215
|
+
variables: { note: "n" },
|
|
216
|
+
});
|
|
217
|
+
assertEquals(asAgent.ok, false, "the agent completer refuses feature-blocked");
|
|
218
|
+
assertEquals(asAgent.reason, "not a completable task");
|
|
219
|
+
assertEquals(completed.length, 0);
|
|
220
|
+
|
|
221
|
+
const asHuman = await completeEscalationAsHuman(data, engine, {
|
|
222
|
+
userTaskKey: "ut-b",
|
|
223
|
+
operatorId: "alice",
|
|
224
|
+
variables: { note: "reassigned to a human" },
|
|
225
|
+
});
|
|
226
|
+
assertEquals(asHuman.ok, true, "the human completer retires feature-blocked");
|
|
227
|
+
assertEquals(asHuman.elementId, "feature-blocked");
|
|
228
|
+
assertEquals(completed.length, 1);
|
|
229
|
+
assertEquals(completed[0].variables, { note: "reassigned to a human" });
|
|
230
|
+
});
|
|
231
|
+
|
|
169
232
|
test("human completer refuses a non-escalation user task and is a no-op for an unknown key", async () => {
|
|
170
233
|
const stores = { task_completions: { rows: [] as any[], key: "id" } };
|
|
171
234
|
const data = memData(stores);
|
|
@@ -177,7 +240,7 @@ test("human completer refuses a non-escalation user task and is a no-op for an u
|
|
|
177
240
|
variables: { resolution: "abandon" },
|
|
178
241
|
});
|
|
179
242
|
assertEquals(notEsc.ok, false);
|
|
180
|
-
assertEquals(notEsc.reason, "not
|
|
243
|
+
assertEquals(notEsc.reason, "not a completable task");
|
|
181
244
|
|
|
182
245
|
const missing = await completeEscalationAsHuman(data, engine, {
|
|
183
246
|
userTaskKey: "ut-missing",
|
|
@@ -185,7 +248,7 @@ test("human completer refuses a non-escalation user task and is a no-op for an u
|
|
|
185
248
|
variables: { resolution: "abandon" },
|
|
186
249
|
});
|
|
187
250
|
assertEquals(missing.ok, false);
|
|
188
|
-
assertEquals(missing.reason, "no open
|
|
251
|
+
assertEquals(missing.reason, "no open completable task");
|
|
189
252
|
|
|
190
253
|
assertEquals(completed.length, 0, "neither refusal completes a task");
|
|
191
254
|
assertEquals(stores.task_completions.rows.length, 0, "and no attribution row is written");
|
|
@@ -455,3 +518,26 @@ test("validateEscalationVariables derives its contract from the canonical .form
|
|
|
455
518
|
// an element with no linked form contract is not enforced
|
|
456
519
|
assertEquals(validateEscalationVariables("some-other-task", { whatever: 1 }), null);
|
|
457
520
|
});
|
|
521
|
+
|
|
522
|
+
test("feature-escalation demands non-blank answer on the answer path, but not on the hidden abandon path", async () => {
|
|
523
|
+
// resolution=answer shows the conditional `answer` field, which is required → blank/missing rejected.
|
|
524
|
+
assert(
|
|
525
|
+
validateEscalationVariables("feature-escalation", { resolution: "answer" }) !== null,
|
|
526
|
+
"resolution=answer with no guidance is rejected",
|
|
527
|
+
);
|
|
528
|
+
assert(
|
|
529
|
+
validateEscalationVariables("feature-escalation", { resolution: "answer", answer: " " }) !== null,
|
|
530
|
+
"resolution=answer with whitespace-only guidance is rejected",
|
|
531
|
+
);
|
|
532
|
+
assertEquals(
|
|
533
|
+
validateEscalationVariables("feature-escalation", { resolution: "answer", answer: "use v2" }),
|
|
534
|
+
null,
|
|
535
|
+
"resolution=answer with real guidance passes",
|
|
536
|
+
);
|
|
537
|
+
// resolution=abandon HIDES the `answer` field, so its required-ness is not enforced.
|
|
538
|
+
assertEquals(
|
|
539
|
+
validateEscalationVariables("feature-escalation", { resolution: "abandon" }),
|
|
540
|
+
null,
|
|
541
|
+
"the abandon path does not demand the hidden answer field",
|
|
542
|
+
);
|
|
543
|
+
});
|
package/app/agentCompletion.ts
CHANGED
|
@@ -71,6 +71,23 @@ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
|
|
|
71
71
|
"wait-merge-answer", // PR merge-loop escalation (merge-loop.bpmn) — same native user-task path (#256)
|
|
72
72
|
]);
|
|
73
73
|
|
|
74
|
+
/** The `feature-blocked` operator user-task element id (feature.bpmn) — the native wait a run parks on
|
|
75
|
+
* when the agent reports a `blocked` outcome. Unlike an escalation it is NOT agent-answerable (only a
|
|
76
|
+
* human operator retires a blocked run), so it lives OUTSIDE `ESCALATION_TASK_ELEMENTS` — the agent
|
|
77
|
+
* completer (`completeEscalationAsAgent`) must never touch it — but it IS a human-completable decision
|
|
78
|
+
* on the Tasks inbox, so the HUMAN completer accepts it (issue #332 folded the bespoke
|
|
79
|
+
* `acknowledge-blocked` door onto the one canonical `complete-user-task` door). */
|
|
80
|
+
export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
|
|
81
|
+
|
|
82
|
+
/** The user-task `elementId`s a HUMAN operator may complete from the Tasks inbox via the one canonical
|
|
83
|
+
* `complete-user-task` door: every agent-answerable escalation PLUS the human-only `feature-blocked`
|
|
84
|
+
* acknowledgement. The AGENT completer stays scoped to `ESCALATION_TASK_ELEMENTS` (no `feature-blocked`),
|
|
85
|
+
* so widening the human surface never lets an agent retire a blocked run. */
|
|
86
|
+
export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
|
|
87
|
+
...ESCALATION_TASK_ELEMENTS,
|
|
88
|
+
FEATURE_BLOCKED_TASK_ELEMENT,
|
|
89
|
+
]);
|
|
90
|
+
|
|
74
91
|
/** Each escalation `elementId` → the `.form` whose contract governs its completion variables (the
|
|
75
92
|
* BPMN `zeebe:formDefinition formId`). Kept beside `ESCALATION_TASK_ELEMENTS` so the completer
|
|
76
93
|
* validates against the SAME `.form` the task inbox renders — one contract, no second field list. */
|
|
@@ -80,36 +97,74 @@ const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
|
80
97
|
"trial-merge-decision": "trial-merge-decision",
|
|
81
98
|
"wait-answer": "pr-escalation",
|
|
82
99
|
"wait-merge-answer": "pr-escalation",
|
|
100
|
+
"feature-blocked": "feature-blocked",
|
|
83
101
|
};
|
|
84
102
|
|
|
103
|
+
/** A field's `conditional.hide` rule, parsed from the FEEL subset the `.form` files use
|
|
104
|
+
* (`=<ref> != "<value>"` / `=<ref> == "<value>"`). A required field is only enforced when it is
|
|
105
|
+
* actually shown, so an "answer only when resolution=answer" field is not demanded on the abandon
|
|
106
|
+
* path. */
|
|
107
|
+
interface HideRule {
|
|
108
|
+
ref: string;
|
|
109
|
+
op: "==" | "!=";
|
|
110
|
+
value: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
85
113
|
interface FormContract {
|
|
86
114
|
/** Field keys marked `validate.required` in the `.form`. */
|
|
87
115
|
required: string[];
|
|
88
116
|
/** `select` field key → its allowed `values`. */
|
|
89
117
|
allowed: Record<string, string[]>;
|
|
118
|
+
/** Field key → its `conditional.hide` rule (only for fields whose visibility is conditional). */
|
|
119
|
+
hideWhen: Record<string, HideRule>;
|
|
90
120
|
}
|
|
91
121
|
|
|
92
122
|
const formContractCache = new Map<string, FormContract>();
|
|
93
123
|
|
|
94
|
-
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
124
|
+
/** Parse the `.form` FEEL subset used by `conditional.hide` — `=<ref> (!=|==) "<value>"` — into a
|
|
125
|
+
* structured rule, or `null` for anything outside that subset (treated as "always shown", so a
|
|
126
|
+
* required field is never silently skipped by an unrecognised expression). */
|
|
127
|
+
function parseHide(expr: string | undefined): HideRule | null {
|
|
128
|
+
if (!expr) return null;
|
|
129
|
+
const m = /^=\s*([A-Za-z_$][\w$]*)\s*(==|!=)\s*"([^"]*)"\s*$/.exec(expr);
|
|
130
|
+
if (!m) return null;
|
|
131
|
+
const op = m[2] === "==" ? "==" : "!=";
|
|
132
|
+
return { ref: m[1], op, value: m[3] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Whether a `conditional.hide` rule hides its field given the submitted `variables`. */
|
|
136
|
+
function isHidden(rule: HideRule, variables: Record<string, unknown>): boolean {
|
|
137
|
+
const actual = String(variables[rule.ref] ?? "");
|
|
138
|
+
return rule.op === "!=" ? actual !== rule.value : actual === rule.value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Derive a `.form`'s required-field + select allowed-value + conditional-visibility contract, cached
|
|
142
|
+
* per formId. The `.form` files (`resources/forms/*.form`, deployed via nano.app.json) are the
|
|
143
|
+
* CANONICAL contract, so this reads them rather than re-encoding the field lists — no drift surface. */
|
|
97
144
|
function formContract(formId: string): FormContract {
|
|
98
145
|
const cached = formContractCache.get(formId);
|
|
99
146
|
if (cached) return cached;
|
|
100
147
|
const raw: {
|
|
101
|
-
components?: {
|
|
148
|
+
components?: {
|
|
149
|
+
key?: string;
|
|
150
|
+
validate?: { required?: boolean };
|
|
151
|
+
values?: { value?: string }[];
|
|
152
|
+
conditional?: { hide?: string };
|
|
153
|
+
}[];
|
|
102
154
|
} = JSON.parse(readFileSync(new URL(`../resources/forms/${formId}.form`, import.meta.url), "utf8"));
|
|
103
155
|
const required: string[] = [];
|
|
104
156
|
const allowed: Record<string, string[]> = {};
|
|
157
|
+
const hideWhen: Record<string, HideRule> = {};
|
|
105
158
|
for (const c of raw.components ?? []) {
|
|
106
159
|
if (!c.key) continue;
|
|
107
160
|
if (c.validate?.required) required.push(c.key);
|
|
108
161
|
if (c.values?.length) {
|
|
109
162
|
allowed[c.key] = c.values.map((v) => v.value ?? "").filter((v) => v !== "");
|
|
110
163
|
}
|
|
164
|
+
const hide = parseHide(c.conditional?.hide);
|
|
165
|
+
if (hide) hideWhen[c.key] = hide;
|
|
111
166
|
}
|
|
112
|
-
const contract: FormContract = { required, allowed };
|
|
167
|
+
const contract: FormContract = { required, allowed, hideWhen };
|
|
113
168
|
formContractCache.set(formId, contract);
|
|
114
169
|
return contract;
|
|
115
170
|
}
|
|
@@ -117,18 +172,23 @@ function formContract(formId: string): FormContract {
|
|
|
117
172
|
/** Validate completion `variables` against the escalation's `.form` contract (required fields present
|
|
118
173
|
* + `select` values within the allowed set), so a completion can never resume the process with a
|
|
119
174
|
* missing/invalid decision (e.g. a `wait-answer` with no `answer`, or a `trial-merge-decision` with
|
|
120
|
-
* an `action` outside proceed/rebase/abandon).
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
175
|
+
* an `action` outside proceed/rebase/abandon). A conditionally-shown required field is only enforced
|
|
176
|
+
* when its `conditional.hide` rule leaves it visible — so a `feature-escalation` with
|
|
177
|
+
* `resolution="answer"` demands a non-blank `answer` (re-dispatch guidance), but the `abandon` path,
|
|
178
|
+
* which hides `answer`, does not. Returns a human-readable reason on violation, or `null` when the
|
|
179
|
+
* variables satisfy the contract. Derived from the canonical `.form` — the same contract the task
|
|
180
|
+
* inbox renders — so both the agent and human completers reject invalid input the exact same way,
|
|
181
|
+
* with one implementation. An element with no linked form contract is not enforced. */
|
|
124
182
|
export function validateEscalationVariables(
|
|
125
183
|
elementId: string,
|
|
126
184
|
variables: Record<string, unknown>,
|
|
127
185
|
): string | null {
|
|
128
186
|
const formId = ESCALATION_FORM_BY_ELEMENT[elementId];
|
|
129
187
|
if (!formId) return null;
|
|
130
|
-
const { required, allowed } = formContract(formId);
|
|
188
|
+
const { required, allowed, hideWhen } = formContract(formId);
|
|
131
189
|
for (const key of required) {
|
|
190
|
+
const hide = hideWhen[key];
|
|
191
|
+
if (hide && isHidden(hide, variables)) continue;
|
|
132
192
|
const v = variables[key];
|
|
133
193
|
if (v === undefined || v === null || (typeof v === "string" && v.trim() === "")) {
|
|
134
194
|
return `${elementId}: "${key}" is required`;
|
|
@@ -222,19 +282,24 @@ export interface AgentCompleteResult {
|
|
|
222
282
|
elementId?: string;
|
|
223
283
|
}
|
|
224
284
|
|
|
225
|
-
/** Resolve a parked escalation user task by key: return its `elementId` if it is one of the
|
|
226
|
-
*
|
|
227
|
-
* refuse a non-
|
|
228
|
-
*
|
|
285
|
+
/** Resolve a parked escalation user task by key: return its `elementId` if it is one of the `allowed`
|
|
286
|
+
* completable tasks, or a failure reason otherwise. Shared by the agent and human completers so both
|
|
287
|
+
* refuse a non-completable / missing target the exact same way (a key with no matching open task is a
|
|
288
|
+
* 404-style no-op). The AGENT completer passes the default `ESCALATION_TASK_ELEMENTS`; the HUMAN
|
|
289
|
+
* completer passes the wider `HUMAN_COMPLETABLE_ELEMENTS` (which also admits `feature-blocked`).
|
|
290
|
+
* Queries `openUserTasks` (lifecycle-state `CREATED` only), NOT `searchUserTasks` (which returns
|
|
291
|
+
* tasks in ANY state) — a looping instance keeps COMPLETED/CANCELED tasks whose key could otherwise
|
|
292
|
+
* match and drive a doomed re-completion (a thrown 5xx) instead of the intended 404-style no-op. */
|
|
229
293
|
async function resolveEscalationTask(
|
|
230
294
|
engine: EngineClient,
|
|
231
295
|
userTaskKey: string,
|
|
296
|
+
allowed: ReadonlySet<string> = ESCALATION_TASK_ELEMENTS,
|
|
232
297
|
): Promise<{ ok: true; elementId: string } | { ok: false; reason: string }> {
|
|
233
|
-
const open = await engine.
|
|
298
|
+
const open = await engine.openUserTasks();
|
|
234
299
|
const match = open.find((t) => t.userTaskKey === userTaskKey);
|
|
235
|
-
if (!match) return { ok: false, reason: "no open
|
|
236
|
-
if (!match.elementId || !
|
|
237
|
-
return { ok: false, reason: "not
|
|
300
|
+
if (!match) return { ok: false, reason: "no open completable task" };
|
|
301
|
+
if (!match.elementId || !allowed.has(match.elementId)) {
|
|
302
|
+
return { ok: false, reason: "not a completable task" };
|
|
238
303
|
}
|
|
239
304
|
return { ok: true, elementId: match.elementId };
|
|
240
305
|
}
|
|
@@ -275,7 +340,8 @@ export async function completeEscalationAsAgent(
|
|
|
275
340
|
* affordance resumes the process through the one implementation a human uses from the task inbox —
|
|
276
341
|
* no parallel completion path — while recording WHO answered in the `task_completions` ledger. A
|
|
277
342
|
* human completion is the authority (not reversible). A key with no matching open escalation task is
|
|
278
|
-
* a 404-style no-op.
|
|
343
|
+
* a 404-style no-op. The human surface is the wider `HUMAN_COMPLETABLE_ELEMENTS`, so it also retires a
|
|
344
|
+
* `feature-blocked` acknowledgement (issue #332 folded the bespoke `acknowledge-blocked` door here). */
|
|
279
345
|
export async function completeEscalationAsHuman(
|
|
280
346
|
data: DataLayer,
|
|
281
347
|
engine: EngineClient,
|
|
@@ -286,7 +352,7 @@ export async function completeEscalationAsHuman(
|
|
|
286
352
|
const operatorId = input.operatorId.trim();
|
|
287
353
|
if (!operatorId) return { ok: false, reason: "operatorId is required" };
|
|
288
354
|
|
|
289
|
-
const resolved = await resolveEscalationTask(engine, userTaskKey);
|
|
355
|
+
const resolved = await resolveEscalationTask(engine, userTaskKey, HUMAN_COMPLETABLE_ELEMENTS);
|
|
290
356
|
if (!resolved.ok) return resolved;
|
|
291
357
|
|
|
292
358
|
const invalid = validateEscalationVariables(resolved.elementId, input.variables);
|
|
@@ -301,44 +367,6 @@ export async function completeEscalationAsHuman(
|
|
|
301
367
|
return { ok: true, completionId, userTaskKey, elementId: resolved.elementId };
|
|
302
368
|
}
|
|
303
369
|
|
|
304
|
-
/** The `feature-blocked` operator user-task element id (feature.bpmn). Unlike an escalation this is not
|
|
305
|
-
* an agent-answerable task — it is a blocked-run acknowledgement only a human operator retires — so it
|
|
306
|
-
* lives outside `ESCALATION_TASK_ELEMENTS` (the agent completer must never touch it) and has its own
|
|
307
|
-
* human-only completer below. */
|
|
308
|
-
export const FEATURE_BLOCKED_TASK_ELEMENT = "feature-blocked";
|
|
309
|
-
|
|
310
|
-
/** Complete the `feature-blocked` operator user task AS A HUMAN (issue #220). The blocked twin of
|
|
311
|
-
* `completeEscalationAsHuman`: it resolves the parked task by key, refuses anything that is not the
|
|
312
|
-
* `feature-blocked` task, and routes the operator's typed form variables (an optional `note`) through
|
|
313
|
-
* the SAME canonical `completeUserTaskAttributed` — so the nwf "Acknowledge blocked" affordance resumes
|
|
314
|
-
* the process (→ `pr.record-blocked-ack`, which settles the row to terminal `blocked`) through the one
|
|
315
|
-
* completion a human drives from the task inbox, recording WHO acknowledged in the `task_completions`
|
|
316
|
-
* ledger. A human completion is the authority (not reversible). A key with no matching open
|
|
317
|
-
* `feature-blocked` task is a 404-style no-op. */
|
|
318
|
-
export async function completeBlockedAsHuman(
|
|
319
|
-
data: DataLayer,
|
|
320
|
-
engine: EngineClient,
|
|
321
|
-
input: { userTaskKey: string; variables: Record<string, unknown>; operatorId: string },
|
|
322
|
-
): Promise<AgentCompleteResult> {
|
|
323
|
-
const userTaskKey = input.userTaskKey.trim();
|
|
324
|
-
if (!userTaskKey) return { ok: false, reason: "userTaskKey is required" };
|
|
325
|
-
const operatorId = input.operatorId.trim();
|
|
326
|
-
if (!operatorId) return { ok: false, reason: "operatorId is required" };
|
|
327
|
-
|
|
328
|
-
const open = await engine.searchUserTasks();
|
|
329
|
-
const match = open.find((t) => t.userTaskKey === userTaskKey);
|
|
330
|
-
if (!match) return { ok: false, reason: "no open blocked task" };
|
|
331
|
-
if (match.elementId !== FEATURE_BLOCKED_TASK_ELEMENT) return { ok: false, reason: "not a blocked task" };
|
|
332
|
-
|
|
333
|
-
const { completionId } = await completeUserTaskAttributed(
|
|
334
|
-
data,
|
|
335
|
-
engine,
|
|
336
|
-
{ userTaskKey, elementId: match.elementId, variables: input.variables },
|
|
337
|
-
{ kind: "human", id: operatorId },
|
|
338
|
-
);
|
|
339
|
-
return { ok: true, completionId, userTaskKey, elementId: match.elementId };
|
|
340
|
-
}
|
|
341
|
-
|
|
342
370
|
export interface RevertResult {
|
|
343
371
|
ok: boolean;
|
|
344
372
|
reason?: string;
|
package/app/convergeGate.test.ts
CHANGED
|
@@ -467,11 +467,15 @@ test("gw-converge-gate default arm finalizes with no condition", () => {
|
|
|
467
467
|
assert(!/conditionExpression/.test(ok), "the default arm must carry no conditionExpression");
|
|
468
468
|
});
|
|
469
469
|
|
|
470
|
-
test("the blocked-comments escalation
|
|
471
|
-
|
|
472
|
-
|
|
470
|
+
test("the blocked-comments escalation routes through gw-escalated toward an answerable wait-answer", () => {
|
|
471
|
+
// #333: previously this flowed UNCONDITIONALLY into wait-answer, so a blank convergeBlockReason
|
|
472
|
+
// (the question is mapped from that OPTIONAL variable) opened no escalation yet still parked a
|
|
473
|
+
// dead wait with a null question. It now routes through gw-escalated, which parks wait-answer
|
|
474
|
+
// only on a real escalation and otherwise re-enters the loop.
|
|
475
|
+
const f = flowElement("f_blockedGate");
|
|
476
|
+
assert(f, "f_blockedGate flow missing");
|
|
473
477
|
assertStringIncludes(f, 'sourceRef="persist-escalation-blockedcomments"');
|
|
474
|
-
assertStringIncludes(f, 'targetRef="
|
|
478
|
+
assertStringIncludes(f, 'targetRef="gw-escalated"');
|
|
475
479
|
const task = flat.match(
|
|
476
480
|
/<bpmn:serviceTask\b[^>]*\bid="persist-escalation-blockedcomments"[^>]*>.*?<\/bpmn:serviceTask>/,
|
|
477
481
|
);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Regression guard for the question-less convergence-loop escalation defect (issue #333).
|
|
2
|
+
//
|
|
3
|
+
// The convergence loop (`resources/processes/convergence-loop.bpmn`) has FIVE `persist-escalation`
|
|
4
|
+
// service tasks, but only ONE — `persist-escalation` (the `f_escalate` agent-verdict arm) — was
|
|
5
|
+
// routed through the `gw-escalated` gateway that honours the worker's `escalated` output. The other
|
|
6
|
+
// four flowed UNCONDITIONALLY into the durable `wait-answer` user task:
|
|
7
|
+
//
|
|
8
|
+
// • persist-escalation-noprogress (no progress made this round)
|
|
9
|
+
// • persist-review-stalled (review-wait timer fired) ← wedged live
|
|
10
|
+
// • persist-escalation-blockedcomments (unaddressed review comments)
|
|
11
|
+
// • persist-escalation-maxrounds (round cap reached)
|
|
12
|
+
//
|
|
13
|
+
// Per ADR 0002 §1 a blank question is a NON-escalation: `pr.persist-escalation` opens no row and
|
|
14
|
+
// returns `escalated:false`. The `persist-escalation-blockedcomments` arm maps its question from the
|
|
15
|
+
// OPTIONAL `convergeBlockReason` process variable, so a blank reason returned `escalated:false` yet
|
|
16
|
+
// STILL parked a dead `wait-answer` — a durable answer-wait with no escalation and a `null` question,
|
|
17
|
+
// surfaced on the merge-driving inbox with nothing for a human to answer (observed live on three
|
|
18
|
+
// instances, issue #333).
|
|
19
|
+
//
|
|
20
|
+
// The fix (mirroring the merge loop's `gw-merge-escalated`, PR #331): route EVERY arm that can reach
|
|
21
|
+
// `wait-answer` through the single `gw-escalated` guard, so a `persist-escalation` returning
|
|
22
|
+
// `escalated:false` RE-ENTERS the loop (`gw-guard`) instead of parking a dead wait. This makes the
|
|
23
|
+
// invariant structural — `wait-answer` is reachable ONLY from a `gw-escalated == true` edge, so a
|
|
24
|
+
// "durable answer-wait with no escalation" is unrepresentable.
|
|
25
|
+
//
|
|
26
|
+
// These are pure text assertions over the committed BPMN (no engine), matching the repo's
|
|
27
|
+
// lightweight model-guard style (see mergeEscalationQuestion.test.ts, mergeRebaseArm.test.ts).
|
|
28
|
+
|
|
29
|
+
import { test } from "node:test";
|
|
30
|
+
import { assert, assertStringIncludes } from "#test-assert";
|
|
31
|
+
import { readFileSync } from "node:fs";
|
|
32
|
+
|
|
33
|
+
const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
|
|
34
|
+
// Collapse whitespace so attribute-order / line-wrapping churn doesn't make the assertions brittle.
|
|
35
|
+
const flat = bpmn.replace(/\s+/g, " ");
|
|
36
|
+
|
|
37
|
+
function flowHasId(id: string, source: string, target: string): boolean {
|
|
38
|
+
const m = flat.match(new RegExp(`<bpmn:sequenceFlow\\b[^>]*\\bid="${id}"[^>]*(?:/>|>)`));
|
|
39
|
+
if (!m) return false;
|
|
40
|
+
const tag = m[0];
|
|
41
|
+
return tag.includes(`sourceRef="${source}"`) && tag.includes(`targetRef="${target}"`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function gatewayDefault(id: string, def: string): boolean {
|
|
45
|
+
const m = flat.match(new RegExp(`<bpmn:exclusiveGateway\\b[^>]*\\bid="${id}"[^>]*>`));
|
|
46
|
+
if (!m) return false;
|
|
47
|
+
return m[0].includes(`default="${def}"`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The <userTask> element for wait-answer, including its incoming flows. */
|
|
51
|
+
const waitAnswer = flat.match(/<bpmn:userTask\b[^>]*\bid="wait-answer"[\s\S]*?<\/bpmn:userTask>/);
|
|
52
|
+
|
|
53
|
+
// Every control-flow escalation arm and the gateway edge it must now route through.
|
|
54
|
+
const ARMS: ReadonlyArray<{ task: string; gate: string }> = [
|
|
55
|
+
{ task: "persist-escalation-noprogress", gate: "f_noprogressGate" },
|
|
56
|
+
{ task: "persist-review-stalled", gate: "f_stalledGate" },
|
|
57
|
+
{ task: "persist-escalation-blockedcomments", gate: "f_blockedGate" },
|
|
58
|
+
{ task: "persist-escalation-maxrounds", gate: "f_maxGate" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
test("every persist-escalation arm routes through gw-escalated, not straight to wait-answer", () => {
|
|
62
|
+
for (const { task, gate } of ARMS) {
|
|
63
|
+
assert(
|
|
64
|
+
flowHasId(gate, task, "gw-escalated"),
|
|
65
|
+
`${task} must route through gw-escalated (flow ${gate}), not directly to wait-answer (the #333 defect)`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
// The agent-verdict arm already routed through the gate — keep it.
|
|
69
|
+
assert(flowHasId("f_escGate", "persist-escalation", "gw-escalated"), "persist-escalation must route through gw-escalated");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("gw-escalated honours persist-escalation's escalated output for every arm", () => {
|
|
73
|
+
// escalated:true → park the native user task for a human to answer.
|
|
74
|
+
assert(flowHasId("f_escWait", "gw-escalated", "wait-answer"), "gw-escalated → wait-answer (escalated) missing");
|
|
75
|
+
const escWait = flat.match(/<bpmn:sequenceFlow[^>]*id="f_escWait"[\s\S]*?<\/bpmn:sequenceFlow>/);
|
|
76
|
+
assert(escWait, "f_escWait flow missing");
|
|
77
|
+
assertStringIncludes(escWait![0], "escalated = true", "the wait arm must be guarded by escalated = true");
|
|
78
|
+
// escalated:false (a non-escalation, e.g. a blank convergeBlockReason) → re-enter the loop, not a dead wait.
|
|
79
|
+
assert(gatewayDefault("gw-escalated", "f_escReenter"), "gw-escalated default must re-enter the loop");
|
|
80
|
+
assert(flowHasId("f_escReenter", "gw-escalated", "gw-guard"), "the non-escalation arm must re-enter via gw-guard, not park a wait");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("wait-answer is reachable ONLY from the gw-escalated == true edge (structural invariant)", () => {
|
|
84
|
+
// The single most important guarantee of #333: a durable answer-wait with no escalation is
|
|
85
|
+
// unrepresentable because the ONLY edge into wait-answer is the guarded f_escWait.
|
|
86
|
+
assert(waitAnswer, "wait-answer user task must exist");
|
|
87
|
+
const incoming = [...waitAnswer![0].matchAll(/<bpmn:incoming>([^<]+)<\/bpmn:incoming>/g)].map((m) => m[1]);
|
|
88
|
+
assert(incoming.length === 1, `wait-answer must have exactly one incoming (the guarded f_escWait); found: ${incoming.join(", ")}`);
|
|
89
|
+
assert(incoming[0] === "f_escWait", `wait-answer's only incoming must be f_escWait; found ${incoming[0]}`);
|
|
90
|
+
// The retired dead-wait edges must be gone.
|
|
91
|
+
for (const dead of ["f_noprogressWait", "f_stalledWait", "f_blockedWait", "f_maxWait"]) {
|
|
92
|
+
assert(!flat.includes(`id="${dead}"`), `the dead-wait edge ${dead} must be removed`);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("each control-flow arm still carries a non-blank, human-actionable question", () => {
|
|
97
|
+
// The guard closes the wedge, but a legitimate control-flow escalation must still open a real
|
|
98
|
+
// escalation with a concrete question. Assert each arm supplies status + question via ioMapping.
|
|
99
|
+
for (const { task } of ARMS) {
|
|
100
|
+
const raw = flat.match(new RegExp(`<bpmn:serviceTask\\b[^>]*\\bid="${task}"[\\s\\S]*?</bpmn:serviceTask>`));
|
|
101
|
+
assert(raw, `${task} service task must exist`);
|
|
102
|
+
const el = raw![0];
|
|
103
|
+
assertStringIncludes(el, 'target="status"', `${task} must set an explicit status`);
|
|
104
|
+
assertStringIncludes(el, 'target="question"', `${task} must set a question`);
|
|
105
|
+
}
|
|
106
|
+
});
|