@gethmy/harness 1.0.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/README.md +66 -0
- package/dist/cli.js +2936 -0
- package/dist/index.js +3734 -0
- package/package.json +65 -0
- package/src/artifact-judge.ts +410 -0
- package/src/cli.ts +272 -0
- package/src/command-metric.ts +594 -0
- package/src/error-classifier.ts +95 -0
- package/src/exec-types.ts +109 -0
- package/src/gate-collectors.ts +431 -0
- package/src/gate-config-error.ts +73 -0
- package/src/git-diff-stat.ts +148 -0
- package/src/git-pr.ts +839 -0
- package/src/harmony-client.ts +197 -0
- package/src/index.ts +37 -0
- package/src/log.ts +129 -0
- package/src/model-tier.test.ts +169 -0
- package/src/model-tier.ts +108 -0
- package/src/oracle-collector.ts +148 -0
- package/src/oracle.ts +434 -0
- package/src/pm.ts +73 -0
- package/src/process-group.ts +149 -0
- package/src/project-type.ts +303 -0
- package/src/revert-guard.ts +99 -0
- package/src/review-types.ts +52 -0
- package/src/runner.ts +184 -0
- package/src/sdk-agent-runner.ts +575 -0
- package/src/stage-cli.ts +302 -0
- package/src/stage-run.ts +91 -0
- package/src/verification.ts +711 -0
- package/src/worktree.ts +639 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Evidence collector for the `oracle_passed` gate. It runs only AFTER the stage's
|
|
3
|
+
* subagent has exited (see stage-run.ts): the held test and a model must never
|
|
4
|
+
* share a filesystem window.
|
|
5
|
+
*
|
|
6
|
+
* It produces evidence and nothing else — `gateEvaluate` in @harmony/shared turns
|
|
7
|
+
* it into a verdict. A missing oracle is `blocked`, never `passed`: a gate with no
|
|
8
|
+
* signal cannot be satisfied.
|
|
9
|
+
*
|
|
10
|
+
* FIX ROUND 1 (Task 12 review, Important): `oracle_passed` is a SECRECY gate —
|
|
11
|
+
* the whole point of a held test is that the implementer never sees it. The
|
|
12
|
+
* persisted evidence must not leak it back out sideways. Telling the implementer
|
|
13
|
+
* WHY the held test failed is itself a partial oracle: an assertion diff
|
|
14
|
+
* ("expected X, got Y") hands over the test's expectations one failure at a
|
|
15
|
+
* time, which is just as much a leak as the file itself would be. So the raw
|
|
16
|
+
* runner output NEVER reaches `structured` — only `exitCode` and `path` do. The
|
|
17
|
+
* full output still goes to the motor's own local log (`log.info`/`log.warn`
|
|
18
|
+
* below) so a human operator can debug a red gate; that log is not the
|
|
19
|
+
* persisted evidence a stage-advance decision or an implementer-visible surface
|
|
20
|
+
* reads. `build_green`'s collector (`gate-collectors.ts`) keeps its raw output —
|
|
21
|
+
* deliberately: it is not a secrecy gate, and its output is exactly what a human
|
|
22
|
+
* needs to see. Do not "make them consistent."
|
|
23
|
+
*/
|
|
24
|
+
import type {
|
|
25
|
+
GateEvidence,
|
|
26
|
+
GateEvidenceCollector,
|
|
27
|
+
GateEvidenceContext,
|
|
28
|
+
} from "@harmony/shared";
|
|
29
|
+
import { log } from "./log.js";
|
|
30
|
+
import type { HeldOracle, OracleDeps } from "./oracle.js";
|
|
31
|
+
|
|
32
|
+
const TAG = "oracle-collector";
|
|
33
|
+
|
|
34
|
+
export class OracleCollector implements GateEvidenceCollector {
|
|
35
|
+
readonly kind = "oracle_passed" as const;
|
|
36
|
+
|
|
37
|
+
constructor(private readonly deps: OracleDeps) {}
|
|
38
|
+
|
|
39
|
+
async collect(context: GateEvidenceContext): Promise<GateEvidence> {
|
|
40
|
+
const oracle = await this.deps.fetchOracle(
|
|
41
|
+
context.cardId,
|
|
42
|
+
context.stageId,
|
|
43
|
+
this.deps.sessionId,
|
|
44
|
+
);
|
|
45
|
+
if (!oracle) {
|
|
46
|
+
log.info(TAG, `No oracle held for stage ${context.stageId} — blocked`);
|
|
47
|
+
return {
|
|
48
|
+
result: "blocked",
|
|
49
|
+
structured: {
|
|
50
|
+
reason: `No oracle is held for stage ${context.stageId}.`,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return await this.runHeld(oracle);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private async runHeld(oracle: HeldOracle): Promise<GateEvidence> {
|
|
59
|
+
await this.deps.place(this.deps.repoPath, oracle);
|
|
60
|
+
try {
|
|
61
|
+
const { exitCode, output } = await this.deps.run(
|
|
62
|
+
this.deps.repoPath,
|
|
63
|
+
oracle,
|
|
64
|
+
);
|
|
65
|
+
// Full output is for the human operator's local log only — never the
|
|
66
|
+
// persisted evidence. See the class doc comment: oracle_passed is a
|
|
67
|
+
// secrecy gate, and an assertion diff is itself a partial oracle.
|
|
68
|
+
const logLine = `Oracle run for ${oracle.path} exited ${exitCode}:\n${output}`;
|
|
69
|
+
if (exitCode === 0) {
|
|
70
|
+
log.info(TAG, logLine);
|
|
71
|
+
} else {
|
|
72
|
+
log.warn(TAG, logLine);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
result: exitCode === 0 ? "passed" : "failed",
|
|
76
|
+
structured: {
|
|
77
|
+
oracle: {
|
|
78
|
+
exitCode,
|
|
79
|
+
path: oracle.path,
|
|
80
|
+
output:
|
|
81
|
+
"withheld — oracle_passed is a secrecy gate; see the motor's local log",
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const message = errText(err);
|
|
87
|
+
log.warn(TAG, `Oracle run threw: ${message} — blocked`);
|
|
88
|
+
return {
|
|
89
|
+
result: "blocked",
|
|
90
|
+
structured: { oracle: { path: oracle.path }, error: message },
|
|
91
|
+
};
|
|
92
|
+
} finally {
|
|
93
|
+
// Always. A held test left in the worktree could be committed, which would
|
|
94
|
+
// hand the implementer the very file it must never see.
|
|
95
|
+
await this.removeBestEffort(oracle);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Remove the held test, and try ONCE more if that throws.
|
|
101
|
+
*
|
|
102
|
+
* `remove` is called from a `finally`, so a throw there would replace the
|
|
103
|
+
* evidence with the removal's own error AND leave the held test sitting in a
|
|
104
|
+
* worktree the daemon's completion path auto-commits — handing the
|
|
105
|
+
* implementer, and then the repo's history, the one file this gate exists to
|
|
106
|
+
* keep from it. Low likelihood (the path was already resolved for the write,
|
|
107
|
+
* and `rm` runs with `force: true`), high consequence.
|
|
108
|
+
*
|
|
109
|
+
* So: a second attempt, then give up LOUDLY. Deliberately not a retry
|
|
110
|
+
* framework — no backoff, no attempt budget. The realistic causes are a
|
|
111
|
+
* transient filesystem error, which one immediate retry covers, and a path
|
|
112
|
+
* the containment check refuses, which no number of retries will change.
|
|
113
|
+
*
|
|
114
|
+
* It never rethrows. The gate's own verdict — the exit code, or the `blocked`
|
|
115
|
+
* from a run that threw — is the honest answer about the implementer's work,
|
|
116
|
+
* and losing it to a cleanup failure would be a worse outcome than the failure
|
|
117
|
+
* itself. The loud log is what an operator acts on; the file is named in it so
|
|
118
|
+
* it can be deleted by hand.
|
|
119
|
+
*/
|
|
120
|
+
private async removeBestEffort(oracle: HeldOracle): Promise<void> {
|
|
121
|
+
try {
|
|
122
|
+
await this.deps.remove(this.deps.repoPath, oracle);
|
|
123
|
+
return;
|
|
124
|
+
} catch (err) {
|
|
125
|
+
log.warn(
|
|
126
|
+
TAG,
|
|
127
|
+
`Removing the held test at ${oracle.path} failed (${errText(err)}) — retrying once`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await this.deps.remove(this.deps.repoPath, oracle);
|
|
132
|
+
log.info(
|
|
133
|
+
TAG,
|
|
134
|
+
`Held test at ${oracle.path} removed on the second attempt`,
|
|
135
|
+
);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
log.error(
|
|
138
|
+
TAG,
|
|
139
|
+
`HELD TEST NOT REMOVED: ${oracle.path} is still in ${this.deps.repoPath} after two attempts (${errText(err)}). ` +
|
|
140
|
+
"It will be auto-committed by the completion path if it is left there — delete it by hand and check whether it reached a commit.",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function errText(err: unknown): string {
|
|
147
|
+
return err instanceof Error ? err.message : String(err);
|
|
148
|
+
}
|
package/src/oracle.ts
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The oracle module: fetch a held test from Harmony, place it in the worktree, run
|
|
3
|
+
* it, remove it. One interface, one implementation — seam ready, no registry.
|
|
4
|
+
*
|
|
5
|
+
* The held test is never committed: `remove` runs in a finally block in
|
|
6
|
+
* `oracle-collector.ts`, so even a crashed runner cannot leave it behind.
|
|
7
|
+
*
|
|
8
|
+
* `fetchOracle` carries a `sessionId` (Task 11 review, ruling 27): the
|
|
9
|
+
* `/stage-oracle/fetch` read route binds the caller to the card's ACTIVE agent
|
|
10
|
+
* session — workspace membership alone is not sufficient, and an ended session is
|
|
11
|
+
* refused. `GateEvidenceContext` (the collector's per-call input) carries
|
|
12
|
+
* `cardId`/`stageId`/`gate`/`workspaceId` but no session id, so it cannot flow
|
|
13
|
+
* through `collect(context)`. It comes in through `OracleDeps` instead, set once
|
|
14
|
+
* when the deps are constructed for the run (the driver that claimed the stage
|
|
15
|
+
* knows its own session id and threads it in as a CLI flag — Task 15's real CLI
|
|
16
|
+
* wiring). `OracleCollector` reads `this.deps.sessionId` at call time, the same
|
|
17
|
+
* way it already reads `this.deps.repoPath`.
|
|
18
|
+
*
|
|
19
|
+
* `place` is a SECOND, independent lock on the file it writes (Task 11 review,
|
|
20
|
+
* ruling 27). The `/stage-oracle` write route validates `path` against a charset
|
|
21
|
+
* allow-list before a row is ever stored — that is the trust boundary where a
|
|
22
|
+
* caller-supplied path enters the system. But `place` is the function that
|
|
23
|
+
* actually opens a file on the motor host, and a charset allow-list only ever
|
|
24
|
+
* covers the traversal shapes someone thought of: round 1 of that route's own
|
|
25
|
+
* validator missed `.git/config`, which does not traverse at all but is code
|
|
26
|
+
* execution on the worktree's next git operation (`[core] hooksPath`/
|
|
27
|
+
* `fsmonitor`), in a worktree where the harness runs git constantly. `place`
|
|
28
|
+
* does not rely on the route alone — it re-derives safety from a property that
|
|
29
|
+
* holds regardless of what a charset list forgot: resolve the target against
|
|
30
|
+
* `repoPath` and refuse anything that does not land strictly inside it. A `..`
|
|
31
|
+
* segment needs no separate rule — resolving it is exactly what pushes the
|
|
32
|
+
* target outside `repoPath`, so the one containment check catches it too. An
|
|
33
|
+
* absolute path IS checked separately, because `path.resolve` treats a later
|
|
34
|
+
* absolute argument as replacing the base entirely, and — in the rare case it
|
|
35
|
+
* happens to resolve to somewhere inside `repoPath` anyway — accepting it would
|
|
36
|
+
* still break the "repo-relative path" contract the route promises. An empty
|
|
37
|
+
* path or `"."` is refused explicitly too (Task 12 review, fix round 1) — both
|
|
38
|
+
* lexically resolve to `repoPath` itself, which would otherwise pass the
|
|
39
|
+
* containment check and send `place` to open the worktree ROOT as a file (it
|
|
40
|
+
* fails safe today with an `EISDIR`, but only by filesystem accident).
|
|
41
|
+
*
|
|
42
|
+
* FIX ROUND 1 (Critical): the containment check above is `path.resolve` plus a
|
|
43
|
+
* string-prefix compare — purely LEXICAL, so it never touches the filesystem and
|
|
44
|
+
* cannot see a symlink. The worktree is populated by the IMPLEMENTER's own
|
|
45
|
+
* commits before this collector ever runs, so the implementer can turn any
|
|
46
|
+
* directory the author will plausibly use into a symlink pointing outside the
|
|
47
|
+
* tree, and the lexical check alone would wave the resulting path straight
|
|
48
|
+
* through (it resolves inside `repoPath` right up until the OS follows the
|
|
49
|
+
* symlink at write time). So `resolveContained` is now also filesystem-aware:
|
|
50
|
+
* after the lexical check passes, it walks every segment of the relative path
|
|
51
|
+
* under the (realpath'd) root and `lstat`s each one, refusing if any segment
|
|
52
|
+
* that already exists is a symlink. Chosen over "realpath the deepest existing
|
|
53
|
+
* ancestor and compare" because walking + `lstat`-ing each segment reads more
|
|
54
|
+
* directly as "no symlink anywhere in this path", the exact property under
|
|
55
|
+
* test, without a second resolve-and-compare step for the ancestor. `repoPath`
|
|
56
|
+
* itself is realpath'd ONCE, unconditionally, and is exempt from this walk — it
|
|
57
|
+
* is the trusted root the caller supplied (worktrees legitimately sit behind a
|
|
58
|
+
* symlink, e.g. macOS's `/tmp` → `/private/tmp`), and realpath-ing it keeps
|
|
59
|
+
* every later comparison self-consistent regardless.
|
|
60
|
+
*
|
|
61
|
+
* A rejected path throws — before any write — and is not caught here. It
|
|
62
|
+
* propagates out of `OracleCollector.collect` to the dispatcher's existing catch
|
|
63
|
+
* (`collectGateEvidence` in `gate-collectors.ts`), which reports `blocked`.
|
|
64
|
+
* Failing the gate rather than writing is the point.
|
|
65
|
+
*/
|
|
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";
|
|
69
|
+
import { DEFAULT_METRIC_TIMEOUT_MS } from "./exec-types.js";
|
|
70
|
+
import { reapGroup, spawnInGroup, terminateGroup } from "./process-group.js";
|
|
71
|
+
|
|
72
|
+
export interface HeldOracle {
|
|
73
|
+
path: string;
|
|
74
|
+
content: string;
|
|
75
|
+
runnerHint: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface OracleDeps {
|
|
79
|
+
repoPath: string;
|
|
80
|
+
/**
|
|
81
|
+
* The card's ACTIVE agent session id — see the module doc comment above for
|
|
82
|
+
* why this lives here rather than on `GateEvidenceContext`. Threaded straight
|
|
83
|
+
* into `fetchOracle`'s third argument.
|
|
84
|
+
*/
|
|
85
|
+
sessionId: string;
|
|
86
|
+
/** Reads via POST /stage-oracle/fetch with purpose "gate_evaluation". */
|
|
87
|
+
fetchOracle(
|
|
88
|
+
cardId: string,
|
|
89
|
+
stageId: string,
|
|
90
|
+
sessionId: string,
|
|
91
|
+
): Promise<HeldOracle | null>;
|
|
92
|
+
place(repoPath: string, oracle: HeldOracle): Promise<void>;
|
|
93
|
+
remove(repoPath: string, oracle: HeldOracle): Promise<void>;
|
|
94
|
+
/**
|
|
95
|
+
* Executes the placed held test. {@link runHeldOracle} is the implementation
|
|
96
|
+
* (Task 15): it picks the argv from `runnerHint` through an allow-list and
|
|
97
|
+
* REJECTS — rather than returning a non-zero exit code — whenever no verdict
|
|
98
|
+
* was produced, so `OracleCollector` reports `blocked` instead of `failed`.
|
|
99
|
+
*/
|
|
100
|
+
run(
|
|
101
|
+
repoPath: string,
|
|
102
|
+
oracle: HeldOracle,
|
|
103
|
+
): Promise<{ exitCode: number; output: string }>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve `relativePath` against `repoPath` and refuse an absolute path, an
|
|
108
|
+
* empty path or `"."`, anything that resolves outside `repoPath` (which is how
|
|
109
|
+
* a `..` segment is refused too — resolving one is exactly what pushes the
|
|
110
|
+
* target outside `repoPath`), and — the filesystem-aware check fix round 1
|
|
111
|
+
* added — anything reached through an existing symlink. See the module doc
|
|
112
|
+
* comment for the full account of why each check exists and why the symlink
|
|
113
|
+
* walk cannot be replaced by the lexical checks alone.
|
|
114
|
+
*
|
|
115
|
+
* The lexical containment compare is `root + sep`, never a bare
|
|
116
|
+
* `startsWith(root)` — a bare prefix compare would wrongly ALLOW a sibling
|
|
117
|
+
* directory that merely shares a string prefix (`/tmp/wt2` starts with
|
|
118
|
+
* `/tmp/wt`, but is not inside it).
|
|
119
|
+
*/
|
|
120
|
+
async function resolveContained(
|
|
121
|
+
repoPath: string,
|
|
122
|
+
relativePath: string,
|
|
123
|
+
): Promise<string> {
|
|
124
|
+
if (isAbsolute(relativePath)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`refusing to place an oracle at an absolute path: ${relativePath}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (relativePath === "" || relativePath === ".") {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`refusing to place an oracle at the empty/self path: "${relativePath}"`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// `repoPath` itself is the trusted root and may legitimately BE a symlink
|
|
136
|
+
// (e.g. macOS's /tmp -> /private/tmp) — realpath it ONCE, unconditionally, so
|
|
137
|
+
// every later comparison is self-consistent regardless.
|
|
138
|
+
const root = await realpath(repoPath);
|
|
139
|
+
const target = resolve(root, relativePath);
|
|
140
|
+
if (target !== root && !target.startsWith(root + sep)) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`refusing to place an oracle outside the worktree: ${relativePath}`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Filesystem-aware, unlike the lexical check above: walk every segment of the
|
|
147
|
+
// relative path under `root` and refuse if any segment that already exists is
|
|
148
|
+
// a symlink. A segment that does NOT exist yet is not a redirection risk —
|
|
149
|
+
// `place` is about to create it — so a missing segment is skipped, not
|
|
150
|
+
// refused; `lstat` throwing (ENOENT) is exactly that "does not exist" signal.
|
|
151
|
+
let cursor = root;
|
|
152
|
+
for (const segment of relativePath.split("/")) {
|
|
153
|
+
cursor = resolve(cursor, segment);
|
|
154
|
+
const stat = await lstat(cursor).catch(() => null);
|
|
155
|
+
if (stat?.isSymbolicLink()) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`refusing an oracle path through a symlink component: ${relativePath}`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return target;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The real `place`: write the held test's content into the worktree at
|
|
167
|
+
* `oracle.path`, after independently verifying containment (see
|
|
168
|
+
* `resolveContained`'s doc comment). Creates any missing parent directories.
|
|
169
|
+
* Throws — and writes nothing — when the path is rejected.
|
|
170
|
+
*/
|
|
171
|
+
export async function place(
|
|
172
|
+
repoPath: string,
|
|
173
|
+
oracle: HeldOracle,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const target = await resolveContained(repoPath, oracle.path);
|
|
176
|
+
await mkdir(dirname(target), { recursive: true });
|
|
177
|
+
await writeFile(target, oracle.content, "utf8");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The real `remove`: delete the placed held test. Shares `resolveContained` with
|
|
182
|
+
* `place` (same hardened, filesystem-aware check) so the two can never disagree
|
|
183
|
+
* about where a file is — a path a rejected write never touches is also never
|
|
184
|
+
* resolved-and-deleted under a differently shaped input.
|
|
185
|
+
*/
|
|
186
|
+
export async function remove(
|
|
187
|
+
repoPath: string,
|
|
188
|
+
oracle: HeldOracle,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
const target = await resolveContained(repoPath, oracle.path);
|
|
191
|
+
await rm(target, { force: true });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// run — execute the placed held test (Task 15, the amendment's ruling 30)
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The runner allow-list. A `runner_hint` is author-supplied data that reached
|
|
200
|
+
* the database through an HTTP route, so it selects a FIXED argv here and is
|
|
201
|
+
* never interpolated into one. `path` is author-supplied too: it travels as one
|
|
202
|
+
* argv element and is never part of a command string — nothing below is ever
|
|
203
|
+
* parsed by a shell (`spawnInGroup` spawns without one).
|
|
204
|
+
*
|
|
205
|
+
* Both entries were verified by hand against a real runner before they were
|
|
206
|
+
* added; adding a third is one line plus the same verification. `npx
|
|
207
|
+
* --no-install` deliberately refuses to download a runner the repo does not
|
|
208
|
+
* already have: an absent runner must fail the gate, not fetch a package onto
|
|
209
|
+
* the motor's host mid-stage.
|
|
210
|
+
*/
|
|
211
|
+
const ORACLE_RUNNERS: Record<string, (path: string) => OracleRunnerArgv> = {
|
|
212
|
+
vitest: (path) => ({
|
|
213
|
+
command: "npx",
|
|
214
|
+
args: ["--no-install", "vitest", "run", path],
|
|
215
|
+
}),
|
|
216
|
+
bun: (path) => ({ command: "bun", args: ["test", path] }),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/** The hints {@link resolveOracleRunner} accepts, for the refusal message. */
|
|
220
|
+
export const ORACLE_RUNNER_HINTS: readonly string[] =
|
|
221
|
+
Object.keys(ORACLE_RUNNERS).sort();
|
|
222
|
+
|
|
223
|
+
/** A resolved executable + args, ready for `spawnInGroup`. */
|
|
224
|
+
export interface OracleRunnerArgv {
|
|
225
|
+
command: string;
|
|
226
|
+
args: string[];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Cap on the runner output kept in memory. The output is a DIAGNOSTIC only —
|
|
231
|
+
* `oracle-collector.ts` withholds it from the persisted evidence and writes it
|
|
232
|
+
* to the motor's local log — so this truncates rather than killing the run the
|
|
233
|
+
* way `runMetricCommand` must (there, stdout IS the measurement, so a partial
|
|
234
|
+
* read would corrupt the number). Memory stays bounded either way, and the
|
|
235
|
+
* timeout still bounds the process.
|
|
236
|
+
*/
|
|
237
|
+
const ORACLE_OUTPUT_LIMIT = 64 * 1024;
|
|
238
|
+
|
|
239
|
+
/** Grace windows for the escalating group termination on a timeout. Mirrors
|
|
240
|
+
* `command-metric.ts`'s ladder — a runner gets a moment to shut down cleanly
|
|
241
|
+
* before the group is SIGKILLed. */
|
|
242
|
+
const ORACLE_SIGINT_GRACE_MS = 2_000;
|
|
243
|
+
const ORACLE_SIGTERM_GRACE_MS = 3_000;
|
|
244
|
+
|
|
245
|
+
/** How long to wait after the leader exits for its stdio pipes to deliver the
|
|
246
|
+
* tail of the output. Bounded for the same reason `command-metric.ts` bounds
|
|
247
|
+
* its own: a grandchild that left the group with `setsid()` holds the pipe open
|
|
248
|
+
* forever, and a run that already produced its exit code must not wait on it. */
|
|
249
|
+
const ORACLE_DRAIN_GRACE_MS = 500;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Choose the argv for a held test from its `runnerHint`, through the allow-list
|
|
253
|
+
* above. THROWS on an unknown or absent hint, naming both the hint and the
|
|
254
|
+
* accepted set.
|
|
255
|
+
*
|
|
256
|
+
* A throw — not a non-zero exit code — is what refuses an unknown hint, and the
|
|
257
|
+
* distinction is load-bearing. `OracleCollector` maps a non-zero exit to
|
|
258
|
+
* `failed` and a thrown error to `blocked` (oracle-collector.ts). Nothing ran
|
|
259
|
+
* here, so there is no signal about the implementer's work: `failed` would
|
|
260
|
+
* blame the implementer for a defect in the oracle's own declaration, and would
|
|
261
|
+
* spend the card's attempt budget re-running a stage that cannot change the
|
|
262
|
+
* answer. `blocked` is the honest report, and it can never satisfy the gate.
|
|
263
|
+
*
|
|
264
|
+
* The throw is caught by `OracleCollector.runHeld`'s OWN try/catch (not the
|
|
265
|
+
* dispatcher's in `collectGateEvidence`), which returns
|
|
266
|
+
* `{ result: "blocked", structured: { oracle: { path }, error } }` — so the
|
|
267
|
+
* reason travels with the block. The Task 15 amendment asked for the `blocked`
|
|
268
|
+
* outcome by way of "returning a non-zero result"; that mechanism would land on
|
|
269
|
+
* `failed` instead, so this follows the outcome it named, not the mechanism.
|
|
270
|
+
*/
|
|
271
|
+
export function resolveOracleRunner(oracle: HeldOracle): OracleRunnerArgv {
|
|
272
|
+
const hint = oracle.runnerHint?.trim().toLowerCase() ?? "";
|
|
273
|
+
// `Object.hasOwn`, never a bare lookup: an inherited key ("constructor",
|
|
274
|
+
// "toString") would otherwise resolve to a function and be called.
|
|
275
|
+
const build = Object.hasOwn(ORACLE_RUNNERS, hint)
|
|
276
|
+
? ORACLE_RUNNERS[hint]
|
|
277
|
+
: undefined;
|
|
278
|
+
if (!build) {
|
|
279
|
+
throw new Error(
|
|
280
|
+
`refusing to run the held test: runner_hint ${JSON.stringify(
|
|
281
|
+
oracle.runnerHint,
|
|
282
|
+
)} is not in the motor's allow-list (${ORACLE_RUNNER_HINTS.join(", ")})`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return build(argvPath(oracle.path));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The path as it enters the argv. The `./` prefix is what keeps a path that
|
|
290
|
+
* begins with `-` (a legal relative path, and one `place`'s containment check
|
|
291
|
+
* has no reason to refuse) from being read as a FLAG by the runner. It does not
|
|
292
|
+
* make the path safe on its own — containment does that, in `place`.
|
|
293
|
+
*/
|
|
294
|
+
function argvPath(path: string): string {
|
|
295
|
+
return path.startsWith("./") ? path : `./${path}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The real `OracleDeps.run`: execute the placed held test and report its exit
|
|
300
|
+
* code plus its output.
|
|
301
|
+
*
|
|
302
|
+
* Resolves `{ exitCode, output }` for any run that produced an exit code —
|
|
303
|
+
* including a non-zero one, which is the gate's `failed` signal and the normal
|
|
304
|
+
* outcome of a held test that the implementer has not satisfied yet. REJECTS
|
|
305
|
+
* when no verdict exists at all: an unknown runner hint, a missing binary, a
|
|
306
|
+
* timeout, or a termination by a signal nobody here sent. `OracleCollector`
|
|
307
|
+
* turns a rejection into `blocked`.
|
|
308
|
+
*
|
|
309
|
+
* This does not reuse `runMetricCommand` (command-metric.ts) even though it
|
|
310
|
+
* follows its supervision pattern closely, because the two contracts are
|
|
311
|
+
* opposites at the point that matters: `runMetricCommand` REJECTS on a non-zero
|
|
312
|
+
* exit and its rejection carries only stderr, so a failing held test would both
|
|
313
|
+
* lose its `failed` signal and drop the stdout where a test runner prints what
|
|
314
|
+
* failed. The three properties are the same ones #823 established there:
|
|
315
|
+
*
|
|
316
|
+
* 1. **Group leader.** `spawnInGroup` makes the child a process-group leader,
|
|
317
|
+
* so a timeout signals the whole tree, not just the direct child.
|
|
318
|
+
* 2. **Reaped on every path.** The pgid is recorded at spawn and swept with
|
|
319
|
+
* `reapGroup` when the promise settles — clean exit included.
|
|
320
|
+
* 3. **Non-blocking.** Nothing here occupies the event loop.
|
|
321
|
+
*
|
|
322
|
+
* `timeoutMs` defaults to the motor's existing {@link DEFAULT_METRIC_TIMEOUT_MS}
|
|
323
|
+
* rather than a second convention; the parameter exists so a test can use a
|
|
324
|
+
* short cap.
|
|
325
|
+
*/
|
|
326
|
+
export async function runHeldOracle(
|
|
327
|
+
repoPath: string,
|
|
328
|
+
oracle: HeldOracle,
|
|
329
|
+
timeoutMs: number = DEFAULT_METRIC_TIMEOUT_MS,
|
|
330
|
+
): Promise<{ exitCode: number; output: string }> {
|
|
331
|
+
const { command, args } = resolveOracleRunner(oracle);
|
|
332
|
+
|
|
333
|
+
return await new Promise<{ exitCode: number; output: string }>(
|
|
334
|
+
(settleOk, settleErr) => {
|
|
335
|
+
let child: ChildProcess;
|
|
336
|
+
try {
|
|
337
|
+
child = spawnInGroup(command, args, {
|
|
338
|
+
cwd: repoPath,
|
|
339
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
340
|
+
});
|
|
341
|
+
} catch (err) {
|
|
342
|
+
// A synchronous spawn throw (unusable cwd, bad argv) — no group exists.
|
|
343
|
+
settleErr(err);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Record the pgid AT SPAWN: `reapGroup` addresses the group by number, so
|
|
348
|
+
// it still sweeps stragglers once the leader handle is spent.
|
|
349
|
+
const pgid = child.pid;
|
|
350
|
+
let output = "";
|
|
351
|
+
let settled = false;
|
|
352
|
+
let killing = false;
|
|
353
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
354
|
+
let drainTimer: ReturnType<typeof setTimeout> | undefined;
|
|
355
|
+
|
|
356
|
+
/** Settle once, and never without sweeping the group. */
|
|
357
|
+
const settle = (
|
|
358
|
+
failure: Error | null,
|
|
359
|
+
result?: { exitCode: number; output: string },
|
|
360
|
+
): void => {
|
|
361
|
+
if (settled) return;
|
|
362
|
+
settled = true;
|
|
363
|
+
if (timer) clearTimeout(timer);
|
|
364
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
365
|
+
reapGroup(pgid);
|
|
366
|
+
if (failure) settleErr(failure);
|
|
367
|
+
else settleOk(result!);
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// stdout and stderr both land in one buffer: a test runner splits its
|
|
371
|
+
// report across them, and the collector only ever hands this to a human.
|
|
372
|
+
const append = (chunk: Buffer): void => {
|
|
373
|
+
if (output.length >= ORACLE_OUTPUT_LIMIT) return;
|
|
374
|
+
output += chunk.toString("utf8");
|
|
375
|
+
if (output.length > ORACLE_OUTPUT_LIMIT) {
|
|
376
|
+
output = `${output.slice(0, ORACLE_OUTPUT_LIMIT)}\n… output truncated at ${ORACLE_OUTPUT_LIMIT} characters`;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
child.stdout?.on("data", append);
|
|
380
|
+
child.stderr?.on("data", append);
|
|
381
|
+
|
|
382
|
+
// A missing binary surfaces as an async `error` event (an async spawn
|
|
383
|
+
// never throws it) — no verdict, so it rejects.
|
|
384
|
+
child.once("error", (err) => settle(err));
|
|
385
|
+
|
|
386
|
+
const settleFromExit = (
|
|
387
|
+
code: number | null,
|
|
388
|
+
signal: NodeJS.Signals | null,
|
|
389
|
+
): void => {
|
|
390
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
391
|
+
if (code === null) {
|
|
392
|
+
// A signal nobody here sent (an operator's `kill`, the OOM killer).
|
|
393
|
+
// The held test produced no verdict, so this is not a `failed` gate.
|
|
394
|
+
settle(new Error(`the held test was terminated by signal ${signal}`));
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
settle(null, { exitCode: code, output });
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
child.once("exit", (code, signal) => {
|
|
401
|
+
if (killing) return; // the timeout path owns this outcome
|
|
402
|
+
// The timeout governs the RUN, which is over. Disarm it so a slow pipe
|
|
403
|
+
// drain cannot turn a finished run into a reported timeout.
|
|
404
|
+
if (timer) clearTimeout(timer);
|
|
405
|
+
// Reap before waiting on the pipes: a backgrounded grandchild inherits
|
|
406
|
+
// the stdout pipe, so `close` cannot fire while it lives.
|
|
407
|
+
reapGroup(pgid);
|
|
408
|
+
drainTimer = setTimeout(
|
|
409
|
+
() => settleFromExit(code, signal),
|
|
410
|
+
ORACLE_DRAIN_GRACE_MS,
|
|
411
|
+
);
|
|
412
|
+
child.once("close", () => settleFromExit(code, signal));
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
timer = setTimeout(() => {
|
|
416
|
+
if (settled) return;
|
|
417
|
+
killing = true;
|
|
418
|
+
terminateGroup(child, {
|
|
419
|
+
sigintTimeoutMs: ORACLE_SIGINT_GRACE_MS,
|
|
420
|
+
sigtermTimeoutMs: ORACLE_SIGTERM_GRACE_MS,
|
|
421
|
+
})
|
|
422
|
+
.catch(() => {
|
|
423
|
+
// terminateGroup swallows its own signal errors; guard anyway so a
|
|
424
|
+
// rejection can never strand the promise unsettled.
|
|
425
|
+
})
|
|
426
|
+
.then(() => {
|
|
427
|
+
settle(
|
|
428
|
+
new Error(`the held test did not finish within ${timeoutMs}ms`),
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
}, timeoutMs);
|
|
432
|
+
},
|
|
433
|
+
);
|
|
434
|
+
}
|
package/src/pm.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { log } from "./log.js";
|
|
4
|
+
|
|
5
|
+
const TAG = "pm";
|
|
6
|
+
|
|
7
|
+
export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
|
|
8
|
+
|
|
9
|
+
let cached: PackageManager | null = null;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Detect the package manager based on lockfiles in the repo root.
|
|
13
|
+
*/
|
|
14
|
+
export function detectPackageManager(): PackageManager {
|
|
15
|
+
if (cached) return cached;
|
|
16
|
+
|
|
17
|
+
let repoRoot: string;
|
|
18
|
+
try {
|
|
19
|
+
repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
20
|
+
encoding: "utf-8",
|
|
21
|
+
}).trim();
|
|
22
|
+
} catch {
|
|
23
|
+
repoRoot = process.cwd();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (
|
|
27
|
+
existsSync(`${repoRoot}/bun.lock`) ||
|
|
28
|
+
existsSync(`${repoRoot}/bun.lockb`)
|
|
29
|
+
) {
|
|
30
|
+
cached = "bun";
|
|
31
|
+
} else if (existsSync(`${repoRoot}/pnpm-lock.yaml`)) {
|
|
32
|
+
cached = "pnpm";
|
|
33
|
+
} else if (existsSync(`${repoRoot}/yarn.lock`)) {
|
|
34
|
+
cached = "yarn";
|
|
35
|
+
} else {
|
|
36
|
+
cached = "npm";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
log.info(TAG, `Detected package manager: ${cached}`);
|
|
40
|
+
return cached;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Return the install command string for the detected package manager.
|
|
45
|
+
*/
|
|
46
|
+
export function installCommand(): string {
|
|
47
|
+
const pm = detectPackageManager();
|
|
48
|
+
switch (pm) {
|
|
49
|
+
case "bun":
|
|
50
|
+
return "bun install --frozen-lockfile";
|
|
51
|
+
case "pnpm":
|
|
52
|
+
return "pnpm install --frozen-lockfile";
|
|
53
|
+
case "yarn":
|
|
54
|
+
return "yarn install --frozen-lockfile";
|
|
55
|
+
case "npm":
|
|
56
|
+
return "npm ci";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Return [cmd, args[]] suitable for spawn() or execFileSync() to run a package script.
|
|
62
|
+
* Adds `--` separator for npm and pnpm when extra args are present.
|
|
63
|
+
*/
|
|
64
|
+
export function spawnRunArgs(
|
|
65
|
+
script: string,
|
|
66
|
+
...extra: string[]
|
|
67
|
+
): [string, string[]] {
|
|
68
|
+
const pm = detectPackageManager();
|
|
69
|
+
if (extra.length > 0 && (pm === "npm" || pm === "pnpm")) {
|
|
70
|
+
return [pm, ["run", script, "--", ...extra]];
|
|
71
|
+
}
|
|
72
|
+
return [pm, ["run", script, ...extra]];
|
|
73
|
+
}
|