@nanobpm/nano-workforce 0.125.0 → 0.127.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.
@@ -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(
@@ -23,34 +23,7 @@ import { dirname, join, resolve } from "node:path";
23
23
  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
- import type { CommandResult, ProbeExec } from "../app/readiness.ts";
27
- import { __setProbeExecForTest } from "../workers/readiness-probe/worker.ts";
28
-
29
- // A synchronous, in-memory ProbeExec so the probe resolves WITHIN the testkit's virtual-clock drain
30
- // fixpoint instead of spawning a REAL subprocess (real-time work `settle()` cannot deterministically
31
- // await — issue #450). It maps the hermetic shell builtins these scenarios use to a deterministic
32
- // `CommandResult` — `true` → exit 0 (green), `false` → exit 1 (never green) — mirroring the real
33
- // commands' semantics exactly, but with zero real time. Any OTHER command, or any HTTP call, is an
34
- // unintended probe escape: because `probeSingleShot` catches a thrown/rejected probe error and folds
35
- // it into a silent "not ready", an escape would otherwise be INVISIBLE and could let a bounded
36
- // not-ready scenario still pass, masking a regression (reviewer note). So we record every escape and
37
- // assert none occurred in teardown, failing the suite loudly instead of swallowing it.
38
- const unexpectedProbeIO: string[] = [];
39
- const deterministicExec: ProbeExec = {
40
- run(command: string): Promise<CommandResult> {
41
- const cmd = command.trim();
42
- if (cmd !== "true" && cmd !== "false") {
43
- unexpectedProbeIO.push(`command: ${cmd}`);
44
- return Promise.resolve({ code: 127, stdout: "", stderr: "" });
45
- }
46
- const code = cmd === "true" ? 0 : 1;
47
- return Promise.resolve({ code, stdout: "", stderr: "" });
48
- },
49
- httpGet(url: string): Promise<never> {
50
- unexpectedProbeIO.push(`http: ${url}`);
51
- return Promise.reject(new Error(`readiness-gate e2e: unexpected real HTTP probe (command probes only)`));
52
- },
53
- };
26
+ import { deterministicProbeSeam } from "./support/probe-exec.ts";
54
27
 
55
28
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
56
29
  let dbSeq = 0;
@@ -60,7 +33,7 @@ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
60
33
  GITHUB_TOKEN: "",
61
34
  };
62
35
  const savedEnv = new Map<string, string | undefined>();
63
- let savedProbeExec: ProbeExec | undefined;
36
+ const probeSeam = deterministicProbeSeam("readiness-gate e2e");
64
37
 
65
38
  interface TakenFlow {
66
39
  from: string;
@@ -94,7 +67,7 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
94
67
  // clock (issue #450). Scenario-agnostic: it maps each scenario's command by string. Capture the
95
68
  // prior override and restore exactly that in teardown, so the seam is restored to its real prior
96
69
  // state rather than assuming production.
97
- savedProbeExec = __setProbeExecForTest(deterministicExec);
70
+ probeSeam.install();
98
71
  });
99
72
 
100
73
  after(() => {
@@ -102,13 +75,11 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
102
75
  if (v === undefined) delete process.env[k];
103
76
  else process.env[k] = v;
104
77
  }
105
- // Restore the prior exec the seam must never outlive this suite.
106
- __setProbeExecForTest(savedProbeExec);
107
- // Fail loudly if the probe ever escaped the hermetic `true`/`false` builtins (an unexpected
108
- // command or any HTTP call). `probeSingleShot` folds a probe error into a silent "not ready", so
109
- // without this assertion an escape would be invisible and could let a bounded not-ready scenario
110
- // still pass, masking a regression.
111
- assert.deepEqual(unexpectedProbeIO, [], `readiness-gate e2e saw unexpected probe I/O: ${unexpectedProbeIO.join(", ")}`);
78
+ // Restore the prior exec (the seam must never outlive this suite) and fail loudly if the probe
79
+ // ever escaped the hermetic `true`/`false` builtins — `probeSingleShot` folds a probe error into
80
+ // a silent "not ready", so without this an escape would be invisible and could let a bounded
81
+ // not-ready scenario still pass, masking a regression.
82
+ probeSeam.restoreAndAssertHermetic();
112
83
  });
113
84
 
114
85
  test("READY: a green probe publishes readiness-ready and the gate releases through wait-ready → gate-ready", async () => {
@@ -0,0 +1,78 @@
1
+ // Negative-path coverage for the deterministic probe seam (e2e/support/probe-exec.ts).
2
+ //
3
+ // The wired readiness-gate suites only ever route the hermetic `true`/`false` builtins through the
4
+ // seam, so a regression in RECORDING or REJECTING an unexpected command/HTTP call — the seam's whole
5
+ // reason to exist — would leave every one of them green while silently swallowing an escape. This
6
+ // exercises the escape contract directly: a non-hermetic command or a real HTTP probe must be
7
+ // recorded and make `restoreAndAssertHermetic()` fail, and the recorded escape must never leak the
8
+ // raw command or a URL's credential material into the teardown assertion (ADR 0004 pinned
9
+ // decision 2 — mirrors production `redactTarget`).
10
+ import assert from "node:assert/strict";
11
+ import { test } from "node:test";
12
+ import { __setProbeExecForTest } from "../../workers/readiness-probe/worker.ts";
13
+ import { deterministicProbeSeam } from "./probe-exec.ts";
14
+
15
+ /** Install the seam, then hand back the exact `ProbeExec` it wired into the worker so a test can
16
+ * route probes through the real installed seam (not a private copy). Uses the documented
17
+ * set-returns-previous contract of `__setProbeExecForTest` to read the current override, then puts
18
+ * it straight back so the seam's own restore still returns the DB to the prior exec. */
19
+ function installAndCaptureExec(seam: ReturnType<typeof deterministicProbeSeam>) {
20
+ seam.install();
21
+ const installed = __setProbeExecForTest(undefined);
22
+ assert.ok(installed, "install() must have wired a ProbeExec into the worker");
23
+ __setProbeExecForTest(installed);
24
+ return installed;
25
+ }
26
+
27
+ test("hermetic true/false probes leave the seam clean", async () => {
28
+ const seam = deterministicProbeSeam("hermetic");
29
+ const exec = installAndCaptureExec(seam);
30
+
31
+ assert.deepEqual(await exec.run("true", {}), { code: 0, stdout: "", stderr: "" });
32
+ assert.deepEqual(await exec.run("false", {}), { code: 1, stdout: "", stderr: "" });
33
+
34
+ // No escape recorded — teardown passes.
35
+ seam.restoreAndAssertHermetic();
36
+ });
37
+
38
+ test("a non-hermetic command escapes, fails teardown, and never leaks the raw command", async () => {
39
+ const seam = deterministicProbeSeam("cmd-escape");
40
+ const exec = installAndCaptureExec(seam);
41
+
42
+ const secret = "curl https://user:supersecret@host/health?token=abc123";
43
+ const result = await exec.run(secret, {});
44
+ // The escaping command still resolves to a non-green result so a bounded not-ready scenario holds.
45
+ assert.equal(result.code, 127);
46
+
47
+ assert.throws(
48
+ () => seam.restoreAndAssertHermetic(),
49
+ (err: unknown) => {
50
+ const msg = String(err);
51
+ assert.match(msg, /cmd-escape saw unexpected probe I\/O/);
52
+ assert.doesNotMatch(msg, /supersecret/, "the credential must never reach teardown output");
53
+ assert.doesNotMatch(msg, /abc123/, "the token must never reach teardown output");
54
+ return true;
55
+ },
56
+ );
57
+ });
58
+
59
+ test("a real HTTP probe escapes, fails teardown, and redacts the URL's credential material", async () => {
60
+ const seam = deterministicProbeSeam("http-escape");
61
+ const exec = installAndCaptureExec(seam);
62
+
63
+ await assert.rejects(
64
+ exec.httpGet("https://user:tok@host/ready?apikey=zzz999", {}),
65
+ /http-escape: unexpected real HTTP probe/,
66
+ );
67
+
68
+ assert.throws(
69
+ () => seam.restoreAndAssertHermetic(),
70
+ (err: unknown) => {
71
+ const msg = String(err);
72
+ assert.match(msg, /http-escape saw unexpected probe I\/O/);
73
+ assert.doesNotMatch(msg, /zzz999/, "the query token must never reach teardown output");
74
+ assert.doesNotMatch(msg, /user:tok/, "the userinfo credential must never reach teardown output");
75
+ return true;
76
+ },
77
+ );
78
+ });
@@ -0,0 +1,69 @@
1
+ // Deterministic readiness-probe exec for e2es driven by the testkit's VIRTUAL clock (issue #450).
2
+ //
3
+ // Production `defaultProbeExec` (app/readiness.ts) runs a `command` probe as a REAL
4
+ // `node:child_process` subprocess. That subprocess resolves on the wall clock, spanning macrotasks
5
+ // the urban-testkit's virtual-clock `settle()`/`drain()` fixpoint cannot deterministically await —
6
+ // so `settle()` can return BEFORE the probe publishes `readiness-ready`, and a gate-flow assertion
7
+ // (e.g. `pf_gw->pf_end`) races the subprocess. That race is the flake behind feature-preflight /
8
+ // plan-fanout-preflight failing intermittently (green probe logged, gate flow not yet taken).
9
+ //
10
+ // This seam injects a synchronous, in-memory `ProbeExec` (via the worker's `__setProbeExecForTest`)
11
+ // so the probe resolves WITHIN the drain fixpoint — no real spawn, no wall-clock race. It maps the
12
+ // hermetic shell builtins the gate e2es use to a deterministic `CommandResult` — `true` → exit 0
13
+ // (green), `false` → exit 1 (never green) — mirroring the real commands exactly, with zero real time.
14
+ //
15
+ // Any OTHER command, or any HTTP call, is an unintended probe escape: `probeSingleShot` folds a
16
+ // thrown/rejected probe error into a silent "not ready", so an escape would be INVISIBLE and could
17
+ // let a bounded not-ready scenario still pass, masking a regression. Every escape is recorded and
18
+ // asserted-none in teardown, failing the suite loudly instead of swallowing it.
19
+ //
20
+ // Single source of truth shared by every readiness-gate e2e (readiness-gate, feature-preflight,
21
+ // plan-fanout-preflight, delivery-graph, inter-epic-dependency) so the deterministic-exec contract
22
+ // can never drift between them.
23
+ import assert from "node:assert/strict";
24
+ import { type CommandResult, type ProbeExec, redactString } from "../../app/readiness.ts";
25
+ import { __setProbeExecForTest } from "../../workers/readiness-probe/worker.ts";
26
+
27
+ export interface DeterministicProbeSeam {
28
+ /** Install the deterministic exec — call once in the suite's `before`. */
29
+ install(): void;
30
+ /** Restore the prior exec and assert no probe escaped the hermetic `true`/`false` builtins — call
31
+ * once in the suite's `after`. */
32
+ restoreAndAssertHermetic(): void;
33
+ }
34
+
35
+ /** Build a deterministic probe seam scoped to one suite. `label` is used in the escape assertion
36
+ * message and the unexpected-HTTP error, so a failure names the offending suite. */
37
+ export function deterministicProbeSeam(label: string): DeterministicProbeSeam {
38
+ const escapes: string[] = [];
39
+ const exec: ProbeExec = {
40
+ run(command: string): Promise<CommandResult> {
41
+ const cmd = command.trim();
42
+ if (cmd !== "true" && cmd !== "false") {
43
+ // A `command` target is an arbitrary shell snippet that can embed a secret, so — exactly as
44
+ // production `redactTarget` does (app/readiness.ts, ADR 0004 pinned decision 2) — record only
45
+ // a fixed placeholder, never the raw command, so an escape can't leak credentials into the
46
+ // teardown assertion at `restoreAndAssertHermetic()`.
47
+ escapes.push("command: <redacted>");
48
+ return Promise.resolve({ code: 127, stdout: "", stderr: "" });
49
+ }
50
+ return Promise.resolve({ code: cmd === "true" ? 0 : 1, stdout: "", stderr: "" });
51
+ },
52
+ httpGet(url: string): Promise<never> {
53
+ // A probe URL can carry a token in its userinfo or query string; strip those before recording.
54
+ escapes.push(`http: ${redactString(url)}`);
55
+ return Promise.reject(new Error(`${label}: unexpected real HTTP probe (command probes only)`));
56
+ },
57
+ };
58
+ let saved: ProbeExec | undefined;
59
+ return {
60
+ install() {
61
+ saved = __setProbeExecForTest(exec);
62
+ },
63
+ restoreAndAssertHermetic() {
64
+ // Restore the prior exec first — the seam must never outlive this suite.
65
+ __setProbeExecForTest(saved);
66
+ assert.deepEqual(escapes, [], `${label} saw unexpected probe I/O: ${escapes.join(", ")}`);
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,42 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { advancePastTimer } from "./time.ts";
4
+
5
+ // Guards the defect class behind issue #474: a long BPMN business-wait must be crossed by advancing
6
+ // the ENGINE clock (fire the boundary timer) + one reconcile settle — NOT by `app.advanceTime`,
7
+ // which steps the scheduler in lockstep and replays every 5s runtime-cadence poll across the window
8
+ // (~18,000× for a 25h jump; ~123s of no-op churn). This pins the mechanism so a regression back to
9
+ // the lockstep path — or an added `scheduler.advance` — fails here instead of silently re-slowing CI.
10
+ test("advancePastTimer advances the engine clock then settles once, never replaying scheduler cadence", async () => {
11
+ const PAST_SLA_MS = 25 * 60 * 60 * 1000;
12
+ const calls: string[] = [];
13
+ let engineMs: number | undefined;
14
+
15
+ const app = {
16
+ engine: {
17
+ advanceTime: async (ms: number) => {
18
+ engineMs = ms;
19
+ calls.push("engine.advanceTime");
20
+ },
21
+ },
22
+ settle: async () => {
23
+ calls.push("settle");
24
+ },
25
+ // Present only to catch a regression: touching the scheduler's own `advance` is the slow
26
+ // lockstep replay path the helper exists to avoid, so it must never be called.
27
+ scheduler: {
28
+ advance: async () => {
29
+ calls.push("scheduler.advance");
30
+ },
31
+ },
32
+ } as unknown as Parameters<typeof advancePastTimer>[0];
33
+
34
+ await advancePastTimer(app, PAST_SLA_MS);
35
+
36
+ assert.deepEqual(
37
+ calls,
38
+ ["engine.advanceTime", "settle"],
39
+ "the engine boundary fires, then exactly one reconcile settle — the scheduler cadence is never replayed",
40
+ );
41
+ assert.equal(engineMs, PAST_SLA_MS, "the full business-wait window is applied to the engine clock");
42
+ });
@@ -0,0 +1,33 @@
1
+ import type { TestApp } from "@nanobpm/urban-testkit";
2
+
3
+ /**
4
+ * Advance a long BPMN **business-wait** boundary timer and reconcile once.
5
+ *
6
+ * A business wait — "park at this task until the 24h SLA elapses, then auto-escalate" — is
7
+ * modelled as a BPMN interrupting boundary timer on the ENGINE clock: durable, engine-owned, and
8
+ * already virtual under the engine clock. This is the line camunda/orchestration-cluster-api-js#450
9
+ * draws: *"Long/business waits are BPMN timer events … not [runtime-cadence] `sleep`/poll."* To
10
+ * cross such a wait we advance the ENGINE clock so the boundary fires, then {@link TestApp.settle}
11
+ * once to drain the follow-on token flow.
12
+ *
13
+ * This deliberately does **not** call {@link TestApp.advanceTime}, which steps the virtual-clock
14
+ * scheduler in lockstep with the engine and therefore REPLAYS every short runtime-cadence poll —
15
+ * the 5s `instanceTracking` reconcilers — once per interval across the whole window. A 25h jump
16
+ * replays each poller ~18,000× (measured: ~123s of pure no-op reconcile churn *per call*, which is
17
+ * essentially the entire e2e wall-clock). Those replays are runtime cadence, not the business wait,
18
+ * and #450 treats such busy-replays as a bug to *surface*, not to coalesce away in the scheduler.
19
+ * Advancing engine time + one settle fires the same boundary in ~2ms.
20
+ *
21
+ * Use ONLY when the assertions target **engine state** — taken sequence-flows (`app.snapshot()`) or
22
+ * instance state — which is populated by `engine.advanceTime` + `engine.drain` (run inside
23
+ * `settle`). Do NOT use it when an assertion depends on a **read model the reconcile pollers
24
+ * project**: for that the pollers must actually run, so use {@link TestApp.advanceTime}. The
25
+ * scheduler's virtual clock intentionally does not track this jump.
26
+ */
27
+ export async function advancePastTimer(
28
+ app: Pick<TestApp, "engine" | "settle">,
29
+ ms: number,
30
+ ): Promise<void> {
31
+ await app.engine.advanceTime(ms);
32
+ await app.settle();
33
+ }