@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 +7 -0
- package/e2e/readiness-gate.e2e.ts +41 -0
- package/package.json +1 -1
- package/workers/readiness-probe/worker.ts +29 -1
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.
|
|
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 =
|
|
137
|
+
const exec = resolveProbeExec();
|
|
110
138
|
const result = await probeSingleShot({
|
|
111
139
|
probe,
|
|
112
140
|
exec,
|