@forwardimpact/libharness 0.1.22 → 1.1.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 +41 -11
- 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 +604 -0
- package/src/benchmark/result.js +127 -0
- package/src/benchmark/runner.js +688 -0
- package/src/benchmark/scheduler.js +78 -0
- package/src/benchmark/task-family.js +260 -0
- package/src/benchmark/workdir.js +344 -0
- package/src/commands/assert.js +153 -0
- package/src/commands/benchmark-definition.js +175 -0
- package/src/commands/benchmark-invariants.js +73 -0
- package/src/commands/benchmark-report.js +51 -0
- package/src/commands/benchmark-run.js +175 -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,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CellScheduler — bounded concurrent execution of benchmark cells.
|
|
3
|
+
*
|
|
4
|
+
* Keeps at most `concurrency` `runCell(cell)` calls in flight at once and
|
|
5
|
+
* yields each settled record in **completion order** (not grid order). The
|
|
6
|
+
* runner's drain loop consumes this async iterable as the sole writer of
|
|
7
|
+
* `results.jsonl`, so concurrency lives here in execution while the ledger
|
|
8
|
+
* stays single-writer — no write mutex on the hot path.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Bounded pool that streams settled cell records in completion order. */
|
|
12
|
+
export class CellScheduler {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} opts
|
|
15
|
+
* @param {number} opts.concurrency - Max cells in flight (integer ≥ 1).
|
|
16
|
+
* @param {(cell: {task: object, runIndex: number}) => Promise<object>} opts.runCell -
|
|
17
|
+
* Runs one cell to a settled record. By contract `runCell` never rejects —
|
|
18
|
+
* the runner's `#runOne` catches setup, agent, and schema failures and
|
|
19
|
+
* returns a record rather than throwing — but a rejection is still guarded
|
|
20
|
+
* so one bad cell cannot wedge the drain.
|
|
21
|
+
*/
|
|
22
|
+
constructor({ concurrency, runCell }) {
|
|
23
|
+
if (!Number.isInteger(concurrency) || concurrency < 1)
|
|
24
|
+
throw new Error("concurrency must be an integer ≥ 1");
|
|
25
|
+
if (typeof runCell !== "function")
|
|
26
|
+
throw new Error("runCell must be a function");
|
|
27
|
+
this.concurrency = concurrency;
|
|
28
|
+
this.runCell = runCell;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run every cell with bounded concurrency, yielding each settled record the
|
|
33
|
+
* moment its cell completes.
|
|
34
|
+
* @param {{task: object, runIndex: number}[]} cells
|
|
35
|
+
* @returns {AsyncGenerator<object>}
|
|
36
|
+
*/
|
|
37
|
+
async *run(cells) {
|
|
38
|
+
let next = 0;
|
|
39
|
+
/** @type {Set<Promise<{p: Promise<*>, record: object}>>} */
|
|
40
|
+
const inFlight = new Set();
|
|
41
|
+
|
|
42
|
+
const launch = () => {
|
|
43
|
+
const cell = cells[next++];
|
|
44
|
+
// The wrapper resolves to its own handle (for O(1) removal) plus the
|
|
45
|
+
// settled record, and never rejects — a thrown runCell becomes a fail
|
|
46
|
+
// record so the drain keeps consuming.
|
|
47
|
+
const p = Promise.resolve()
|
|
48
|
+
.then(() => this.runCell(cell))
|
|
49
|
+
.then(
|
|
50
|
+
(record) => ({ p, record }),
|
|
51
|
+
(error) => ({ p, record: schedulerFailRecord(cell, error) }),
|
|
52
|
+
);
|
|
53
|
+
inFlight.add(p);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
while (next < cells.length && inFlight.size < this.concurrency) launch();
|
|
57
|
+
while (inFlight.size > 0) {
|
|
58
|
+
const { p, record } = await Promise.race(inFlight);
|
|
59
|
+
inFlight.delete(p);
|
|
60
|
+
yield record;
|
|
61
|
+
if (next < cells.length) launch();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Defensive fallback when `runCell` rejects (contract says it cannot). Keeps
|
|
68
|
+
* the drain consumable; the record is intentionally minimal and will be
|
|
69
|
+
* skipped by `report`'s schema validation, counted as skipped.
|
|
70
|
+
*/
|
|
71
|
+
function schedulerFailRecord(cell, error) {
|
|
72
|
+
return {
|
|
73
|
+
taskId: cell.task?.id,
|
|
74
|
+
runIndex: cell.runIndex,
|
|
75
|
+
verdict: "fail",
|
|
76
|
+
schedulerError: error?.message ?? String(error),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -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,344 @@
|
|
|
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
|
+
// One registry per manager: hands out distinct, bindable ports under a lock
|
|
63
|
+
// so concurrent cells can never be handed the same number.
|
|
64
|
+
this.ports = new PortRegistry();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create the per-task working directory and run the pre-flight probe.
|
|
69
|
+
* @param {import("./task-family.js").Task} task
|
|
70
|
+
* @param {number} runIndex
|
|
71
|
+
* @returns {Promise<Workdir>}
|
|
72
|
+
*/
|
|
73
|
+
async start(task, runIndex) {
|
|
74
|
+
const fs = this.runtime.fs;
|
|
75
|
+
const slug = task.id.replace("/", "__");
|
|
76
|
+
const runDir = join(this.runOutputDir, "runs", slug, String(runIndex));
|
|
77
|
+
const cwd = join(runDir, "cwd");
|
|
78
|
+
await fs.mkdir(cwd, { recursive: true });
|
|
79
|
+
|
|
80
|
+
// Family-level shared fixtures: convention-over-configuration, copied if
|
|
81
|
+
// present. They form the shared base; the per-task workdir/specs below
|
|
82
|
+
// overlay on top (fs.cp defaults to force:true, so a per-task file wins).
|
|
83
|
+
if (this.familyRootPath) {
|
|
84
|
+
await fs
|
|
85
|
+
.cp(join(this.familyRootPath, "workdir"), cwd, { recursive: true })
|
|
86
|
+
.catch((e) => {
|
|
87
|
+
if (e.code !== "ENOENT") throw e;
|
|
88
|
+
});
|
|
89
|
+
await fs
|
|
90
|
+
.cp(join(this.familyRootPath, "specs"), join(cwd, "specs"), {
|
|
91
|
+
recursive: true,
|
|
92
|
+
})
|
|
93
|
+
.catch((e) => {
|
|
94
|
+
if (e.code !== "ENOENT") throw e;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await fs.cp(task.paths.workdir, cwd, { recursive: true }).catch((e) => {
|
|
99
|
+
if (e.code !== "ENOENT") throw e;
|
|
100
|
+
});
|
|
101
|
+
await fs
|
|
102
|
+
.cp(task.paths.specs, join(cwd, "specs"), {
|
|
103
|
+
recursive: true,
|
|
104
|
+
})
|
|
105
|
+
.catch((e) => {
|
|
106
|
+
if (e.code !== "ENOENT") throw e;
|
|
107
|
+
});
|
|
108
|
+
await fs.cp(join(this.stagingDir, ".claude"), join(cwd, ".claude"), {
|
|
109
|
+
recursive: true,
|
|
110
|
+
});
|
|
111
|
+
await fs
|
|
112
|
+
.cp(join(this.stagingDir, "node_modules"), join(cwd, "node_modules"), {
|
|
113
|
+
recursive: true,
|
|
114
|
+
})
|
|
115
|
+
.catch((e) => {
|
|
116
|
+
if (e.code !== "ENOENT") throw e;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const envDirs = [
|
|
120
|
+
...(this.familyRootPath ? [this.familyRootPath] : []),
|
|
121
|
+
...(task.paths.taskDir ? [task.paths.taskDir] : []),
|
|
122
|
+
];
|
|
123
|
+
const envNames =
|
|
124
|
+
envDirs.length > 0 ? await loadEnv(envDirs, cwd, this.runtime) : [];
|
|
125
|
+
|
|
126
|
+
const port = await this.ports.acquire();
|
|
127
|
+
const agentTracePath = join(runDir, "agent.ndjson");
|
|
128
|
+
const supervisorTracePath = join(runDir, "supervisor.ndjson");
|
|
129
|
+
const judgeTracePath = join(runDir, "judge.ndjson");
|
|
130
|
+
|
|
131
|
+
const preflight = task.paths.preflight
|
|
132
|
+
? await runPreflight(this.runtime, task.paths.preflight, cwd, port, {
|
|
133
|
+
taskId: task.id,
|
|
134
|
+
taskDir: task.paths.taskDir,
|
|
135
|
+
hooksDir: task.paths.hooks,
|
|
136
|
+
familyDir: this.familyRootPath,
|
|
137
|
+
})
|
|
138
|
+
: { pgid: 0 };
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
cwd,
|
|
142
|
+
runDir,
|
|
143
|
+
port,
|
|
144
|
+
pgid: preflight.pgid,
|
|
145
|
+
scaffold: null,
|
|
146
|
+
agentTracePath,
|
|
147
|
+
supervisorTracePath,
|
|
148
|
+
judgeTracePath,
|
|
149
|
+
envNames,
|
|
150
|
+
...(preflight.error && { preflightError: preflight.error }),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Tear down the per-task process group: SIGTERM, wait, SIGKILL, then probe.
|
|
156
|
+
* @param {Workdir} workdir
|
|
157
|
+
* @returns {Promise<{portFree: boolean, descendants: number}>}
|
|
158
|
+
*/
|
|
159
|
+
async teardown(workdir) {
|
|
160
|
+
const { proc, clock } = this.runtime;
|
|
161
|
+
if (workdir.pgid && workdir.pgid > 0) {
|
|
162
|
+
try {
|
|
163
|
+
proc.kill(-workdir.pgid, "SIGTERM");
|
|
164
|
+
} catch {
|
|
165
|
+
// Process group already gone — fine.
|
|
166
|
+
}
|
|
167
|
+
await clock.sleep(this.termGraceMs);
|
|
168
|
+
try {
|
|
169
|
+
proc.kill(-workdir.pgid, "SIGKILL");
|
|
170
|
+
} catch {
|
|
171
|
+
// Already exited.
|
|
172
|
+
}
|
|
173
|
+
// Poll briefly until the process group is empty — SIGKILL returns
|
|
174
|
+
// before the kernel finishes reaping descendants.
|
|
175
|
+
await waitFor(
|
|
176
|
+
this.runtime,
|
|
177
|
+
async () => (await countDescendants(this.runtime, workdir.pgid)) === 0,
|
|
178
|
+
2_000,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
// Release the reservation in a finally so a throwing probe cannot leak the
|
|
182
|
+
// number from the in-use set; release after the port-free probe so a freed
|
|
183
|
+
// number can be re-handed to a waiting cell.
|
|
184
|
+
try {
|
|
185
|
+
const portFree = await isPortFree(workdir.port);
|
|
186
|
+
const descendants = await countDescendants(this.runtime, workdir.pgid);
|
|
187
|
+
return { portFree, descendants };
|
|
188
|
+
} finally {
|
|
189
|
+
this.ports.release(workdir.port);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Hands out distinct, bindable TCP ports under a lock. Replaces the bare
|
|
196
|
+
* close-then-return allocator whose allocate→bind window let two concurrent
|
|
197
|
+
* cells receive the same number.
|
|
198
|
+
*
|
|
199
|
+
* The reservation is the *number*, not a held socket — a held socket could not
|
|
200
|
+
* be bound by the agent later. `acquire` serializes through a one-slot promise
|
|
201
|
+
* chain and re-probes if the OS hands back a number already in the live in-use
|
|
202
|
+
* set, so no two in-flight cells share a port.
|
|
203
|
+
*/
|
|
204
|
+
export class PortRegistry {
|
|
205
|
+
#inUse = new Set();
|
|
206
|
+
#tail = Promise.resolve();
|
|
207
|
+
|
|
208
|
+
/** @returns {Promise<number>} A distinct, currently-bindable port. */
|
|
209
|
+
acquire() {
|
|
210
|
+
const next = this.#tail.then(async () => {
|
|
211
|
+
let port;
|
|
212
|
+
do {
|
|
213
|
+
port = await probeFreePort();
|
|
214
|
+
} while (this.#inUse.has(port));
|
|
215
|
+
this.#inUse.add(port);
|
|
216
|
+
return port;
|
|
217
|
+
});
|
|
218
|
+
// Keep the chain alive even if one acquire rejects, so later acquires
|
|
219
|
+
// still run; swallow here, surface the rejection on `next`.
|
|
220
|
+
this.#tail = next.catch(() => {});
|
|
221
|
+
return next;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** @param {number} port */
|
|
225
|
+
release(port) {
|
|
226
|
+
this.#inUse.delete(port);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Spawn preflight. Stays detached so we can SIGTERM the whole process group.
|
|
232
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
233
|
+
* @param {string} script
|
|
234
|
+
* @param {string} cwd - Agent CWD passed via $AGENT_CWD.
|
|
235
|
+
* @param {number} port - Free TCP port passed via $PORT.
|
|
236
|
+
* @param {{taskId: string, taskDir: string, hooksDir: string, familyDir: string|null}} vars - Extra hook env vars.
|
|
237
|
+
* @returns {Promise<{pgid: number, error?: {phase: string, message: string, exitCode: number}}>}
|
|
238
|
+
*/
|
|
239
|
+
async function runPreflight(runtime, script, cwd, port, vars) {
|
|
240
|
+
const child = runtime.subprocess.spawn(script, [], {
|
|
241
|
+
cwd,
|
|
242
|
+
env: buildHookEnv(runtime.proc.env, { cwd, port, ...vars }),
|
|
243
|
+
detached: true,
|
|
244
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
245
|
+
});
|
|
246
|
+
if (child.pid === undefined) {
|
|
247
|
+
throw new Error(`failed to spawn preflight: ${script}`);
|
|
248
|
+
}
|
|
249
|
+
const pgid = child.pid;
|
|
250
|
+
let stderr = "";
|
|
251
|
+
const drainStdout = (async () => {
|
|
252
|
+
for await (const _chunk of child.stdout) {
|
|
253
|
+
// discard
|
|
254
|
+
}
|
|
255
|
+
})();
|
|
256
|
+
for await (const chunk of child.stderr) stderr += chunk.toString();
|
|
257
|
+
await drainStdout;
|
|
258
|
+
const code = await child.exitCode;
|
|
259
|
+
if (code === 0) return { pgid };
|
|
260
|
+
const message = stderr.trim() || `preflight exited with code ${code}`;
|
|
261
|
+
return {
|
|
262
|
+
pgid,
|
|
263
|
+
error: {
|
|
264
|
+
phase: "preflight",
|
|
265
|
+
message,
|
|
266
|
+
exitCode: typeof code === "number" ? code : -1,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function probeFreePort() {
|
|
272
|
+
return new Promise((res, rej) => {
|
|
273
|
+
const server = createServer();
|
|
274
|
+
server.unref();
|
|
275
|
+
server.on("error", rej);
|
|
276
|
+
server.listen(0, "127.0.0.1", () => {
|
|
277
|
+
const addr = server.address();
|
|
278
|
+
if (!addr || typeof addr === "string") {
|
|
279
|
+
server.close();
|
|
280
|
+
rej(new Error("failed to allocate port"));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const port = addr.port;
|
|
284
|
+
server.close(() => res(port));
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function isPortFree(port) {
|
|
290
|
+
if (!port) return Promise.resolve(true);
|
|
291
|
+
return new Promise((res) => {
|
|
292
|
+
const socket = connect({ port, host: "127.0.0.1" }, () => {
|
|
293
|
+
socket.destroy();
|
|
294
|
+
res(false);
|
|
295
|
+
});
|
|
296
|
+
socket.on("error", () => res(true));
|
|
297
|
+
socket.setTimeout(500, () => {
|
|
298
|
+
socket.destroy();
|
|
299
|
+
res(true);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function countDescendants(runtime, pgid) {
|
|
305
|
+
if (!pgid || pgid <= 0) return 0;
|
|
306
|
+
const child = runtime.subprocess.spawn(
|
|
307
|
+
"ps",
|
|
308
|
+
["-o", "pid=", "-g", String(pgid)],
|
|
309
|
+
{
|
|
310
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
let out = "";
|
|
314
|
+
try {
|
|
315
|
+
for await (const chunk of child.stdout) out += chunk.toString();
|
|
316
|
+
await child.exitCode;
|
|
317
|
+
} catch {
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
const pids = out
|
|
321
|
+
.split("\n")
|
|
322
|
+
.map((s) => s.trim())
|
|
323
|
+
.filter(Boolean)
|
|
324
|
+
.filter((s) => Number(s) !== runtime.proc.pid);
|
|
325
|
+
return pids.length;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function waitFor(runtime, predicate, timeoutMs) {
|
|
329
|
+
const deadline = runtime.clock.now() + timeoutMs;
|
|
330
|
+
while (runtime.clock.now() < deadline) {
|
|
331
|
+
if (await predicate()) return true;
|
|
332
|
+
await runtime.clock.sleep(50);
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Factory function — wires real dependencies.
|
|
339
|
+
* @param {ConstructorParameters<typeof WorkdirManager>[0]} deps
|
|
340
|
+
* @returns {WorkdirManager}
|
|
341
|
+
*/
|
|
342
|
+
export function createWorkdirManager(deps) {
|
|
343
|
+
return new WorkdirManager(deps);
|
|
344
|
+
}
|