@nanobpm/nano-workforce 0.111.1 → 0.112.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 +7 -0
- package/app/escalationTaxonomy.test.ts +1 -1
- package/app/escalationTaxonomy.ts +4 -2
- package/app/planFanoutCleanTerminal.test.ts +40 -0
- package/app/pollUserTasks.test.ts +208 -0
- package/app/service.ts +211 -196
- package/app/userTasks.test.ts +20 -2
- package/app/userTasks.ts +11 -4
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/plan-fanout.bpmn +161 -135
- package/workers/record-wave/worker.test.ts +181 -0
- package/workers/record-wave/worker.ts +44 -11
- package/workers/record-wave-escalation/worker.test.ts +95 -0
- package/workers/record-wave-escalation/worker.ts +69 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [0.112.0](https://github.com/nanobpm/nano-workforce/compare/v0.111.1...v0.112.0) (2026-08-20)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **plan-fanout:** implement-stage escalation net + Tasks-inbox projection ([#358](https://github.com/nanobpm/nano-workforce/issues/358), [#360](https://github.com/nanobpm/nano-workforce/issues/360)) ([#387](https://github.com/nanobpm/nano-workforce/issues/387)) ([c884074](https://github.com/nanobpm/nano-workforce/commit/c884074ca9266d5a17c44d3a1286bb667b43a73a))
|
|
7
|
+
|
|
1
8
|
## [0.111.1](https://github.com/nanobpm/nano-workforce/compare/v0.111.0...v0.111.1) (2026-08-20)
|
|
2
9
|
|
|
3
10
|
|
|
@@ -80,7 +80,7 @@ test("merge-protocol: only a `ui` land method is decision-required; the rest are
|
|
|
80
80
|
}
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
// --- task (plan-fanout w_gw "
|
|
83
|
+
// --- task (plan-fanout w_gw "clean terminal?" — the implement-stage escalation net, #360) ---
|
|
84
84
|
|
|
85
85
|
test("task: status=escalated with an answerable question is decision-required; blank is none", () => {
|
|
86
86
|
assertEquals(classifyEscalation({ kind: "task", question: "which approach?" }), "decision-required");
|
|
@@ -39,8 +39,10 @@ export type EscalationKind =
|
|
|
39
39
|
| "dead-end-base"
|
|
40
40
|
// `mergeProtocol` (app/mergeProtocol.ts) — the repo's declared land method.
|
|
41
41
|
| "merge-protocol"
|
|
42
|
-
// plan-fanout `w_gw` "
|
|
43
|
-
//
|
|
42
|
+
// plan-fanout `w_gw` "clean terminal?" gateway — the implement-stage escalation net (issue #360).
|
|
43
|
+
// Any non-clean-terminal slice outcome routes through the `record-wave-escalation` worker, which
|
|
44
|
+
// classifies with this kind: the agent's own answerable question passes through, and a no-machine-
|
|
45
|
+
// readable result (or a blank-question `escalated`) is synthesised into an answerable one.
|
|
44
46
|
| "task";
|
|
45
47
|
|
|
46
48
|
/** Everything the classifier may need from any raise site. Each field is consumed only by the
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Structural guard for the wave subprocess's "clean terminal?" gateway (w_gw) — the implement-stage
|
|
2
|
+
// escalation net (#358/#360). The whole point of the net is that a slice with NO clean terminal
|
|
3
|
+
// status escalates to a human. The no-result case (implement-task completes with `status`
|
|
4
|
+
// missing/undefined) is EXACTLY what must escalate, so the gateway must not depend on a `not(...)`
|
|
5
|
+
// negation that FEEL leaves `null` for a missing `status` (a null condition takes NO flow and would
|
|
6
|
+
// fall through to the default). We eliminate that failure mode categorically: ESCALATE is the
|
|
7
|
+
// DEFAULT flow and DONE is gated on the closed set of clean terminal statuses — so anything that is
|
|
8
|
+
// not a recognised clean terminal (including a missing/undefined status) escalates, regardless of
|
|
9
|
+
// how the engine evaluates equality against null.
|
|
10
|
+
//
|
|
11
|
+
// Pure text assertions over the committed BPMN (no engine), matching the repo's model-guard style.
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { assert, assertStringIncludes } from "#test-assert";
|
|
15
|
+
|
|
16
|
+
const bpmn = readFileSync("resources/processes/plan-fanout.bpmn", "utf8");
|
|
17
|
+
const flat = bpmn.replace(/\s+/g, " ");
|
|
18
|
+
|
|
19
|
+
const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="w_gw"[^>]*>/)?.[0] ?? "";
|
|
20
|
+
const wDone = flat.match(/<bpmn:sequenceFlow\b[^>]*\bid="w_done"[^>]*\/>|<bpmn:sequenceFlow\b[^>]*\bid="w_done"[\s\S]*?<\/bpmn:sequenceFlow>/)?.[0] ?? "";
|
|
21
|
+
const wEscalate = flat.match(/<bpmn:sequenceFlow\b[^>]*\bid="w_escalate"[^>]*\/>|<bpmn:sequenceFlow\b[^>]*\bid="w_escalate"[\s\S]*?<\/bpmn:sequenceFlow>/)?.[0] ?? "";
|
|
22
|
+
|
|
23
|
+
test("w_gw: ESCALATE is the default flow, so a missing/undefined status can never fall through to done", () => {
|
|
24
|
+
assert(gw, "w_gw gateway must exist");
|
|
25
|
+
assertStringIncludes(gw, 'default="w_escalate"', "escalate must be the default — the no-result case escalates, never silently completes");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("w_gw: DONE is gated on the closed set of clean terminal statuses (not a fragile not(...) negation)", () => {
|
|
29
|
+
assert(wDone, "w_done flow must exist");
|
|
30
|
+
assertStringIncludes(wDone, "conditionExpression", "the done flow must be conditional, not the default");
|
|
31
|
+
assertStringIncludes(wDone, 'status = "opened"', "done requires a recognised clean terminal status");
|
|
32
|
+
assertStringIncludes(wDone, 'status = "blocked"', "done requires a recognised clean terminal status");
|
|
33
|
+
assertStringIncludes(wDone, 'status = "skipped"', "done requires a recognised clean terminal status");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("w_gw: the escalate flow carries no condition — it is the unconditional default sink", () => {
|
|
37
|
+
assert(wEscalate, "w_escalate flow must exist");
|
|
38
|
+
assert(!wEscalate.includes("conditionExpression"), "escalate is the default flow and must carry no condition");
|
|
39
|
+
assert(!wEscalate.includes("not("), "escalate must not depend on a not(...) negation that FEEL leaves null for a missing status");
|
|
40
|
+
});
|
|
@@ -131,6 +131,36 @@ test("pollUserTasks: projects feature / plan-review / trial-merge / PR-wait esca
|
|
|
131
131
|
assertEquals(byKey["ut-pr"].subject_title, "Resolve the reviews");
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
+
test("pollUserTasks: projects a feature-escalation that lands on a plan-fanout plan instance (issue #358)", async () => {
|
|
135
|
+
// plan-fanout embeds each wave slice as a multi-instance `implement` subprocess, so a slice that
|
|
136
|
+
// escalates parks on the `feature-escalation` user task on the PLAN-ROOT process instance — never on
|
|
137
|
+
// a standalone `feature_runs` instance. The feature scan above only walks `feature_runs`, so before
|
|
138
|
+
// #358 the plan scan's hardcoded {plan-review, trial-merge} whitelist silently dropped it and the
|
|
139
|
+
// escalation was invisible in the Tasks inbox (the instance-19153 orphan). The plan scan must project
|
|
140
|
+
// EVERY open user-task element in the canonical registry, keyed to the epic (plan) subject, sourcing
|
|
141
|
+
// the question from the `feature_escalations` audit log the escalate arm writes (keyed by plan_key).
|
|
142
|
+
const { data, stores } = memData({
|
|
143
|
+
plans: [
|
|
144
|
+
{ plan_key: "o/r#64", status: "dispatched", process_key: "pp-64", issue_url: "https://github.com/o/r/issues/64", title: "Learn BPMN scaffold" },
|
|
145
|
+
],
|
|
146
|
+
feature_escalations: [
|
|
147
|
+
{ id: 1, feature_key: "o/r#64", question: "the agent returned no machine-readable result — enrol the PR?", created_at: "2025-01-01T00:00:00.000Z", job_key: "j1" },
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
const engine = fakeEngine({ "pp-64": [{ userTaskKey: "ut-embedded-feat", elementId: "feature-escalation" }] });
|
|
151
|
+
|
|
152
|
+
await pollUserTasks(data, engine);
|
|
153
|
+
|
|
154
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
155
|
+
assertEquals(Object.keys(byKey), ["ut-embedded-feat"]);
|
|
156
|
+
assertEquals(byKey["ut-embedded-feat"].element_id, "feature-escalation");
|
|
157
|
+
assertEquals(byKey["ut-embedded-feat"].kind_label, "Feature escalation");
|
|
158
|
+
assertEquals(byKey["ut-embedded-feat"].subject_type, "plan");
|
|
159
|
+
assertEquals(byKey["ut-embedded-feat"].subject_key, "o/r#64");
|
|
160
|
+
assertEquals(byKey["ut-embedded-feat"].subject_title, "Learn BPMN scaffold");
|
|
161
|
+
assertEquals(byKey["ut-embedded-feat"].question, "the agent returned no machine-readable result — enrol the PR?");
|
|
162
|
+
});
|
|
163
|
+
|
|
134
164
|
test("pollUserTasks: projects a merge-loop wait-merge-answer escalation into user_tasks as \"PR merge\"", async () => {
|
|
135
165
|
// During the merge phase a PR's process_key points at its merge-loop instance; the merge escalation
|
|
136
166
|
// parks on a native `wait-merge-answer` userTask (#256) and writes the SAME `escalations` row the
|
|
@@ -328,3 +358,181 @@ test("pollUserTasks: an instance whose only task is COMPLETED surfaces no row",
|
|
|
328
358
|
|
|
329
359
|
assertEquals(stores.user_tasks ?? [], []);
|
|
330
360
|
});
|
|
361
|
+
|
|
362
|
+
// ── Engine-first sweep (issue #358) ────────────────────────────────────────────────────────────────
|
|
363
|
+
// When the raw-REST surface is available (production always supplies it), the projection's source of
|
|
364
|
+
// truth for WHICH escalations are open is the ENGINE, not the tracked subject set: every open escalation
|
|
365
|
+
// the engine reports is surfaced — even on an instance NO tracked subject row references (an
|
|
366
|
+
// orphaned/untracked instance, the reported 19153 case) — enriched by a subject row when one exists and
|
|
367
|
+
// by a per-kind fallback when it does not. These drive the sweep over a stubbed Camunda-8
|
|
368
|
+
// `/v2/user-tasks/search`, the raw surface that (unlike the typed `openUserTasks` seam) carries each
|
|
369
|
+
// task's `processInstanceKey`.
|
|
370
|
+
|
|
371
|
+
/** A single task as the raw Camunda-8 `/v2/user-tasks/search` reports it — carries `processInstanceKey`
|
|
372
|
+
* (the typed seam omits it) so the sweep can map a task back to its subject for enrichment. */
|
|
373
|
+
type RawTask = { userTaskKey: string; elementId?: string; processInstanceKey?: string; state?: string };
|
|
374
|
+
|
|
375
|
+
/** Stub `globalThis.fetch` so `pollUserTasks`' engine-first sweep reads its open tasks from `tasks`.
|
|
376
|
+
* Honours the `page.from`/`page.limit` pagination the sweep drives, and 404s any other path so a stray
|
|
377
|
+
* call is loud. Returns a restore fn. */
|
|
378
|
+
function stubUserTaskSearch(tasks: RawTask[]): () => void {
|
|
379
|
+
const orig = globalThis.fetch;
|
|
380
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal fetch double for the raw-REST search surface
|
|
381
|
+
globalThis.fetch = (async (url: string | URL, init?: any) => {
|
|
382
|
+
const u = String(url);
|
|
383
|
+
if (!u.endsWith("/user-tasks/search")) return new Response("not found", { status: 404 });
|
|
384
|
+
const body = JSON.parse(init?.body ?? "{}");
|
|
385
|
+
const from: number = body?.page?.from ?? 0;
|
|
386
|
+
const limit: number = body?.page?.limit ?? 100;
|
|
387
|
+
return new Response(JSON.stringify({ items: tasks.slice(from, from + limit) }), {
|
|
388
|
+
status: 200,
|
|
389
|
+
headers: { "content-type": "application/json" },
|
|
390
|
+
});
|
|
391
|
+
}) as typeof fetch;
|
|
392
|
+
return () => {
|
|
393
|
+
globalThis.fetch = orig;
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const REST = { restAddress: "http://engine.test/v2" };
|
|
398
|
+
|
|
399
|
+
test("pollUserTasks (engine-first): surfaces an escalation on an UNTRACKED/orphaned instance — the 19153 case (issue #358)", async () => {
|
|
400
|
+
// No `feature_runs`/`plans`/`pull_requests` row references instance 19153, yet the engine reports its
|
|
401
|
+
// `feature-escalation` (key 27337) open. Before #358 the subject-tracking-gated scan dropped it and the
|
|
402
|
+
// operator could never see nor answer it. The engine-first sweep surfaces it, keyed to a stable
|
|
403
|
+
// non-blank fallback subject (the instance) so the row renders and stays answerable.
|
|
404
|
+
const { data, stores } = memData({});
|
|
405
|
+
const restore = stubUserTaskSearch([
|
|
406
|
+
{ userTaskKey: "27337", elementId: "feature-escalation", processInstanceKey: "19153", state: "CREATED" },
|
|
407
|
+
]);
|
|
408
|
+
try {
|
|
409
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
410
|
+
} finally {
|
|
411
|
+
restore();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
415
|
+
assertEquals(Object.keys(byKey), ["27337"]);
|
|
416
|
+
assertEquals(byKey["27337"].element_id, "feature-escalation");
|
|
417
|
+
assertEquals(byKey["27337"].kind_label, "Feature escalation");
|
|
418
|
+
assertEquals(byKey["27337"].subject_type, "feature");
|
|
419
|
+
assertEquals(byKey["27337"].subject_key, "19153"); // fallback to the instance — non-blank so it renders
|
|
420
|
+
assertEquals(byKey["27337"].subject_title, "19153");
|
|
421
|
+
assertEquals(byKey["27337"].question, null); // no tracked audit source for an orphan → null, still listed
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("pollUserTasks (engine-first): orphaned plan-review and PR-wait escalations are surfaced too (issue #358)", async () => {
|
|
425
|
+
// Same failure class across aggregates: a `plan-review-decision` with no `plans` row and a `wait-answer`
|
|
426
|
+
// with no `pull_requests` row are each surfaced, bucketed to the aggregate their kind implies.
|
|
427
|
+
const { data, stores } = memData({});
|
|
428
|
+
const restore = stubUserTaskSearch([
|
|
429
|
+
{ userTaskKey: "ut-orphan-plan", elementId: "plan-review-decision", processInstanceKey: "pi-1", state: "CREATED" },
|
|
430
|
+
{ userTaskKey: "ut-orphan-pr", elementId: "wait-answer", processInstanceKey: "pi-2", state: "CREATED" },
|
|
431
|
+
]);
|
|
432
|
+
try {
|
|
433
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
434
|
+
} finally {
|
|
435
|
+
restore();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
439
|
+
assertEquals(Object.keys(byKey).sort(), ["ut-orphan-plan", "ut-orphan-pr"]);
|
|
440
|
+
assertEquals(byKey["ut-orphan-plan"].subject_type, "plan");
|
|
441
|
+
assertEquals(byKey["ut-orphan-plan"].subject_key, "pi-1");
|
|
442
|
+
assertEquals(byKey["ut-orphan-pr"].subject_type, "pr");
|
|
443
|
+
assertEquals(byKey["ut-orphan-pr"].kind_label, "PR review");
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("pollUserTasks (engine-first): a TRACKED task is still fully enriched from its subject row (no regression)", async () => {
|
|
447
|
+
// Enrich, don't gate: when a subject row DOES reference the task's instance, title/url/question come
|
|
448
|
+
// from it exactly as the per-subject scan produced — the sweep maps by `processInstanceKey`.
|
|
449
|
+
const { data, stores } = memData({
|
|
450
|
+
feature_runs: [
|
|
451
|
+
{ feature_key: "o/r#10", status: "escalated", process_key: "fp-10", issue_url: "https://github.com/o/r/issues/10", title: "Add the framework selector", delivery_label: null },
|
|
452
|
+
],
|
|
453
|
+
feature_escalations: [
|
|
454
|
+
{ id: 1, feature_key: "o/r#10", question: "which framework?", created_at: "2025-01-01T00:00:00.000Z", job_key: "j1" },
|
|
455
|
+
],
|
|
456
|
+
plans: [
|
|
457
|
+
{ plan_key: "o/r#20", status: "dispatched", process_key: "pp-20", issue_url: "https://github.com/o/r/issues/20", title: "Broaden the epic scope" },
|
|
458
|
+
],
|
|
459
|
+
plan_reviews: [
|
|
460
|
+
{ plan_key: "o/r#20", epoch: 0, round: 1, approved: 0, findings: "scope too broad", created_at: "2025-01-02T00:00:00.000Z" },
|
|
461
|
+
],
|
|
462
|
+
});
|
|
463
|
+
const restore = stubUserTaskSearch([
|
|
464
|
+
{ userTaskKey: "ut-feat", elementId: "feature-escalation", processInstanceKey: "fp-10", state: "CREATED" },
|
|
465
|
+
{ userTaskKey: "ut-plan", elementId: "plan-review-decision", processInstanceKey: "pp-20", state: "CREATED" },
|
|
466
|
+
]);
|
|
467
|
+
try {
|
|
468
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
469
|
+
} finally {
|
|
470
|
+
restore();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
474
|
+
assertEquals(Object.keys(byKey).sort(), ["ut-feat", "ut-plan"]);
|
|
475
|
+
assertEquals(byKey["ut-feat"].subject_key, "o/r#10");
|
|
476
|
+
assertEquals(byKey["ut-feat"].subject_title, "Add the framework selector");
|
|
477
|
+
assertEquals(byKey["ut-feat"].question, "which framework?");
|
|
478
|
+
assertEquals(byKey["ut-plan"].subject_title, "Broaden the epic scope");
|
|
479
|
+
assertEquals(byKey["ut-plan"].question, "scope too broad");
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("pollUserTasks (engine-first): never leaks a non-escalation element nor a non-CREATED task", async () => {
|
|
483
|
+
// The `USER_TASK_KIND_LABELS` gate keeps an arbitrary internal user task out of the inbox, and the
|
|
484
|
+
// defensive state re-filter drops a lagging COMPLETED/CANCELED read (a dead affordance, #294) even if
|
|
485
|
+
// the wire `state` filter is ignored.
|
|
486
|
+
const { data, stores } = memData({});
|
|
487
|
+
const restore = stubUserTaskSearch([
|
|
488
|
+
{ userTaskKey: "ut-internal", elementId: "some-internal-task", processInstanceKey: "pi-9", state: "CREATED" },
|
|
489
|
+
{ userTaskKey: "ut-done", elementId: "feature-escalation", processInstanceKey: "pi-8", state: "COMPLETED" },
|
|
490
|
+
{ userTaskKey: "ut-live", elementId: "feature-escalation", processInstanceKey: "pi-7", state: "CREATED" },
|
|
491
|
+
]);
|
|
492
|
+
try {
|
|
493
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
494
|
+
} finally {
|
|
495
|
+
restore();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const keys = (stores.user_tasks ?? []).map((r) => r.user_task_key);
|
|
499
|
+
assertEquals(keys, ["ut-live"]);
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
test("pollUserTasks (engine-first): an answered task (no longer open) is deleted on the next pass", async () => {
|
|
503
|
+
// Feed the engine-derived desired set to the unchanged reconcile: a persisted row whose task the engine
|
|
504
|
+
// no longer reports open is deleted, so `showCount` tracks live work — identical to the scan path.
|
|
505
|
+
const { data, stores } = memData({
|
|
506
|
+
user_tasks: [
|
|
507
|
+
{ user_task_key: "ut-gone", element_id: "wait-answer", kind_label: "PR review", subject_type: "pr", subject_key: "o/r#30", subject_url: null, question: null, process_key: "rp-30", created_at: "2025-01-01T00:00:00.000Z", updated_at: "2025-01-01T00:00:00.000Z" },
|
|
508
|
+
],
|
|
509
|
+
});
|
|
510
|
+
const restore = stubUserTaskSearch([]); // engine reports nothing open
|
|
511
|
+
try {
|
|
512
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
513
|
+
} finally {
|
|
514
|
+
restore();
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
assertEquals(stores.user_tasks, []);
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("pollUserTasks (engine-first): pages through a large open set (no first-page truncation)", async () => {
|
|
521
|
+
// Open escalations are normally few, but the sweep must page defensively so a large set is not silently
|
|
522
|
+
// truncated to the first page. 150 open escalations across a 100-item page size → all 150 projected.
|
|
523
|
+
const { data, stores } = memData({});
|
|
524
|
+
const tasks: RawTask[] = Array.from({ length: 150 }, (_, i) => ({
|
|
525
|
+
userTaskKey: `ut-${i}`,
|
|
526
|
+
elementId: "feature-escalation",
|
|
527
|
+
processInstanceKey: `pi-${i}`,
|
|
528
|
+
state: "CREATED",
|
|
529
|
+
}));
|
|
530
|
+
const restore = stubUserTaskSearch(tasks);
|
|
531
|
+
try {
|
|
532
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
533
|
+
} finally {
|
|
534
|
+
restore();
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
assertEquals((stores.user_tasks ?? []).length, 150);
|
|
538
|
+
});
|