@forwardimpact/libharness 0.1.20 → 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/LICENSE +21 -201
- package/README.md +196 -80
- package/bin/fit-benchmark.js +44 -0
- package/bin/fit-harness.js +358 -0
- package/bin/fit-selfedit.js +165 -0
- package/bin/fit-trace.js +510 -0
- package/package.json +42 -12
- package/src/agent-runner.js +256 -0
- package/src/benchmark/apm-installer.js +207 -0
- package/src/benchmark/env-loader.js +158 -0
- package/src/benchmark/hook-env.js +40 -0
- package/src/benchmark/invariants.js +141 -0
- package/src/benchmark/judge.js +187 -0
- package/src/benchmark/npm-installer.js +87 -0
- package/src/benchmark/report.js +522 -0
- package/src/benchmark/result.js +127 -0
- package/src/benchmark/runner.js +583 -0
- package/src/benchmark/task-family.js +260 -0
- package/src/benchmark/workdir.js +298 -0
- package/src/commands/assert.js +153 -0
- package/src/commands/benchmark-definition.js +165 -0
- package/src/commands/benchmark-invariants.js +73 -0
- package/src/commands/benchmark-report.js +51 -0
- package/src/commands/benchmark-run.js +111 -0
- package/src/commands/by-discussion.js +94 -0
- package/src/commands/callback.js +119 -0
- package/src/commands/discuss.js +132 -0
- package/src/commands/facilitate.js +123 -0
- package/src/commands/output.js +36 -0
- package/src/commands/run.js +152 -0
- package/src/commands/supervise.js +136 -0
- package/src/commands/task-input.js +54 -0
- package/src/commands/tee.js +53 -0
- package/src/commands/trace.js +630 -0
- package/src/commands/work-tracker.js +35 -0
- package/src/cost.js +79 -0
- package/src/discuss-tools.js +173 -0
- package/src/discusser.js +394 -0
- package/src/events/github.js +161 -0
- package/src/facilitator.js +205 -0
- package/src/inbox-poller.js +81 -0
- package/src/index.js +72 -2
- package/src/judge.js +210 -0
- package/src/message-bus.js +118 -0
- package/src/orchestration-loop.js +330 -0
- package/src/orchestration-toolkit.js +441 -0
- package/src/orchestrator-helpers.js +23 -0
- package/src/profile-prompt.js +266 -0
- package/src/redaction.js +253 -0
- package/src/render/line-renderer.js +54 -0
- package/src/render/orchestrator-filter.js +19 -0
- package/src/render/palette.js +63 -0
- package/src/render/tool-hints.js +154 -0
- package/src/render/turn-renderer.js +96 -0
- package/src/reply-emitter.js +47 -0
- package/src/sequence-counter.js +21 -0
- package/src/signature-filter.js +27 -0
- package/src/supervisor.js +236 -0
- package/src/tee-writer.js +150 -0
- package/src/trace-collector.js +444 -0
- package/src/trace-github.js +473 -0
- package/src/trace-multi.js +101 -0
- package/src/trace-query.js +748 -0
- package/src/trace-render.js +211 -0
- package/src/trace-usage.js +249 -0
- package/src/fixture/assertions.js +0 -42
- package/src/fixture/cache.js +0 -50
- package/src/fixture/eval.js +0 -146
- package/src/fixture/index.js +0 -9
- package/src/fixture/pathway.js +0 -451
- package/src/fixture/services.js +0 -56
- package/src/mock/clients.js +0 -135
- package/src/mock/config.js +0 -45
- package/src/mock/data.js +0 -46
- package/src/mock/fs.js +0 -111
- package/src/mock/grpc.js +0 -94
- package/src/mock/http.js +0 -60
- package/src/mock/index.js +0 -36
- package/src/mock/infra.js +0 -219
- package/src/mock/logger.js +0 -42
- package/src/mock/observer.js +0 -74
- package/src/mock/resource-index.js +0 -95
- package/src/mock/service-callbacks.js +0 -39
- package/src/mock/services.js +0 -79
- package/src/mock/spy.js +0 -44
- package/src/mock/storage.js +0 -118
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task-family loader. A task family is a directory under
|
|
3
|
+
* <root>/
|
|
4
|
+
* apm.lock.yaml
|
|
5
|
+
* .claude/ # pre-staged skills + agents
|
|
6
|
+
* tasks/<task_name>/
|
|
7
|
+
* agent.task.md
|
|
8
|
+
* supervisor.task.md # optional; appended to the task as supervisor context
|
|
9
|
+
* judge.task.md
|
|
10
|
+
* hooks/ # harness-only; never copied to agent CWD
|
|
11
|
+
* preflight.sh
|
|
12
|
+
* invariants.sh
|
|
13
|
+
* specs/ # copied into agent CWD
|
|
14
|
+
* workdir/ # copied into agent CWD
|
|
15
|
+
*
|
|
16
|
+
* Local paths or git URLs are both accepted; git URLs are shallow-cloned into
|
|
17
|
+
* a temp dir and `familyRevision` becomes `git:<sha>` of HEAD at clone time.
|
|
18
|
+
* Local paths use the canonical-tree algorithm from design § Family revision
|
|
19
|
+
* algorithm so the result is stable across operating systems.
|
|
20
|
+
*
|
|
21
|
+
* Filesystem and subprocess access route through the injected `runtime` bag
|
|
22
|
+
* (`runtime.fs` async, `runtime.subprocess.run` one-shot, `tmpdir` derived
|
|
23
|
+
* from `runtime.proc.env`).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createHash } from "node:crypto";
|
|
27
|
+
import { join, posix, relative, resolve, sep } from "node:path";
|
|
28
|
+
|
|
29
|
+
const GIT_URL_RE = /^(git@|https?:\/\/|ssh:\/\/|git:\/\/)/;
|
|
30
|
+
const SKIP_DIRS = new Set([".git", "node_modules"]);
|
|
31
|
+
// POSIX `X_OK` (execute permission); node's fs honours the numeric mode, so we
|
|
32
|
+
// avoid importing `node:fs`'s `constants` (which would light the fs smell).
|
|
33
|
+
const X_OK = 1;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Derive the system temp dir from the env (node's `os.tmpdir()` is itself an
|
|
37
|
+
* env-respecting wrapper). The runtime bag has no `os` slot by design.
|
|
38
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
39
|
+
* @returns {string}
|
|
40
|
+
*/
|
|
41
|
+
function tmpdir(runtime) {
|
|
42
|
+
return runtime.proc.env.TMPDIR ?? "/tmp";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Load a task family from a local path or git URL.
|
|
47
|
+
* @param {string} rootPathOrGitUrl
|
|
48
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
49
|
+
* @returns {Promise<TaskFamily>}
|
|
50
|
+
*/
|
|
51
|
+
export async function loadTaskFamily(rootPathOrGitUrl, runtime) {
|
|
52
|
+
if (!runtime) throw new Error("runtime is required");
|
|
53
|
+
const isGit = GIT_URL_RE.test(rootPathOrGitUrl);
|
|
54
|
+
let rootPath;
|
|
55
|
+
let familyRevision;
|
|
56
|
+
if (isGit) {
|
|
57
|
+
const dir = await runtime.fs.mkdtemp(
|
|
58
|
+
join(tmpdir(runtime), "fit-benchmark-family-"),
|
|
59
|
+
);
|
|
60
|
+
await gitClone(runtime, rootPathOrGitUrl, dir);
|
|
61
|
+
rootPath = dir;
|
|
62
|
+
familyRevision = "git:" + (await gitHead(runtime, dir));
|
|
63
|
+
} else {
|
|
64
|
+
rootPath = resolve(rootPathOrGitUrl);
|
|
65
|
+
familyRevision = "sha256:" + (await canonicalTreeHash(runtime, rootPath));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const tasks = await discoverTasks(runtime, rootPath);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
rootPath,
|
|
72
|
+
familyRevision,
|
|
73
|
+
tasks() {
|
|
74
|
+
return tasks;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Assert that `<judgeProfilesDir>/<judgeProfile>.md` exists. Called from
|
|
81
|
+
* `BenchmarkRunner.run()` so a missing judge profile fails the family
|
|
82
|
+
* install before any agent session starts.
|
|
83
|
+
* @param {TaskFamily} _family
|
|
84
|
+
* @param {string} judgeProfilesDir
|
|
85
|
+
* @param {string} judgeProfile
|
|
86
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
87
|
+
* @returns {Promise<void>}
|
|
88
|
+
*/
|
|
89
|
+
export async function assertJudgeProfileStaged(
|
|
90
|
+
_family,
|
|
91
|
+
judgeProfilesDir,
|
|
92
|
+
judgeProfile,
|
|
93
|
+
runtime,
|
|
94
|
+
) {
|
|
95
|
+
const candidate = join(judgeProfilesDir, `${judgeProfile}.md`);
|
|
96
|
+
try {
|
|
97
|
+
await runtime.fs.access(candidate);
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error(`judge profile not staged: ${candidate}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function discoverTasks(runtime, rootPath) {
|
|
104
|
+
const fs = runtime.fs;
|
|
105
|
+
const tasksRoot = join(rootPath, "tasks");
|
|
106
|
+
const tasks = [];
|
|
107
|
+
let entries;
|
|
108
|
+
try {
|
|
109
|
+
entries = await fs.readdir(tasksRoot, { withFileTypes: true });
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (e.code === "ENOENT") return tasks;
|
|
112
|
+
throw e;
|
|
113
|
+
}
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
if (!entry.isDirectory()) continue;
|
|
116
|
+
const taskDir = join(tasksRoot, entry.name);
|
|
117
|
+
const supervisorPath = join(taskDir, "supervisor.task.md");
|
|
118
|
+
const judgePath = join(taskDir, "judge.task.md");
|
|
119
|
+
const preflightPath = join(taskDir, "hooks", "preflight.sh");
|
|
120
|
+
const invariantsPath = join(taskDir, "hooks", "invariants.sh");
|
|
121
|
+
tasks.push({
|
|
122
|
+
id: entry.name,
|
|
123
|
+
paths: {
|
|
124
|
+
taskDir,
|
|
125
|
+
instructions: join(taskDir, "agent.task.md"),
|
|
126
|
+
supervisor: (await fileExists(fs, supervisorPath))
|
|
127
|
+
? supervisorPath
|
|
128
|
+
: null,
|
|
129
|
+
judge: (await fileExists(fs, judgePath)) ? judgePath : null,
|
|
130
|
+
hooks: join(taskDir, "hooks"),
|
|
131
|
+
preflight: (await fileExecutable(fs, preflightPath))
|
|
132
|
+
? preflightPath
|
|
133
|
+
: null,
|
|
134
|
+
invariants: (await fileExecutable(fs, invariantsPath))
|
|
135
|
+
? invariantsPath
|
|
136
|
+
: null,
|
|
137
|
+
specs: join(taskDir, "specs"),
|
|
138
|
+
workdir: join(taskDir, "workdir"),
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
tasks.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
143
|
+
return tasks;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function fileExists(fs, path) {
|
|
147
|
+
try {
|
|
148
|
+
await fs.access(path);
|
|
149
|
+
return true;
|
|
150
|
+
} catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function fileExecutable(fs, path) {
|
|
156
|
+
try {
|
|
157
|
+
await fs.access(path, X_OK);
|
|
158
|
+
return true;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Canonical-tree hash per design § Family revision algorithm:
|
|
166
|
+
* list regular files (excluding .git/, node_modules/)
|
|
167
|
+
* resolve symlinks before reading
|
|
168
|
+
* sort by NFC-normalised POSIX-style root-relative path
|
|
169
|
+
* row = <rel-path>\0<hex-sha256>\n
|
|
170
|
+
* sha256(concat(rows))
|
|
171
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
172
|
+
* @param {string} rootPath
|
|
173
|
+
* @returns {Promise<string>} hex digest
|
|
174
|
+
*/
|
|
175
|
+
async function canonicalTreeHash(runtime, rootPath) {
|
|
176
|
+
const fs = runtime.fs;
|
|
177
|
+
const real = await fs.realpath(rootPath);
|
|
178
|
+
const rows = [];
|
|
179
|
+
for await (const filePath of walkFiles(fs, real)) {
|
|
180
|
+
const rel = toPosix(relative(real, filePath)).normalize("NFC");
|
|
181
|
+
const target = await fs.realpath(filePath);
|
|
182
|
+
const bytes = await fs.readFile(target);
|
|
183
|
+
const hex = createHash("sha256").update(bytes).digest("hex");
|
|
184
|
+
rows.push({ rel, hex });
|
|
185
|
+
}
|
|
186
|
+
rows.sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0));
|
|
187
|
+
const acc = createHash("sha256");
|
|
188
|
+
for (const r of rows) acc.update(`${r.rel}\0${r.hex}\n`, "utf8");
|
|
189
|
+
return acc.digest("hex");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function* walkFiles(fs, dir) {
|
|
193
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
const full = join(dir, entry.name);
|
|
196
|
+
if (entry.isDirectory()) {
|
|
197
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
198
|
+
yield* walkFiles(fs, full);
|
|
199
|
+
} else if (entry.isSymbolicLink()) {
|
|
200
|
+
const resolvedFile = await resolveSymlinkToFile(fs, full);
|
|
201
|
+
if (resolvedFile) yield full;
|
|
202
|
+
} else if (entry.isFile()) {
|
|
203
|
+
yield full;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Return the resolved path if `linkPath` is a symlink to a regular file.
|
|
210
|
+
* Returns null for dangling symlinks or links to non-file targets.
|
|
211
|
+
*/
|
|
212
|
+
async function resolveSymlinkToFile(fs, linkPath) {
|
|
213
|
+
const st = await fs.lstat(linkPath);
|
|
214
|
+
if (!st.isSymbolicLink()) return null;
|
|
215
|
+
try {
|
|
216
|
+
const resolved = await fs.realpath(linkPath);
|
|
217
|
+
const tstat = await fs.lstat(resolved);
|
|
218
|
+
return tstat.isFile() ? resolved : null;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function toPosix(p) {
|
|
225
|
+
if (sep === posix.sep) return p;
|
|
226
|
+
return p.split(sep).join(posix.sep);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function gitClone(runtime, url, dir) {
|
|
230
|
+
await git(runtime, ["clone", "--depth", "1", url, dir]);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function gitHead(runtime, dir) {
|
|
234
|
+
const out = await git(runtime, ["-C", dir, "rev-parse", "HEAD"]);
|
|
235
|
+
return out.trim();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function git(runtime, args) {
|
|
239
|
+
const { stdout, stderr, exitCode } = await runtime.subprocess.run(
|
|
240
|
+
"git",
|
|
241
|
+
args,
|
|
242
|
+
);
|
|
243
|
+
if (exitCode !== 0) {
|
|
244
|
+
throw new Error(`git ${args.join(" ")} exited ${exitCode}: ${stderr}`);
|
|
245
|
+
}
|
|
246
|
+
return stdout;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* @typedef {object} Task
|
|
251
|
+
* @property {string} id - Task name (directory name under tasks/)
|
|
252
|
+
* @property {{taskDir: string, instructions: string, supervisor: string|null, judge: string|null, hooks: string, preflight: string|null, invariants: string|null, specs: string, workdir: string}} paths
|
|
253
|
+
*/
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @typedef {object} TaskFamily
|
|
257
|
+
* @property {string} rootPath
|
|
258
|
+
* @property {string} familyRevision - `git:<sha>` or `sha256:<hex>`
|
|
259
|
+
* @property {() => Task[]} tasks
|
|
260
|
+
*/
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WorkdirManager — per-task lifecycle: create the agent CWD, seed it from the
|
|
3
|
+
* task's workdir + specs + staged .claude/, allocate a free TCP port, run
|
|
4
|
+
* the pre-flight smoke probe, and tear down the process group at end of run.
|
|
5
|
+
*
|
|
6
|
+
* The Workdir handle threads `cwd`, `port`, `pgid`, and trace paths through
|
|
7
|
+
* runAgent → invariants → judge → teardown.
|
|
8
|
+
*
|
|
9
|
+
* Filesystem, subprocess, clock, and process-signal access all route through
|
|
10
|
+
* the injected `runtime` bag. Only raw TCP plumbing (`node:net`) stays direct —
|
|
11
|
+
* it is not an ambient-dependency smell and the runtime bag models no socket
|
|
12
|
+
* surface.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createServer } from "node:net";
|
|
16
|
+
import { connect } from "node:net";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
import { loadEnv } from "./env-loader.js";
|
|
20
|
+
import { buildHookEnv } from "./hook-env.js";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_TERM_GRACE_MS = 5_000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} Workdir
|
|
26
|
+
* @property {string} cwd - Agent CWD (per-task copy).
|
|
27
|
+
* @property {string} runDir - Parent of `cwd`; holds trace/log siblings.
|
|
28
|
+
* @property {number} port - Allocated TCP port for the agent.
|
|
29
|
+
* @property {number} pgid - Process-group id captured from the preflight child.
|
|
30
|
+
* @property {*} scaffold - Reserved per design § Components; v1 sets null.
|
|
31
|
+
* @property {string} agentTracePath
|
|
32
|
+
* @property {string} supervisorTracePath
|
|
33
|
+
* @property {string} judgeTracePath
|
|
34
|
+
* @property {string[]} [envNames] - Env var names loaded from .env files.
|
|
35
|
+
* @property {{phase: string, message: string, exitCode: number}} [preflightError]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Per-task workdir lifecycle: seed → preflight → teardown. */
|
|
39
|
+
export class WorkdirManager {
|
|
40
|
+
/**
|
|
41
|
+
* @param {object} deps
|
|
42
|
+
* @param {string} deps.stagingDir - Output of `installApm(...)`.
|
|
43
|
+
* @param {string} deps.runOutputDir - Root run-output directory (parent of `runs/`).
|
|
44
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} deps.runtime -
|
|
45
|
+
* Ambient collaborators; uses `fs`, `subprocess`, `clock`, `proc`.
|
|
46
|
+
*/
|
|
47
|
+
constructor({
|
|
48
|
+
stagingDir,
|
|
49
|
+
runOutputDir,
|
|
50
|
+
termGraceMs,
|
|
51
|
+
familyRootPath,
|
|
52
|
+
runtime,
|
|
53
|
+
}) {
|
|
54
|
+
if (!stagingDir) throw new Error("stagingDir is required");
|
|
55
|
+
if (!runOutputDir) throw new Error("runOutputDir is required");
|
|
56
|
+
if (!runtime) throw new Error("runtime is required");
|
|
57
|
+
this.stagingDir = stagingDir;
|
|
58
|
+
this.runOutputDir = runOutputDir;
|
|
59
|
+
this.termGraceMs = termGraceMs ?? DEFAULT_TERM_GRACE_MS;
|
|
60
|
+
this.familyRootPath = familyRootPath ?? null;
|
|
61
|
+
this.runtime = runtime;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create the per-task working directory and run the pre-flight probe.
|
|
66
|
+
* @param {import("./task-family.js").Task} task
|
|
67
|
+
* @param {number} runIndex
|
|
68
|
+
* @returns {Promise<Workdir>}
|
|
69
|
+
*/
|
|
70
|
+
async start(task, runIndex) {
|
|
71
|
+
const fs = this.runtime.fs;
|
|
72
|
+
const slug = task.id.replace("/", "__");
|
|
73
|
+
const runDir = join(this.runOutputDir, "runs", slug, String(runIndex));
|
|
74
|
+
const cwd = join(runDir, "cwd");
|
|
75
|
+
await fs.mkdir(cwd, { recursive: true });
|
|
76
|
+
|
|
77
|
+
// Family-level shared fixtures: convention-over-configuration, copied if
|
|
78
|
+
// present. They form the shared base; the per-task workdir/specs below
|
|
79
|
+
// overlay on top (fs.cp defaults to force:true, so a per-task file wins).
|
|
80
|
+
if (this.familyRootPath) {
|
|
81
|
+
await fs
|
|
82
|
+
.cp(join(this.familyRootPath, "workdir"), cwd, { recursive: true })
|
|
83
|
+
.catch((e) => {
|
|
84
|
+
if (e.code !== "ENOENT") throw e;
|
|
85
|
+
});
|
|
86
|
+
await fs
|
|
87
|
+
.cp(join(this.familyRootPath, "specs"), join(cwd, "specs"), {
|
|
88
|
+
recursive: true,
|
|
89
|
+
})
|
|
90
|
+
.catch((e) => {
|
|
91
|
+
if (e.code !== "ENOENT") throw e;
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await fs.cp(task.paths.workdir, cwd, { recursive: true }).catch((e) => {
|
|
96
|
+
if (e.code !== "ENOENT") throw e;
|
|
97
|
+
});
|
|
98
|
+
await fs
|
|
99
|
+
.cp(task.paths.specs, join(cwd, "specs"), {
|
|
100
|
+
recursive: true,
|
|
101
|
+
})
|
|
102
|
+
.catch((e) => {
|
|
103
|
+
if (e.code !== "ENOENT") throw e;
|
|
104
|
+
});
|
|
105
|
+
await fs.cp(join(this.stagingDir, ".claude"), join(cwd, ".claude"), {
|
|
106
|
+
recursive: true,
|
|
107
|
+
});
|
|
108
|
+
await fs
|
|
109
|
+
.cp(join(this.stagingDir, "node_modules"), join(cwd, "node_modules"), {
|
|
110
|
+
recursive: true,
|
|
111
|
+
})
|
|
112
|
+
.catch((e) => {
|
|
113
|
+
if (e.code !== "ENOENT") throw e;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const envDirs = [
|
|
117
|
+
...(this.familyRootPath ? [this.familyRootPath] : []),
|
|
118
|
+
...(task.paths.taskDir ? [task.paths.taskDir] : []),
|
|
119
|
+
];
|
|
120
|
+
const envNames =
|
|
121
|
+
envDirs.length > 0 ? await loadEnv(envDirs, cwd, this.runtime) : [];
|
|
122
|
+
|
|
123
|
+
const port = await allocatePort();
|
|
124
|
+
const agentTracePath = join(runDir, "agent.ndjson");
|
|
125
|
+
const supervisorTracePath = join(runDir, "supervisor.ndjson");
|
|
126
|
+
const judgeTracePath = join(runDir, "judge.ndjson");
|
|
127
|
+
|
|
128
|
+
const preflight = task.paths.preflight
|
|
129
|
+
? await runPreflight(this.runtime, task.paths.preflight, cwd, port, {
|
|
130
|
+
taskId: task.id,
|
|
131
|
+
taskDir: task.paths.taskDir,
|
|
132
|
+
hooksDir: task.paths.hooks,
|
|
133
|
+
familyDir: this.familyRootPath,
|
|
134
|
+
})
|
|
135
|
+
: { pgid: 0 };
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
cwd,
|
|
139
|
+
runDir,
|
|
140
|
+
port,
|
|
141
|
+
pgid: preflight.pgid,
|
|
142
|
+
scaffold: null,
|
|
143
|
+
agentTracePath,
|
|
144
|
+
supervisorTracePath,
|
|
145
|
+
judgeTracePath,
|
|
146
|
+
envNames,
|
|
147
|
+
...(preflight.error && { preflightError: preflight.error }),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Tear down the per-task process group: SIGTERM, wait, SIGKILL, then probe.
|
|
153
|
+
* @param {Workdir} workdir
|
|
154
|
+
* @returns {Promise<{portFree: boolean, descendants: number}>}
|
|
155
|
+
*/
|
|
156
|
+
async teardown(workdir) {
|
|
157
|
+
const { proc, clock } = this.runtime;
|
|
158
|
+
if (workdir.pgid && workdir.pgid > 0) {
|
|
159
|
+
try {
|
|
160
|
+
proc.kill(-workdir.pgid, "SIGTERM");
|
|
161
|
+
} catch {
|
|
162
|
+
// Process group already gone — fine.
|
|
163
|
+
}
|
|
164
|
+
await clock.sleep(this.termGraceMs);
|
|
165
|
+
try {
|
|
166
|
+
proc.kill(-workdir.pgid, "SIGKILL");
|
|
167
|
+
} catch {
|
|
168
|
+
// Already exited.
|
|
169
|
+
}
|
|
170
|
+
// Poll briefly until the process group is empty — SIGKILL returns
|
|
171
|
+
// before the kernel finishes reaping descendants.
|
|
172
|
+
await waitFor(
|
|
173
|
+
this.runtime,
|
|
174
|
+
async () => (await countDescendants(this.runtime, workdir.pgid)) === 0,
|
|
175
|
+
2_000,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
const portFree = await isPortFree(workdir.port);
|
|
179
|
+
const descendants = await countDescendants(this.runtime, workdir.pgid);
|
|
180
|
+
return { portFree, descendants };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Spawn preflight. Stays detached so we can SIGTERM the whole process group.
|
|
186
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
187
|
+
* @param {string} script
|
|
188
|
+
* @param {string} cwd - Agent CWD passed via $AGENT_CWD.
|
|
189
|
+
* @param {number} port - Free TCP port passed via $PORT.
|
|
190
|
+
* @param {{taskId: string, taskDir: string, hooksDir: string, familyDir: string|null}} vars - Extra hook env vars.
|
|
191
|
+
* @returns {Promise<{pgid: number, error?: {phase: string, message: string, exitCode: number}}>}
|
|
192
|
+
*/
|
|
193
|
+
async function runPreflight(runtime, script, cwd, port, vars) {
|
|
194
|
+
const child = runtime.subprocess.spawn(script, [], {
|
|
195
|
+
cwd,
|
|
196
|
+
env: buildHookEnv(runtime.proc.env, { cwd, port, ...vars }),
|
|
197
|
+
detached: true,
|
|
198
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
199
|
+
});
|
|
200
|
+
if (child.pid === undefined) {
|
|
201
|
+
throw new Error(`failed to spawn preflight: ${script}`);
|
|
202
|
+
}
|
|
203
|
+
const pgid = child.pid;
|
|
204
|
+
let stderr = "";
|
|
205
|
+
const drainStdout = (async () => {
|
|
206
|
+
for await (const _chunk of child.stdout) {
|
|
207
|
+
// discard
|
|
208
|
+
}
|
|
209
|
+
})();
|
|
210
|
+
for await (const chunk of child.stderr) stderr += chunk.toString();
|
|
211
|
+
await drainStdout;
|
|
212
|
+
const code = await child.exitCode;
|
|
213
|
+
if (code === 0) return { pgid };
|
|
214
|
+
const message = stderr.trim() || `preflight exited with code ${code}`;
|
|
215
|
+
return {
|
|
216
|
+
pgid,
|
|
217
|
+
error: {
|
|
218
|
+
phase: "preflight",
|
|
219
|
+
message,
|
|
220
|
+
exitCode: typeof code === "number" ? code : -1,
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function allocatePort() {
|
|
226
|
+
return new Promise((res, rej) => {
|
|
227
|
+
const server = createServer();
|
|
228
|
+
server.unref();
|
|
229
|
+
server.on("error", rej);
|
|
230
|
+
server.listen(0, "127.0.0.1", () => {
|
|
231
|
+
const addr = server.address();
|
|
232
|
+
if (!addr || typeof addr === "string") {
|
|
233
|
+
server.close();
|
|
234
|
+
rej(new Error("failed to allocate port"));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const port = addr.port;
|
|
238
|
+
server.close(() => res(port));
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isPortFree(port) {
|
|
244
|
+
if (!port) return Promise.resolve(true);
|
|
245
|
+
return new Promise((res) => {
|
|
246
|
+
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
|
247
|
+
socket.destroy();
|
|
248
|
+
res(false);
|
|
249
|
+
});
|
|
250
|
+
socket.on("error", () => res(true));
|
|
251
|
+
socket.setTimeout(500, () => {
|
|
252
|
+
socket.destroy();
|
|
253
|
+
res(true);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function countDescendants(runtime, pgid) {
|
|
259
|
+
if (!pgid || pgid <= 0) return 0;
|
|
260
|
+
const child = runtime.subprocess.spawn(
|
|
261
|
+
"ps",
|
|
262
|
+
["-o", "pid=", "-g", String(pgid)],
|
|
263
|
+
{
|
|
264
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
let out = "";
|
|
268
|
+
try {
|
|
269
|
+
for await (const chunk of child.stdout) out += chunk.toString();
|
|
270
|
+
await child.exitCode;
|
|
271
|
+
} catch {
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
const pids = out
|
|
275
|
+
.split("\n")
|
|
276
|
+
.map((s) => s.trim())
|
|
277
|
+
.filter(Boolean)
|
|
278
|
+
.filter((s) => Number(s) !== runtime.proc.pid);
|
|
279
|
+
return pids.length;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function waitFor(runtime, predicate, timeoutMs) {
|
|
283
|
+
const deadline = runtime.clock.now() + timeoutMs;
|
|
284
|
+
while (runtime.clock.now() < deadline) {
|
|
285
|
+
if (await predicate()) return true;
|
|
286
|
+
await runtime.clock.sleep(50);
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Factory function — wires real dependencies.
|
|
293
|
+
* @param {ConstructorParameters<typeof WorkdirManager>[0]} deps
|
|
294
|
+
* @returns {WorkdirManager}
|
|
295
|
+
*/
|
|
296
|
+
export function createWorkdirManager(deps) {
|
|
297
|
+
return new WorkdirManager(deps);
|
|
298
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import jmespath from "jmespath";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Evaluate an assertion and return the structured result.
|
|
6
|
+
* @param {object} values - { grep?: string, query?: string, exists?: boolean, not?: boolean, message?: string }
|
|
7
|
+
* @param {string[]} args - [testName, file]
|
|
8
|
+
* @param {object} fsSync - Sync filesystem surface (`runtime.fsSync`): `existsSync`, `readFileSync`.
|
|
9
|
+
* @returns {{ test: string, pass: boolean, message?: string }}
|
|
10
|
+
*/
|
|
11
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: assertion dispatch by type
|
|
12
|
+
export function evaluateAssertion(values, args, fsSync) {
|
|
13
|
+
const testName = args[0];
|
|
14
|
+
if (!testName) throw new Error("assert: missing test name");
|
|
15
|
+
|
|
16
|
+
const file = args[1];
|
|
17
|
+
const modes = [
|
|
18
|
+
values.grep,
|
|
19
|
+
values.query,
|
|
20
|
+
values.exists,
|
|
21
|
+
values["cites-job"],
|
|
22
|
+
].filter((v) => v !== undefined && v !== false);
|
|
23
|
+
if (modes.length === 0) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"assert: specify one of --grep, --query, --exists, or --cites-job",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (modes.length > 1) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
"assert: specify only one of --grep, --query, --exists, or --cites-job",
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let result;
|
|
35
|
+
if (values.exists) {
|
|
36
|
+
if (!file) throw new Error("assert: missing file argument");
|
|
37
|
+
result = assertExists(file, fsSync);
|
|
38
|
+
} else if (values.grep) {
|
|
39
|
+
if (!file) throw new Error("assert: missing file argument for --grep");
|
|
40
|
+
result = assertGrep(values.grep, file, fsSync);
|
|
41
|
+
} else if (values["cites-job"]) {
|
|
42
|
+
if (!file) throw new Error("assert: missing file argument for --cites-job");
|
|
43
|
+
result = assertCitesJob(values["cites-job"], file, fsSync);
|
|
44
|
+
} else {
|
|
45
|
+
if (!file) throw new Error("assert: missing file argument for --query");
|
|
46
|
+
result = assertQuery(values.query, file, fsSync);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (values.not) {
|
|
50
|
+
result.pass = !result.pass;
|
|
51
|
+
if (result.pass) {
|
|
52
|
+
delete result.message;
|
|
53
|
+
} else {
|
|
54
|
+
result.message =
|
|
55
|
+
result.message ?? `inverted assertion failed for ${basename(file)}`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!result.pass && values.message) {
|
|
60
|
+
result.message = values.message;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const output = { test: testName, pass: result.pass };
|
|
64
|
+
if (result.message) output.message = result.message;
|
|
65
|
+
return output;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run an assertion, write JSON to stdout, and return a failure envelope when
|
|
70
|
+
* the assertion does not pass.
|
|
71
|
+
* @param {import("@forwardimpact/libcli").InvocationContext} ctx
|
|
72
|
+
* @returns {Promise<{ok: true} | {ok: false, code: number, error: string}>}
|
|
73
|
+
*/
|
|
74
|
+
export async function runAssertCommand(ctx) {
|
|
75
|
+
const runtime = ctx.deps.runtime;
|
|
76
|
+
const args = [ctx.args["test-name"], ctx.args.file];
|
|
77
|
+
let result;
|
|
78
|
+
try {
|
|
79
|
+
result = evaluateAssertion(ctx.options, args, runtime.fsSync);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return { ok: false, code: 1, error: err.message };
|
|
82
|
+
}
|
|
83
|
+
runtime.proc.stdout.write(JSON.stringify(result) + "\n");
|
|
84
|
+
return result.pass ? { ok: true } : { ok: false, code: 1, error: "" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function assertExists(file, fsSync) {
|
|
88
|
+
if (fsSync.existsSync(file)) return { pass: true };
|
|
89
|
+
return { pass: false, message: `${file} not found` };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function assertGrep(pattern, file, fsSync) {
|
|
93
|
+
const content = fsSync.readFileSync(file, "utf8");
|
|
94
|
+
const re = new RegExp(pattern, "im");
|
|
95
|
+
if (re.test(content)) return { pass: true };
|
|
96
|
+
return {
|
|
97
|
+
pass: false,
|
|
98
|
+
message: `pattern "${pattern}" not found in ${basename(file)}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function assertQuery(expression, file, fsSync) {
|
|
103
|
+
const content = fsSync.readFileSync(file, "utf8");
|
|
104
|
+
const data = parseJsonOrNdjson(content);
|
|
105
|
+
const result = jmespath.search(data, expression);
|
|
106
|
+
const truthy =
|
|
107
|
+
result !== null &&
|
|
108
|
+
result !== undefined &&
|
|
109
|
+
result !== false &&
|
|
110
|
+
(Array.isArray(result) ? result.length > 0 : true);
|
|
111
|
+
if (truthy) return { pass: true };
|
|
112
|
+
return {
|
|
113
|
+
pass: false,
|
|
114
|
+
message: `query returned ${JSON.stringify(result)}`,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const JOB_TAG_RE = /<job\s+user="([^"]*)"\s+goal="([^"]*)">/;
|
|
119
|
+
|
|
120
|
+
function assertCitesJob(jobFile, file, fsSync) {
|
|
121
|
+
const jobContent = fsSync.readFileSync(jobFile, "utf8");
|
|
122
|
+
const match = JOB_TAG_RE.exec(jobContent);
|
|
123
|
+
if (!match) {
|
|
124
|
+
return {
|
|
125
|
+
pass: false,
|
|
126
|
+
message: `no <job> tag found in ${basename(jobFile)}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const citation = `${match[1]}: ${match[2]}`;
|
|
130
|
+
const content = fsSync.readFileSync(file, "utf8");
|
|
131
|
+
if (content.includes(citation)) return { pass: true };
|
|
132
|
+
return { pass: false, message: `missing "${citation}"` };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseJsonOrNdjson(content) {
|
|
136
|
+
try {
|
|
137
|
+
return JSON.parse(content);
|
|
138
|
+
} catch {
|
|
139
|
+
// Fall through to NDJSON
|
|
140
|
+
}
|
|
141
|
+
const lines = [];
|
|
142
|
+
for (const raw of content.split("\n")) {
|
|
143
|
+
const trimmed = raw.trim();
|
|
144
|
+
if (!trimmed) continue;
|
|
145
|
+
try {
|
|
146
|
+
lines.push(JSON.parse(trimmed));
|
|
147
|
+
} catch {
|
|
148
|
+
// skip unparseable lines
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (lines.length === 0) throw new Error("assert: no valid JSON in file");
|
|
152
|
+
return lines;
|
|
153
|
+
}
|