@nanobpm/nano-workforce 0.126.0 → 0.128.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.
Files changed (54) hide show
  1. package/.github/workflows/invariants.yml +8 -0
  2. package/.github/workflows/pr-title-lint.yml +9 -1
  3. package/.github/workflows/release.yml +37 -8
  4. package/AGENTS.md +37 -0
  5. package/CHANGELOG.md +14 -0
  6. package/app/agentCompletion.ts +11 -0
  7. package/app/agentic/cockpit/cockpit-route.test.ts +21 -0
  8. package/app/agentic/cockpit/cockpit-route.ts +17 -0
  9. package/app/agentic/cockpit/index.ts +16 -0
  10. package/app/agentic/cockpit/supply-boot-past.test.ts +44 -0
  11. package/app/agentic/cockpit/supply-boot.test.ts +2 -2
  12. package/app/agentic/cockpit/supply-boot.ts +76 -10
  13. package/app/agentic/cockpit/supply-render.test.ts +13 -4
  14. package/app/agentic/cockpit/supply-render.ts +14 -2
  15. package/app/agentic/cockpit/transcript-render.ts +6 -2
  16. package/app/agentic/cockpit/transcript-view.ts +9 -0
  17. package/app/agentic/cockpit/worker-detail-render.test.ts +86 -0
  18. package/app/agentic/cockpit/worker-detail-render.ts +88 -0
  19. package/app/agentic/cockpit/worker-detail-view.ts +43 -0
  20. package/app/agentic/correlation-store.test.ts +99 -0
  21. package/app/agentic/correlation-store.ts +162 -0
  22. package/app/agentic/families/presence.family.test.ts +12 -0
  23. package/app/agentic/families/presence.family.ts +14 -0
  24. package/app/agentic/families/relay.family.test.ts +72 -0
  25. package/app/agentic/families/relay.family.ts +130 -1
  26. package/app/agentic/transcript-read.test.ts +55 -3
  27. package/app/agentic/transcript-read.ts +49 -9
  28. package/app/agentic/vocab/demand-report.test.ts +23 -10
  29. package/app/pollUserTasks.test.ts +66 -2
  30. package/app/service.ts +21 -8
  31. package/app/tasksPage.test.ts +44 -0
  32. package/app/userTasks.test.ts +19 -0
  33. package/app/userTasks.ts +13 -1
  34. package/db/migrations/077_user_tasks_form_key.sql +25 -0
  35. package/db/migrations/078_agentic_correlation.sql +32 -0
  36. package/docs/adr/0006-delivery-units-one-representation.md +59 -20
  37. package/e2e/delivery-graph.e2e.ts +7 -0
  38. package/e2e/feature-preflight.e2e.ts +7 -0
  39. package/e2e/inter-epic-dependency.e2e.ts +8 -0
  40. package/e2e/plan-fanout-preflight.e2e.ts +7 -0
  41. package/e2e/plan-fanout-sla.e2e.ts +4 -3
  42. package/e2e/plan-fanout.e2e.ts +2 -2
  43. package/e2e/readiness-gate.e2e.ts +8 -37
  44. package/e2e/support/probe-exec.test.ts +78 -0
  45. package/e2e/support/probe-exec.ts +69 -0
  46. package/e2e/support/time.test.ts +42 -0
  47. package/e2e/support/time.ts +33 -0
  48. package/openapi.yaml +26 -0
  49. package/operations/getAgenticTranscript.ts +3 -2
  50. package/operations/listAgenticTranscripts.ts +2 -1
  51. package/package.json +3 -3
  52. package/pages/cockpit/cockpit.css +65 -2
  53. package/pages/cockpit/mount.js +187 -16
  54. package/pages/tasks.page.json +24 -554
package/app/service.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import { readFileSync } from "node:fs";
11
11
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
12
12
  import { ABANDONED_STATUS, abandonUrl, mintAbandonToken, renderAbandonBrief } from "./abandon.ts";
13
+ import { escalationFormId } from "./agentCompletion.ts";
13
14
  import { agentSlaTimeout } from "./agentSla.ts";
14
15
  import {
15
16
  CAPS_RESOLVED_MESSAGE,
@@ -2186,6 +2187,10 @@ interface UserTaskSearchItem {
2186
2187
  elementId?: string;
2187
2188
  processInstanceKey?: string | number;
2188
2189
  state?: string;
2190
+ /** The engine's resolution of the task's `.form` linkage (its `formId="X"`) to the deployed form's
2191
+ * key, attached to the open task. Denormalised onto the row so the collapsed Tasks grid can render
2192
+ * the deployed form per row (issue #461). The wire may send a JSON number or string. */
2193
+ formKey?: string | number;
2189
2194
  }
2190
2195
 
2191
2196
  /** One discovered open escalation user task, normalised for projection. */
@@ -2193,6 +2198,9 @@ interface OpenUserTask {
2193
2198
  userTaskKey: string;
2194
2199
  elementId: string;
2195
2200
  processInstanceKey: string;
2201
+ /** The engine-reported `formKey`, or "" when the search omitted it (the poller then falls back to the
2202
+ * kind's static `.form` linkage). */
2203
+ formKey: string;
2196
2204
  }
2197
2205
 
2198
2206
  /** Engine-first sweep (issue #358): read EVERY open (`CREATED`) native user task from the engine over
@@ -2234,7 +2242,7 @@ async function sweepOpenEscalationTasks(base: string, headers: Record<string, st
2234
2242
  const userTaskKey = it.userTaskKey == null ? "" : String(it.userTaskKey);
2235
2243
  if (!userTaskKey || seen.has(userTaskKey)) continue;
2236
2244
  seen.add(userTaskKey);
2237
- out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey) });
2245
+ out.push({ userTaskKey, elementId, processInstanceKey: it.processInstanceKey == null ? "" : String(it.processInstanceKey), formKey: it.formKey == null ? "" : String(it.formKey) });
2238
2246
  }
2239
2247
  if (items.length < limit) break; // last page
2240
2248
  from += items.length;
@@ -2379,7 +2387,7 @@ export async function pollUserTasks(
2379
2387
  // element + the instance it parks on) into its desired-row context, enriching from its subject row
2380
2388
  // when the instance is tracked or a per-kind fallback when it is orphaned. Returns `null` for a
2381
2389
  // non-escalation element (the leak guard) so an arbitrary internal user task can never reach the inbox.
2382
- const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string): Promise<UserTaskContext | null> => {
2390
+ const contextFor = async (elementId: string, userTaskKey: string, processInstanceKey: string, formKey: string): Promise<UserTaskContext | null> => {
2383
2391
  if (userTaskKindLabel(elementId) === undefined) return null;
2384
2392
  const subj = subjectByInstance.get(processInstanceKey);
2385
2393
  // Orphaned-task fallback: the kind implies its aggregate even when no subject row references the
@@ -2387,6 +2395,11 @@ export async function pollUserTasks(
2387
2395
  // derived from the predicate rather than the static per-element table.
2388
2396
  const subjectType = subj?.type ?? DEFAULT_SUBJECT_TYPE[elementId] ?? (isDeliveryHumanElement(elementId) ? "delivery" : "plan");
2389
2397
  const subjectKey = subj?.key ?? processInstanceKey;
2398
+ // Denormalise the engine `formKey` so the collapsed Tasks grid renders the deployed `.form` per row
2399
+ // (issue #461). Prefer the engine-resolved key the search reported; fall back to the fixed-form kind's
2400
+ // static `.form` linkage (`escalationFormId`) when the search omitted it — the delivery-graph `human`
2401
+ // node has no static form (varies per node), so it relies wholly on the engine-reported key.
2402
+ const resolvedFormKey = formKey.trim() || escalationFormId(elementId) || null;
2390
2403
  let question: string | null = null;
2391
2404
  switch (elementId) {
2392
2405
  case FEATURE_ESCALATION_ELEMENT:
@@ -2412,17 +2425,17 @@ export async function pollUserTasks(
2412
2425
  question = conformanceEscalationQuestion(subj ? { summary: subj.conformanceSummary } : undefined);
2413
2426
  break;
2414
2427
  }
2415
- return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey };
2428
+ return { userTaskKey, elementId, subjectType, subjectKey, subjectTitle: subj?.title ?? null, subjectUrl: subj?.url ?? null, question, processKey: processInstanceKey, formKey: resolvedFormKey };
2416
2429
  };
2417
2430
 
2418
2431
  // Desired set, deduped by completable key (a task is open at most once; guard a page overlap / a
2419
2432
  // subject seen under two statuses mid-pass).
2420
2433
  const desiredByKey = new Map<string, UserTaskRow>();
2421
- const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string) => {
2434
+ const project = async (elementId: string | undefined, userTaskKey: string, processInstanceKey: string, formKey: string) => {
2422
2435
  if (!elementId) return;
2423
2436
  const rowKey = userTaskKey.trim();
2424
2437
  if (!rowKey || desiredByKey.has(rowKey)) return;
2425
- const ctx = await contextFor(elementId, userTaskKey, processInstanceKey);
2438
+ const ctx = await contextFor(elementId, userTaskKey, processInstanceKey, formKey);
2426
2439
  if (!ctx) return;
2427
2440
  const row = buildUserTaskRow(ctx, at);
2428
2441
  if (row) desiredByKey.set(rowKey, row);
@@ -2433,7 +2446,7 @@ export async function pollUserTasks(
2433
2446
  const headers: Record<string, string> = { "content-type": "application/json" };
2434
2447
  if (engineRest.token) headers.authorization = `Bearer ${engineRest.token}`;
2435
2448
  for (const t of await sweepOpenEscalationTasks(base, headers)) {
2436
- await project(t.elementId, t.userTaskKey, t.processInstanceKey);
2449
+ await project(t.elementId, t.userTaskKey, t.processInstanceKey, t.formKey);
2437
2450
  }
2438
2451
  } else {
2439
2452
  // Reduced-capability fallback (no raw-REST surface): typed-seam per-active-subject scan, tracked-only.
@@ -2441,14 +2454,14 @@ export async function pollUserTasks(
2441
2454
  const scanInstance = async (processKey: string | null | undefined) => {
2442
2455
  if (!processKey || seen.has(processKey)) return;
2443
2456
  seen.add(processKey);
2444
- let tasks: { userTaskKey: string; elementId?: string }[];
2457
+ let tasks: { userTaskKey: string; elementId?: string; formKey?: string }[];
2445
2458
  try {
2446
2459
  tasks = await engine.openUserTasks({ processInstanceKey: processKey });
2447
2460
  } catch (err) {
2448
2461
  console.error(`[poller] user tasks (${processKey}): ${err}`);
2449
2462
  return;
2450
2463
  }
2451
- for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey);
2464
+ for (const t of tasks) await project(t.elementId, t.userTaskKey, processKey, t.formKey ?? "");
2452
2465
  };
2453
2466
  for (const status of FEATURE_ACTIVE_STATUSES) for (const run of await featureRuns(data).find({ status })) await scanInstance(run.process_key);
2454
2467
  for (const status of PLAN_ACTIVE_STATUSES) for (const plan of await plans(data).find({ status })) await scanInstance(plan.process_key);
@@ -0,0 +1,44 @@
1
+ // Structure guard for the collapsed Tasks page (issue #461). The Tasks page is ONE `user_tasks`
2
+ // dataGrid (`filter: []`, `orderBy updated_at desc`) with `kind_label` as a "Type" column, completing
3
+ // each row via its ENGINE-declared form (nano-ide#457's `detail.engineForm`) — not seven
4
+ // `element_id`-allowlisted grids each with a hand-authored `detail.form` that duplicates a deployed
5
+ // `.form` resource AND leaves dynamic-id delivery-graph tasks (counted by the `filter: []` badge)
6
+ // rendered by no grid. This test pins that end-state so the anti-pattern can't creep back:
7
+ // • exactly one `dataGrid`, over `user_tasks`, unfiltered, recency-ordered → badge ≡ list;
8
+ // • a "Type" column bound to `kind_label`;
9
+ // • an engine-form detail (`form_key` / `user_task_key`), and NO page-local `detail.form`.
10
+ import { readFileSync } from "node:fs";
11
+ import { fileURLToPath } from "node:url";
12
+ import { test } from "node:test";
13
+ import { assert, assertEquals } from "#test-assert";
14
+
15
+ // biome-ignore lint/suspicious/noExplicitAny: reading an untyped page manifest for structural assertions
16
+ const page: any = JSON.parse(readFileSync(fileURLToPath(new URL("../pages/tasks.page.json", import.meta.url)), "utf8"));
17
+ // biome-ignore lint/suspicious/noExplicitAny: see above
18
+ const nodes: any[] = page.nodes ?? [];
19
+ // biome-ignore lint/suspicious/noExplicitAny: see above
20
+ const grids: any[] = nodes.filter((n) => n.type === "dataGrid");
21
+
22
+ test("Tasks page is exactly ONE dataGrid over user_tasks (filter [], orderBy updated_at desc)", () => {
23
+ assertEquals(grids.length, 1);
24
+ const [grid] = grids;
25
+ assertEquals(grid.props.data.table, "user_tasks");
26
+ assertEquals(grid.props.data.filter, []);
27
+ assertEquals(grid.props.data.orderBy, { field: "updated_at", dir: "desc" });
28
+ });
29
+
30
+ test("Tasks grid surfaces kind_label as a 'Type' column", () => {
31
+ // biome-ignore lint/suspicious/noExplicitAny: see above
32
+ const typeCol = grids[0].props.columns.find((c: any) => c.field === "kind_label");
33
+ assert(typeCol, "a kind_label column must exist");
34
+ assertEquals(typeCol.header, "Type");
35
+ });
36
+
37
+ test("Tasks grid completes each row via its engine-declared form (nano-ide#457), not a page-local detail.form", () => {
38
+ const { detail } = grids[0].props;
39
+ assert(detail?.engineForm, "the grid detail must opt into engineForm rendering");
40
+ assertEquals(detail.engineForm.formKeyField, "form_key");
41
+ assertEquals(detail.engineForm.userTaskKeyField, "user_task_key");
42
+ // The seven bespoke per-type detail.form blocks (each a copy of a deployed `.form`) are gone.
43
+ for (const g of grids) assert(!g.props.detail?.form, "no grid may carry a page-local detail.form");
44
+ });
@@ -49,11 +49,30 @@ test("buildUserTaskRow: a plan-review task becomes a labelled row with its findi
49
49
  subject_url: "https://github.com/o/r/issues/1",
50
50
  question: "cap reached: revise scope",
51
51
  process_key: "pk-1",
52
+ form_key: null,
52
53
  created_at: AT,
53
54
  updated_at: AT,
54
55
  });
55
56
  });
56
57
 
58
+ test("buildUserTaskRow: denormalises the engine form_key, trimming blanks to null (issue #461)", () => {
59
+ const withForm = buildUserTaskRow(
60
+ { userTaskKey: "ut-f", elementId: PLAN_REVIEW_ELEMENT, subjectType: "plan", subjectKey: "o/r#1", formKey: " form-9 " },
61
+ AT,
62
+ );
63
+ assertEquals(withForm?.form_key, "form-9");
64
+ const blank = buildUserTaskRow(
65
+ { userTaskKey: "ut-b", elementId: PLAN_REVIEW_ELEMENT, subjectType: "plan", subjectKey: "o/r#1", formKey: " " },
66
+ AT,
67
+ );
68
+ assertEquals(blank?.form_key, null);
69
+ const absent = buildUserTaskRow(
70
+ { userTaskKey: "ut-n", elementId: PLAN_REVIEW_ELEMENT, subjectType: "plan", subjectKey: "o/r#1" },
71
+ AT,
72
+ );
73
+ assertEquals(absent?.form_key, null);
74
+ });
75
+
57
76
  test("buildUserTaskRow: a blank question / missing url normalises to null", () => {
58
77
  const row = buildUserTaskRow(
59
78
  { userTaskKey: "ut-2", elementId: TRIAL_MERGE_ELEMENT, subjectType: "plan", subjectKey: "o/r#2", question: " " },
package/app/userTasks.ts CHANGED
@@ -60,6 +60,11 @@ export interface UserTaskRow {
60
60
  subject_url: string | null;
61
61
  question: string | null;
62
62
  process_key: string | null;
63
+ /** The engine `formKey` of the parked user task's engine-declared form, denormalised so the single
64
+ * collapsed Tasks grid can resolve and render the deployed `.form` per row (nano-ide#457). Derived in
65
+ * the poller from the `/v2/user-tasks/search` result, falling back to the fixed-form kinds' static
66
+ * `.form` linkage; NULL when neither resolves (the grid degrades to bare completion). */
67
+ form_key: string | null;
63
68
  created_at: string;
64
69
  updated_at: string;
65
70
  }
@@ -103,6 +108,10 @@ export interface UserTaskContext {
103
108
  subjectUrl?: string | null;
104
109
  question?: string | null;
105
110
  processKey?: string | null;
111
+ /** The engine-resolved `formKey` of the task's engine-declared form, as the poller read it from the
112
+ * `/v2/user-tasks/search` result (or the fixed-form fallback). Optional/blank tolerated —
113
+ * `buildUserTaskRow` normalises a blank to NULL. */
114
+ formKey?: string | null;
106
115
  }
107
116
 
108
117
  /** Pure: turn one resolved open escalation task into its desired read-model row, or `null` when the
@@ -123,6 +132,7 @@ export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): User
123
132
  const subjectKey = ctx.subjectKey.trim() || (ctx.processKey ?? "").trim() || userTaskKey;
124
133
  const question = typeof ctx.question === "string" && ctx.question.trim() ? ctx.question.trim() : null;
125
134
  const subjectTitle = typeof ctx.subjectTitle === "string" && ctx.subjectTitle.trim() ? ctx.subjectTitle.trim() : subjectKey;
135
+ const formKey = typeof ctx.formKey === "string" && ctx.formKey.trim() ? ctx.formKey.trim() : null;
126
136
  return {
127
137
  user_task_key: userTaskKey,
128
138
  element_id: ctx.elementId,
@@ -133,6 +143,7 @@ export function buildUserTaskRow(ctx: UserTaskContext, at: string = now()): User
133
143
  subject_url: ctx.subjectUrl ?? null,
134
144
  question,
135
145
  process_key: ctx.processKey ?? null,
146
+ form_key: formKey,
136
147
  created_at: at,
137
148
  updated_at: at,
138
149
  };
@@ -158,7 +169,8 @@ function sameRow(a: UserTaskRow, b: UserTaskRow): boolean {
158
169
  a.subject_title === b.subject_title &&
159
170
  a.subject_url === b.subject_url &&
160
171
  a.question === b.question &&
161
- a.process_key === b.process_key
172
+ a.process_key === b.process_key &&
173
+ a.form_key === b.form_key
162
174
  );
163
175
  }
164
176
 
@@ -0,0 +1,25 @@
1
+ -- Denormalise the engine `form_key` onto the unified Tasks-inbox read-model (issue #461) — additive,
2
+ -- nullable. The collapsed Tasks page renders ONE `user_tasks` grid and completes each heterogeneous row
3
+ -- via its ENGINE-declared form (nano-ide#457's `detail.engineForm`), instead of seven
4
+ -- `element_id`-allowlisted grids each with a hand-authored `detail.form` copy of a deployed `.form`.
5
+ -- Rendering the deployed form per row needs the task's engine `formKey` on the row, so the grid can
6
+ -- resolve `GET /app/actions/form?formKey=<row.form_key>` — the SAME single source of truth
7
+ -- `taskInbox` uses (no page-local field duplication).
8
+ --
9
+ -- Derived in the canonical poller path exactly as `kind_label` is (no drift surface): `pollUserTasks`
10
+ -- (app/service.ts) reads the engine-resolved `formKey` from the Camunda `/v2/user-tasks/search` result
11
+ -- and `buildUserTaskRow` (app/userTasks.ts) writes it, falling back to the fixed-form kinds' static
12
+ -- `.form` linkage (`ESCALATION_FORM_BY_ELEMENT`, app/agentCompletion.ts) when the search omits it.
13
+ -- NULL when neither resolves — the grid degrades to bare completion, matching `taskInbox`.
14
+ --
15
+ -- Forward-only, additive (expand): a new nullable column on an existing table, no shape rewrite and no
16
+ -- backfill (the poller repopulates every open row on its next pass). Numbered after the current highest
17
+ -- prefix (076); the runner wraps each file in its own transaction, so this file must NOT contain
18
+ -- BEGIN/COMMIT.
19
+ ALTER TABLE user_tasks ADD COLUMN form_key TEXT;
20
+
21
+ -- The collapsed Tasks page reads `user_tasks` UNFILTERED ordered by `updated_at desc` (pages/tasks.page.json),
22
+ -- an access pattern the existing composite indexes (`(element_id, updated_at)`, `(subject, element_id)`) can't
23
+ -- serve — SQLite would scan + sort the whole table as the inbox grows. Front the unified inbox's sort with a
24
+ -- single-column index on `updated_at`. Additive and idempotent.
25
+ CREATE INDEX IF NOT EXISTS idx_user_tasks_updated ON user_tasks(updated_at);
@@ -0,0 +1,32 @@
1
+ -- Durable per-job worker attribution + engine context (#485, provisioning #232).
2
+ --
3
+ -- The in-memory correlation registry (app/agentic/correlation.ts) is the live jobKey ⇄ worker join,
4
+ -- but it is RELEASED on job end / worker disconnect and is empty after a restart. So a COMPLETED
5
+ -- (past) session — what the cockpit "past sessions" / worker-history view reads — otherwise loses
6
+ -- which worker ran it (instance / identity / host) and its process-instance / plan context. The
7
+ -- package-mirrored transcript store (024_agentic_transcript.sql, byte-for-byte guarded) carries no
8
+ -- correlation columns, so this app-side table closes the gap WITHOUT touching that mirrored schema.
9
+ --
10
+ -- The relay slice records a row here at job-completion time; the transcript read path falls back to
11
+ -- it when the live registry has released the job. Advisory / read-only (ADR 0056) — it NEVER gates a
12
+ -- BPMN sequence flow.
13
+ --
14
+ -- Single source of truth: this DDL mirrors AGENTIC_CORRELATION_SCHEMA_SQL in
15
+ -- app/agentic/correlation-store.ts byte-for-byte; a drift-guard test (correlation-store.test.ts) pins
16
+ -- the two together.
17
+ CREATE TABLE IF NOT EXISTS agentic_correlation (
18
+ job_key TEXT PRIMARY KEY,
19
+ stream TEXT NOT NULL,
20
+ instance TEXT NOT NULL,
21
+ identity TEXT,
22
+ host TEXT,
23
+ process_instance_key TEXT,
24
+ bpmn_process_id TEXT,
25
+ element_id TEXT,
26
+ plan_key TEXT,
27
+ linked_at TEXT,
28
+ completed_at TEXT NOT NULL
29
+ );
30
+ CREATE INDEX IF NOT EXISTS ix_agentic_correlation_instance ON agentic_correlation (instance);
31
+ CREATE INDEX IF NOT EXISTS ix_agentic_correlation_process_instance ON agentic_correlation (process_instance_key);
32
+ CREATE INDEX IF NOT EXISTS ix_agentic_correlation_plan ON agentic_correlation (plan_key);
@@ -20,7 +20,7 @@ to collapse three bespoke status unions into one),
20
20
  nano-ide **#424** (datasource can read a SQL VIEW — the *data-level* unlock),
21
21
  nano-workforce **#416** (the PR bumping the testkit to engine-wasm 0.7.2, which executes `callActivity`
22
22
  — the *process-level* unlock),
23
- nano-workforce **#464** (the tracking issue with slices S1–S5),
23
+ nano-workforce **#464** (the tracking issue with slices S1–S6),
24
24
  nano-workforce **#305** (consolidate escalations on native `user_tasks` — a natural sub-step of S1/S3).
25
25
 
26
26
  ## Context
@@ -89,7 +89,7 @@ because it did nothing. **Unlocked by #416** (engine-wasm 0.4.0 → **0.7.2**).
89
89
  **Verified live:** a `callActivity` parent+child model deployed through engine-wasm 0.7.2 runs to
90
90
  `COMPLETED`. Caveat: #416 bumps only the **dev-only** `@nanobpm/urban-testkit`; the production
91
91
  `@nanobpm/urban` broker does not itself pin `engine-wasm`, so this verification proves the in-process
92
- testkit, not the broker/runtime that will execute future `callActivity` models. S4/S5 therefore also
92
+ testkit, not the broker/runtime that will execute future `callActivity` models. S4–S6 therefore also
93
93
  carry a **deployment-runtime prerequisite** — the deployed broker's `engine-core` must carry the same
94
94
  `callActivity` support — which green testkit CI does not by itself guarantee.
95
95
 
@@ -129,18 +129,52 @@ a copy — expressed here as the **target** state:
129
129
  content digest (`app/deliveryGraphRun.ts` `computeRunKey`) — so S2's compatibility VIEWs map each
130
130
  legacy key onto the new identity, ensuring unrelated runs are never merged onto one row.
131
131
 
132
- ### 2. Process encoding — shared cells composed by `callActivity`
133
-
134
- - Extract the atomic *implement-cell* (and its sibling wait-gate and human-escalation cells) into
135
- standalone processes (`resources/processes/implement-cell.bpmn`, …).
136
- - Compose them by reference replacing only the inlined *implement/escalation segment*, not the
137
- surrounding orchestration: in **feature** (`feature.bpmn`) the readiness preflight, base-branch setup,
138
- `record-feature`, and convergence handoff are retained; only the implement-cell segment becomes one
139
- `callActivity`. **Epic** = the multi-instance `implement` body is a `callActivity`; **delivery graph**
140
- = the compiler *emits* `callActivity` references, not inlined subprocess copies.
132
+ ### 2. Process encoding — **fine-grained** cells composed by `callActivity`
133
+
134
+ The primitive is a set of **small, single-purpose cells** — `implement-cell`, `converge-cell`,
135
+ `merge-cell`, plus the sibling wait-gate and human-escalation cells — each a standalone process
136
+ (`resources/processes/implement-cell.bpmn`, …), composed by reference and gated by edges. This is a
137
+ deliberate choice of granularity **over** a single coarse whole-feature subprocess with opaque
138
+ completion flags.
139
+
140
+ - Compose by reference, replacing the inlined segments, not the surrounding orchestration: in
141
+ **feature** (`feature.bpmn`) the readiness preflight, base-branch setup, and `record-feature` remain;
142
+ the implement/escalation segment and the convergence/merge tail each become `callActivity`s. **Epic**
143
+ = the multi-instance `implement` body is a `callActivity`; **delivery graph** = the compiler *emits*
144
+ `callActivity` references, not inlined subprocess copies.
145
+ - `feature` / `epic` are **derived macros** over the cells, not hand-written processes: a `feature`
146
+ expands to `implement → converge? → merge?`, so the cells stay the single source of truth and the
147
+ common case is still one node. (Same derivation discipline as the data encoding: compose by
148
+ reference, never inline a copy.)
149
+ - **Why fine-grained, not coarse.** A coarse whole-feature `callActivity` with `converge?`/`merge?`
150
+ flags buries those steps *inside* the black box, so a graph can never insert a gate *between*
151
+ "converged" and "merged". Fine-grained cells let a graph converge features A **and** B, then hold and
152
+ land both behind a single `human`/`wait` fan-in — the integration-branch / gated-landing pattern.
153
+ `feature.bpmn` already separates `converge` (its `gw-converge` gateway) from `autoMerge`, so the seam
154
+ exists today; this promotes it to a **node boundary** the graph can wire (see §3).
141
155
  - This is gated on the engine-wasm 0.7.2 unlock, which is now live on `main`.
142
156
 
143
- ### 3. Status lifecycleone derived union
157
+ ### 3. Node completion policy `converge` / `merge` are first-class cells, not smuggled state
158
+
159
+ Today the "get to green, then land" tail lives in two places the delivery graph cannot reach: a gateway
160
+ *inside* `feature.bpmn` (`gw-converge` + the `autoMerge` boolean on `ConvergeFeatureIn`), and — for a
161
+ delivery-graph `agent` node — **free text in a prompt** (`{ jobType: "senior:feature", prompt: "un-draft
162
+ + merge #B" }`), with the graph only *observing* the result via a downstream `wait` node that emits
163
+ `mergedSha`. A graph therefore drives merge by asking an agent nicely, not structurally. Promote them to
164
+ first-class, edge-gated cells:
165
+
166
+ - **`converge`** = drive the PR through its review-convergence loop to green. **`merge`** = land it.
167
+ These are deliberately **separable phases** (`feature.bpmn` already splits `gw-converge` from
168
+ `autoMerge`), so a graph can stop at "green" and gate the landing behind any upstream node.
169
+ - A `feature`/`epic` node's `converge?` / `merge?` selectors choose whether the derived macro (§2)
170
+ includes those cells; omitting `merge` and wiring an explicit `merge` cell downstream of a gate is the
171
+ advanced case.
172
+ - **`merge` is two-level (ADR 0003 base-branch admission).** A unit's `merge` cell lands onto the
173
+ epic/graph **base branch**, never `main` directly; the graph's final merge-to-`main` is a *separate*
174
+ top-level step. `feature.bpmn`'s `autoMerge` is exactly this per-unit knob — do not collapse the two
175
+ levels.
176
+
177
+ ### 4. Status lifecycle — one derived union
144
178
 
145
179
  The three bespoke status unions collapse into **one derived union** via ADR 0065's `defineReadModel`,
146
180
  so a change to lifecycle semantics is made once and derived everywhere, not re-declared per
@@ -153,7 +187,7 @@ representation. These unions are **not** identical today — features use
153
187
  separate node contract — plus the per-shape mapping and precedence and the write/`instanceTracking`
154
188
  behavior — not merely projecting an existing value.
155
189
 
156
- ### 4. Preserve — the static-vs-adaptive execution axis (do NOT bundle it)
190
+ ### 5. Preserve — the static-vs-adaptive execution axis (do NOT bundle it)
157
191
 
158
192
  This ADR consolidates the *representation*, not the *execution strategy*. ADR 0005's deliberate
159
193
  distinction stays intact: **plan-fanout remains adaptive** (agent-discovered slices, waves that adapt),
@@ -183,8 +217,8 @@ their topology is produced. Unifying that axis is explicitly out of scope here.
183
217
 
184
218
  ## Rollout (see #464 for the live checklist)
185
219
 
186
- Each slice is independently shippable; the process slices (S4/S5) are sequenced behind the engine-wasm
187
- 0.7.2 unlock. The **dev-testkit** side of that unlock has landed (#416, verified in-process above); S4/S5
220
+ Each slice is independently shippable; the process slices (S4–S6) are sequenced behind the engine-wasm
221
+ 0.7.2 unlock. The **dev-testkit** side of that unlock has landed (#416, verified in-process above); S4–S6
188
222
  additionally gate on the **deployed broker/runtime** carrying verified `callActivity` support (the
189
223
  deployment-runtime prerequisite noted above), not on #416 alone.
190
224
 
@@ -207,15 +241,20 @@ deployment-runtime prerequisite noted above), not on #416 alone.
207
241
  bindings and every other writer off the legacy tables does the table-to-VIEW contract phase retire the
208
242
  legacy write paths.
209
243
  - **S3 · collapse doors** — unify the three `instanceTracking` bindings + `senior:*` dispatch doors.
210
- - **S4 · shared cells** — extract the atomic `implement-cell.bpmn` **and its sibling wait-gate and
211
- human-escalation cells** (Decision §2) into standalone processes; `feature.bpmn` + the `plan-fanout`
212
- MI body compose them via `callActivity`.
213
- - **S5 · compiler emits calls** — `deliveryGraphCompiler` references shared cells instead of inlining
244
+ - **S4 · fine-grained cells** — extract `implement-cell.bpmn`, `converge-cell.bpmn`, `merge-cell.bpmn`
245
+ **and the sibling wait-gate and human-escalation cells** (Decision §2) into standalone processes;
246
+ `feature.bpmn` + the `plan-fanout` MI body compose them via `callActivity`, with `feature`/`epic` as
247
+ derived macros over the cells.
248
+ - **S5 · `converge?` / `merge?` as first-class node policy** — promote convergence + landing from the
249
+ `feature.bpmn` `gw-converge`/`autoMerge` gateway and the delivery-graph *prompt prose* into edge-gated
250
+ `converge`/`merge` cell nodes on the delivery vocabulary (Decision §3); honour ADR 0003 two-level
251
+ merge (unit → base branch; graph → `main`).
252
+ - **S6 · compiler emits calls** — `deliveryGraphCompiler` references shared cells instead of inlining
214
253
  per-node copies.
215
254
 
216
255
  ## Non-goals / deferred
217
256
 
218
- - **Unifying the static-vs-adaptive execution axis** (see Decision §4) — preserved deliberately.
257
+ - **Unifying the static-vs-adaptive execution axis** (see Decision §5) — preserved deliberately.
219
258
  - **Changing the downstream PR/convergence loop** — already single-sourced (`pull_requests`); untouched.
220
259
  - **Cross-repo/platform representation** — this ADR is nano-workforce-local; any platform-wide delivery
221
260
  aggregate would be a separate nano-bpm ADR.
@@ -24,6 +24,7 @@ import { connectorDedupeKey, deliveryConnectorDispatches, dispatchConnector } fr
24
24
  import { readConnectorInput } from "../workers/delivery-connector/worker.ts";
25
25
  import { runDeliveryGraph } from "../app/deliveryRunner.ts";
26
26
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
27
+ import { deterministicProbeSeam } from "./support/probe-exec.ts";
27
28
 
28
29
  const APP_ROOT = resolve(import.meta.dirname, "..");
29
30
  const GITHUB_ENV: Record<string, string> = { NANO_PR_GITHUB_TRANSPORT: "token", GITHUB_TOKEN: "" };
@@ -57,9 +58,15 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
57
58
  apps.push(app);
58
59
  return app;
59
60
  };
61
+ // The `wait` nodes drive `command: true`/`false` probes through the shared readiness-probe worker.
62
+ // Inject the deterministic exec so they resolve within the virtual clock's drain fixpoint instead
63
+ // of racing a real subprocess `settle()` cannot await (issue #450).
64
+ const probeSeam = deterministicProbeSeam("delivery-graph e2e");
65
+ before(() => probeSeam.install());
60
66
  after(async () => {
61
67
  for (const app of apps) await app.stop?.();
62
68
  for (const d of dirs) rmSync(d, { recursive: true, force: true });
69
+ probeSeam.restoreAndAssertHermetic();
63
70
  });
64
71
 
65
72
  test("runs end-to-end: agent, wait, human execute; edges gate; fan-in works; human fact late-binds into the connector", async () => {
@@ -24,6 +24,7 @@ import { after, before, describe, test } from "node:test";
24
24
  import { fileURLToPath } from "node:url";
25
25
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
26
26
  import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
27
+ import { deterministicProbeSeam } from "./support/probe-exec.ts";
27
28
 
28
29
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
29
30
 
@@ -92,12 +93,17 @@ async function boot(): Promise<{ app: TestApp; dbDir: string }> {
92
93
 
93
94
  describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)", () => {
94
95
  let restoreGithub: (() => void) | undefined;
96
+ const probeSeam = deterministicProbeSeam("feature-preflight e2e");
95
97
 
96
98
  before(() => {
97
99
  for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
98
100
  savedEnv.set(k, process.env[k]);
99
101
  process.env[k] = v;
100
102
  }
103
+ // Inject the deterministic probe exec so the `command: true` probe resolves WITHIN the virtual
104
+ // clock's drain fixpoint instead of spawning a real subprocess whose wall-clock completion
105
+ // `settle()` cannot await — the flake behind this suite (issue #450).
106
+ probeSeam.install();
101
107
  // `pr.ensure-base-branch` reads the base ref via the token transport, which would throw
102
108
  // `no GitHub transport available` under an empty token. Pin the shared hermetic admit-github
103
109
  // stub (dummy token + fetch intercept) like the sibling preflight e2e so base-branch admission
@@ -106,6 +112,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
106
112
  });
107
113
  after(() => {
108
114
  restoreGithub?.();
115
+ probeSeam.restoreAndAssertHermetic();
109
116
  for (const [k, v] of savedEnv) {
110
117
  if (v === undefined) delete process.env[k];
111
118
  else process.env[k] = v;
@@ -23,6 +23,7 @@ import { after, before, describe, test } from "node:test";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
25
25
  import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
26
+ import { deterministicProbeSeam } from "./support/probe-exec.ts";
26
27
 
27
28
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
28
29
 
@@ -89,8 +90,14 @@ async function boot(): Promise<{ app: TestApp; dbDir: string }> {
89
90
 
90
91
  describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #292 S5)", () => {
91
92
  let restoreGithub: (() => void) | undefined;
93
+ // The adversarial gates poll `command: false` (never-green) probes through the readiness-probe
94
+ // worker; inject the deterministic exec so each poll resolves inside the virtual clock's drain
95
+ // fixpoint (the escalation/timeout path is driven by engine-clock advancement, not a real
96
+ // subprocess) — issue #450.
97
+ const probeSeam = deterministicProbeSeam("inter-epic adversarial e2e");
92
98
 
93
99
  before(() => {
100
+ probeSeam.install();
94
101
  for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
95
102
  savedEnv.set(k, process.env[k]);
96
103
  process.env[k] = v;
@@ -105,6 +112,7 @@ describe("inter-epic capability gate — adversarial (plan-fanout.bpmn, issue #2
105
112
  if (v === undefined) delete process.env[k];
106
113
  else process.env[k] = v;
107
114
  }
115
+ probeSeam.restoreAndAssertHermetic();
108
116
  });
109
117
 
110
118
  // ── S5 P1 — the gate HOLDS wave 0 ───────────────────────────────────────────────────────────────
@@ -22,6 +22,7 @@ import { after, before, describe, test } from "node:test";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
24
24
  import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
25
+ import { deterministicProbeSeam } from "./support/probe-exec.ts";
25
26
 
26
27
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
28
 
@@ -80,12 +81,17 @@ async function boot(): Promise<{ app: TestApp; dbDir: string }> {
80
81
 
81
82
  describe("plan-fanout inter-epic capability preflight (plan-fanout.bpmn, issue #292 S3)", () => {
82
83
  let restoreGithub: (() => void) | undefined;
84
+ const probeSeam = deterministicProbeSeam("plan-fanout-preflight e2e");
83
85
 
84
86
  before(() => {
85
87
  for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
86
88
  savedEnv.set(k, process.env[k]);
87
89
  process.env[k] = v;
88
90
  }
91
+ // Inject the deterministic probe exec so the `command: true` probe resolves WITHIN the virtual
92
+ // clock's drain fixpoint instead of spawning a real subprocess whose wall-clock completion
93
+ // `settle()` cannot await — the flake behind this suite (issue #450).
94
+ probeSeam.install();
89
95
  // The fan-out head (`pr.ensure-base-branch`, ADR 0003) reads/creates the base ref via the token
90
96
  // transport, which would throw `no GitHub transport available` under an empty token. Pin the
91
97
  // shared hermetic admit-github stub (dummy token + fetch intercept) like the sibling plan-fanout
@@ -94,6 +100,7 @@ describe("plan-fanout inter-epic capability preflight (plan-fanout.bpmn, issue #
94
100
  });
95
101
  after(() => {
96
102
  restoreGithub?.();
103
+ probeSeam.restoreAndAssertHermetic();
97
104
  for (const [k, v] of savedEnv) {
98
105
  if (v === undefined) delete process.env[k];
99
106
  else process.env[k] = v;
@@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url";
25
25
  import type { EngineJob } from "@nanobpm/urban/runtime";
26
26
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
27
27
  import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
28
+ import { advancePastTimer } from "./support/time.ts";
28
29
 
29
30
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
30
31
 
@@ -154,7 +155,7 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
154
155
 
155
156
  // Never answer — let the SLA elapse. The interrupting boundary cancels the parked task and
156
157
  // routes to the task-done end (the safe auto-abandon default).
157
- await app.advanceTime(PAST_SLA_MS);
158
+ await advancePastTimer(app, PAST_SLA_MS);
158
159
 
159
160
  const flows = takenFlows(app);
160
161
  assert.ok(
@@ -180,7 +181,7 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
180
181
  await openTask(app, processKey, "plan-review-decision");
181
182
  await assertAssignmentFilterable(app, processKey, "plan-review-decision");
182
183
 
183
- await app.advanceTime(PAST_SLA_MS);
184
+ await advancePastTimer(app, PAST_SLA_MS);
184
185
 
185
186
  const flows = takenFlows(app);
186
187
  assert.ok(
@@ -221,7 +222,7 @@ describe("plan-fanout escalation SLA + assignment (U5)", () => {
221
222
  await openTask(app, processKey, "trial-merge-decision");
222
223
  await assertAssignmentFilterable(app, processKey, "trial-merge-decision");
223
224
 
224
- await app.advanceTime(PAST_SLA_MS);
225
+ await advancePastTimer(app, PAST_SLA_MS);
225
226
 
226
227
  const flows = takenFlows(app);
227
228
  assert.ok(
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
22
22
  import type { EngineJob } from "@nanobpm/urban/runtime";
23
23
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
24
24
  import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
25
+ import { advancePastTimer } from "./support/time.ts";
25
26
 
26
27
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
28
 
@@ -413,8 +414,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
413
414
 
414
415
  // Never publish caps-resolved — let the bound (default P1D) elapse. Advancing past it is the
415
416
  // ONLY way the token can move, so this proves the wait is genuinely bounded.
416
- await app.advanceTime(25 * 60 * 60 * 1000);
417
- await app.settle();
417
+ await advancePastTimer(app, 25 * 60 * 60 * 1000);
418
418
 
419
419
  const flows = takenFlows(app);
420
420
  assert.ok(