@gethmy/harness 1.2.0 → 1.3.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/src/oracle.ts CHANGED
@@ -64,11 +64,25 @@
64
64
  * Failing the gate rather than writing is the point.
65
65
  */
66
66
  import type { ChildProcess } from "node:child_process";
67
- import { lstat, mkdir, realpath, rm, writeFile } from "node:fs/promises";
68
- import { dirname, isAbsolute, resolve, sep } from "node:path";
67
+ import { lstatSync, readFileSync, statSync } from "node:fs";
68
+ import {
69
+ chmod,
70
+ lstat,
71
+ mkdir,
72
+ mkdtemp,
73
+ realpath,
74
+ rm,
75
+ writeFile,
76
+ } from "node:fs/promises";
77
+ import { tmpdir } from "node:os";
78
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
79
+ import { StringDecoder } from "node:string_decoder";
69
80
  import { DEFAULT_METRIC_TIMEOUT_MS } from "./exec-types.js";
81
+ import { log } from "./log.js";
70
82
  import { reapGroup, spawnInGroup, terminateGroup } from "./process-group.js";
71
83
 
84
+ const TAG = "oracle";
85
+
72
86
  export interface HeldOracle {
73
87
  /**
74
88
  * The `stage_oracles` row id, when the API returns one (card #927 — older
@@ -90,6 +104,24 @@ export interface OracleDeps {
90
104
  * into `fetchOracle`'s third argument.
91
105
  */
92
106
  sessionId: string;
107
+ /**
108
+ * The stage the held oracle is KEYED TO — the downstream stage whose gate is
109
+ * `oracle_passed`, as resolved by `findOracleTargetStage`. Needed only by the
110
+ * `oracle_red` gate (#921) and `null` when there is no such stage.
111
+ *
112
+ * A green gate does not use this: the stage that runs `oracle_passed` IS the
113
+ * target, so `context.stageId` already addresses the row. A RED gate is the
114
+ * author's own stage, one stage EARLIER, and the row is keyed to the target
115
+ * rather than to the writer (see the `/stage-oracle` route: "The row is keyed
116
+ * to the TARGET"). So the red collector must ask for the target's id, or it
117
+ * would look up an oracle under its own stage id and find nothing.
118
+ *
119
+ * Keying stays single: ONE held test, written once by the author, read by both
120
+ * gates. That is also why no edge-side authorization change was needed — both
121
+ * reads name a target stage that declares `oracle_passed`, which is exactly
122
+ * what `mayReadOracle` requires.
123
+ */
124
+ targetStageId?: string | null;
93
125
  /** Reads via POST /stage-oracle/fetch with purpose "gate_evaluation". */
94
126
  fetchOracle(
95
127
  cardId: string,
@@ -107,7 +139,23 @@ export interface OracleDeps {
107
139
  run(
108
140
  repoPath: string,
109
141
  oracle: HeldOracle,
110
- ): Promise<{ exitCode: number; output: string }>;
142
+ ): Promise<{
143
+ exitCode: number;
144
+ /** stdout + stderr merged, for the operator's log. Never the verdict. */
145
+ output: string;
146
+ /**
147
+ * The runner's OWN structured report of what it executed — the only thing
148
+ * the `oracle_red` gate grades (#1019). `null` ⇒ no report this recognizes,
149
+ * which the red gate must treat as `blocked`, never as a pass.
150
+ *
151
+ * Read from a file the runner writes OUTSIDE the worktree, never from the
152
+ * child's stdout or stderr. See {@link OracleReportSpec} for why no
153
+ * in-band channel can carry this: the held test shares stdout and stderr
154
+ * with the runner, and the drain window after the leader exits is exactly
155
+ * where a detached grandchild gets to write the last word.
156
+ */
157
+ report: OracleRunSummary | null;
158
+ }>;
111
159
  }
112
160
 
113
161
  /**
@@ -215,14 +263,287 @@ export async function remove(
215
263
  * already have: an absent runner must fail the gate, not fetch a package onto
216
264
  * the motor's host mid-stage.
217
265
  */
218
- const ORACLE_RUNNERS: Record<string, (path: string) => OracleRunnerArgv> = {
219
- vitest: (path) => ({
220
- command: "npx",
221
- args: ["--no-install", "vitest", "run", path],
222
- }),
223
- bun: (path) => ({ command: "bun", args: ["test", path] }),
266
+ const ORACLE_RUNNERS: Record<string, OracleRunnerSpec> = {
267
+ vitest: {
268
+ argv: (path) => ({
269
+ command: "npx",
270
+ args: ["--no-install", "vitest", "run", path],
271
+ }),
272
+ // Measured: vitest prints its human summary block on STDOUT. Diagnostic
273
+ // only since #1019 — nothing grades this stream.
274
+ verdictStream: "stdout",
275
+ report: {
276
+ file: "report.json",
277
+ // `--reporter=default` is listed FIRST and is not decoration: measured
278
+ // against vitest 4.1.11, `--reporter=json` ALONE replaces the default
279
+ // reporter, so stdout loses the `Tests N failed (N)` block entirely and
280
+ // the operator's log is left with one line saying a file was written.
281
+ // Naming both reporters keeps the human log AND gets the machine report.
282
+ flags: (reportPath) => [
283
+ "--reporter=default",
284
+ "--reporter=json",
285
+ `--outputFile=${reportPath}`,
286
+ ],
287
+ // Measured against vitest 4.1.11:
288
+ // genuine mixed failure → numTotalTests 3, numFailedTests 2
289
+ // clean pass → numTotalTests N, numFailedTests 0
290
+ // syntax/collect error → numTotalTests 0 (`Tests no tests`)
291
+ // file with no tests → numTotalTests 0
292
+ // path matched nothing → numTotalTests 0
293
+ // runner absent → NO FILE AT ALL, so `null` before this runs
294
+ // Every one of the zero-total cases exits non-zero and is a "could not
295
+ // run", which `gradeOracleRed` maps to `no_verdict` on `total === 0`.
296
+ parse: (content) => {
297
+ const parsed: unknown = JSON.parse(content);
298
+ if (typeof parsed !== "object" || parsed === null) return null;
299
+ const { numTotalTests, numFailedTests } = parsed as Record<
300
+ string,
301
+ unknown
302
+ >;
303
+ // Both counts must be present and finite. A report missing either is a
304
+ // shape we do not know, and `null` (⇒ blocked) is the safe reading —
305
+ // never "assume it ran".
306
+ if (
307
+ typeof numTotalTests !== "number" ||
308
+ typeof numFailedTests !== "number" ||
309
+ !Number.isFinite(numTotalTests) ||
310
+ !Number.isFinite(numFailedTests)
311
+ ) {
312
+ return null;
313
+ }
314
+ return { total: numTotalTests, failed: numFailedTests };
315
+ },
316
+ },
317
+ },
318
+ bun: {
319
+ argv: (path) => ({ command: "bun", args: ["test", path] }),
320
+ // Measured: bun test prints ` N fail` and `Ran N tests…` on STDERR, not
321
+ // stdout (stdout carries only its 28-byte version banner). Diagnostic only
322
+ // since #1019 — nothing grades this stream.
323
+ verdictStream: "stderr",
324
+ report: {
325
+ file: "report.xml",
326
+ // #921 rejected `--reporter=junit` because bun refuses it without
327
+ // `--reporter-outfile`, and a report file INSIDE the worktree is one the
328
+ // completion path could commit — handing the implementer the held test's
329
+ // shape. #1019 answers that objection rather than living with it: the
330
+ // outfile goes to a temp dir outside the worktree (see
331
+ // `runHeldOracle`), so there is nothing in the tree to commit. Measured:
332
+ // bun keeps printing its human stderr summary with this flag on.
333
+ flags: (reportPath) => [
334
+ "--reporter=junit",
335
+ `--reporter-outfile=${reportPath}`,
336
+ ],
337
+ // Measured against bun 1.3.13. The root element carries the run-wide
338
+ // counts:
339
+ // `<testsuites name="bun test" tests="2" assertions="2" failures="1" …>`
340
+ // A thrown non-assertion error counts under `failures` too (measured),
341
+ // and the XML carries NO assertion text — only `type="AssertionError"`.
342
+ // An unmatched path and a file with no tests both write NO FILE at all
343
+ // (measured), so those reach `gradeOracleRed` as `null` ⇒ `no_verdict`
344
+ // without this parser running.
345
+ parse: (content) => {
346
+ // The ROOT `<testsuites>` element only — never a per-file
347
+ // `<testsuite>`. Anchoring on the run-wide totals means a multi-file
348
+ // report cannot be read as just its first file's counts.
349
+ const root = /<testsuites\b[^>]*>/.exec(content);
350
+ if (!root) return null;
351
+ const total = /\btests="(\d+)"/.exec(root[0]);
352
+ const failed = /\bfailures="(\d+)"/.exec(root[0]);
353
+ if (!total || !failed) return null;
354
+ return { total: Number(total[1]), failed: Number(failed[1]) };
355
+ },
356
+ },
357
+ },
224
358
  };
225
359
 
360
+ /**
361
+ * WHY THE VERDICT IS NOT READ FROM stdout/stderr AT ALL (#1019).
362
+ *
363
+ * Until #1019 the red gate parsed the runner's human summary out of the child's
364
+ * verdict stream, taking the LAST match on the reasoning that both runners print
365
+ * their summary last. Three forgeries were closed in that order, and the third
366
+ * could not be:
367
+ *
368
+ * 1. **First-match forgery** (#921). A held test that PASSES while printing
369
+ * `Tests 7 failed (7)` was measured reading as 7 failures. Closed by
370
+ * reading the last match, since anything the test prints precedes the
371
+ * runner's own summary. This was the accidental case too — an honest test
372
+ * that merely logs.
373
+ * 2. **Cross-pipe forgery** (PR #721's security review, MEDIUM
374
+ * `authorization_bypass`). stdout and stderr are appended by two
375
+ * independent `data` handlers with no ordering guarantee BETWEEN the pipes,
376
+ * so a forged line on the stream the runner does not use could land after
377
+ * the real summary. Closed by reading one measured stream per runner.
378
+ * 3. **The detached grandchild** — not closable in-band, which is why the
379
+ * channel moved. `runHeldOracle` waits {@link ORACLE_DRAIN_GRACE_MS} after
380
+ * the leader exits so a slow pipe can still deliver legitimate trailing
381
+ * output, and `reapGroup` addresses the group by number, so it cannot
382
+ * reach a grandchild that called `setsid` to leave it. A held test that
383
+ * spawns a detached writer inheriting the runner's verdict fd therefore
384
+ * gets to emit into that same stream AFTER the runner's real summary:
385
+ *
386
+ * spawn('sh', ['-c', 'sleep 0.1; echo "Ran 9 tests across 9 files."'],
387
+ * { detached: true, stdio: 'inherit' });
388
+ *
389
+ * Read as `{ total: 9, failed: 9 }` ⇒ `reproduced`, on a run where nothing
390
+ * failed. The same test passes the green gate on exit 0, so red-green is
391
+ * satisfied end to end by a contract that proves nothing.
392
+ *
393
+ * Every in-band tightening trades the hole for a worse property, which is what
394
+ * makes this a channel problem rather than a parser problem:
395
+ *
396
+ * - **Snapshot the buffer at leader exit.** The drain window exists BECAUSE
397
+ * the summary may not have been delivered yet, so this trades an
398
+ * adversarial hole for a flaky gate. It is also defeatable: a sprayer that
399
+ * writes continuously lands a line in the window between the runner's own
400
+ * summary and its exit, which is inside any such snapshot.
401
+ * - **Kill the group harder first.** `setsid` is precisely what puts the
402
+ * grandchild out of reach — there is nothing left to signal.
403
+ * - **Require the summary to be the final line, or preceded by the runner's
404
+ * banner.** Legitimate trailing output exists (coverage tables, teardown
405
+ * warnings), so ordinary runs would report `blocked` — and a sprayer can
406
+ * emit a banner too.
407
+ *
408
+ * So the graded channel is a FILE the runner writes, in a temp dir outside the
409
+ * worktree (see {@link OracleReportSpec} and `runHeldOracle`). The streams are
410
+ * still captured for the operator's log, and nothing parses them.
411
+ *
412
+ * WHAT THAT DOES AND DOES NOT BUY, measured rather than assumed. It closes the
413
+ * three stream forgeries above outright: nothing written to stdout or stderr
414
+ * changes a verdict any more, so the accidental case, the first-match case and
415
+ * the detached-grandchild case are all gone.
416
+ *
417
+ * It does NOT make the verdict unforgeable, and nothing here should be read as
418
+ * saying so. The held test is arbitrary code running as the motor's own user, on
419
+ * the same host, so it can write the report file itself:
420
+ *
421
+ * - The directory is NOT secret. `mkdtemp` randomizes the suffix, but the
422
+ * prefix is a fixed string and `readdirSync(os.tmpdir())` enumerates the
423
+ * directory in one call — measured from inside a real held test, under both
424
+ * runners. 0700 does not hide it either; the held test is the owner.
425
+ * - The forgery does NOT need to win a race. A held test can write its own
426
+ * report and then drop write permission (on the file, or on the directory)
427
+ * so the runner's later write cannot land. Measured: a trivially GREEN bun
428
+ * held test doing this produced `{ total: 1, failed: 1 }` and exit 0 — a
429
+ * `reproduced` red gate AND a passing green gate from one file.
430
+ *
431
+ * {@link assertUntampered} refuses that one shape, because both ways of making
432
+ * it stick change a mode the motor set. It does NOT close the class, and the
433
+ * cheapest bypass needs no mode change and no race at all: the held test
434
+ * pre-writes the report and then stops the runner from writing its own.
435
+ * `process.exit(0)` in the test body is enough — measured, 3 runs of 3 under
436
+ * bun, giving `{ total: 1, failed: 1 }` with exit 0, which is `reproduced` from
437
+ * the red gate AND a pass from the green one. Under vitest the same trick yields
438
+ * the same forged counts. `oracle-run.test.ts` pins that this is still open, so
439
+ * the description cannot quietly drift back to "closed".
440
+ *
441
+ * Two other reasons not to read the check as a boundary: `statSync` follows
442
+ * symlinks, so REPLACING the directory (rmdir + mkdir 0700, or a symlink) leaves
443
+ * every mode the motor set intact; and a mode change is restorable by a
444
+ * grandchild that waits for the runner to exit and chmods back.
445
+ *
446
+ * So the accurate summary, and what the docs say: this gate is evidence against
447
+ * a CARELESS author, not against a hostile one, and the mode check raises the
448
+ * cost of one attack rather than establishing a property. Anything short of not
449
+ * running the held test as the motor's own user is a mitigation — the sandbox
450
+ * `repair-sandbox.ts` (#1015) already uses for untrusted code, with the report
451
+ * read from outside it, is the actual fix. Tracked at card #1021; see
452
+ * `docs/agent-daemon.md`.
453
+ */
454
+
455
+ /**
456
+ * What a runner reported about the tests it actually executed. This exists for
457
+ * the `oracle_red` gate (#921) and for nothing else: `oracle_passed` needs only
458
+ * the exit code, because a green gate that misreads "could not run" as `failed`
459
+ * merely under-grants, which is safe.
460
+ *
461
+ * A red gate has no such luxury. It passes on FAILURE, so reading "could not
462
+ * run" as a failure would over-grant — and the three cases are indistinguishable
463
+ * by exit code alone. All of these exit 1, measured against the real runners:
464
+ *
465
+ * - a genuine assertion failure (the signal the gate wants)
466
+ * - `npx --no-install vitest`, absent (the runner never started)
467
+ * - `bun test ./unmatched-path` (nothing was collected)
468
+ *
469
+ * So the red gate requires the runner's own count of executed and failed tests,
470
+ * and reports `blocked` whenever it cannot read one.
471
+ */
472
+ export interface OracleRunSummary {
473
+ /** Tests the runner reported having EXECUTED. */
474
+ total: number;
475
+ /** Of those, how many the runner reported as FAILING. */
476
+ failed: number;
477
+ }
478
+
479
+ /**
480
+ * How one allow-listed runner writes a MACHINE report, and how to read it.
481
+ *
482
+ * This is the red gate's only graded channel (#1019). The point is not that the
483
+ * format is structured — it is that the held test does not share the channel.
484
+ * A file the runner writes outside the worktree is not something an inherited
485
+ * stdio fd reaches, so the detached-grandchild forgery
486
+ * that no in-band parser could refuse has nowhere to land. The long comment
487
+ * above `OracleRunnerSpec` has the full account.
488
+ *
489
+ * `flags` must keep the runner's HUMAN output intact as well: the operator's log
490
+ * is how a person understands a blocked gate, and a reporter flag that replaces
491
+ * the default reporter (measured: vitest's `--reporter=json` does exactly that)
492
+ * would silently empty it.
493
+ */
494
+ interface OracleReportSpec {
495
+ /**
496
+ * The report's filename inside the dir `runHeldOracle` creates. A plain fixed
497
+ * name on purpose: the directory's random suffix is not a secret either (the
498
+ * held test can enumerate it — see `runHeldOracle`), so a random filename
499
+ * would buy nothing and only make the operator's log harder to read.
500
+ */
501
+ file: string;
502
+ /** Extra argv elements that make the runner write its report to `reportPath`. */
503
+ flags(reportPath: string): string[];
504
+ /**
505
+ * Read `{ total, failed }` out of the runner's own report, or `null` when the
506
+ * content carries no report this recognizes.
507
+ *
508
+ * `null` is load-bearing and must stay the DEFAULT for anything unfamiliar:
509
+ * the red gate maps it to `blocked`, so a runner that changes its report
510
+ * format degrades to "we could not tell" rather than to a false red. Never
511
+ * widen one of these to "assume it ran" — that inverts the safe direction.
512
+ *
513
+ * May throw (a JSON parse error is the obvious one); `runHeldOracle` catches
514
+ * and treats a throw exactly as `null`.
515
+ *
516
+ * The formats are pinned by tests in `oracle-red.test.ts` against captured
517
+ * real-runner reports, so drift fails a test instead of going quiet — and by
518
+ * an end-to-end test against the real runners in `oracle-run.test.ts`, so a
519
+ * runner that stops honoring the flag fails there rather than leaving the gate
520
+ * permanently `blocked` in production.
521
+ */
522
+ parse(content: string): OracleRunSummary | null;
523
+ }
524
+
525
+ /** One allow-listed runner: the argv to run it, and how to read its report. */
526
+ interface OracleRunnerSpec {
527
+ argv(path: string): OracleRunnerArgv;
528
+ /**
529
+ * WHICH stream this runner prints its human summary on.
530
+ *
531
+ * DIAGNOSTIC ONLY since #1019 — nothing grades it. `runHeldOracle` keeps this
532
+ * stream in a buffer of its own so the operator's log can show the runner's
533
+ * own summary apart from whatever the held test printed, and so the
534
+ * end-to-end tests can pin which fd each runner uses. A verdict is never
535
+ * parsed out of it: see the comment above `OracleRunnerSpec` for why no
536
+ * in-band channel can carry one.
537
+ *
538
+ * Measured per runner, because they differ and the difference is not
539
+ * guessable: **vitest** prints `Tests N failed (N)` on **stdout**, while
540
+ * **bun** prints ` N fail` / `Ran N tests…` on **stderr**.
541
+ */
542
+ verdictStream: "stdout" | "stderr";
543
+ /** The machine report: the gate's ONLY graded channel. */
544
+ report: OracleReportSpec;
545
+ }
546
+
226
547
  /** The hints {@link resolveOracleRunner} accepts, for the refusal message. */
227
548
  export const ORACLE_RUNNER_HINTS: readonly string[] =
228
549
  Object.keys(ORACLE_RUNNERS).sort();
@@ -240,6 +561,14 @@ export interface OracleRunnerArgv {
240
561
  * way `runMetricCommand` must (there, stdout IS the measurement, so a partial
241
562
  * read would corrupt the number). Memory stays bounded either way, and the
242
563
  * timeout still bounds the process.
564
+ *
565
+ * Truncation keeps the TAIL, and that outlived its original reason. #921 made
566
+ * it security-critical (the red gate parsed the runner's summary block, printed
567
+ * LAST, out of this very buffer, so trimming the end destroyed the gate's only
568
+ * signal on a verbose held test). #1019 moved the verdict to a report file, so
569
+ * the tail is no longer load-bearing for GRADING — but it stays, because the
570
+ * runner's summary is still the most useful thing in an operator's log and the
571
+ * head of a verbose run is the least. See `append` in {@link runHeldOracle}.
243
572
  */
244
573
  const ORACLE_OUTPUT_LIMIT = 64 * 1024;
245
574
 
@@ -252,7 +581,13 @@ const ORACLE_SIGTERM_GRACE_MS = 3_000;
252
581
  /** How long to wait after the leader exits for its stdio pipes to deliver the
253
582
  * tail of the output. Bounded for the same reason `command-metric.ts` bounds
254
583
  * its own: a grandchild that left the group with `setsid()` holds the pipe open
255
- * forever, and a run that already produced its exit code must not wait on it. */
584
+ * forever, and a run that already produced its exit code must not wait on it.
585
+ *
586
+ * This window used to be the red gate's exposure (#1019): a detached grandchild
587
+ * could write a forged summary into the verdict stream during it and win the
588
+ * parse. Since the verdict comes from a report file read AT the `exit` event,
589
+ * anything appended during this window reaches the operator's log and nothing
590
+ * else — so the window can stay as generous as legitimate late output needs. */
256
591
  const ORACLE_DRAIN_GRACE_MS = 500;
257
592
 
258
593
  /**
@@ -276,20 +611,128 @@ const ORACLE_DRAIN_GRACE_MS = 500;
276
611
  * `failed` instead, so this follows the outcome it named, not the mechanism.
277
612
  */
278
613
  export function resolveOracleRunner(oracle: HeldOracle): OracleRunnerArgv {
614
+ return resolveOracleRunnerSpec(oracle).argv(argvPath(oracle.path));
615
+ }
616
+
617
+ /**
618
+ * The whole allow-list entry for a held test's `runnerHint` — argv AND summary
619
+ * reader. Throws on an unknown or absent hint, exactly as
620
+ * {@link resolveOracleRunner} does (they share this function), so the refusal
621
+ * message and the `blocked` outcome are identical for both gate polarities.
622
+ */
623
+ function resolveOracleRunnerSpec(oracle: HeldOracle): OracleRunnerSpec {
279
624
  const hint = oracle.runnerHint?.trim().toLowerCase() ?? "";
280
625
  // `Object.hasOwn`, never a bare lookup: an inherited key ("constructor",
281
626
  // "toString") would otherwise resolve to a function and be called.
282
- const build = Object.hasOwn(ORACLE_RUNNERS, hint)
627
+ const spec = Object.hasOwn(ORACLE_RUNNERS, hint)
283
628
  ? ORACLE_RUNNERS[hint]
284
629
  : undefined;
285
- if (!build) {
630
+ if (!spec) {
286
631
  throw new Error(
287
632
  `refusing to run the held test: runner_hint ${JSON.stringify(
288
633
  oracle.runnerHint,
289
634
  )} is not in the motor's allow-list (${ORACLE_RUNNER_HINTS.join(", ")})`,
290
635
  );
291
636
  }
292
- return build(argvPath(oracle.path));
637
+ return spec;
638
+ }
639
+
640
+ /**
641
+ * Read the runner's own test counts out of a machine report's CONTENT, through
642
+ * the same allow-list that chose the argv. `null` ⇒ no report this recognizes,
643
+ * which the red gate must treat as `blocked` (never as a pass). A parser that
644
+ * throws (malformed JSON, most obviously) is `null` too — the safe reading of
645
+ * "we could not tell".
646
+ *
647
+ * EXPORTED AS A TEST SEAM, and has no production caller by design: production
648
+ * reads the report through `runHeldOracle`, which owns the file's lifetime.
649
+ * The export exists so `oracle-red.test.ts` can pin each runner's report format
650
+ * against captured real output without going near the filesystem, which is the
651
+ * only thing that makes a format drift fail loudly instead of degrading the gate
652
+ * to a silent `blocked`. Nothing else should be handed report content: it is
653
+ * secret-bearing for one of the two runners (vitest's JSON carries
654
+ * `failureMessages`, measured — see `captureReport`).
655
+ *
656
+ * The green gate never calls this: `oracle_passed` decides on the exit code
657
+ * alone, and widening it to consult a report would be a behavior change to a
658
+ * security-reviewed gate for no gain — a green gate that misreads "could not
659
+ * run" as `failed` under-grants, which is already the safe direction.
660
+ */
661
+ export function summarizeOracleReport(
662
+ oracle: HeldOracle,
663
+ content: string,
664
+ ): OracleRunSummary | null {
665
+ // Resolved OUTSIDE the try, so an unknown `runner_hint` still THROWS. The two
666
+ // failures are not the same thing and must not collapse: an unrecognized
667
+ // report is "we could not tell" (`null` ⇒ blocked), while an unknown hint is a
668
+ // defect in the oracle's own declaration that `OracleCollector` reports as
669
+ // `blocked` with the refusal message attached. Swallowing the throw here
670
+ // would lose the message that names the hint and the allow-list.
671
+ const spec = resolveOracleRunnerSpec(oracle);
672
+ try {
673
+ return spec.report.parse(content) ?? null;
674
+ } catch {
675
+ return null;
676
+ }
677
+ }
678
+
679
+ /**
680
+ * The three outcomes a RED reading of a held-test run can have. Pure, and
681
+ * separated from the collector so the decision table is testable on its own.
682
+ *
683
+ * `no_verdict` is the outcome that makes the gate honest: it is the answer
684
+ * whenever the run cannot be shown to have executed the held test and found it
685
+ * failing. It maps to `blocked`, which can never satisfy a gate.
686
+ */
687
+ export type OracleRedOutcome =
688
+ /** The held test ran and reported at least one failing test — the bug is reproduced. */
689
+ | { outcome: "reproduced"; summary: OracleRunSummary }
690
+ /** The held test ran and nothing failed — this does not reproduce the bug. */
691
+ | { outcome: "not_reproduced"; summary: OracleRunSummary }
692
+ /** The run produced no trustworthy red/green answer at all. */
693
+ | { outcome: "no_verdict"; reason: string };
694
+
695
+ /**
696
+ * Grade a completed held-test run RED.
697
+ *
698
+ * The order of these checks is the safety property, not a style choice. Every
699
+ * "could not run" case exits non-zero exactly like a genuine assertion failure
700
+ * (see {@link OracleRunSummary}), so the exit code is consulted LAST and only
701
+ * ever to reject — never to grant. A pass requires a positive count of failing
702
+ * tests from the runner's own summary.
703
+ */
704
+ export function gradeOracleRed(
705
+ summary: OracleRunSummary | null,
706
+ exitCode: number,
707
+ ): OracleRedOutcome {
708
+ if (!summary) {
709
+ return {
710
+ outcome: "no_verdict",
711
+ reason:
712
+ `the runner exited ${exitCode} but the motor has no report it could read — absent, unreadable, or refused, ` +
713
+ "so it cannot be shown to have run the held test (an absent runner and a failing test share this exit code). " +
714
+ "The report is a file the runner writes outside the worktree; the motor's local log says which of the three it was",
715
+ };
716
+ }
717
+ if (summary.total === 0) {
718
+ return {
719
+ outcome: "no_verdict",
720
+ reason: `the runner started but executed no tests (exit ${exitCode}), so the held test produced no verdict`,
721
+ };
722
+ }
723
+ if (summary.failed > 0) {
724
+ return { outcome: "reproduced", summary };
725
+ }
726
+ if (exitCode === 0) {
727
+ return { outcome: "not_reproduced", summary };
728
+ }
729
+ // Every test the runner ran passed, yet it still failed overall — a coverage
730
+ // threshold, a post-run hook, an unhandled rejection. That is not a
731
+ // reproduction of the card's bug, and it is not a clean green either.
732
+ return {
733
+ outcome: "no_verdict",
734
+ reason: `all ${summary.total} test(s) passed but the runner exited ${exitCode}, so the failure is not the held test's`,
735
+ };
293
736
  }
294
737
 
295
738
  /**
@@ -334,108 +777,413 @@ export async function runHeldOracle(
334
777
  repoPath: string,
335
778
  oracle: HeldOracle,
336
779
  timeoutMs: number = DEFAULT_METRIC_TIMEOUT_MS,
337
- ): Promise<{ exitCode: number; output: string }> {
338
- const { command, args } = resolveOracleRunner(oracle);
780
+ ): Promise<{
781
+ exitCode: number;
782
+ output: string;
783
+ verdict: string;
784
+ report: OracleRunSummary | null;
785
+ }> {
786
+ // Resolve the runner BEFORE creating anything: an unknown hint must reject
787
+ // without leaving a temp directory behind (and without spawning, which the
788
+ // "rejects an unknown hint before it spawns anything" test pins).
789
+ const spec = resolveOracleRunnerSpec(oracle);
790
+
791
+ // The graded channel (#1019). OUTSIDE the worktree, because a report file
792
+ // inside the tree is one the completion path could commit — handing the
793
+ // implementer the held test's shape, the exact hazard `removeBestEffort`
794
+ // exists to prevent, and the objection that made #921 avoid a report file at
795
+ // all.
796
+ //
797
+ // `mkdtemp` randomizes the SUFFIX and creates the directory 0700. Neither
798
+ // hides the path from the held test: the prefix is a fixed string and
799
+ // `readdirSync(tmpdir())` enumerates the directory in one call, 0700 included
800
+ // (the held test runs as the motor's own user, who owns it). This was measured,
801
+ // not assumed. So the randomness is not a secret and nothing here should be
802
+ // read as one — see `assertUntampered` for what actually defends the file, and
803
+ // the comment above `OracleRunnerSpec` for what remains open.
804
+ const reportDir = await mkdtemp(join(tmpdir(), reportDirPrefix()));
805
+ const reportPath = join(reportDir, spec.report.file);
806
+ try {
807
+ return await spawnHeldOracle(spec, repoPath, oracle, reportPath, timeoutMs);
808
+ } finally {
809
+ await removeReportDir(reportDir);
810
+ }
811
+ }
812
+
813
+ /**
814
+ * The report directory's fixed name prefix. Deliberately a constant and not a
815
+ * secret (see `runHeldOracle`); it exists so an operator can recognize a
816
+ * stranded directory as this module's, and so {@link removeReportDir} logs
817
+ * something identifiable.
818
+ */
819
+ function reportDirPrefix(): string {
820
+ return "harmony-oracle-report-";
821
+ }
822
+
823
+ /**
824
+ * Refuse a report whose file or directory shows that something other than the
825
+ * runner has been at it. THROWS on any doubt; `captureReport` turns a throw into
826
+ * `null` ⇒ `blocked`, which can never satisfy a gate.
827
+ *
828
+ * What this catches, and it is worth being exact because the rest is open: a
829
+ * held test can find this directory (the prefix is fixed and `readdir` lists
830
+ * it), so the cheapest forgery is to write the report ITSELF and then remove
831
+ * write permission — from the file, or from the directory — so the runner's own
832
+ * write cannot land on top of it. That variant is deterministic and needs no
833
+ * race. Both halves of it change a mode the motor set, so both are visible here:
834
+ *
835
+ * - the directory must still be exactly 0700, as `mkdtemp` created it
836
+ * - the report must be a REGULAR file (never a symlink, which would redirect
837
+ * the read somewhere the motor did not create)
838
+ * - the report must still be writable by its owner — a 0444 report is one the
839
+ * runner could not have overwritten, so it is not necessarily the runner's
840
+ *
841
+ * What this does NOT catch, stated so nobody reads it as more than it is:
842
+ *
843
+ * - **Pre-write, then stop the runner writing.** No mode change, no race:
844
+ * `process.exit(0)` in the held test skips the reporter's write entirely and
845
+ * leaves the forgery as the only file on disk, at ordinary modes inside a
846
+ * still-0700 directory. Measured under both runners. This is CHEAPER than
847
+ * the attack above, so the check below is one shape of an open class.
848
+ * - **Replacing the directory.** `statSync` follows symlinks, so an rmdir +
849
+ * mkdir 0700, or a symlink to the attacker's own directory, changes no mode
850
+ * the motor set.
851
+ * - **Restoring the mode.** A grandchild that waits for the runner to exit and
852
+ * chmods back defeats the file-mode arm.
853
+ *
854
+ * Mode checks raise the cost of one attack; they are not a boundary. The only
855
+ * boundary is not running the held test as the motor's user at all — see the
856
+ * comment above `OracleRunnerSpec` and card #1021.
857
+ *
858
+ * The exact-0700 requirement is deliberate but is a false-positive risk worth
859
+ * knowing about: `mkdtemp` asks for 0700 through `mkdir`, so an exotic umask
860
+ * that clears an owner bit, or a `TMPDIR` on a filesystem that does not carry
861
+ * POSIX modes, would refuse every report and block the gate permanently. Both
862
+ * runners were measured writing the report 0644 inside a 0700 directory under a
863
+ * normal umask. `captureReport` logs the refusal reason so that case is
864
+ * diagnosable rather than silent.
865
+ */
866
+ function assertUntampered(reportPath: string): void {
867
+ const dir = statSync(dirname(reportPath));
868
+ // `& 0o777` drops the file-type bits; only the permission bits are the claim.
869
+ if ((dir.mode & 0o777) !== 0o700) {
870
+ throw new Error(
871
+ `the oracle report directory's mode changed to ${(dir.mode & 0o777).toString(8)} — refusing the report`,
872
+ );
873
+ }
874
+ // `lstat`, never `stat`: a symlink is exactly what a redirect would look like,
875
+ // and `stat` would follow it and report the target as a fine regular file.
876
+ const file = lstatSync(reportPath);
877
+ if (!file.isFile()) {
878
+ throw new Error("the oracle report is not a regular file — refusing it");
879
+ }
880
+ if ((file.mode & 0o200) === 0) {
881
+ throw new Error(
882
+ `the oracle report is not owner-writable (mode ${(file.mode & 0o777).toString(8)}), so the runner could not have written it last — refusing it`,
883
+ );
884
+ }
885
+ }
339
886
 
340
- return await new Promise<{ exitCode: number; output: string }>(
341
- (settleOk, settleErr) => {
342
- let child: ChildProcess;
887
+ /**
888
+ * Delete the report directory. Attempted on EVERY path — completion, rejection,
889
+ * timeout — because the report is secret-bearing for one of the two runners:
890
+ * vitest's JSON carries `failureMessages` with the assertion text verbatim
891
+ * (measured), which is itself a partial oracle.
892
+ *
893
+ * BEST EFFORT, and said so out loud rather than in a silent `catch`. A held test
894
+ * that chmods the directory to remove write permission makes the removal fail,
895
+ * and a stranded directory then accumulates on a long-lived daemon host with the
896
+ * assertion text still in it. So this restores the mode first — the motor owns
897
+ * the directory, so it may — and WARNS when the removal still fails, giving the
898
+ * operator the path to clean up. It never throws: a directory we could not
899
+ * delete must not turn a completed gate into a blocked one.
900
+ */
901
+ async function removeReportDir(reportDir: string): Promise<void> {
902
+ try {
903
+ // A held test may have chmod'ed this to make its forged report stick. We
904
+ // own it, so take the permission back before removing.
905
+ await chmod(reportDir, 0o700).catch(() => {});
906
+ await rm(reportDir, { recursive: true, force: true });
907
+ } catch (err) {
908
+ log.warn(
909
+ TAG,
910
+ `Could not remove the oracle report directory ${reportDir} (${
911
+ err instanceof Error ? err.message : String(err)
912
+ }) — it may still hold the runner's report, which carries assertion text for vitest. Remove it by hand.`,
913
+ );
914
+ }
915
+ }
916
+
917
+ /**
918
+ * The supervised spawn half of {@link runHeldOracle}, split out so the report
919
+ * directory's lifetime is a plain `try`/`finally` in one place rather than a
920
+ * cleanup duplicated down every settle path.
921
+ */
922
+ async function spawnHeldOracle(
923
+ spec: OracleRunnerSpec,
924
+ repoPath: string,
925
+ oracle: HeldOracle,
926
+ reportPath: string,
927
+ timeoutMs: number,
928
+ ): Promise<{
929
+ exitCode: number;
930
+ output: string;
931
+ verdict: string;
932
+ report: OracleRunSummary | null;
933
+ }> {
934
+ const { command, args: baseArgs } = spec.argv(argvPath(oracle.path));
935
+ // The report flags ride on BOTH gate polarities, not just the red one. #921
936
+ // kept the two polarities on one argv on purpose, so the security-reviewed
937
+ // allow-list stays a single shape per runner; making the report conditional
938
+ // would have split it. The green gate simply ignores the report it produces.
939
+ //
940
+ // The cost of that choice, so it is not a surprise: the green gate grades on
941
+ // the EXIT CODE alone, and a runner that cannot write its report may exit
942
+ // non-zero for that reason (measured: vitest does). `oracle_passed` would then
943
+ // report `failed` — blaming the implementer and spending an attempt — for a
944
+ // problem with the motor's temp dir rather than with the diff. That has not
945
+ // been seen outside a deliberately broken temp dir, and the alternative (two
946
+ // argv shapes) was judged worse, but it is the trade being made.
947
+ const args = [...baseArgs, ...spec.report.flags(reportPath)];
948
+
949
+ return await new Promise<{
950
+ exitCode: number;
951
+ output: string;
952
+ verdict: string;
953
+ report: OracleRunSummary | null;
954
+ }>((settleOk, settleErr) => {
955
+ let child: ChildProcess;
956
+ try {
957
+ child = spawnInGroup(command, args, {
958
+ cwd: repoPath,
959
+ stdio: ["ignore", "pipe", "pipe"],
960
+ });
961
+ } catch (err) {
962
+ // A synchronous spawn throw (unusable cwd, bad argv) — no group exists.
963
+ settleErr(err);
964
+ return;
965
+ }
966
+
967
+ // Record the pgid AT SPAWN: `reapGroup` addresses the group by number, so
968
+ // it still sweeps stragglers once the leader handle is spent.
969
+ const pgid = child.pid;
970
+ let output = "";
971
+ let truncated = false;
972
+ // ONE DECODER PER PIPE, not one shared: each pipe delivers its own byte
973
+ // stream, so a shared decoder would hand a partial character from stdout
974
+ // to the next stderr chunk and corrupt both.
975
+ const outDecoder = new StringDecoder("utf8");
976
+ const errDecoder = new StringDecoder("utf8");
977
+ /** Only the runner's own stream. Diagnostic since #1019; nothing parses it. */
978
+ let verdict = "";
979
+ /**
980
+ * The buffer as the caller sees it: the kept tail, labelled if trimmed.
981
+ *
982
+ * Flushes the decoder first, so bytes it is still holding for an
983
+ * incomplete character at end-of-stream are not silently dropped. Called
984
+ * once — `settle` is guarded by `settled` — and guarded here too, because
985
+ * `decoder.end()` is not idempotent.
986
+ */
987
+ let flushed = false;
988
+ const flush = (): void => {
989
+ if (flushed) return;
990
+ flushed = true;
991
+ const outTail = outDecoder.end();
992
+ const errTail = errDecoder.end();
993
+ output += outTail + errTail;
994
+ verdict += spec.verdictStream === "stdout" ? outTail : errTail;
995
+ };
996
+ const finalOutput = (): string => {
997
+ flush();
998
+ return truncated
999
+ ? `… earlier output dropped; kept the last ${ORACLE_OUTPUT_LIMIT} characters\n${output}`
1000
+ : output;
1001
+ };
1002
+ /**
1003
+ * The verdict stream, flushed. The operator's per-runner view of what the
1004
+ * runner itself said; NOT parsed for a verdict (#1019).
1005
+ */
1006
+ const finalVerdict = (): string => {
1007
+ flush();
1008
+ return verdict;
1009
+ };
1010
+
1011
+ /**
1012
+ * The runner's machine report, captured AT THE `exit` EVENT.
1013
+ *
1014
+ * `readFileSync`, deliberately, in the `exit` handler — not an awaited
1015
+ * async read. Both runners write this file and then exit, so at `exit` it is
1016
+ * complete; reading it synchronously right there leaves no gap in which
1017
+ * anything else could rewrite it. An async read would hand the held test's
1018
+ * detached grandchild a scheduling window to overwrite the file, which is
1019
+ * the same shape of race the drain window used to be. The file is a few KB
1020
+ * and the run is already over, so blocking the loop here costs nothing.
1021
+ *
1022
+ * Every failure is `null`, which `gradeOracleRed` maps to `no_verdict` ⇒
1023
+ * `blocked`: the runner never wrote a report (measured — bun writes none at
1024
+ * all for an unmatched path or a file with no tests), the content is
1025
+ * malformed, the parser does not recognize the shape, or the file shows
1026
+ * signs of tampering. Never a pass.
1027
+ */
1028
+ let report: OracleRunSummary | null = null;
1029
+ const captureReport = (): void => {
343
1030
  try {
344
- child = spawnInGroup(command, args, {
345
- cwd: repoPath,
346
- stdio: ["ignore", "pipe", "pipe"],
347
- });
1031
+ assertUntampered(reportPath);
1032
+ report = spec.report.parse(readFileSync(reportPath, "utf8"));
348
1033
  } catch (err) {
349
- // A synchronous spawn throw (unusable cwd, bad argv) — no group exists.
350
- settleErr(err);
351
- return;
1034
+ report = null;
1035
+ // Never silent. This is the module's only tamper DETECTOR, and a
1036
+ // detection that logs nothing is not a detection an operator can act
1037
+ // on. It is also the one place a false positive can produce a
1038
+ // permanently blocked gate (see `assertUntampered` on the exact-0700
1039
+ // requirement), and the gate's own reason cannot name which case it
1040
+ // was — so the message that distinguishes "no report at all" from
1041
+ // "a report we refused" has to land here, in the motor's local log.
1042
+ //
1043
+ // ENOENT is the ordinary "runner wrote nothing" path (measured: bun
1044
+ // writes no report for an unmatched path or a file with no tests), so
1045
+ // it is info, not a warning — it is not evidence of anything wrong.
1046
+ const message = err instanceof Error ? err.message : String(err);
1047
+ const absent =
1048
+ err instanceof Error &&
1049
+ (err as NodeJS.ErrnoException).code === "ENOENT";
1050
+ if (absent) {
1051
+ log.info(TAG, `No oracle report at ${reportPath} — no verdict.`);
1052
+ } else {
1053
+ log.warn(
1054
+ TAG,
1055
+ `Refusing the oracle report at ${reportPath}: ${message} — the gate will report no verdict.`,
1056
+ );
1057
+ }
352
1058
  }
1059
+ };
1060
+ let settled = false;
1061
+ let killing = false;
1062
+ let timer: ReturnType<typeof setTimeout> | undefined;
1063
+ let drainTimer: ReturnType<typeof setTimeout> | undefined;
353
1064
 
354
- // Record the pgid AT SPAWN: `reapGroup` addresses the group by number, so
355
- // it still sweeps stragglers once the leader handle is spent.
356
- const pgid = child.pid;
357
- let output = "";
358
- let settled = false;
359
- let killing = false;
360
- let timer: ReturnType<typeof setTimeout> | undefined;
361
- let drainTimer: ReturnType<typeof setTimeout> | undefined;
362
-
363
- /** Settle once, and never without sweeping the group. */
364
- const settle = (
365
- failure: Error | null,
366
- result?: { exitCode: number; output: string },
367
- ): void => {
368
- if (settled) return;
369
- settled = true;
370
- if (timer) clearTimeout(timer);
371
- if (drainTimer) clearTimeout(drainTimer);
372
- reapGroup(pgid);
373
- if (failure) settleErr(failure);
374
- else settleOk(result!);
375
- };
376
-
377
- // stdout and stderr both land in one buffer: a test runner splits its
378
- // report across them, and the collector only ever hands this to a human.
379
- const append = (chunk: Buffer): void => {
380
- if (output.length >= ORACLE_OUTPUT_LIMIT) return;
381
- output += chunk.toString("utf8");
382
- if (output.length > ORACLE_OUTPUT_LIMIT) {
383
- output = `${output.slice(0, ORACLE_OUTPUT_LIMIT)}\n… output truncated at ${ORACLE_OUTPUT_LIMIT} characters`;
384
- }
385
- };
386
- child.stdout?.on("data", append);
387
- child.stderr?.on("data", append);
388
-
389
- // A missing binary surfaces as an async `error` event (an async spawn
390
- // never throws it) no verdict, so it rejects.
391
- child.once("error", (err) => settle(err));
392
-
393
- const settleFromExit = (
394
- code: number | null,
395
- signal: NodeJS.Signals | null,
396
- ): void => {
397
- if (drainTimer) clearTimeout(drainTimer);
398
- if (code === null) {
399
- // A signal nobody here sent (an operator's `kill`, the OOM killer).
400
- // The held test produced no verdict, so this is not a `failed` gate.
401
- settle(new Error(`the held test was terminated by signal ${signal}`));
402
- return;
1065
+ /** Settle once, and never without sweeping the group. */
1066
+ const settle = (
1067
+ failure: Error | null,
1068
+ result?: {
1069
+ exitCode: number;
1070
+ output: string;
1071
+ verdict: string;
1072
+ report: OracleRunSummary | null;
1073
+ },
1074
+ ): void => {
1075
+ if (settled) return;
1076
+ settled = true;
1077
+ if (timer) clearTimeout(timer);
1078
+ if (drainTimer) clearTimeout(drainTimer);
1079
+ reapGroup(pgid);
1080
+ if (failure) settleErr(failure);
1081
+ else settleOk(result!);
1082
+ };
1083
+
1084
+ // stdout and stderr both land in one buffer: a test runner splits its
1085
+ // report across them.
1086
+ //
1087
+ // Truncation keeps the TAIL, not the head. Both runners print their
1088
+ // summary block LAST, so the tail is the useful end of an operator's log
1089
+ // and the head is the least useful. This used to be load-bearing for
1090
+ // GRADING the red gate parsed that block out of this buffer, and keeping
1091
+ // the head instead left the gate permanently `blocked` on any verbose held
1092
+ // test — but since #1019 the verdict comes from the report file, so a
1093
+ // truncated buffer costs a reader context and costs the gate nothing.
1094
+ //
1095
+ // Re-slicing on every append keeps memory bounded to ~the cap without
1096
+ // holding the whole stream, and the notice is composed at settle time
1097
+ // rather than stored, so repeated trims cannot mangle it.
1098
+ // Decoded through a StringDecoder, NOT `chunk.toString("utf8")` per
1099
+ // chunk: a multi-byte sequence split across a chunk boundary decodes to
1100
+ // U+FFFD that way. Now that nothing parses these buffers this is a
1101
+ // legibility property rather than a correctness one a replacement
1102
+ // character in the operator's log is merely ugly — but it is still what
1103
+ // keeps a UTF-8 test name readable. The decoder holds the partial bytes
1104
+ // until the next chunk completes them.
1105
+ const append = (stream: "stdout" | "stderr", chunk: Buffer): void => {
1106
+ const text = (stream === "stdout" ? outDecoder : errDecoder).write(chunk);
1107
+ output += text;
1108
+ if (output.length > ORACLE_OUTPUT_LIMIT) {
1109
+ output = output.slice(-ORACLE_OUTPUT_LIMIT);
1110
+ truncated = true;
1111
+ }
1112
+ // The runner's own stream is ALSO kept unmerged, so an operator can read
1113
+ // what the RUNNER said apart from what the held test printed. Nothing
1114
+ // grades it (see `verdictStream`); it exists for the log and for the
1115
+ // end-to-end tests that pin which fd each runner uses.
1116
+ if (stream === spec.verdictStream) {
1117
+ verdict += text;
1118
+ if (verdict.length > ORACLE_OUTPUT_LIMIT) {
1119
+ verdict = verdict.slice(-ORACLE_OUTPUT_LIMIT);
403
1120
  }
404
- settle(null, { exitCode: code, output });
405
- };
406
-
407
- child.once("exit", (code, signal) => {
408
- if (killing) return; // the timeout path owns this outcome
409
- // The timeout governs the RUN, which is over. Disarm it so a slow pipe
410
- // drain cannot turn a finished run into a reported timeout.
411
- if (timer) clearTimeout(timer);
412
- // Reap before waiting on the pipes: a backgrounded grandchild inherits
413
- // the stdout pipe, so `close` cannot fire while it lives.
414
- reapGroup(pgid);
415
- drainTimer = setTimeout(
416
- () => settleFromExit(code, signal),
417
- ORACLE_DRAIN_GRACE_MS,
418
- );
419
- child.once("close", () => settleFromExit(code, signal));
1121
+ }
1122
+ };
1123
+ child.stdout?.on("data", (chunk: Buffer) => append("stdout", chunk));
1124
+ child.stderr?.on("data", (chunk: Buffer) => append("stderr", chunk));
1125
+
1126
+ // A missing binary surfaces as an async `error` event (an async spawn
1127
+ // never throws it) no verdict, so it rejects.
1128
+ child.once("error", (err) => settle(err));
1129
+
1130
+ const settleFromExit = (
1131
+ code: number | null,
1132
+ signal: NodeJS.Signals | null,
1133
+ ): void => {
1134
+ if (drainTimer) clearTimeout(drainTimer);
1135
+ if (code === null) {
1136
+ // A signal nobody here sent (an operator's `kill`, the OOM killer).
1137
+ // The held test produced no verdict, so this is not a `failed` gate.
1138
+ settle(new Error(`the held test was terminated by signal ${signal}`));
1139
+ return;
1140
+ }
1141
+ settle(null, {
1142
+ exitCode: code,
1143
+ output: finalOutput(),
1144
+ verdict: finalVerdict(),
1145
+ // Captured at `exit`, never re-read here: `settleFromExit` runs once
1146
+ // the pipes drain, up to ORACLE_DRAIN_GRACE_MS later, and re-reading
1147
+ // then would reopen the very window the at-exit capture closes.
1148
+ report,
420
1149
  });
1150
+ };
421
1151
 
422
- timer = setTimeout(() => {
423
- if (settled) return;
424
- killing = true;
425
- terminateGroup(child, {
426
- sigintTimeoutMs: ORACLE_SIGINT_GRACE_MS,
427
- sigtermTimeoutMs: ORACLE_SIGTERM_GRACE_MS,
1152
+ child.once("exit", (code, signal) => {
1153
+ if (killing) return; // the timeout path owns this outcome
1154
+ // FIRST, before anything that yields: the runner has written its report
1155
+ // and exited, so this is the moment the file is both complete and not yet
1156
+ // reachable by a straggler. See `captureReport`.
1157
+ captureReport();
1158
+ // The timeout governs the RUN, which is over. Disarm it so a slow pipe
1159
+ // drain cannot turn a finished run into a reported timeout.
1160
+ if (timer) clearTimeout(timer);
1161
+ // Reap before waiting on the pipes: a backgrounded grandchild inherits
1162
+ // the stdout pipe, so `close` cannot fire while it lives.
1163
+ reapGroup(pgid);
1164
+ drainTimer = setTimeout(
1165
+ () => settleFromExit(code, signal),
1166
+ ORACLE_DRAIN_GRACE_MS,
1167
+ );
1168
+ child.once("close", () => settleFromExit(code, signal));
1169
+ });
1170
+
1171
+ timer = setTimeout(() => {
1172
+ if (settled) return;
1173
+ killing = true;
1174
+ terminateGroup(child, {
1175
+ sigintTimeoutMs: ORACLE_SIGINT_GRACE_MS,
1176
+ sigtermTimeoutMs: ORACLE_SIGTERM_GRACE_MS,
1177
+ })
1178
+ .catch(() => {
1179
+ // terminateGroup swallows its own signal errors; guard anyway so a
1180
+ // rejection can never strand the promise unsettled.
428
1181
  })
429
- .catch(() => {
430
- // terminateGroup swallows its own signal errors; guard anyway so a
431
- // rejection can never strand the promise unsettled.
432
- })
433
- .then(() => {
434
- settle(
435
- new Error(`the held test did not finish within ${timeoutMs}ms`),
436
- );
437
- });
438
- }, timeoutMs);
439
- },
440
- );
1182
+ .then(() => {
1183
+ settle(
1184
+ new Error(`the held test did not finish within ${timeoutMs}ms`),
1185
+ );
1186
+ });
1187
+ }, timeoutMs);
1188
+ });
441
1189
  }