@nanobpm/nano-workforce 0.123.0 → 0.123.1

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 CHANGED
@@ -1,3 +1,10 @@
1
+ ## [0.123.1](https://github.com/nanobpm/nano-workforce/compare/v0.123.0...v0.123.1) (2026-08-22)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **readiness:** inject deterministic exec in readiness-gate e2e ([#450](https://github.com/nanobpm/nano-workforce/issues/450)) ([#452](https://github.com/nanobpm/nano-workforce/issues/452)) ([5597d2a](https://github.com/nanobpm/nano-workforce/commit/5597d2aaacf361677fcc54ef277d05345eff8446)), closes [nano-ide#446](https://github.com/nano-ide/issues/446)
7
+
1
8
  # [0.123.0](https://github.com/nanobpm/nano-workforce/compare/v0.122.0...v0.123.0) (2026-08-22)
2
9
 
3
10
 
@@ -23,6 +23,34 @@ 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
54
 
27
55
  const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
28
56
  let dbSeq = 0;
@@ -32,6 +60,7 @@ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
32
60
  GITHUB_TOKEN: "",
33
61
  };
34
62
  const savedEnv = new Map<string, string | undefined>();
63
+ let savedProbeExec: ProbeExec | undefined;
35
64
 
36
65
  interface TakenFlow {
37
66
  from: string;
@@ -61,6 +90,11 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
61
90
  savedEnv.set(k, process.env[k]);
62
91
  process.env[k] = v;
63
92
  }
93
+ // Inject the deterministic exec so the probe never spawns a real subprocess under the virtual
94
+ // clock (issue #450). Scenario-agnostic: it maps each scenario's command by string. Capture the
95
+ // prior override and restore exactly that in teardown, so the seam is restored to its real prior
96
+ // state rather than assuming production.
97
+ savedProbeExec = __setProbeExecForTest(deterministicExec);
64
98
  });
65
99
 
66
100
  after(() => {
@@ -68,6 +102,13 @@ describe("nano-workforce artifact-readiness wait-gate (readiness-gate.bpmn)", ()
68
102
  if (v === undefined) delete process.env[k];
69
103
  else process.env[k] = v;
70
104
  }
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(", ")}`);
71
112
  });
72
113
 
73
114
  test("READY: a green probe publishes readiness-ready and the gate releases through wait-ready → gate-ready", async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.123.0",
3
+ "version": "0.123.1",
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",
@@ -103,10 +103,38 @@ export function readGateVars(vars: { gateKey?: unknown; probeTimeout?: unknown }
103
103
  return { gateKey, probeTimeout };
104
104
  }
105
105
 
106
+ // ── Deterministic-exec test seam (issue #450) ───────────────────────────────────────────────────
107
+ // The probe's I/O runs through {@link defaultProbeExec} — a REAL `node:child_process` subprocess (for
108
+ // `command` probes) / `fetch` (for `http`). A real subprocess is real-time async work that spans
109
+ // multiple macrotasks, which the urban-testkit's *virtual-clock* `settle()`/`drain()` fixpoint cannot
110
+ // deterministically await: it can return before the probe publishes `readiness-ready`, so a gate-flow
111
+ // e2e races the subprocess (the same fire-and-forget-across-teardown hazard behind the testkit
112
+ // use-after-free, nano-ide#446). An e2e under the virtual clock injects a synchronous, in-memory
113
+ // `ProbeExec` here so the probe resolves *within* the drain fixpoint — no real spawn, no wall-clock
114
+ // race — while production leaves the override unset and uses `defaultProbeExec()`. Deliberately a
115
+ // process-scoped seam (not urban worker DI, which `bootTestApp` does not expose per-worker); the e2e
116
+ // sets it before creating instances and clears it in teardown so it can never leak into production.
117
+ let probeExecOverride: ProbeExec | undefined;
118
+
119
+ /** Test-only seam: inject a deterministic {@link ProbeExec} for e2es driven by the virtual clock, or
120
+ * pass `undefined` to restore the production {@link defaultProbeExec}. Never called in production.
121
+ * Returns the PREVIOUS override so a caller can narrowly scope its change with `try/finally`
122
+ * (restore the prior value rather than assuming production), keeping the seam safe even if the set
123
+ * and clear are not lexically paired. */
124
+ export function __setProbeExecForTest(exec: ProbeExec | undefined): ProbeExec | undefined {
125
+ const previous = probeExecOverride;
126
+ probeExecOverride = exec;
127
+ return previous;
128
+ }
129
+
130
+ function resolveProbeExec(): ProbeExec {
131
+ return probeExecOverride ?? defaultProbeExec();
132
+ }
133
+
106
134
  const handler: AppJobHandler<In, Out> = async (job, app) => {
107
135
  const probe = parseProbe(job.variables.probe);
108
136
  const { gateKey } = readGateVars(job.variables);
109
- const exec = defaultProbeExec();
137
+ const exec = resolveProbeExec();
110
138
  const result = await probeSingleShot({
111
139
  probe,
112
140
  exec,