@nanobpm/nano-workforce 0.167.4 → 0.168.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 +6 -0
- package/app/agentCompletion.ts +3 -0
- package/app/pollEpicPhase.test.ts +64 -0
- package/app/pollUserTasks.test.ts +76 -2
- package/app/service.ts +202 -39
- package/app/userTasks.ts +12 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.168.0](https://github.com/nanobpm/nano-workforce/compare/v0.167.4...v0.168.0) (2026-08-31)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* correlate callActivity child-cell escalations & epic phase via parent/root keys ([#633](https://github.com/nanobpm/nano-workforce/issues/633)) ([#657](https://github.com/nanobpm/nano-workforce/issues/657)) ([22b8674](https://github.com/nanobpm/nano-workforce/commit/22b86746548627921a1bc049b7999eeaa8fb5104)), closes [Magikcraft/nano-bpm#977](https://github.com/Magikcraft/nano-bpm/issues/977) [#464](https://github.com/nanobpm/nano-workforce/issues/464) [#591](https://github.com/nanobpm/nano-workforce/issues/591)
|
|
6
|
+
|
|
1
7
|
## [0.167.4](https://github.com/nanobpm/nano-workforce/compare/v0.167.3...v0.167.4) (2026-08-31)
|
|
2
8
|
|
|
3
9
|
### Build System
|
package/app/agentCompletion.ts
CHANGED
|
@@ -68,6 +68,8 @@ export const taskCompletions = (data: DataLayer) =>
|
|
|
68
68
|
* agent path is scoped to escalations — it can never complete an arbitrary internal user task. */
|
|
69
69
|
export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
|
|
70
70
|
"feature-escalation",
|
|
71
|
+
"escalation", // shared human-escalation cell (human-escalation.bpmn, ADR 0006 S4 #603/#633) — the same
|
|
72
|
+
// agent-answerable feature-escalation task, relocated into a callActivity child cell
|
|
71
73
|
"plan-review-decision",
|
|
72
74
|
"trial-merge-decision",
|
|
73
75
|
"wait-answer", // PR review-loop escalation (convergence-loop.bpmn, U3)
|
|
@@ -121,6 +123,7 @@ export const HUMAN_COMPLETABLE_ELEMENTS: ReadonlySet<string> = new Set([
|
|
|
121
123
|
* validates against the SAME `.form` the task inbox renders — one contract, no second field list. */
|
|
122
124
|
const ESCALATION_FORM_BY_ELEMENT: Readonly<Record<string, string>> = {
|
|
123
125
|
"feature-escalation": "feature-escalation",
|
|
126
|
+
"escalation": "feature-escalation", // shared human-escalation cell renders the SAME feature-escalation form (#603/#633)
|
|
124
127
|
"plan-review-decision": "plan-review-decision",
|
|
125
128
|
"trial-merge-decision": "trial-merge-decision",
|
|
126
129
|
"wait-answer": "pr-escalation",
|
|
@@ -157,3 +157,67 @@ test("pollEpicPhase skips a live epic that has no engine instance yet", async ()
|
|
|
157
157
|
assertEquals(called, false);
|
|
158
158
|
});
|
|
159
159
|
});
|
|
160
|
+
|
|
161
|
+
/** Stub `globalThis.fetch` so `pollEpicPhase`'s callActivity hierarchy walk (issue #633) reads its
|
|
162
|
+
* descendant instances from `childrenByParent` (keyed on the queried `parentProcessInstanceKey`), and
|
|
163
|
+
* 404s any other path so a stray call is loud. Returns a restore fn. */
|
|
164
|
+
function stubProcessInstanceSearch(childrenByParent: Record<string, string[]>): () => void {
|
|
165
|
+
const orig = globalThis.fetch;
|
|
166
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal fetch double for the raw-REST search surface
|
|
167
|
+
globalThis.fetch = (async (url: string | URL, init?: any) => {
|
|
168
|
+
const u = String(url);
|
|
169
|
+
if (!u.endsWith("/process-instances/search")) return new Response("not found", { status: 404 });
|
|
170
|
+
const body = JSON.parse(init?.body ?? "{}");
|
|
171
|
+
const parent: string = body?.filter?.parentProcessInstanceKey ?? "";
|
|
172
|
+
const items = (childrenByParent[parent] ?? []).map((k) => ({ processInstanceKey: k }));
|
|
173
|
+
return new Response(JSON.stringify({ items }), {
|
|
174
|
+
status: 200,
|
|
175
|
+
headers: { "content-type": "application/json" },
|
|
176
|
+
});
|
|
177
|
+
}) as typeof fetch;
|
|
178
|
+
return () => {
|
|
179
|
+
globalThis.fetch = orig;
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
test("pollEpicPhase derives from an element INSIDE a callActivity CHILD cell via parent/root traversal (issue #633)", async () => {
|
|
184
|
+
// ADR 0006 S4 (#603/#633): once a wave/slice runs as a callActivity CHILD cell, the furthest-reached
|
|
185
|
+
// live token can sit INSIDE that child instance ("child-pi"), not on the parent plan-fanout spine
|
|
186
|
+
// ("pi-1"). The parent instance shows only a settled (COMPLETED) `record-plan`, so a parent-ONLY read
|
|
187
|
+
// would leave the phase at PLANNING; walking the hierarchy (parent → child) surfaces the child's ACTIVE
|
|
188
|
+
// `review-plan`, advancing the phase to Reviewing. The pure `deriveEpicPhaseLive` is unchanged — only
|
|
189
|
+
// its INPUT is widened to the whole instance hierarchy.
|
|
190
|
+
await withData(async (data) => {
|
|
191
|
+
await seedPlan(data, { epic_phase: EPIC_PHASE.PLANNING });
|
|
192
|
+
const engine = {
|
|
193
|
+
searchElementInstances: async ({ processInstanceKey }: { processInstanceKey: string }) =>
|
|
194
|
+
processInstanceKey === "child-pi"
|
|
195
|
+
? [{ elementInstanceKey: "c1", processInstanceKey: "child-pi", elementId: "review-plan", state: "ACTIVE" }]
|
|
196
|
+
: [{ elementInstanceKey: "e1", processInstanceKey: "pi-1", elementId: "record-plan", state: "COMPLETED" }],
|
|
197
|
+
};
|
|
198
|
+
const restore = stubProcessInstanceSearch({ "pi-1": ["child-pi"], "child-pi": [] });
|
|
199
|
+
try {
|
|
200
|
+
await pollEpicPhase(data, engine as never, { restAddress: "http://engine.test/v2" });
|
|
201
|
+
} finally {
|
|
202
|
+
restore();
|
|
203
|
+
}
|
|
204
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.REVIEWING);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("pollEpicPhase without a raw-REST surface reads the parent instance ALONE (no traversal, pre-#633 behaviour)", async () => {
|
|
209
|
+
// The typed seam cannot enumerate children, so with no `engineRest` the walk degrades to the parent
|
|
210
|
+
// plan-fanout instance only — a child-cell token is invisible and the phase stays at PLANNING. This
|
|
211
|
+
// pins the two-arg (no-REST) call the unit path and degraded hosts use.
|
|
212
|
+
await withData(async (data) => {
|
|
213
|
+
await seedPlan(data, { epic_phase: EPIC_PHASE.PLANNING });
|
|
214
|
+
const engine = {
|
|
215
|
+
searchElementInstances: async ({ processInstanceKey }: { processInstanceKey: string }) =>
|
|
216
|
+
processInstanceKey === "child-pi"
|
|
217
|
+
? [{ elementInstanceKey: "c1", processInstanceKey: "child-pi", elementId: "review-plan", state: "ACTIVE" }]
|
|
218
|
+
: [{ elementInstanceKey: "e1", processInstanceKey: "pi-1", elementId: "record-plan", state: "COMPLETED" }],
|
|
219
|
+
};
|
|
220
|
+
await pollEpicPhase(data, engine as never);
|
|
221
|
+
assertEquals((await plans(data).get("owner/repo#7"))?.epic_phase, EPIC_PHASE.PLANNING);
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -370,7 +370,7 @@ test("pollUserTasks: an instance whose only task is COMPLETED surfaces no row",
|
|
|
370
370
|
|
|
371
371
|
/** A single task as the raw Camunda-8 `/v2/user-tasks/search` reports it — carries `processInstanceKey`
|
|
372
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; formKey?: string | number | null };
|
|
373
|
+
type RawTask = { userTaskKey: string; elementId?: string; processInstanceKey?: string; rootProcessInstanceKey?: string | number | null; state?: string; formKey?: string | number | null };
|
|
374
374
|
|
|
375
375
|
/** Stub `globalThis.fetch` so `pollUserTasks`' engine-first sweep reads its open tasks from `tasks`.
|
|
376
376
|
* Honours the `page.from`/`page.limit` pagination the sweep drives, and 404s any other path so a stray
|
|
@@ -444,7 +444,6 @@ test("pollUserTasks (engine-first): orphaned plan-review and PR-wait escalations
|
|
|
444
444
|
});
|
|
445
445
|
|
|
446
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
447
|
// from it exactly as the per-subject scan produced — the sweep maps by `processInstanceKey`.
|
|
449
448
|
const { data, stores } = memData({
|
|
450
449
|
feature_runs: [
|
|
@@ -479,6 +478,40 @@ test("pollUserTasks (engine-first): a TRACKED task is still fully enriched from
|
|
|
479
478
|
assertEquals(byKey["ut-plan"].question, "scope too broad");
|
|
480
479
|
});
|
|
481
480
|
|
|
481
|
+
test("pollUserTasks (engine-first): a child-cell escalation correlates to its PARENT run via rootProcessInstanceKey (issue #633)", async () => {
|
|
482
|
+
// ADR 0006 S4 (#603/#633): once a slice's implement step runs as a callActivity CHILD cell, the
|
|
483
|
+
// agent-stuck escalation parks on the shared `human-escalation` cell's `escalation` element inside a
|
|
484
|
+
// CHILD instance ("child-pi") whose key NO subject row tracks — the owning feature run is tracked under
|
|
485
|
+
// the PARENT/root instance ("fp-10") the engine reports as `rootProcessInstanceKey`. The poller must
|
|
486
|
+
// correlate the child-instance task back to the parent run (subject + question + kind), not strand it
|
|
487
|
+
// as an orphan keyed to the raw child instance.
|
|
488
|
+
const { data, stores } = memData({
|
|
489
|
+
feature_runs: [
|
|
490
|
+
{ 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 },
|
|
491
|
+
],
|
|
492
|
+
feature_escalations: [
|
|
493
|
+
{ id: 1, feature_key: "o/r#10", question: "which framework?", created_at: "2025-01-01T00:00:00.000Z", job_key: "j1" },
|
|
494
|
+
],
|
|
495
|
+
});
|
|
496
|
+
const restore = stubUserTaskSearch([
|
|
497
|
+
{ userTaskKey: "ut-child", elementId: "escalation", processInstanceKey: "child-pi", rootProcessInstanceKey: "fp-10", state: "CREATED" },
|
|
498
|
+
]);
|
|
499
|
+
try {
|
|
500
|
+
await pollUserTasks(data, fakeEngine({}), REST);
|
|
501
|
+
} finally {
|
|
502
|
+
restore();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const byKey = Object.fromEntries((stores.user_tasks ?? []).map((r) => [r.user_task_key, r]));
|
|
506
|
+
assertEquals(Object.keys(byKey), ["ut-child"]);
|
|
507
|
+
assertEquals(byKey["ut-child"].element_id, "escalation");
|
|
508
|
+
assertEquals(byKey["ut-child"].kind_label, "Feature escalation");
|
|
509
|
+
assertEquals(byKey["ut-child"].subject_type, "feature");
|
|
510
|
+
assertEquals(byKey["ut-child"].subject_key, "o/r#10"); // correlated to the PARENT run, not the child instance
|
|
511
|
+
assertEquals(byKey["ut-child"].subject_title, "Add the framework selector");
|
|
512
|
+
assertEquals(byKey["ut-child"].question, "which framework?"); // same feature_escalations log via the parent subject
|
|
513
|
+
});
|
|
514
|
+
|
|
482
515
|
test("pollUserTasks (engine-first): never leaks a non-escalation element nor a non-CREATED task", async () => {
|
|
483
516
|
// The `USER_TASK_KIND_LABELS` gate keeps an arbitrary internal user task out of the inbox, and the
|
|
484
517
|
// defensive state re-filter drops a lagging COMPLETED/CANCELED read (a dead affordance, #294) even if
|
|
@@ -825,6 +858,47 @@ test("pollUserTasks (engine-first): does NOT heal a JUST-escalated run inside th
|
|
|
825
858
|
assertEquals(byKey["o/r#old"].status, "running", "a genuinely-stranded (old) run is still healed");
|
|
826
859
|
});
|
|
827
860
|
|
|
861
|
+
test("pollUserTasks (engine-first): does NOT heal an escalated run parked in a callActivity CHILD instance the sweep missed (issue #633)", async () => {
|
|
862
|
+
// A run's escalation can park inside a callActivity CHILD instance on the shared `human-escalation`
|
|
863
|
+
// cell's `escalation` element (ADR 0006 S4, #633), correlated back to the parent run via the root key.
|
|
864
|
+
// When this pass's engine-first sweep is truncated/unavailable, that child task is absent from `desired`,
|
|
865
|
+
// so the run falls through to the per-instance confirmation. Confirming ONLY the parent `process_key`
|
|
866
|
+
// (whose own open tasks are empty — the escalation lives in the CHILD instance) would read "no
|
|
867
|
+
// escalation open" and wrongly flip a genuinely-parked run back to `running`. The confirmation must
|
|
868
|
+
// include the callActivity descendants when the raw-REST surface is available.
|
|
869
|
+
const stale = new Date(Date.now() - 60 * 60_000).toISOString(); // past the heal grace window
|
|
870
|
+
const { data, stores } = memData({
|
|
871
|
+
feature_runs: [
|
|
872
|
+
{ feature_key: "o/r#child", status: "escalated", process_key: "fp-parent", updated_at: stale, issue_url: null, title: "parked in child cell", delivery_label: null },
|
|
873
|
+
],
|
|
874
|
+
});
|
|
875
|
+
// The sweep is unavailable (returns empty), so fp-parent is NOT confirmed parked via `desired`; the
|
|
876
|
+
// descendant walk over `/process-instances/search` surfaces the child instance carrying the escalation.
|
|
877
|
+
const orig = globalThis.fetch;
|
|
878
|
+
// biome-ignore lint/suspicious/noExplicitAny: minimal fetch double for the raw-REST search surfaces
|
|
879
|
+
globalThis.fetch = (async (url: string | URL, init?: any) => {
|
|
880
|
+
const u = String(url);
|
|
881
|
+
if (u.endsWith("/user-tasks/search")) {
|
|
882
|
+
return new Response(JSON.stringify({ items: [] }), { status: 200, headers: { "content-type": "application/json" } });
|
|
883
|
+
}
|
|
884
|
+
if (u.endsWith("/process-instances/search")) {
|
|
885
|
+
const parent = JSON.parse(init?.body ?? "{}")?.filter?.parentProcessInstanceKey;
|
|
886
|
+
const items = parent === "fp-parent" ? [{ processInstanceKey: "child-1" }] : [];
|
|
887
|
+
return new Response(JSON.stringify({ items }), { status: 200, headers: { "content-type": "application/json" } });
|
|
888
|
+
}
|
|
889
|
+
return new Response("not found", { status: 404 });
|
|
890
|
+
}) as typeof fetch;
|
|
891
|
+
// The escalation is parked in the CHILD instance on the `escalation` element, not on the parent.
|
|
892
|
+
const engine = fakeEngine({ "fp-parent": [], "child-1": [{ userTaskKey: "ut-child", elementId: "escalation" }] });
|
|
893
|
+
try {
|
|
894
|
+
await pollUserTasks(data, engine, REST);
|
|
895
|
+
} finally {
|
|
896
|
+
globalThis.fetch = orig;
|
|
897
|
+
}
|
|
898
|
+
const byKey = Object.fromEntries((stores.feature_runs ?? []).map((r) => [r.feature_key, r]));
|
|
899
|
+
assertEquals(byKey["o/r#child"].status, "escalated", "a run parked in a callActivity child cell survives when the sweep missed it");
|
|
900
|
+
});
|
|
901
|
+
|
|
828
902
|
test("pollUserTasks (typed-seam fallback): self-heals an escalated run with no open feature-escalation task (issue #642)", async () => {
|
|
829
903
|
// The reduced-capability path scans FEATURE_ACTIVE_STATUSES instances (incl. `escalated`) directly,
|
|
830
904
|
// so the per-instance open-task read is just as authoritative for the self-heal.
|
package/app/service.ts
CHANGED
|
@@ -88,6 +88,7 @@ import { clampNudgeMinutes, reviewWaitTimeout } from "./reviewWait.ts";
|
|
|
88
88
|
import { trialMergeAudits } from "./trialMerge.ts";
|
|
89
89
|
import {
|
|
90
90
|
buildUserTaskRow,
|
|
91
|
+
HUMAN_ESCALATION_ELEMENT,
|
|
91
92
|
latestFeatureEscalationQuestion,
|
|
92
93
|
latestOpenEscalationQuestion,
|
|
93
94
|
latestPlanReviewFindings,
|
|
@@ -2270,6 +2271,14 @@ interface UserTaskSearchItem {
|
|
|
2270
2271
|
userTaskKey?: string | number;
|
|
2271
2272
|
elementId?: string;
|
|
2272
2273
|
processInstanceKey?: string | number;
|
|
2274
|
+
/** The top-level ancestor process instance in this task's callActivity hierarchy — present for
|
|
2275
|
+
* hierarchies created on an engine carrying the read-model parent/root keys (Magikcraft/nano-bpm#977,
|
|
2276
|
+
* engine-wasm ≥ 0.8.4). Equals `processInstanceKey` for a top-level task; for a task parked inside a
|
|
2277
|
+
* callActivity CHILD instance (the shared `human-escalation`/`implement-cell` cells, ADR 0006 S4) it
|
|
2278
|
+
* is the PARENT run's instance, so the poller can correlate the child-instance task back to the
|
|
2279
|
+
* tracked feature/plan subject (issue #633). Absent (`null`) on a pre-#977 engine or a top-level
|
|
2280
|
+
* task. The wire may send a JSON number or string. */
|
|
2281
|
+
rootProcessInstanceKey?: string | number | null;
|
|
2273
2282
|
state?: string;
|
|
2274
2283
|
/** The engine's resolution of the task's `.form` linkage (its `formId="X"`) to the deployed form's
|
|
2275
2284
|
* key, attached to the open task. Denormalised onto the row so the collapsed Tasks grid can render
|
|
@@ -2282,6 +2291,10 @@ interface OpenUserTask {
|
|
|
2282
2291
|
userTaskKey: string;
|
|
2283
2292
|
elementId: string;
|
|
2284
2293
|
processInstanceKey: string;
|
|
2294
|
+
/** The top-level ancestor instance key (`""` when the engine did not report one — a pre-#977 engine
|
|
2295
|
+
* or a top-level task), used to correlate a callActivity child-instance task back to its parent run
|
|
2296
|
+
* (issue #633). */
|
|
2297
|
+
rootProcessInstanceKey: string;
|
|
2285
2298
|
/** The engine-reported `formKey`, or "" when the search omitted it (the poller then falls back to the
|
|
2286
2299
|
* kind's static `.form` linkage). */
|
|
2287
2300
|
formKey: string;
|
|
@@ -2326,7 +2339,7 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
|
|
|
2326
2339
|
const userTaskKey = it.userTaskKey == null ? "" : String(it.userTaskKey);
|
|
2327
2340
|
if (!userTaskKey || seen.has(userTaskKey)) continue;
|
|
2328
2341
|
seen.add(userTaskKey);
|
|
2329
|
-
out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey), formKey: it.formKey == null ? "" : String(it.formKey) });
|
|
2342
|
+
out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey), rootProcessInstanceKey: it.rootProcessInstanceKey == null ? "" : String(it.rootProcessInstanceKey), formKey: it.formKey == null ? "" : String(it.formKey) });
|
|
2330
2343
|
}
|
|
2331
2344
|
if (items.length < limit) break; // last page
|
|
2332
2345
|
from += items.length;
|
|
@@ -2360,23 +2373,121 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
|
|
|
2360
2373
|
* completed task's row is deleted (answered here, via the task inbox, or out-of-band) and `showCount`
|
|
2361
2374
|
* reflects live pending work. Best-effort + idempotent — per-instance failures are isolated so one bad
|
|
2362
2375
|
* instance never stalls the pass. */
|
|
2376
|
+
/** The subset of a Camunda-8 `/v2/process-instances/search` result item this app reads to walk a
|
|
2377
|
+
* callActivity instance hierarchy (issue #633). `processInstanceKey` is the instance; the engine's
|
|
2378
|
+
* read-model parent/root keys (Magikcraft/nano-bpm#977) let a caller relate a child instance to its
|
|
2379
|
+
* ancestors — this pass only needs `processInstanceKey` (it filters BY `parentProcessInstanceKey`, so
|
|
2380
|
+
* every returned row is by construction a child of the queried parent). Keys are stringified
|
|
2381
|
+
* defensively (the wire may send a JSON number or string). */
|
|
2382
|
+
interface ProcessInstanceSearchItem {
|
|
2383
|
+
processInstanceKey?: string | number;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
/** Enumerate every DESCENDANT process instance of `rootKey` in its callActivity hierarchy (issue #633)
|
|
2387
|
+
* over the raw Camunda-8 `/v2/process-instances/search` surface, walking `parentProcessInstanceKey`
|
|
2388
|
+
* breadth-first. Returns the descendant instance keys (NOT including `rootKey` itself). The engine
|
|
2389
|
+
* exposes a `parentProcessInstanceKey` filter but no `rootProcessInstanceKey` one, so the hierarchy is
|
|
2390
|
+
* walked level by level (parent → children → grandchildren): the fine-grained cells nest two deep
|
|
2391
|
+
* (`plan-fanout` → `implement-cell` → `human-escalation`), and a deeper future graph is covered by the
|
|
2392
|
+
* BFS. Bounded by a total-instance guard so a pathological/looping hierarchy can neither explode nor
|
|
2393
|
+
* spin; best-effort transport — a failed page/level returns what was gathered so far. Deduped so a
|
|
2394
|
+
* re-parented row can't be walked twice. */
|
|
2395
|
+
async function searchDescendantInstanceKeys(base: string, headers: Record<string, string>, rootKey: string): Promise<string[]> {
|
|
2396
|
+
const out: string[] = [];
|
|
2397
|
+
const seen = new Set<string>([rootKey]);
|
|
2398
|
+
let frontier = [rootKey];
|
|
2399
|
+
const limit = 100;
|
|
2400
|
+
const MAX_INSTANCES = 1000; // hard cap so a pathological hierarchy can't unbounded-fan-out this pass
|
|
2401
|
+
for (let depth = 0; depth < 64 && frontier.length > 0 && out.length < MAX_INSTANCES; depth++) {
|
|
2402
|
+
const next: string[] = [];
|
|
2403
|
+
for (const parent of frontier) {
|
|
2404
|
+
let from = 0;
|
|
2405
|
+
for (let guard = 0; guard < 1000; guard++) {
|
|
2406
|
+
let items: ProcessInstanceSearchItem[];
|
|
2407
|
+
try {
|
|
2408
|
+
const res = await fetch(`${base}/process-instances/search`, {
|
|
2409
|
+
method: "POST",
|
|
2410
|
+
headers,
|
|
2411
|
+
body: JSON.stringify({ filter: { parentProcessInstanceKey: parent }, page: { from, limit } }),
|
|
2412
|
+
});
|
|
2413
|
+
if (!res.ok) break; // engine unhappy → walk what we have, retry next pass
|
|
2414
|
+
// biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
|
|
2415
|
+
const body = (await res.json()) as { items?: ProcessInstanceSearchItem[] };
|
|
2416
|
+
items = body.items ?? [];
|
|
2417
|
+
} catch (err) {
|
|
2418
|
+
console.error(`[poller] process-instance hierarchy walk (${parent}): ${err}`);
|
|
2419
|
+
break;
|
|
2420
|
+
}
|
|
2421
|
+
for (const it of items) {
|
|
2422
|
+
const key = it.processInstanceKey == null ? "" : String(it.processInstanceKey);
|
|
2423
|
+
if (!key || seen.has(key)) continue;
|
|
2424
|
+
seen.add(key);
|
|
2425
|
+
out.push(key);
|
|
2426
|
+
next.push(key);
|
|
2427
|
+
if (out.length >= MAX_INSTANCES) break;
|
|
2428
|
+
}
|
|
2429
|
+
if (items.length < limit || out.length >= MAX_INSTANCES) break; // last page / capped
|
|
2430
|
+
from += items.length;
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
frontier = next;
|
|
2434
|
+
}
|
|
2435
|
+
return out;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
/** Gather the element instances of an epic's plan-fanout instance AND every callActivity DESCENDANT of
|
|
2439
|
+
* it (issue #633) into ONE flat list the canonical `deriveEpicPhaseLive` reads — so once a wave/slice
|
|
2440
|
+
* runs as a child cell instance (ADR 0006 S4) the "furthest-reached live element" derivation still sees
|
|
2441
|
+
* the token position INSIDE the child, not just the parent spine. Extends the derivation's INPUT via the
|
|
2442
|
+
* engine's native parent/root traversal rather than adding a second phase deriver (derivation over
|
|
2443
|
+
* duplication). Without the raw-REST surface (`rest` null — unit tests / a degraded no-REST host) it
|
|
2444
|
+
* degrades to the parent instance alone (the pre-composition behaviour), since the typed seam cannot
|
|
2445
|
+
* enumerate children. */
|
|
2446
|
+
async function gatherHierarchyElementInstances(
|
|
2447
|
+
engine: Pick<EngineClient, "searchElementInstances">,
|
|
2448
|
+
rest: { base: string; headers: Record<string, string> } | null,
|
|
2449
|
+
rootKey: string,
|
|
2450
|
+
): Promise<Awaited<ReturnType<EngineClient["searchElementInstances"]>>> {
|
|
2451
|
+
const instanceKeys = [rootKey];
|
|
2452
|
+
if (rest) {
|
|
2453
|
+
const descendants = await searchDescendantInstanceKeys(rest.base, rest.headers, rootKey);
|
|
2454
|
+
instanceKeys.push(...descendants);
|
|
2455
|
+
}
|
|
2456
|
+
const all: Awaited<ReturnType<EngineClient["searchElementInstances"]>> = [];
|
|
2457
|
+
for (const key of instanceKeys) {
|
|
2458
|
+
all.push(...(await engine.searchElementInstances({ processInstanceKey: key })));
|
|
2459
|
+
}
|
|
2460
|
+
return all;
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2363
2463
|
/** Poll pass (S8, #542 / ADR 0006 §4b): reconcile each LIVE epic's `plans.epic_phase` from the engine
|
|
2364
2464
|
* element-instance model — the PURE read-model derivation that RETIRES the write-time stamp the spine
|
|
2365
2465
|
* workers used to write. For each plan still live (`EPIC_LIVE_STATUSES`) with a running instance, read
|
|
2366
|
-
* its element instances (`searchElementInstances`, nano-ide#473)
|
|
2367
|
-
*
|
|
2368
|
-
*
|
|
2369
|
-
*
|
|
2370
|
-
*
|
|
2371
|
-
*
|
|
2372
|
-
*
|
|
2373
|
-
*
|
|
2466
|
+
* its element instances (`searchElementInstances`, nano-ide#473) — the plan-fanout instance AND, once
|
|
2467
|
+
* the raw-REST surface is available, every callActivity CHILD cell instance (ADR 0006 S4, #603/#633),
|
|
2468
|
+
* gathered via the engine's native parent/root traversal (`gatherHierarchyElementInstances`) so the
|
|
2469
|
+
* furthest-reached element INSIDE a child cell is seen — and project the furthest-reached active spine
|
|
2470
|
+
* element onto its domain phase (`deriveEpicPhaseLive`, app/epicPhase.ts — the SAME `ELEMENT_PHASE`
|
|
2471
|
+
* structural map the stamp used). The wave label rides the `plan_wave_progress` rollup VIEW (the single
|
|
2472
|
+
* wave-frontier source, 060/082), so the Implementing band reads `wave n/t` without a second wave
|
|
2473
|
+
* derivation. Writes only on a real change (a steady-state pass is a no-op) and leaves the last phase
|
|
2474
|
+
* untouched when nothing active marks one (`null`), so a plan parked on non-spine plumbing never
|
|
2475
|
+
* clobbers to blank. The terminal `Dispatched` phase is a COMPLETION marker (no ACTIVE token to read
|
|
2476
|
+
* once the instance ends), so a second pass derives it from the durable terminal status
|
|
2374
2477
|
* (`deriveTerminalEpicPhase` over `done` epics) rather than the fleeting ACTIVE `record-results` token
|
|
2375
2478
|
* a coarse poll would miss. Best-effort + idempotent — a per-plan failure is isolated. */
|
|
2376
2479
|
export async function pollEpicPhase(
|
|
2377
2480
|
data: DataLayer,
|
|
2378
2481
|
engine: Pick<EngineClient, "searchElementInstances">,
|
|
2482
|
+
engineRest?: { restAddress: string; token?: string },
|
|
2379
2483
|
) {
|
|
2484
|
+
const rest = engineRest
|
|
2485
|
+
? (() => {
|
|
2486
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
2487
|
+
if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
|
|
2488
|
+
return { base: engineRest.restAddress.replace(/\/+$/, ""), headers };
|
|
2489
|
+
})()
|
|
2490
|
+
: null;
|
|
2380
2491
|
const waveByPlan = new Map<string, { current: number | null; total: number | null }>();
|
|
2381
2492
|
for (const w of await data
|
|
2382
2493
|
.table<{ plan_key: string; wave_count: number | null; current_wave: number | null }>(
|
|
@@ -2396,7 +2507,7 @@ export async function pollEpicPhase(
|
|
|
2396
2507
|
for (const plan of await plans(data).find({ status })) {
|
|
2397
2508
|
if (!plan.process_key) continue;
|
|
2398
2509
|
try {
|
|
2399
|
-
const elements = await engine
|
|
2510
|
+
const elements = await gatherHierarchyElementInstances(engine, rest, plan.process_key);
|
|
2400
2511
|
const phase = deriveEpicPhaseLive(elements, waveByPlan.get(plan.plan_key) ?? undefined);
|
|
2401
2512
|
if (phase !== null && phase !== plan.epic_phase) {
|
|
2402
2513
|
await plans(data).update(plan.plan_key, { epic_phase: phase, updated_at: now() });
|
|
@@ -2535,6 +2646,15 @@ export async function pollUserTasks(
|
|
|
2535
2646
|
engineRest?: { restAddress: string; token?: string },
|
|
2536
2647
|
) {
|
|
2537
2648
|
const at = now();
|
|
2649
|
+
// Raw-REST context (present in production; the reduced-capability seam host passes no `engineRest`) —
|
|
2650
|
+
// used by the engine-first sweep AND the escalation self-heal's callActivity-hierarchy confirmation.
|
|
2651
|
+
const rest = engineRest
|
|
2652
|
+
? (() => {
|
|
2653
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
2654
|
+
if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
|
|
2655
|
+
return { base: engineRest.restAddress.replace(/\/+$/, ""), headers };
|
|
2656
|
+
})()
|
|
2657
|
+
: null;
|
|
2538
2658
|
|
|
2539
2659
|
// ── Enrichment: subject descriptors keyed by the ENGINE process-instance the task parks on ────────
|
|
2540
2660
|
// Built from EVERY subject row (regardless of status), so a task on a subject whose row already went
|
|
@@ -2578,6 +2698,7 @@ export async function pollUserTasks(
|
|
|
2578
2698
|
// when tracking is lost, so the fallback row still buckets correctly on the page.
|
|
2579
2699
|
const DEFAULT_SUBJECT_TYPE: Readonly<Record<string, "feature" | "plan" | "pr">> = {
|
|
2580
2700
|
[FEATURE_ESCALATION_ELEMENT]: "feature",
|
|
2701
|
+
[HUMAN_ESCALATION_ELEMENT]: "feature",
|
|
2581
2702
|
[FEATURE_BLOCKED_ELEMENT]: "feature",
|
|
2582
2703
|
[PLAN_REVIEW_ELEMENT]: "plan",
|
|
2583
2704
|
[TRIAL_MERGE_ELEMENT]: "plan",
|
|
@@ -2590,9 +2711,17 @@ export async function pollUserTasks(
|
|
|
2590
2711
|
// element + the instance it parks on) into its desired-row context, enriching from its subject row
|
|
2591
2712
|
// when the instance is tracked or a per-kind fallback when it is orphaned. Returns `null` for a
|
|
2592
2713
|
// non-escalation element (the leak guard) so an arbitrary internal user task can never reach the inbox.
|
|
2593
|
-
|
|
2714
|
+
//
|
|
2715
|
+
// Parent/root correlation (#603/#633): a task parked inside a callActivity CHILD instance (the shared
|
|
2716
|
+
// `human-escalation`/`implement-cell` cells, ADR 0006 S4) has a `processInstanceKey` no subject row
|
|
2717
|
+
// tracks — its owning feature/plan run is the PARENT run, tracked under the `rootProcessInstanceKey`
|
|
2718
|
+
// the engine reports (Magikcraft/nano-bpm#977). So the subject resolves off the DIRECT instance first
|
|
2719
|
+
// and falls back to the ROOT (top-level ancestor) instance, correlating the child-instance escalation
|
|
2720
|
+
// back to the parent run rather than stranding it as an orphan.
|
|
2721
|
+
const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string, rootProcessInstanceKey: string, formKey: string): Promise<UserTaskContext | null> => {
|
|
2594
2722
|
if (userTaskKindLabel(elementId) === undefined) return null;
|
|
2595
|
-
const
|
|
2723
|
+
const root = rootProcessInstanceKey.trim();
|
|
2724
|
+
const subj = subjectByInstance.get(processInstanceKey) ?? (root && root !== processInstanceKey ? subjectByInstance.get(root) : undefined);
|
|
2596
2725
|
// Orphaned-task fallback: the kind implies its aggregate even when no subject row references the
|
|
2597
2726
|
// instance. A delivery-human node's id is inlined (`delivery-human-task__<node>`), so its bucket is
|
|
2598
2727
|
// derived from the predicate rather than the static per-element table.
|
|
@@ -2606,9 +2735,13 @@ export async function pollUserTasks(
|
|
|
2606
2735
|
let question: string | null = null;
|
|
2607
2736
|
switch (elementId) {
|
|
2608
2737
|
case FEATURE_ESCALATION_ELEMENT:
|
|
2738
|
+
case HUMAN_ESCALATION_ELEMENT:
|
|
2609
2739
|
// The escalate arm writes the synthesised question to `feature_escalations` keyed by the subject
|
|
2610
2740
|
// (a standalone slice's `feature_key`, or the epic's `plan_key` for a plan-embedded slice, which
|
|
2611
2741
|
// has no standalone `feature_runs` row) — the same key `subjectKey` resolves to for either subject.
|
|
2742
|
+
// The shared `human-escalation` cell (`escalation`) is the same feature-escalation task relocated
|
|
2743
|
+
// into a callActivity child, so it reads the SAME `feature_escalations` log via the correlated
|
|
2744
|
+
// (parent/root) subject.
|
|
2612
2745
|
question = latestFeatureEscalationQuestion(await featureEscalations(data).find({ feature_key: subjectKey }));
|
|
2613
2746
|
break;
|
|
2614
2747
|
case FEATURE_BLOCKED_ELEMENT:
|
|
@@ -2634,25 +2767,25 @@ export async function pollUserTasks(
|
|
|
2634
2767
|
// Desired set, deduped by completable key (a task is open at most once; guard a page overlap / a
|
|
2635
2768
|
// subject seen under two statuses mid-pass).
|
|
2636
2769
|
const desiredByKey = new Map<string, UserTaskRow>();
|
|
2637
|
-
const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string, formKey: string) => {
|
|
2770
|
+
const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string, rootProcessInstanceKey: string, formKey: string) => {
|
|
2638
2771
|
if (!elementId) return;
|
|
2639
2772
|
const rowKey = userTaskKey.trim();
|
|
2640
2773
|
if (!rowKey || desiredByKey.has(rowKey)) return;
|
|
2641
|
-
const ctx = await contextFor(elementId, userTaskKey, processInstanceKey, formKey);
|
|
2774
|
+
const ctx = await contextFor(elementId, userTaskKey, processInstanceKey, rootProcessInstanceKey, formKey);
|
|
2642
2775
|
if (!ctx) return;
|
|
2643
2776
|
const row = buildUserTaskRow(ctx, at);
|
|
2644
2777
|
if (row) desiredByKey.set(rowKey, row);
|
|
2645
2778
|
};
|
|
2646
2779
|
|
|
2647
|
-
if (
|
|
2648
|
-
const
|
|
2649
|
-
|
|
2650
|
-
if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
|
|
2651
|
-
for (const t of await sweepOpenEscalationTasks(base, headers)) {
|
|
2652
|
-
await project(t.elementId, t.userTaskKey, t.processInstanceKey, t.formKey);
|
|
2780
|
+
if (rest) {
|
|
2781
|
+
for (const t of await sweepOpenEscalationTasks(rest.base, rest.headers)) {
|
|
2782
|
+
await project(t.elementId, t.userTaskKey, t.processInstanceKey, t.rootProcessInstanceKey, t.formKey);
|
|
2653
2783
|
}
|
|
2654
2784
|
} else {
|
|
2655
2785
|
// Reduced-capability fallback (no raw-REST surface): typed-seam per-active-subject scan, tracked-only.
|
|
2786
|
+
// The typed `openUserTasks` seam carries no parent/root key, so a child-instance task cannot be
|
|
2787
|
+
// correlated here — this path reaches a task only THROUGH the tracked subject whose OWN instance it
|
|
2788
|
+
// parks on (root correlation is a no-op, passed as "").
|
|
2656
2789
|
const seen = new Set<string>();
|
|
2657
2790
|
const scanInstance = async (processKey: string | null | undefined) => {
|
|
2658
2791
|
if (!processKey || seen.has(processKey)) return;
|
|
@@ -2664,7 +2797,7 @@ export async function pollUserTasks(
|
|
|
2664
2797
|
console.error(`[poller] user tasks (${processKey}): ${err}`);
|
|
2665
2798
|
return;
|
|
2666
2799
|
}
|
|
2667
|
-
for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey, t.formKey ?? "");
|
|
2800
|
+
for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey, "", t.formKey ?? "");
|
|
2668
2801
|
};
|
|
2669
2802
|
for (const status of FEATURE_ACTIVE_STATUSES) for (const run of await featureRuns(data).find({ status })) await scanInstance(run.process_key);
|
|
2670
2803
|
for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
|
|
@@ -2702,32 +2835,62 @@ export async function pollUserTasks(
|
|
|
2702
2835
|
// is never guessed at.
|
|
2703
2836
|
//
|
|
2704
2837
|
// Presence in THIS pass's `desired` set is itself POSITIVE evidence of parking (truncation only ever
|
|
2705
|
-
// DROPS tasks, never invents one), so a run whose
|
|
2706
|
-
//
|
|
2707
|
-
//
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
)
|
|
2838
|
+
// DROPS tasks, never invents one), so a run whose escalation task was already swept is genuinely
|
|
2839
|
+
// parked — skip its per-instance `openUserTasks` RPC entirely (an avoidable N+1 on every tick). Only a
|
|
2840
|
+
// run NOT confirmed parked by the sweep falls through to the per-instance check below.
|
|
2841
|
+
//
|
|
2842
|
+
// A run's escalation may park on its OWN inline `feature-escalation` (same instance as `process_key`)
|
|
2843
|
+
// OR — once its implement step runs as a callActivity child cell (ADR 0006 S4, #603/#633) — on the
|
|
2844
|
+
// shared `human-escalation` cell's `escalation` element inside a CHILD instance, correlated back to
|
|
2845
|
+
// the run via the parent/root key (so the desired row's `subject_key` is the run's `feature_key`, not
|
|
2846
|
+
// the child instance). So the swept set is keyed by BOTH the task's own `process_key` AND its
|
|
2847
|
+
// correlated `subject_key`, and a run counts as parked when either matches (`process_key` for the
|
|
2848
|
+
// inline case, `feature_key` for the child-cell case).
|
|
2849
|
+
const sweptParkedEscalations = new Set<string>();
|
|
2850
|
+
for (const r of desired) {
|
|
2851
|
+
if (r.element_id !== FEATURE_ESCALATION_ELEMENT && r.element_id !== HUMAN_ESCALATION_ELEMENT) continue;
|
|
2852
|
+
if (r.subject_type !== "feature") continue;
|
|
2853
|
+
if (r.process_key) sweptParkedEscalations.add(r.process_key);
|
|
2854
|
+
if (r.subject_key) sweptParkedEscalations.add(r.subject_key);
|
|
2855
|
+
}
|
|
2711
2856
|
for (const run of await featureRuns(data).find({ status: "escalated" })) {
|
|
2712
2857
|
if (!run.process_key) continue;
|
|
2713
|
-
if (sweptParkedEscalations.has(run.process_key)) continue; // already seen parked this pass — no RPC, no heal
|
|
2858
|
+
if (sweptParkedEscalations.has(run.process_key) || sweptParkedEscalations.has(run.feature_key)) continue; // already seen parked this pass — no RPC, no heal
|
|
2714
2859
|
// A just-written escalation may not have its `feature-escalation` user task yet: the sole writer,
|
|
2715
2860
|
// `record-feature-escalation`, stamps `updated_at` immediately BEFORE the engine creates the task.
|
|
2716
2861
|
// Skip healing inside the grace window so this pass never races that transition and steals a fresh
|
|
2717
2862
|
// escalation; a genuinely-stranded (old, or timestamp-less) row is past the window and still healed.
|
|
2718
2863
|
const escalatedAt = Date.parse(run.updated_at ?? "");
|
|
2719
2864
|
if (Number.isFinite(escalatedAt) && Date.now() - escalatedAt < FEATURE_ESCALATION_HEAL_GRACE_MS) continue;
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2865
|
+
// Confirm across the run's WHOLE callActivity hierarchy, not just its parent instance: a child-cell
|
|
2866
|
+
// escalation (ADR 0006 S4, #603/#633) parks on the shared `human-escalation` cell's `escalation`
|
|
2867
|
+
// element inside a DESCENDANT instance that the parent's `openUserTasks` never reports. Confirming
|
|
2868
|
+
// the parent alone would read "no escalation open" and wrongly flip a genuinely-parked child-cell run
|
|
2869
|
+
// back to `running` whenever THIS pass's sweep missed it (truncated/unavailable, so it is absent from
|
|
2870
|
+
// `desired`). Include the callActivity descendants when the raw-REST surface is available; the
|
|
2871
|
+
// reduced-capability seam (no `rest`) cannot walk the hierarchy, so it stays parent-only (child-cell
|
|
2872
|
+
// correlation is a no-op on that path anyway). Any per-instance query error is negative evidence, not
|
|
2873
|
+
// proof the run is unparked — skip the heal and leave the row for a later pass (parity with before).
|
|
2874
|
+
const confirmInstances = [run.process_key];
|
|
2875
|
+
if (rest) confirmInstances.push(...(await searchDescendantInstanceKeys(rest.base, rest.headers, run.process_key)));
|
|
2876
|
+
let stillParked = false;
|
|
2877
|
+
let queryErrored = false;
|
|
2878
|
+
for (const instanceKey of confirmInstances) {
|
|
2879
|
+
let openTasks: { elementId?: string }[];
|
|
2880
|
+
try {
|
|
2881
|
+
openTasks = await engine.openUserTasks({ processInstanceKey: instanceKey });
|
|
2882
|
+
} catch (err) {
|
|
2883
|
+
console.error(`[poller] escalated-run self-heal (${run.feature_key} @ ${instanceKey}): ${err}`);
|
|
2884
|
+
queryErrored = true;
|
|
2885
|
+
break;
|
|
2886
|
+
}
|
|
2887
|
+
if (openTasks.some((t) => t.elementId === FEATURE_ESCALATION_ELEMENT || t.elementId === HUMAN_ESCALATION_ELEMENT)) {
|
|
2888
|
+
stillParked = true;
|
|
2889
|
+
break;
|
|
2890
|
+
}
|
|
2730
2891
|
}
|
|
2892
|
+
if (queryErrored || stillParked) continue;
|
|
2893
|
+
await featureRuns(data).update(run.feature_key, { status: "running", updated_at: at });
|
|
2731
2894
|
}
|
|
2732
2895
|
}
|
|
2733
2896
|
|
|
@@ -2751,7 +2914,7 @@ export async function pollOnce(
|
|
|
2751
2914
|
await pollFeatureDelivery(data);
|
|
2752
2915
|
await pollLineage(data);
|
|
2753
2916
|
await pollUserTasks(data, engine, engineRest);
|
|
2754
|
-
await pollEpicPhase(data, engine);
|
|
2917
|
+
await pollEpicPhase(data, engine, engineRest);
|
|
2755
2918
|
await pollTasklessPlanTermination(data, engine);
|
|
2756
2919
|
await pollDeliveryGraphPhase(data, engine);
|
|
2757
2920
|
await pollDeliveryProposals(data);
|
package/app/userTasks.ts
CHANGED
|
@@ -60,6 +60,17 @@ export const PR_WAIT_MERGE_ANSWER_ELEMENT = "wait-merge-answer";
|
|
|
60
60
|
* hire — a `yolo`-policy request never reaches this path (see `app/agentic/permission-bridge.ts`). */
|
|
61
61
|
export const ACP_PERMISSION_ELEMENT = "acp-permission";
|
|
62
62
|
|
|
63
|
+
/** The shared human-escalation cell's escalation user task (`human-escalation.bpmn`, ADR 0006 S4,
|
|
64
|
+
* issue #603/#633). The fine-grained `implement-cell` delegates its escalation to the reusable
|
|
65
|
+
* `human-escalation` cell via `callActivity`, so once a wave/slice runs as a child instance the
|
|
66
|
+
* agent-stuck escalation parks on THIS element id (`escalation`) inside that child — NOT on the
|
|
67
|
+
* parent's inline `feature-escalation`. It renders the SAME `feature-escalation` form and is the same
|
|
68
|
+
* agent-answerable task escalation, so the inbox surfaces it under the one "Feature escalation" kind
|
|
69
|
+
* and the poller correlates it back to the parent run via the engine's native parent/root instance
|
|
70
|
+
* keys (Magikcraft/nano-bpm#977, the #464 option-B decision). Without this the child-instance
|
|
71
|
+
* escalation is filtered out by the unknown-kind guard and silently vanishes from the Tasks inbox. */
|
|
72
|
+
export const HUMAN_ESCALATION_ELEMENT = "escalation";
|
|
73
|
+
|
|
63
74
|
/** One row per currently-open native user-task escalation, denormalised for the Tasks page. Keyed on
|
|
64
75
|
* the completable `user_task_key` (a task is open at most once). Present iff the engine reports the
|
|
65
76
|
* task open; `pollUserTasks` deletes it once the task is gone. */
|
|
@@ -92,6 +103,7 @@ export const userTasks = (data: DataLayer) => data.table<UserTaskRow>("user_task
|
|
|
92
103
|
* ignored by `buildUserTaskRow`, so an arbitrary internal user task can never leak into the inbox. */
|
|
93
104
|
export const USER_TASK_KIND_LABELS: Readonly<Record<string, string>> = {
|
|
94
105
|
[FEATURE_ESCALATION_ELEMENT]: "Feature escalation",
|
|
106
|
+
[HUMAN_ESCALATION_ELEMENT]: "Feature escalation",
|
|
95
107
|
[FEATURE_BLOCKED_ELEMENT]: "Blocked feature run",
|
|
96
108
|
[PLAN_REVIEW_ELEMENT]: "Plan review",
|
|
97
109
|
[EMPTY_PLAN_ELEMENT]: "Empty plan",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.168.0",
|
|
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",
|