@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.
Files changed (87) hide show
  1. package/LICENSE +21 -201
  2. package/README.md +196 -80
  3. package/bin/fit-benchmark.js +44 -0
  4. package/bin/fit-harness.js +358 -0
  5. package/bin/fit-selfedit.js +165 -0
  6. package/bin/fit-trace.js +510 -0
  7. package/package.json +41 -11
  8. package/src/agent-runner.js +256 -0
  9. package/src/benchmark/apm-installer.js +207 -0
  10. package/src/benchmark/env-loader.js +158 -0
  11. package/src/benchmark/hook-env.js +40 -0
  12. package/src/benchmark/invariants.js +141 -0
  13. package/src/benchmark/judge.js +187 -0
  14. package/src/benchmark/npm-installer.js +87 -0
  15. package/src/benchmark/report.js +604 -0
  16. package/src/benchmark/result.js +127 -0
  17. package/src/benchmark/runner.js +688 -0
  18. package/src/benchmark/scheduler.js +78 -0
  19. package/src/benchmark/task-family.js +260 -0
  20. package/src/benchmark/workdir.js +344 -0
  21. package/src/commands/assert.js +153 -0
  22. package/src/commands/benchmark-definition.js +175 -0
  23. package/src/commands/benchmark-invariants.js +73 -0
  24. package/src/commands/benchmark-report.js +51 -0
  25. package/src/commands/benchmark-run.js +175 -0
  26. package/src/commands/by-discussion.js +94 -0
  27. package/src/commands/callback.js +119 -0
  28. package/src/commands/discuss.js +132 -0
  29. package/src/commands/facilitate.js +123 -0
  30. package/src/commands/output.js +36 -0
  31. package/src/commands/run.js +152 -0
  32. package/src/commands/supervise.js +136 -0
  33. package/src/commands/task-input.js +54 -0
  34. package/src/commands/tee.js +53 -0
  35. package/src/commands/trace.js +630 -0
  36. package/src/commands/work-tracker.js +35 -0
  37. package/src/cost.js +79 -0
  38. package/src/discuss-tools.js +173 -0
  39. package/src/discusser.js +394 -0
  40. package/src/events/github.js +161 -0
  41. package/src/facilitator.js +205 -0
  42. package/src/inbox-poller.js +81 -0
  43. package/src/index.js +72 -2
  44. package/src/judge.js +210 -0
  45. package/src/message-bus.js +118 -0
  46. package/src/orchestration-loop.js +330 -0
  47. package/src/orchestration-toolkit.js +441 -0
  48. package/src/orchestrator-helpers.js +23 -0
  49. package/src/profile-prompt.js +266 -0
  50. package/src/redaction.js +253 -0
  51. package/src/render/line-renderer.js +54 -0
  52. package/src/render/orchestrator-filter.js +19 -0
  53. package/src/render/palette.js +63 -0
  54. package/src/render/tool-hints.js +154 -0
  55. package/src/render/turn-renderer.js +96 -0
  56. package/src/reply-emitter.js +47 -0
  57. package/src/sequence-counter.js +21 -0
  58. package/src/signature-filter.js +27 -0
  59. package/src/supervisor.js +236 -0
  60. package/src/tee-writer.js +150 -0
  61. package/src/trace-collector.js +444 -0
  62. package/src/trace-github.js +473 -0
  63. package/src/trace-multi.js +101 -0
  64. package/src/trace-query.js +748 -0
  65. package/src/trace-render.js +211 -0
  66. package/src/trace-usage.js +249 -0
  67. package/src/fixture/assertions.js +0 -42
  68. package/src/fixture/cache.js +0 -50
  69. package/src/fixture/eval.js +0 -146
  70. package/src/fixture/index.js +0 -9
  71. package/src/fixture/pathway.js +0 -451
  72. package/src/fixture/services.js +0 -56
  73. package/src/mock/clients.js +0 -135
  74. package/src/mock/config.js +0 -45
  75. package/src/mock/data.js +0 -46
  76. package/src/mock/fs.js +0 -111
  77. package/src/mock/grpc.js +0 -94
  78. package/src/mock/http.js +0 -60
  79. package/src/mock/index.js +0 -36
  80. package/src/mock/infra.js +0 -219
  81. package/src/mock/logger.js +0 -42
  82. package/src/mock/observer.js +0 -74
  83. package/src/mock/resource-index.js +0 -95
  84. package/src/mock/service-callbacks.js +0 -39
  85. package/src/mock/services.js +0 -79
  86. package/src/mock/spy.js +0 -44
  87. 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
+ }