@gethmy/harness 1.5.0 → 1.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gethmy/harness",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Execution motor for Harmony playbook stages. Runs exactly one stage per invocation: worktree, role-separated subagents, held oracle, gate evidence. It never routes, never judges, never pushes.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/exec-types.ts CHANGED
@@ -95,9 +95,73 @@ export interface VerificationConfig {
95
95
  * report a timeout as a test failure.
96
96
  */
97
97
  testTimeout: number;
98
+ /**
99
+ * Container image for the verification steps (#1036). Empty — the default —
100
+ * runs them in the daemon process as before.
101
+ *
102
+ * `runBuild` / `runTests` / `runFormatFix` / `runLint` execute
103
+ * `bun run <script>` out of the WORKTREE'S OWN `package.json`, which a
104
+ * contained implement run is allowed to rewrite (the worktree is inside
105
+ * `sandbox.filesystem.allowWrite` by design). #988 gave those spawns
106
+ * `containedEnv()`, which removes the secret-shaped variables and was
107
+ * stated there as a reduction and not a fix: the command still ran as the
108
+ * operator and could read `~/.ssh` and `~/.harmony-mcp/config.json` off
109
+ * disk. Setting this moves them into `repair-sandbox.ts`'s container, where
110
+ * the only mount is the worktree and there is no network.
111
+ *
112
+ * **Opt-in on purpose, unlike #988's own containment.** That one could ship
113
+ * on by default because the SDK sandbox is inside the CLI. This one needs a
114
+ * reachable Docker AND an image carrying the repo's toolchain, so a default
115
+ * would stop verification dead on every host that has neither. There is
116
+ * deliberately no guessed image, for the reason `types.ts` already records
117
+ * for the repair path: a container that cannot build anything reports every
118
+ * run as unverified.
119
+ *
120
+ * The image must carry the toolchain — the container has no network, so
121
+ * nothing installs at verification time. Dependencies are already present:
122
+ * `worktree.ts` installs them at worktree creation with `--ignore-scripts`.
123
+ * Point this at whatever your CI uses.
124
+ *
125
+ * Does NOT cover the two `dev` servers (`runDeepReview` and the review
126
+ * worker's). Those are probed over HTTP from the daemon, so a no-network
127
+ * container is unreachable by construction; they remain in
128
+ * `run-containment.ts`'s residual list.
129
+ */
130
+ sandboxImage: string;
98
131
  };
99
132
  }
100
133
 
134
+ /**
135
+ * A configured verification container (#1036), or `undefined` for host
136
+ * execution. Resolved once by the caller from {@link VerificationConfig} so the
137
+ * four steps need no config import and stay unit-testable.
138
+ */
139
+ export interface VerificationSandbox {
140
+ /** Image carrying the repo's toolchain. Empty means "not configured". */
141
+ image: string;
142
+ }
143
+
144
+ /**
145
+ * Head-room added to a step's own timeout when it runs in a container (#1036).
146
+ *
147
+ * There is deliberately NO separate sandbox timeout knob. Each step already
148
+ * carries the cap its operator chose — `verification.timeout` for build, lint
149
+ * and format, `verification.testTimeout` for the suite, and those two are
150
+ * separate precisely because a real suite outruns a build. A single
151
+ * container-wide cap would silently replace all four: with the values this repo
152
+ * ships, turning an image on would have taken build and lint from 2 minutes to
153
+ * 10, and would have ignored a `testTimeout` an operator had narrowed for a
154
+ * fast suite. Switching on containment must not change what any other setting
155
+ * means.
156
+ *
157
+ * So the step's own timeout wins, and this covers only what the container adds
158
+ * on top: image resolution and process start. Measured at ~5.1 s on macOS /
159
+ * Docker Desktop with a warm image; 60 s is that with an order of magnitude of
160
+ * head-room for a slower host or a cold layer, and it is not a budget for the
161
+ * command, which has already been capped by the time it is added.
162
+ */
163
+ export const SANDBOX_STARTUP_GRACE_MS = 60_000;
164
+
101
165
  /** The slice of the daemon's config that the git helpers actually read. */
102
166
  export interface WorktreeConfig {
103
167
  worktree: VerificationConfig["worktree"];
@@ -46,6 +46,7 @@ import {
46
46
  CommandMetricCollector,
47
47
  type CommandMetricDeps,
48
48
  } from "./command-metric.js";
49
+ import type { VerificationSandbox } from "./exec-types.js";
49
50
  import { log } from "./log.js";
50
51
  import type { OracleDeps } from "./oracle.js";
51
52
  import { OracleCollector, OracleRedCollector } from "./oracle-collector.js";
@@ -152,10 +153,25 @@ export interface BuildGreenDeps {
152
153
  worktreePath: string;
153
154
  buildTimeout: number;
154
155
  lintTimeout: number;
156
+ /**
157
+ * Verification container (#1036). Passed straight through to `runBuild` /
158
+ * `runLint`, so the gate's build runs wherever the daemon's own verification
159
+ * runs — a gate that ran the worktree's scripts in this process while
160
+ * `runVerification` contained them would be the hole under a different name.
161
+ */
162
+ sandbox?: VerificationSandbox;
155
163
  /** Inject for tests; defaults to verification.ts `runBuild`. Returns error lines. */
156
- runBuild?: (worktreePath: string, timeout: number) => string[];
164
+ runBuild?: (
165
+ worktreePath: string,
166
+ timeout: number,
167
+ sandbox?: VerificationSandbox,
168
+ ) => string[] | Promise<string[]>;
157
169
  /** Inject for tests; defaults to verification.ts `runLint`. Returns warning lines. */
158
- runLint?: (worktreePath: string, timeout: number) => string[];
170
+ runLint?: (
171
+ worktreePath: string,
172
+ timeout: number,
173
+ sandbox?: VerificationSandbox,
174
+ ) => string[] | Promise<string[]>;
159
175
  }
160
176
 
161
177
  /**
@@ -192,8 +208,18 @@ export class BuildGreenCollector implements GateEvidenceCollector {
192
208
  async collect(_context: GateEvidenceContext): Promise<GateEvidence> {
193
209
  const doBuild = this.deps.runBuild ?? runBuild;
194
210
  const doLint = this.deps.runLint ?? runLint;
195
- const buildErrors = doBuild(this.deps.worktreePath, this.deps.buildTimeout);
196
- const lintWarnings = doLint(this.deps.worktreePath, this.deps.lintTimeout);
211
+ // Awaited: both are async since #1036, and an injected test double may
212
+ // still be sync — `await` accepts either.
213
+ const buildErrors = await doBuild(
214
+ this.deps.worktreePath,
215
+ this.deps.buildTimeout,
216
+ this.deps.sandbox,
217
+ );
218
+ const lintWarnings = await doLint(
219
+ this.deps.worktreePath,
220
+ this.deps.lintTimeout,
221
+ this.deps.sandbox,
222
+ );
197
223
  const buildPassed = buildErrors.length === 0;
198
224
  const lintPassed = lintWarnings.length === 0;
199
225
  // Match verification.ts: only a failing BUILD makes the gate red. Lint
@@ -78,24 +78,44 @@
78
78
  * was found by a review round rather than by reading the code, and a residual
79
79
  * nobody wrote down is indistinguishable from one nobody saw.
80
80
  *
81
- * - **Everything resolved through `spawnRunArgs` runs the run's own
82
- * `package.json` scripts.** Six call sites, not four: `runBuild`,
83
- * `runTests`, `runFormatFix` and `runLint` (`verification.ts`), plus the two
84
- * `dev` servers `runDeepReview` (`verification.ts`) and the review
85
- * worker's own (`review-worker.ts`). Each executes `bun run <script>` from
86
- * the worktree, which the run may rewrite, in the DAEMON process and outside
87
- * the sandbox with network. All six now carry `containedEnv()`, so the
88
- * secret-shaped variables are gone; the command itself is still the run's to
89
- * choose. Closing that means running the repo's build somewhere the
90
- * operator's credentials are not; that is `repair-sandbox.ts`'s problem shape
91
- * and its own piece of work.
92
- *
93
- * The count is in this bullet on purpose. The first version of this list
94
- * named the four verification steps and stopped, and both dev servers then
95
- * shipped with the daemon's whole environment while the changelog told users
96
- * these commands run "without your credentials". `spawn-run-containment.test.ts`
97
- * scans for the property instead of trusting this prose, because the residual
98
- * is a completeness claim over CALL SITES and a hand-list has already lost it.
81
+ * - **The two `dev` servers, plus the standalone harness CLI.** #1036 closed
82
+ * the four verification steps AND the `build_green` gate collector, which
83
+ * re-runs the same two scripts: `runBuild`, `runTests`, `runFormatFix` and `runLint`
84
+ * now run inside `repair-sandbox.ts`'s container when
85
+ * `verification.sandboxImage` is set worktree the only mount, no network,
86
+ * no host fallback. What is left of this bullet is `runDeepReview`'s dev
87
+ * server and the review worker's own. Both are the same `spawnRunArgs`
88
+ * shape, and both are **probed over HTTP from the daemon**, so
89
+ * `--network=none` makes them unreachable by construction: containing them
90
+ * needs a different answer, not the same one applied twice. They keep
91
+ * `containedEnv()`, so the secret-shaped environment is gone; the command is
92
+ * still the run's to choose and still executes in the daemon process.
93
+ *
94
+ * The containment for the four is **opt-in**, so on a host with no
95
+ * `sandboxImage` all six remain as described above. That is a deliberate
96
+ * asymmetry with this module's own containment, which defaults on: the SDK
97
+ * sandbox is inside the CLI, whereas a container needs a reachable Docker
98
+ * and an image carrying the repo's toolchain.
99
+ *
100
+ * `packages/harmony-harness/src/cli.ts` builds the same collector and passes
101
+ * no image, because the motor holds no operator config of its own — there is
102
+ * nothing for it to read. That path is driven by a person running the CLI
103
+ * against their own checkout rather than by board text, so it is a different
104
+ * threat model, but it is named here rather than left to be rediscovered.
105
+ *
106
+ * The count is in this bullet on purpose, and it has now been wrong twice.
107
+ * The first version named the four verification steps and stopped, and both
108
+ * dev servers then shipped with the daemon's whole environment while the
109
+ * changelog told users these commands run "without your credentials". The
110
+ * second version — #1036's own — said "the two dev servers" while the
111
+ * `build_green` gate ran the worktree's script on the host for any operator
112
+ * who had set an image precisely to stop that. Both were caught by a reader,
113
+ * not by a gate.
114
+ * `spawn-run-containment.test.ts` scans for the environment property instead
115
+ * of trusting this prose, and `verification-sandbox.test.ts` asserts that a
116
+ * configured image takes the four out of this process entirely — because the
117
+ * residual is a completeness claim over CALL SITES and a hand-list has
118
+ * already lost it once.
99
119
  * - **The MCP surface.** The sandbox governs commands. MCP stdio servers are
100
120
  * hosted by the CLI and are not sandboxed, so `mcp__harmony__*` is bounded by
101
121
  * the tool allow-list and the daemon-owned denials (#525/#576), not by this.
@@ -1,6 +1,10 @@
1
1
  import { type ChildProcess, execFileSync, spawn } from "node:child_process";
2
2
  import type { HarmonyApiClient } from "@gethmy/mcp/src/api-client.js";
3
- import type { VerificationConfig } from "./exec-types.js";
3
+ import {
4
+ SANDBOX_STARTUP_GRACE_MS,
5
+ type VerificationConfig,
6
+ type VerificationSandbox,
7
+ } from "./exec-types.js";
4
8
  import { log } from "./log.js";
5
9
  import { spawnRunArgs } from "./pm.js";
6
10
  import {
@@ -10,6 +14,7 @@ import {
10
14
  supportsDevServer,
11
15
  testCommand,
12
16
  } from "./project-type.js";
17
+ import { runInSandbox, sandboxAvailable } from "./repair-sandbox.js";
13
18
  import { findDeletedTestFiles } from "./revert-guard.js";
14
19
  import {
15
20
  containedEnv,
@@ -63,6 +68,11 @@ export async function runVerification(
63
68
  revertWarnings: [],
64
69
  };
65
70
 
71
+ // Resolved once (#1036). Undefined unless the operator configured an image,
72
+ // in which case the four script-resolved steps run in a container instead of
73
+ // in this process. The two dev servers below are deliberately not passed it.
74
+ const sandbox = verificationSandbox(config);
75
+
66
76
  if (config.verification.revertGuard) {
67
77
  log.info(TAG, `[worker:${workerId}] Checking for reverted merged work...`);
68
78
  const deletedTests = findDeletedTestFiles(
@@ -89,7 +99,11 @@ export async function runVerification(
89
99
 
90
100
  if (config.verification.build) {
91
101
  log.info(TAG, `[worker:${workerId}] Running build...`);
92
- result.buildErrors = runBuild(worktreePath, config.verification.timeout);
102
+ result.buildErrors = await runBuild(
103
+ worktreePath,
104
+ config.verification.timeout,
105
+ sandbox,
106
+ );
93
107
  if (result.buildErrors.length > 0) {
94
108
  log.warn(
95
109
  TAG,
@@ -107,9 +121,10 @@ export async function runVerification(
107
121
  // still gates the branch.
108
122
  if (config.verification.test && result.buildErrors.length === 0) {
109
123
  log.info(TAG, `[worker:${workerId}] Running tests...`);
110
- result.testFailures = runTests(
124
+ result.testFailures = await runTests(
111
125
  worktreePath,
112
126
  config.verification.testTimeout,
127
+ sandbox,
113
128
  );
114
129
  if (result.testFailures.length > 0) {
115
130
  log.warn(
@@ -125,7 +140,11 @@ export async function runVerification(
125
140
 
126
141
  if (config.verification.lint) {
127
142
  log.info(TAG, `[worker:${workerId}] Running lint...`);
128
- result.lintWarnings = runLint(worktreePath, config.verification.timeout);
143
+ result.lintWarnings = await runLint(
144
+ worktreePath,
145
+ config.verification.timeout,
146
+ sandbox,
147
+ );
129
148
  if (result.lintWarnings.length > 0) {
130
149
  log.warn(
131
150
  TAG,
@@ -154,46 +173,151 @@ export async function runVerification(
154
173
  }
155
174
 
156
175
  /**
157
- * ## The verification steps run the RUN'S OWN scripts, and that is a known hole
176
+ * ## The verification steps run the RUN'S OWN scripts contained since #1036
177
+ *
178
+ * `runBuild`, `runTests`, `runFormatFix` and `runLint` resolve their command
179
+ * through `spawnRunArgs` — `bun run <script>` out of the worktree's own
180
+ * `package.json`. The worktree is in `sandbox.filesystem.allowWrite` by design,
181
+ * so a contained implement run can rewrite `"build"` to anything it likes, and
182
+ * before #1036 the daemon then ran it **in its own process**, as the operator,
183
+ * with network. No failure was needed to trigger it; verification runs on every
184
+ * successful run.
158
185
  *
159
- * `runBuild`, `runTests`, `runFormatFix`, `runLint` and `runDeepReview`'s dev
160
- * server all resolve their command through `spawnRunArgs``bun run <script>`
161
- * out of the worktree's own `package.json` and execute it **in the daemon
162
- * process**. (`review-worker.ts` spawns the sixth, its own `dev` server, for the
163
- * same reason and with the same exposure.)
164
- * The worktree is in `sandbox.filesystem.allowWrite` by design, so a contained
165
- * implement run can rewrite `"build"` to anything it likes and the daemon will
166
- * then run it, outside the sandbox, with full network. No failure is needed to
167
- * trigger this; verification runs on every successful run.
186
+ * `env: containedEnv()` (#988) removed the secret-shaped variables and was
187
+ * stated there as a reduction and NOT a fix the command still read `~/.ssh`
188
+ * and `~/.harmony-mcp/config.json` off disk. **#1036 closes the rest, for these
189
+ * four, when `verification.sandboxImage` is set:** `execStep` routes them into
190
+ * `repair-sandbox.ts`'s container, whose only mount is the worktree and which
191
+ * has no network. There is no host fallback, because falling back would return
192
+ * exactly the exposure the image was configured to remove.
168
193
  *
169
- * `env: containedEnv()` (#988) is a real reduction and NOT a fix: it removes
170
- * the secret-shaped variables, so `HARMONY_API_KEY`, `GITHUB_PERSONAL_ACCESS_TOKEN`
171
- * and their neighbours are gone from the child. What remains is a command of
172
- * the run's choosing, executing as the operator, able to read `~/.ssh` and
173
- * reach the network.
194
+ * It is opt-in, and unlike #988 that is a COST decision as well as a capability
195
+ * one. #988 measured no penalty for its own containment. This one, measured on
196
+ * macOS / Docker Desktop 29.7.2 with a warm `node:22-alpine` and a ~0.35 s
197
+ * workload:
174
198
  *
175
- * Closing it properly means running the repo's own build and test somewhere the
176
- * operator's credentials are not which is the problem `repair-sandbox.ts`
177
- * solved for the CI repair with a container, and it is a piece of work of its
178
- * own rather than a line in this one. Until then this is listed in
179
- * `run-containment.ts`'s "what this does not cover" and in `docs/agent-daemon.md`,
180
- * because a residual nobody wrote down is indistinguishable from one nobody saw.
199
+ * uncontained 0.38 / 0.35 / 0.34 s
200
+ * contained 5.53 / 5.42 / 5.41 s -> ~5.1 s added per command
181
201
  *
182
- * That is not a figure of speech here. The first version of both lists named
183
- * the four verification steps only, and the two `dev` servers which are the
184
- * same `spawnRunArgs` shape then shipped with the daemon's full environment,
185
- * unlisted and unnoticed through every gate. `spawn-run-containment.test.ts`
186
- * now asserts the property over call sites rather than over a written list.
202
+ * The workload is 0.35 s, so that is essentially all container start-up rather
203
+ * than a slower command which means it is a FIXED cost paid per step, about
204
+ * 20 s on a run that builds, tests, lints and formats. On a Linux daemon host
205
+ * with no VM in the path it should be well below this; nobody has measured that,
206
+ * and this comment is the only record either way. That is the second reason
207
+ * there is no default: a fixed ~20 s per run is not a cost to start spending on
208
+ * an operator's behalf, and the first is that a guessed image reports every run
209
+ * as unverified — the reason `types.ts` already records for the repair path.
210
+ *
211
+ * **The image must match the repo's DETECTED package manager, not merely carry
212
+ * "a toolchain".** `spawnRunArgs` resolves through `detectPackageManager`, so a
213
+ * repo that resolves to `npm` fails in an `oven/bun:1` image with a raw OCI
214
+ * error (`exec: "npm": executable file not found in $PATH`). That surfaces as a
215
+ * `sandboxError` — the step reports it did not run — which is the right
216
+ * outcome, but the message is Docker's and not a friendly one. Found while
217
+ * taking the measurement above.
218
+ *
219
+ * The `build_green` gate collector re-runs the same build and lint scripts and
220
+ * takes the same sandbox, wired at the worker's registry call site. One config
221
+ * value, both paths.
222
+ *
223
+ * ## Still uncontained: the two `dev` servers, and the standalone CLI
224
+ *
225
+ * `runDeepReview`'s and the review worker's. Both are the same `spawnRunArgs`
226
+ * shape, and both are **probed over HTTP from the daemon**, so a `--network=none`
227
+ * container is unreachable by construction. They keep `containedEnv()` and stay
228
+ * named in `run-containment.ts`'s residual list rather than being quietly
229
+ * counted as covered. `harmony-harness`'s own CLI builds the same gate
230
+ * collector and passes no image, because the motor holds no operator config —
231
+ * a person running it against their own checkout, not board text.
232
+ *
233
+ * That distinction is stated rather than left implied because the first version
234
+ * of both lists named the four verification steps only, and the two dev servers
235
+ * then shipped with the daemon's full environment, unlisted and unnoticed
236
+ * through every gate. `spawn-run-containment.test.ts` asserts the environment
237
+ * property over call sites, and `verification-sandbox.test.ts` asserts that a
238
+ * configured image takes the four out of the daemon entirely.
187
239
  */
188
- export function runBuild(worktreePath: string, timeout: number): string[] {
189
- const command = buildCommand(worktreePath);
190
- if (!command) {
191
- log.warn(
192
- TAG,
193
- `No known build toolchain for ${worktreePath} skipping build`,
194
- );
195
- return [];
240
+ /**
241
+ * Resolve the sandbox for the verification steps out of the daemon's config
242
+ * (#1036). Returns `undefined` when no image is configured, which is the
243
+ * default and means host execution exactly as before.
244
+ *
245
+ * An empty string is "not configured", never an image literally named `""` —
246
+ * that is the same reading `ci-patch.ts` gives `sandboxImage` on the repair
247
+ * path, and the two must not diverge.
248
+ */
249
+ export function verificationSandbox(
250
+ config: VerificationConfig,
251
+ ): VerificationSandbox | undefined {
252
+ const image = config.verification.sandboxImage?.trim();
253
+ if (!image) return undefined;
254
+ return { image };
255
+ }
256
+
257
+ /**
258
+ * One verification command, run in the container when one is configured and in
259
+ * the daemon process when it is not.
260
+ *
261
+ * ## Why the outcome has three shapes and not two
262
+ *
263
+ * `sandboxError` is not `ok: false`. "The command failed" and "the container
264
+ * never started" must not both read as a bad branch, because only the first is
265
+ * the branch's fault — the same distinction `repair-sandbox.ts` draws for the
266
+ * repair path, and for the same reason.
267
+ *
268
+ * ## Why there is no host fallback
269
+ *
270
+ * An operator who configured an image asked for these commands to run without
271
+ * their credentials in scope. Silently running them on the host when Docker is
272
+ * unreachable would hand back precisely the exposure the image was set to
273
+ * remove, and would do it invisibly. So an unavailable runtime is reported, not
274
+ * routed around. `repair-sandbox.ts`'s header states the general form: a
275
+ * half-sandbox on the host is the one option that trades the property for
276
+ * convenience, so it is not offered.
277
+ */
278
+ async function execStep(
279
+ command: { cmd: string; args: string[] },
280
+ args: {
281
+ worktreePath: string;
282
+ timeout: number;
283
+ sandbox: VerificationSandbox | undefined;
284
+ },
285
+ ): Promise<{ ok: boolean; err?: unknown; sandboxError?: string }> {
286
+ const { worktreePath, timeout } = args;
287
+ // An empty image is "not configured", never an image named `""` — and the
288
+ // check is here as well as in `verificationSandbox` because these four are
289
+ // exported and a caller can hand-build the object. Docker reads `""` as a
290
+ // missing argument and would fail in a way that reads like a broken runtime.
291
+ const sandbox = args.sandbox?.image.trim() ? args.sandbox : undefined;
292
+
293
+ if (sandbox) {
294
+ if (!(await sandboxAvailable())) {
295
+ return {
296
+ ok: false,
297
+ sandboxError:
298
+ `verification.sandboxImage is set to "${sandbox.image}" but no container runtime answered — ` +
299
+ "start Docker or unset the image to verify on the host",
300
+ };
301
+ }
302
+ const result = await runInSandbox({
303
+ image: sandbox.image,
304
+ worktree: worktreePath,
305
+ command,
306
+ // The STEP's own cap, plus what the container costs to start. Not a
307
+ // separate sandbox-wide timeout: build/lint and the test suite carry
308
+ // different caps on purpose, and one shared knob would silently replace
309
+ // both the moment an image is configured.
310
+ timeoutMs: timeout + SANDBOX_STARTUP_GRACE_MS,
311
+ });
312
+ if (result.sandboxError) {
313
+ return { ok: false, sandboxError: result.sandboxError };
314
+ }
315
+ if (result.passed) return { ok: true };
316
+ // Re-shape into what the existing parsers read, so the container path and
317
+ // the host path produce identical diagnostics from identical output.
318
+ return { ok: false, err: { stdout: result.output, stderr: "" } };
196
319
  }
320
+
197
321
  try {
198
322
  execFileSync(command.cmd, command.args, {
199
323
  cwd: worktreePath,
@@ -202,18 +326,44 @@ export function runBuild(worktreePath: string, timeout: number): string[] {
202
326
  maxBuffer: MAX_OUTPUT_BUFFER,
203
327
  env: containedEnv(),
204
328
  });
205
- return [];
329
+ return { ok: true };
206
330
  } catch (err: unknown) {
207
- return parseErrorOutput(err);
331
+ return { ok: false, err };
208
332
  }
209
333
  }
210
334
 
335
+ export async function runBuild(
336
+ worktreePath: string,
337
+ timeout: number,
338
+ sandbox?: VerificationSandbox,
339
+ ): Promise<string[]> {
340
+ const command = buildCommand(worktreePath);
341
+ if (!command) {
342
+ log.warn(
343
+ TAG,
344
+ `No known build toolchain for ${worktreePath} — skipping build`,
345
+ );
346
+ return [];
347
+ }
348
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
349
+ if (outcome.ok) return [];
350
+ if (outcome.sandboxError) {
351
+ log.error(TAG, `Build not verified: ${outcome.sandboxError}`);
352
+ return [`Build did not run: ${outcome.sandboxError}`];
353
+ }
354
+ return parseErrorOutput(outcome.err);
355
+ }
356
+
211
357
  /**
212
358
  * Run the repo's own test suite. Returns the failures (empty = passed, or no
213
359
  * suite to run). A non-zero exit ALWAYS yields at least one entry — an
214
360
  * unparsable failure must never read as a pass (#688).
215
361
  */
216
- export function runTests(worktreePath: string, timeout: number): string[] {
362
+ export async function runTests(
363
+ worktreePath: string,
364
+ timeout: number,
365
+ sandbox?: VerificationSandbox,
366
+ ): Promise<string[]> {
217
367
  const command = testCommand(worktreePath);
218
368
  if (!command) {
219
369
  log.warn(
@@ -222,25 +372,23 @@ export function runTests(worktreePath: string, timeout: number): string[] {
222
372
  );
223
373
  return [];
224
374
  }
225
- try {
226
- execFileSync(command.cmd, command.args, {
227
- cwd: worktreePath,
228
- timeout,
229
- stdio: "pipe",
230
- maxBuffer: MAX_OUTPUT_BUFFER,
231
- env: containedEnv(),
232
- });
233
- return [];
234
- } catch (err: unknown) {
235
- // The suite's own output is the diagnostic — put its tail in the run log,
236
- // not only the parsed lines that become subtasks.
237
- const output = combineOutput(err);
238
- log.warn(
239
- TAG,
240
- `Test run failed:\n${output.slice(-4000) || "(no output captured)"}`,
241
- );
242
- return parseTestFailures(err, timeout);
375
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
376
+ if (outcome.ok) return [];
377
+ if (outcome.sandboxError) {
378
+ // #688's rule — a non-pass must never yield [] — with the reason being the
379
+ // sandbox rather than a test that never ran. Reporting a phantom assertion
380
+ // here would send someone to read a suite that produced no output.
381
+ log.error(TAG, `Tests not verified: ${outcome.sandboxError}`);
382
+ return [`Test run did not happen: ${outcome.sandboxError}`];
243
383
  }
384
+ // The suite's own output is the diagnostic — put its tail in the run log,
385
+ // not only the parsed lines that become subtasks.
386
+ const output = combineOutput(outcome.err);
387
+ log.warn(
388
+ TAG,
389
+ `Test run failed:\n${output.slice(-4000) || "(no output captured)"}`,
390
+ );
391
+ return parseTestFailures(outcome.err, timeout);
244
392
  }
245
393
 
246
394
  /**
@@ -255,36 +403,42 @@ export function runTests(worktreePath: string, timeout: number): string[] {
255
403
  * rule) is logged, never thrown. This does NOT change the lint-warn-only
256
404
  * policy — the later `runLint` still reports whatever the fixer left behind.
257
405
  */
258
- export function runFormatFix(
406
+ export async function runFormatFix(
259
407
  worktreePath: string,
260
408
  timeout: number,
261
409
  workerId: number,
262
- ): void {
410
+ sandbox?: VerificationSandbox,
411
+ ): Promise<void> {
263
412
  const command = formatFixCommand(worktreePath);
264
413
  if (!command) return;
265
- try {
266
- execFileSync(command.cmd, command.args, {
267
- cwd: worktreePath,
268
- timeout,
269
- stdio: "pipe",
270
- maxBuffer: MAX_OUTPUT_BUFFER,
271
- env: containedEnv(),
272
- });
414
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
415
+ if (outcome.ok) {
273
416
  log.info(
274
417
  TAG,
275
418
  `[worker:${workerId}] Auto-formatted worktree before commit/push`,
276
419
  );
277
- } catch (err: unknown) {
278
- log.warn(
279
- TAG,
280
- `[worker:${workerId}] Auto-format step exited non-zero (non-fatal): ${
281
- err instanceof Error ? err.message : String(err)
282
- }`,
283
- );
420
+ return;
284
421
  }
422
+ // Best-effort by contract: this step is tied to the lint step and never
423
+ // blocks. A broken container is one more reason it did not run, reported at
424
+ // the same level as a fixer that exited non-zero — the later `runLint` still
425
+ // reports whatever was left behind.
426
+ const why = outcome.sandboxError
427
+ ? outcome.sandboxError
428
+ : outcome.err instanceof Error
429
+ ? outcome.err.message
430
+ : String(outcome.err);
431
+ log.warn(
432
+ TAG,
433
+ `[worker:${workerId}] Auto-format step did not complete (non-fatal): ${why}`,
434
+ );
285
435
  }
286
436
 
287
- export function runLint(worktreePath: string, timeout: number): string[] {
437
+ export async function runLint(
438
+ worktreePath: string,
439
+ timeout: number,
440
+ sandbox?: VerificationSandbox,
441
+ ): Promise<string[]> {
288
442
  const command = lintCommand(worktreePath);
289
443
  if (!command) {
290
444
  log.info(
@@ -293,18 +447,16 @@ export function runLint(worktreePath: string, timeout: number): string[] {
293
447
  );
294
448
  return [];
295
449
  }
296
- try {
297
- execFileSync(command.cmd, command.args, {
298
- cwd: worktreePath,
299
- timeout,
300
- stdio: "pipe",
301
- maxBuffer: MAX_OUTPUT_BUFFER,
302
- env: containedEnv(),
303
- });
304
- return [];
305
- } catch (err: unknown) {
306
- return parseErrorOutput(err);
450
+ const outcome = await execStep(command, { worktreePath, timeout, sandbox });
451
+ if (outcome.ok) return [];
452
+ if (outcome.sandboxError) {
453
+ // Lint is warn-only, so this is reported as a warning line rather than
454
+ // blocking — but it must still say the step did not run, because an empty
455
+ // lint result is indistinguishable from a clean one.
456
+ log.error(TAG, `Lint not verified: ${outcome.sandboxError}`);
457
+ return [`Lint did not run: ${outcome.sandboxError}`];
307
458
  }
459
+ return parseErrorOutput(outcome.err);
308
460
  }
309
461
 
310
462
  export async function runDeepReview(