@forwardimpact/libharness 3.0.1 → 3.0.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libharness",
3
- "version": "3.0.1",
3
+ "version": "3.0.2",
4
4
  "description": "Autonomous agent team harness — coordinate a lead and participant agents in one async session, with eval, benchmark, and trace tooling to prove the changes worked.",
5
5
  "keywords": [
6
6
  "orchestration",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Hidden-test engine — runs a task's `tests/` overlay against the post-run
3
3
  * agent CWD. The engine stages each file at its mirrored path. It runs each
4
- * check with `node --test`. It converts the exit status into one check row.
4
+ * check with `bun test`. It converts the exit status into one check row.
5
5
  * It restores the tree, so the judge sees the workdir exactly as the agent
6
6
  * left it.
7
7
  *
@@ -69,7 +69,25 @@ async function runOneCheck(task, ctx, runtime, timeoutMs, check) {
69
69
  }
70
70
 
71
71
  /**
72
- * Spawn `node --test <staged path>` from the agent CWD under the hook env
72
+ * Reads a child pipe to a string. Returns what it read when the stream tears
73
+ * down mid-read, because a pipe that closes as the child exits is a race, not
74
+ * a check failure. The caller reads the exit code for the verdict.
75
+ *
76
+ * @param {import("node:stream").Readable} stream - Child stdout or stderr.
77
+ * @returns {Promise<string>} Everything read before the stream ended.
78
+ */
79
+ async function drainQuietly(stream) {
80
+ let out = "";
81
+ try {
82
+ for await (const chunk of stream) out += chunk.toString();
83
+ } catch {
84
+ // The stream closed under us. Keep what arrived.
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * Spawn `bun test <staged path>` from the agent CWD under the hook env
73
91
  * and map the exit status onto one row. The clock timer SIGKILLs a child
74
92
  * that outlives the per-check budget. The row then fails with a timeout
75
93
  * message.
@@ -83,27 +101,40 @@ async function spawnCheck(task, ctx, runtime, timeoutMs, check) {
83
101
  hooksDir: task.paths.hooks,
84
102
  familyDir: ctx.familyDir,
85
103
  });
86
- // An inherited test-runner context makes the child `node --test` report
87
- // exit 0 even when its tests fail. A check that fails would then mint a row
88
- // that passes whenever the harness itself runs under `node --test`.
89
- delete env.NODE_TEST_CONTEXT;
90
- const child = runtime.subprocess.spawn("node", ["--test", check.stagePath], {
91
- cwd: ctx.cwd,
92
- env,
93
- stdio: ["ignore", "pipe", "pipe"],
94
- });
104
+ // `bun test` sets no test-context variable that a nested run inherits, so
105
+ // a child reports its own exit status even when the harness itself runs
106
+ // under a test runner. That removes the `NODE_TEST_CONTEXT` scrub the
107
+ // `node --test` engine needed to stop a failing check minting a passing
108
+ // row. The "fractional score" grade test is the standing guard: it fails
109
+ // if a failing check ever reports success again.
110
+ //
111
+ // Pass the ABSOLUTE staged path. `bun test` reads its argument as a
112
+ // substring filter over discovered paths, not as one file, so the relative
113
+ // `app/test/x.test.js` also matches an agent-authored `sub/app/test/
114
+ // x.test.js` and folds that file's result into this check's row. An
115
+ // absolute path cannot be a substring of a deeper path, so it selects
116
+ // exactly the staged file. One `*.test.js` stays one check.
117
+ const child = runtime.subprocess.spawn(
118
+ "bun",
119
+ ["test", join(ctx.cwd, check.stagePath)],
120
+ {
121
+ cwd: ctx.cwd,
122
+ env,
123
+ stdio: ["ignore", "pipe", "pipe"],
124
+ },
125
+ );
95
126
  let timedOut = false;
96
127
  const timer = runtime.clock.setTimeout(() => {
97
128
  timedOut = true;
98
129
  child.kill("SIGKILL");
99
130
  }, timeoutMs);
100
- const drainStdout = (async () => {
101
- for await (const _chunk of child.stdout) {
102
- // discard
103
- }
104
- })();
105
- let stderr = "";
106
- for await (const chunk of child.stderr) stderr += chunk.toString();
131
+ // Both pipes drain only so a chatty child never blocks on a full pipe.
132
+ // A child that exits while its pipe is still open makes the async iterator
133
+ // reject with ERR_STREAM_PREMATURE_CLOSE on some runtimes. That teardown
134
+ // race is not a check result, so it must not throw out of the check. The
135
+ // exit code below is the verdict.
136
+ const drainStdout = drainQuietly(child.stdout);
137
+ const stderr = await drainQuietly(child.stderr);
107
138
  await drainStdout;
108
139
  const exit = await child.exitCode;
109
140
  runtime.clock.clearTimeout(timer);