infinity-harness 2.6.6 → 2.7.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/CHANGELOG.md +66 -0
- package/README.md +68 -15
- package/extensions/infinity-harness/index.ts +391 -18
- package/package.json +1 -1
- package/src/core/config.ts +1 -1
- package/src/core/settings.ts +10 -3
- package/src/core/types.ts +16 -0
- package/src/exec/piWorker.ts +707 -0
- package/src/intake.ts +4 -1
- package/src/loop.ts +35 -34
- package/src/remote.ts +26 -6
- package/src/scheduler.ts +10 -3
- package/src/supervisor.ts +955 -0
- package/src/ui/dashboard.ts +127 -0
- package/src/ui/widget.ts +134 -0
- package/src/ui/wizard.ts +43 -7
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — the background pi worker.
|
|
3
|
+
*
|
|
4
|
+
* The harness used to do all of its work inside the session the human was
|
|
5
|
+
* typing into. That is the wrong shape for this product, for three reasons:
|
|
6
|
+
*
|
|
7
|
+
* - every token the run spends comes out of the human's own session, on the
|
|
8
|
+
* human's own model, however carefully the router was configured
|
|
9
|
+
* - a "session handoff" that replaces the human's session takes their REPL
|
|
10
|
+
* away from them mid-run
|
|
11
|
+
* - one model does every task, so the difficulty tiers the wizard collects
|
|
12
|
+
* have nowhere to be applied
|
|
13
|
+
*
|
|
14
|
+
* A unit of work runs in a *separate pi process* instead: its own session
|
|
15
|
+
* file, its own model, its own context window, started with the brief and
|
|
16
|
+
* nothing else. The main session keeps the widget and the log, and speaks
|
|
17
|
+
* only when the human speaks to it.
|
|
18
|
+
*
|
|
19
|
+
* The child is driven over pi's RPC protocol rather than `--print`, because a
|
|
20
|
+
* unit is not always one turn. With handoff at `feature`, one worker must
|
|
21
|
+
* carry a whole feature across several gate cycles — same session, same
|
|
22
|
+
* model, growing context — and only *then* be replaced. `--print` would end
|
|
23
|
+
* the session after every turn, which is a handoff nobody asked for.
|
|
24
|
+
*
|
|
25
|
+
* This module owns exactly one thing: turning a `WorkerSpec` into a running
|
|
26
|
+
* pi and a stream of events. It has no idea what a phase is.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
30
|
+
import { createRequire } from "node:module";
|
|
31
|
+
import { existsSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs";
|
|
32
|
+
import { join, resolve, dirname } from "node:path";
|
|
33
|
+
|
|
34
|
+
/** Env flag the child sets so the harness extension there does not drive a loop of its own. */
|
|
35
|
+
export const WORKER_ENV = "INFINITY_HARNESS_WORKER";
|
|
36
|
+
/** Env var naming the unit the child was started for, for the child's own logging. */
|
|
37
|
+
export const WORKER_UNIT_ENV = "INFINITY_HARNESS_UNIT";
|
|
38
|
+
export const WORKER_RUN_ENV = "INFINITY_HARNESS_RUN";
|
|
39
|
+
/** Escape hatch: point at a pi CLI explicitly when discovery cannot find one. */
|
|
40
|
+
export const PI_CLI_ENV = "INFINITY_HARNESS_PI_CLI";
|
|
41
|
+
|
|
42
|
+
export const PROMPT_FILE = "prompt.md";
|
|
43
|
+
export const OUTPUT_FILE = "output.log";
|
|
44
|
+
export const EVENTS_FILE = "events.jsonl";
|
|
45
|
+
|
|
46
|
+
/** True when this process *is* a background worker rather than the human's session. */
|
|
47
|
+
export function isWorkerProcess(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
48
|
+
return env[WORKER_ENV] === "1";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── locating pi ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
export type PiCli = {
|
|
54
|
+
/** Executable to spawn. */
|
|
55
|
+
command: string;
|
|
56
|
+
/** Leading arguments before pi's own flags (e.g. the path to cli.js). */
|
|
57
|
+
leading: string[];
|
|
58
|
+
/** Where this came from, for the "could not find pi" message. */
|
|
59
|
+
source: string;
|
|
60
|
+
/**
|
|
61
|
+
* Spawn through a shell.
|
|
62
|
+
*
|
|
63
|
+
* Only ever true for a Windows `.cmd`/`.bat` shim: Node refuses to execute
|
|
64
|
+
* those directly (it has since the 2024 command-injection fix), so the npm
|
|
65
|
+
* `pi.cmd` wrapper cannot be spawned any other way. Every other case runs
|
|
66
|
+
* the executable directly, which is what keeps arguments out of a shell.
|
|
67
|
+
*/
|
|
68
|
+
shell?: boolean;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** A Windows batch shim — spawnable only through a shell. */
|
|
72
|
+
function isBatchShim(command: string): boolean {
|
|
73
|
+
return process.platform === "win32" && /\.(cmd|bat)$/i.test(command);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The npm global-bin layout puts `pi.cmd` next to
|
|
78
|
+
* `node_modules/@earendil-works/pi-coding-agent/dist/cli.js`. Finding the real
|
|
79
|
+
* script means we can run it with this very node instead of going through a
|
|
80
|
+
* shell — faster, and with no quoting to get wrong.
|
|
81
|
+
*/
|
|
82
|
+
function cliBesideShim(shimPath: string): string | null {
|
|
83
|
+
try {
|
|
84
|
+
const cli = join(dirname(shimPath), "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js");
|
|
85
|
+
return existsSync(cli) ? cli : null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find the pi that should run the worker.
|
|
93
|
+
*
|
|
94
|
+
* Order matters, and the first entry is the one that is almost always right:
|
|
95
|
+
* this code is running *inside* pi, so the CLI that started us is by
|
|
96
|
+
* definition a working pi with the user's config, on this machine, at the
|
|
97
|
+
* version they installed. Everything after it is a fallback for tests and for
|
|
98
|
+
* the odd embedding where argv is not what we expect.
|
|
99
|
+
*/
|
|
100
|
+
export function resolvePiCli(env: NodeJS.ProcessEnv = process.env, argv: string[] = process.argv): PiCli {
|
|
101
|
+
const override = (env[PI_CLI_ENV] ?? "").trim();
|
|
102
|
+
if (override) {
|
|
103
|
+
// "node /path/cli.js" and "/path/to/pi" are both reasonable things to set.
|
|
104
|
+
const parts = override.split(/\s+/).filter(Boolean);
|
|
105
|
+
const [command, ...leading] = parts;
|
|
106
|
+
if (command) return { command, leading, source: PI_CLI_ENV };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const entry = argv[1] ?? "";
|
|
110
|
+
if (entry && /(^|[\\/])(cli|pi)(\.[cm]?js)?$/i.test(entry) && existsSync(entry)) {
|
|
111
|
+
// A compiled single-file pi has no separate script: argv[1] === execPath.
|
|
112
|
+
if (resolve(entry) === resolve(argv[0] ?? "")) return { command: entry, leading: [], source: "argv" };
|
|
113
|
+
return { command: process.execPath, leading: [entry], source: "argv" };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const req = createRequire(import.meta.url);
|
|
118
|
+
const pkg = req.resolve("@earendil-works/pi-coding-agent/package.json");
|
|
119
|
+
const cli = join(dirname(pkg), "dist", "cli.js");
|
|
120
|
+
if (existsSync(cli)) return { command: process.execPath, leading: [cli], source: "node_modules" };
|
|
121
|
+
} catch {
|
|
122
|
+
/* not installed beside us — fall through */
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Last resort: whatever `pi` means on PATH. On Windows that is a `.cmd`
|
|
126
|
+
// shim, so look for the script it wraps before resorting to a shell.
|
|
127
|
+
if (process.platform === "win32") {
|
|
128
|
+
const found = whichSync("pi");
|
|
129
|
+
const beside = found ? cliBesideShim(found) : null;
|
|
130
|
+
if (beside) return { command: process.execPath, leading: [beside], source: "PATH (script beside shim)" };
|
|
131
|
+
const command = found ?? "pi.cmd";
|
|
132
|
+
return { command, leading: [], source: "PATH", shell: isBatchShim(command) };
|
|
133
|
+
}
|
|
134
|
+
return { command: "pi", leading: [], source: "PATH" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Where a command lives, or null. Sync on purpose: it runs once per worker. */
|
|
138
|
+
function whichSync(name: string): string | null {
|
|
139
|
+
try {
|
|
140
|
+
const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
|
|
141
|
+
const cmd = process.platform === "win32" ? "where" : "which";
|
|
142
|
+
const out = String(execFileSync(cmd, [name], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }));
|
|
143
|
+
const first = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)[0];
|
|
144
|
+
return first && existsSync(first) ? first : null;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── the event stream ────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* What the supervisor and the widget care about, distilled from pi's RPC
|
|
154
|
+
* stream. The raw stream is far richer; none of the rest belongs on a human's
|
|
155
|
+
* screen while several workers are running.
|
|
156
|
+
*/
|
|
157
|
+
export type WorkerEvent =
|
|
158
|
+
| { kind: "spawn"; argv: string[] }
|
|
159
|
+
| { kind: "model"; provider: string | null; model: string | null }
|
|
160
|
+
| { kind: "tool"; tool: string; summary: string }
|
|
161
|
+
| { kind: "text"; text: string }
|
|
162
|
+
| { kind: "usage"; inputTokens: number; outputTokens: number }
|
|
163
|
+
| { kind: "compaction" }
|
|
164
|
+
| { kind: "settled" }
|
|
165
|
+
| { kind: "exit"; code: number | null }
|
|
166
|
+
| { kind: "error"; message: string };
|
|
167
|
+
|
|
168
|
+
export type WorkerUsage = { inputTokens: number; outputTokens: number };
|
|
169
|
+
|
|
170
|
+
export type TurnResult = {
|
|
171
|
+
/** What the worker said it did, trimmed. */
|
|
172
|
+
summary: string;
|
|
173
|
+
/** Tool calls it made this turn, newest last. */
|
|
174
|
+
tools: string[];
|
|
175
|
+
usage: WorkerUsage;
|
|
176
|
+
/** Fraction of the child's context window in use, or null when pi cannot say. */
|
|
177
|
+
contextRatio: number | null;
|
|
178
|
+
/** True when the worker stopped because it died or was killed, not because it settled. */
|
|
179
|
+
aborted: boolean;
|
|
180
|
+
/** Set when the turn could not run at all. */
|
|
181
|
+
error: string | null;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export type WorkerSpec = {
|
|
185
|
+
projectDir: string;
|
|
186
|
+
/** Where prompt.md, output.log and events.jsonl for this worker go. */
|
|
187
|
+
attemptDir: string;
|
|
188
|
+
/** "provider/id", or empty to let the child use pi's configured default. */
|
|
189
|
+
model?: string | null;
|
|
190
|
+
thinking?: string | null;
|
|
191
|
+
/** Session files go here so a human can `/resume` any worker afterwards. */
|
|
192
|
+
sessionDir?: string | null;
|
|
193
|
+
sessionName?: string | null;
|
|
194
|
+
unitKey?: string | null;
|
|
195
|
+
runId?: string | null;
|
|
196
|
+
/** Cap on one prompt→settled cycle. */
|
|
197
|
+
turnTimeoutMs?: number;
|
|
198
|
+
/** Extra argv for the child. Tests use it; nothing else should need to. */
|
|
199
|
+
extraArgs?: string[];
|
|
200
|
+
/**
|
|
201
|
+
* Load the harness extension explicitly.
|
|
202
|
+
*
|
|
203
|
+
* Normally the child discovers it exactly as the parent did — the harness
|
|
204
|
+
* is an installed pi package — and the worker gets `infinity_task_list` and
|
|
205
|
+
* friends for free. When the parent was started with `-e <path>` (a dev
|
|
206
|
+
* checkout, and every e2e run) discovery finds nothing, and a worker with
|
|
207
|
+
* no plan tools cannot record the work it just did. `harnessExtension` is
|
|
208
|
+
* the path to fall back to; `probeCommands` decides whether it is needed.
|
|
209
|
+
*/
|
|
210
|
+
harnessExtension?: string | null;
|
|
211
|
+
env?: NodeJS.ProcessEnv;
|
|
212
|
+
/** Injected for tests: anything with the shape of node:child_process.spawn. */
|
|
213
|
+
spawnFn?: typeof spawn;
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const DEFAULT_TURN_TIMEOUT_MS = 30 * 60 * 1000;
|
|
217
|
+
const KILL_GRACE_MS = 5000;
|
|
218
|
+
const OUTPUT_TAIL_BYTES = 40000;
|
|
219
|
+
const START_TIMEOUT_MS = 60_000;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A model reference reaches the command line, so anything outside the
|
|
223
|
+
* characters a real reference uses is refused rather than escaped. The value
|
|
224
|
+
* comes from a config file a human edits; a typo must not become an argument
|
|
225
|
+
* injection. Nothing is passed through a shell here, but a leading `-` would
|
|
226
|
+
* still be read as a flag.
|
|
227
|
+
*/
|
|
228
|
+
const MODEL_REF_RE = /^[A-Za-z0-9._:@/-]{1,160}$/;
|
|
229
|
+
|
|
230
|
+
export function safeModelRef(model: string | null | undefined): string | null {
|
|
231
|
+
const v = (model ?? "").trim();
|
|
232
|
+
if (!v || v.startsWith("-")) return null;
|
|
233
|
+
return MODEL_REF_RE.test(v) ? v : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const THINKING_RE = /^(off|minimal|low|medium|high|xhigh|max)$/;
|
|
237
|
+
|
|
238
|
+
export function safeThinking(level: string | null | undefined): string | null {
|
|
239
|
+
const v = (level ?? "").trim();
|
|
240
|
+
return v && THINKING_RE.test(v) ? v : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Compact one-liner for a tool call: `edit src/loop.ts`, `bash npm test`. */
|
|
244
|
+
export function describeToolCall(tool: string, args: unknown): string {
|
|
245
|
+
const a = (args ?? {}) as Record<string, unknown>;
|
|
246
|
+
const pick = (...keys: string[]): string => {
|
|
247
|
+
for (const k of keys) {
|
|
248
|
+
const v = a[k];
|
|
249
|
+
if (typeof v === "string" && v.trim()) return v.trim();
|
|
250
|
+
}
|
|
251
|
+
return "";
|
|
252
|
+
};
|
|
253
|
+
const detail =
|
|
254
|
+
pick("path", "file", "filePath", "file_path") ||
|
|
255
|
+
pick("command", "cmd", "script") ||
|
|
256
|
+
pick("pattern", "query", "regex") ||
|
|
257
|
+
pick("task", "name", "title", "url");
|
|
258
|
+
const oneLine = detail.replace(/\s+/g, " ").trim();
|
|
259
|
+
const clipped = oneLine.length > 68 ? oneLine.slice(0, 67) + "…" : oneLine;
|
|
260
|
+
return clipped ? `${tool} ${clipped}` : tool;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function num(v: unknown): number {
|
|
264
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function textOf(content: unknown): string {
|
|
268
|
+
if (typeof content === "string") return content.trim();
|
|
269
|
+
if (!Array.isArray(content)) return "";
|
|
270
|
+
const parts: string[] = [];
|
|
271
|
+
for (const block of content) {
|
|
272
|
+
const b = block as { type?: string; text?: string };
|
|
273
|
+
if (b && b.type === "text" && typeof b.text === "string") parts.push(b.text);
|
|
274
|
+
}
|
|
275
|
+
return parts.join("").trim();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Turn one RPC line into an event, or null.
|
|
280
|
+
*
|
|
281
|
+
* Unknown event types are dropped rather than surfaced: pi adds them over
|
|
282
|
+
* time, and a harness that shows every one of them is a harness whose log is
|
|
283
|
+
* unreadable on the day pi ships a new event.
|
|
284
|
+
*/
|
|
285
|
+
export function parseEventLine(line: string): WorkerEvent | null {
|
|
286
|
+
const trimmed = line.trim();
|
|
287
|
+
if (!trimmed || trimmed[0] !== "{") return null;
|
|
288
|
+
let ev: Record<string, unknown>;
|
|
289
|
+
try {
|
|
290
|
+
ev = JSON.parse(trimmed) as Record<string, unknown>;
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
switch (typeof ev.type === "string" ? ev.type : "") {
|
|
295
|
+
case "message_start": {
|
|
296
|
+
const msg = ev.message as { role?: string; provider?: string; model?: string } | undefined;
|
|
297
|
+
if (!msg || msg.role !== "assistant" || !msg.model) return null;
|
|
298
|
+
// Proof of which model actually served the turn. A routed reference that
|
|
299
|
+
// silently fell back to pi's default is otherwise invisible.
|
|
300
|
+
return { kind: "model", provider: msg.provider ?? null, model: msg.model };
|
|
301
|
+
}
|
|
302
|
+
case "tool_execution_start":
|
|
303
|
+
return {
|
|
304
|
+
kind: "tool",
|
|
305
|
+
tool: String(ev.toolName ?? "tool"),
|
|
306
|
+
summary: describeToolCall(String(ev.toolName ?? "tool"), ev.args),
|
|
307
|
+
};
|
|
308
|
+
case "message_end": {
|
|
309
|
+
const msg = ev.message as { role?: string; content?: unknown } | undefined;
|
|
310
|
+
if (!msg || msg.role !== "assistant") return null;
|
|
311
|
+
const text = textOf(msg.content);
|
|
312
|
+
return text ? { kind: "text", text } : null;
|
|
313
|
+
}
|
|
314
|
+
case "message_update": {
|
|
315
|
+
const usage = ev.usage as Record<string, unknown> | undefined;
|
|
316
|
+
if (!usage) return null;
|
|
317
|
+
const inputTokens = num(usage.inputTokens ?? usage.input);
|
|
318
|
+
const outputTokens = num(usage.outputTokens ?? usage.output);
|
|
319
|
+
if (!inputTokens && !outputTokens) return null;
|
|
320
|
+
return { kind: "usage", inputTokens, outputTokens };
|
|
321
|
+
}
|
|
322
|
+
case "compaction_end":
|
|
323
|
+
return { kind: "compaction" };
|
|
324
|
+
case "agent_settled":
|
|
325
|
+
return { kind: "settled" };
|
|
326
|
+
case "error":
|
|
327
|
+
return { kind: "error", message: String((ev as { message?: unknown }).message ?? "error") };
|
|
328
|
+
default:
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ── argv ────────────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
export function buildWorkerArgs(spec: WorkerSpec): string[] {
|
|
336
|
+
const args: string[] = ["--mode", "rpc", "--approve"];
|
|
337
|
+
const model = safeModelRef(spec.model);
|
|
338
|
+
if (model) args.push("--model", model);
|
|
339
|
+
const thinking = safeThinking(spec.thinking);
|
|
340
|
+
if (thinking) args.push("--thinking", thinking);
|
|
341
|
+
if (spec.sessionDir) args.push("--session-dir", spec.sessionDir);
|
|
342
|
+
else args.push("--no-session");
|
|
343
|
+
if (spec.sessionName) args.push("--name", spec.sessionName);
|
|
344
|
+
if (spec.harnessExtension) args.push("--no-extensions", "--extension", spec.harnessExtension);
|
|
345
|
+
if (spec.extraArgs?.length) args.push(...spec.extraArgs);
|
|
346
|
+
return args;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* The one line of prompt that is not the brief.
|
|
351
|
+
*
|
|
352
|
+
* Sent with every unit so a worker that has been running for an hour still
|
|
353
|
+
* knows there is nobody to ask.
|
|
354
|
+
*/
|
|
355
|
+
export const WORKER_DIRECTIVE =
|
|
356
|
+
"Work autonomously — there is no human in this session, so never ask a question or wait " +
|
|
357
|
+
"for confirmation. Carry out the brief above until the work it names is done or genuinely " +
|
|
358
|
+
"blocked, then stop and state in one short paragraph what you changed and what remains.";
|
|
359
|
+
|
|
360
|
+
// ── the worker session ──────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* One background pi session, alive across several prompts.
|
|
364
|
+
*
|
|
365
|
+
* Alive matters. The unit of *model choice* is the unit of *session*, so a
|
|
366
|
+
* worker that owns a feature keeps its context across the three or four gate
|
|
367
|
+
* cycles that feature takes, exactly as a human's session would — and is torn
|
|
368
|
+
* down, not compacted into uselessness, when the run moves to the next one.
|
|
369
|
+
*/
|
|
370
|
+
export class WorkerSession {
|
|
371
|
+
readonly spec: WorkerSpec;
|
|
372
|
+
readonly argv: string[];
|
|
373
|
+
private child: ChildProcess | null = null;
|
|
374
|
+
private buffer = "";
|
|
375
|
+
private output = "";
|
|
376
|
+
private seq = 0;
|
|
377
|
+
private pending = new Map<string, (msg: Record<string, unknown>) => void>();
|
|
378
|
+
private listeners = new Set<(e: WorkerEvent) => void>();
|
|
379
|
+
private settledWaiters: Array<() => void> = [];
|
|
380
|
+
private exitWaiters: Array<(code: number | null) => void> = [];
|
|
381
|
+
private eventsPath: string;
|
|
382
|
+
|
|
383
|
+
/** What actually served the last turn, as `provider/id`. */
|
|
384
|
+
servedModel: string | null = null;
|
|
385
|
+
sessionId: string | null = null;
|
|
386
|
+
usage: WorkerUsage = { inputTokens: 0, outputTokens: 0 };
|
|
387
|
+
exited = false;
|
|
388
|
+
exitCode: number | null = null;
|
|
389
|
+
startError: string | null = null;
|
|
390
|
+
/** Tool calls seen since the last `takeTools()`. */
|
|
391
|
+
private tools: string[] = [];
|
|
392
|
+
private lastText = "";
|
|
393
|
+
|
|
394
|
+
constructor(spec: WorkerSpec) {
|
|
395
|
+
this.spec = spec;
|
|
396
|
+
mkdirSync(spec.attemptDir, { recursive: true });
|
|
397
|
+
this.eventsPath = join(spec.attemptDir, EVENTS_FILE);
|
|
398
|
+
const cli = resolvePiCli(spec.env ?? process.env);
|
|
399
|
+
this.argv = [cli.command, ...cli.leading, ...buildWorkerArgs(spec)];
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
on(listener: (e: WorkerEvent) => void): () => void {
|
|
403
|
+
this.listeners.add(listener);
|
|
404
|
+
return () => this.listeners.delete(listener);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private emit(e: WorkerEvent): void {
|
|
408
|
+
try {
|
|
409
|
+
appendFileSync(this.eventsPath, JSON.stringify({ at: new Date().toISOString(), ...e }) + "\n", "utf-8");
|
|
410
|
+
} catch {
|
|
411
|
+
/* the log is a convenience, never a reason to fail a run */
|
|
412
|
+
}
|
|
413
|
+
for (const l of this.listeners) {
|
|
414
|
+
try {
|
|
415
|
+
l(e);
|
|
416
|
+
} catch {
|
|
417
|
+
/* a broken listener must not kill the worker */
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
start(): void {
|
|
423
|
+
const cli = resolvePiCli(this.spec.env ?? process.env);
|
|
424
|
+
const args = [...cli.leading, ...buildWorkerArgs(this.spec)];
|
|
425
|
+
const env: NodeJS.ProcessEnv = {
|
|
426
|
+
...(this.spec.env ?? process.env),
|
|
427
|
+
[WORKER_ENV]: "1",
|
|
428
|
+
...(this.spec.unitKey ? { [WORKER_UNIT_ENV]: this.spec.unitKey } : {}),
|
|
429
|
+
...(this.spec.runId ? { [WORKER_RUN_ENV]: this.spec.runId } : {}),
|
|
430
|
+
// A worker's terminal is a pipe. Colour codes in the log help nobody.
|
|
431
|
+
NO_COLOR: "1",
|
|
432
|
+
FORCE_COLOR: "0",
|
|
433
|
+
};
|
|
434
|
+
const spawnImpl = this.spec.spawnFn ?? spawn;
|
|
435
|
+
try {
|
|
436
|
+
this.child = spawnImpl(cli.command, args, {
|
|
437
|
+
cwd: this.spec.projectDir,
|
|
438
|
+
env,
|
|
439
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
440
|
+
windowsHide: true,
|
|
441
|
+
...(cli.shell ? { shell: true } : {}),
|
|
442
|
+
});
|
|
443
|
+
} catch (e) {
|
|
444
|
+
this.startError = `could not start pi (${cli.source}) — ${e instanceof Error ? e.message : String(e)}`;
|
|
445
|
+
this.exited = true;
|
|
446
|
+
this.emit({ kind: "error", message: this.startError });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
this.emit({ kind: "spawn", argv: this.argv });
|
|
450
|
+
|
|
451
|
+
// A worker that exits while we are writing to it turns the next write
|
|
452
|
+
// into an asynchronous EPIPE on the socket. With no listener, Node throws
|
|
453
|
+
// it as an unhandled 'error' — inside the human's pi process, which is how
|
|
454
|
+
// a dead child would take down the terminal it was supposed to serve.
|
|
455
|
+
this.child.stdin?.on("error", (e: Error) => {
|
|
456
|
+
this.emit({ kind: "error", message: `worker stdin: ${e.message}` });
|
|
457
|
+
});
|
|
458
|
+
this.child.stdout?.on("error", () => {});
|
|
459
|
+
this.child.stderr?.on("error", () => {});
|
|
460
|
+
this.child.stdout?.setEncoding("utf-8");
|
|
461
|
+
this.child.stderr?.setEncoding("utf-8");
|
|
462
|
+
this.child.stdout?.on("data", (c: string) => this.ingest(c));
|
|
463
|
+
this.child.stderr?.on("data", (c: string) => {
|
|
464
|
+
this.output = (this.output + c).slice(-OUTPUT_TAIL_BYTES);
|
|
465
|
+
});
|
|
466
|
+
this.child.on("error", (e: Error) => {
|
|
467
|
+
this.startError = e.message;
|
|
468
|
+
this.emit({ kind: "error", message: e.message });
|
|
469
|
+
this.finish(-1);
|
|
470
|
+
});
|
|
471
|
+
this.child.on("close", (code: number | null) => this.finish(code));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private finish(code: number | null): void {
|
|
475
|
+
if (this.exited) return;
|
|
476
|
+
this.exited = true;
|
|
477
|
+
this.exitCode = code;
|
|
478
|
+
try {
|
|
479
|
+
writeFileSync(join(this.spec.attemptDir, OUTPUT_FILE), this.output, "utf-8");
|
|
480
|
+
} catch {
|
|
481
|
+
/* best effort */
|
|
482
|
+
}
|
|
483
|
+
this.emit({ kind: "exit", code });
|
|
484
|
+
// Anything waiting on this child must be released, or the supervisor
|
|
485
|
+
// parks forever on a process that is already gone. This is the failure
|
|
486
|
+
// mode that turns "the run stopped" into "the run hung".
|
|
487
|
+
for (const w of this.settledWaiters.splice(0)) w();
|
|
488
|
+
for (const w of this.exitWaiters.splice(0)) w(code);
|
|
489
|
+
for (const [, resolveP] of this.pending) resolveP({ success: false, error: "worker exited" });
|
|
490
|
+
this.pending.clear();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
private ingest(chunk: string): void {
|
|
494
|
+
this.output = (this.output + chunk).slice(-OUTPUT_TAIL_BYTES);
|
|
495
|
+
this.buffer += chunk;
|
|
496
|
+
// RPC is strict JSONL on LF. Node's readline also splits U+2028/U+2029,
|
|
497
|
+
// which are legal inside a JSON string, so it cannot be used here.
|
|
498
|
+
let nl: number;
|
|
499
|
+
while ((nl = this.buffer.indexOf("\n")) !== -1) {
|
|
500
|
+
const line = this.buffer.slice(0, nl).replace(/\r$/, "");
|
|
501
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
502
|
+
if (!line.trim()) continue;
|
|
503
|
+
let msg: Record<string, unknown>;
|
|
504
|
+
try {
|
|
505
|
+
msg = JSON.parse(line) as Record<string, unknown>;
|
|
506
|
+
} catch {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (msg.type === "response" && typeof msg.id === "string") {
|
|
510
|
+
const waiter = this.pending.get(msg.id);
|
|
511
|
+
if (waiter) {
|
|
512
|
+
this.pending.delete(msg.id);
|
|
513
|
+
waiter(msg);
|
|
514
|
+
}
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const ev = parseEventLine(line);
|
|
518
|
+
if (!ev) continue;
|
|
519
|
+
if (ev.kind === "model") this.servedModel = ev.provider ? `${ev.provider}/${ev.model}` : ev.model;
|
|
520
|
+
else if (ev.kind === "tool") this.tools.push(ev.summary);
|
|
521
|
+
else if (ev.kind === "text") this.lastText = ev.text;
|
|
522
|
+
else if (ev.kind === "usage") {
|
|
523
|
+
// pi reports cumulative usage; the last word wins rather than the sum.
|
|
524
|
+
this.usage.inputTokens = Math.max(this.usage.inputTokens, ev.inputTokens);
|
|
525
|
+
this.usage.outputTokens = Math.max(this.usage.outputTokens, ev.outputTokens);
|
|
526
|
+
}
|
|
527
|
+
this.emit(ev);
|
|
528
|
+
if (ev.kind === "settled") for (const w of this.settledWaiters.splice(0)) w();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private send(command: Record<string, unknown>, timeoutMs = 20_000): Promise<Record<string, unknown>> {
|
|
533
|
+
if (!this.child?.stdin || this.exited) return Promise.resolve({ success: false, error: "worker not running" });
|
|
534
|
+
const id = `ih-${++this.seq}`;
|
|
535
|
+
return new Promise((resolveP) => {
|
|
536
|
+
const timer = setTimeout(() => {
|
|
537
|
+
this.pending.delete(id);
|
|
538
|
+
resolveP({ success: false, error: "timeout" });
|
|
539
|
+
}, timeoutMs);
|
|
540
|
+
timer.unref?.();
|
|
541
|
+
this.pending.set(id, (msg) => {
|
|
542
|
+
clearTimeout(timer);
|
|
543
|
+
resolveP(msg);
|
|
544
|
+
});
|
|
545
|
+
try {
|
|
546
|
+
this.child?.stdin?.write(JSON.stringify({ id, ...command }) + "\n");
|
|
547
|
+
} catch (e) {
|
|
548
|
+
clearTimeout(timer);
|
|
549
|
+
this.pending.delete(id);
|
|
550
|
+
resolveP({ success: false, error: e instanceof Error ? e.message : String(e) });
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Does this child have the harness's own commands?
|
|
557
|
+
*
|
|
558
|
+
* A worker without them can edit code but cannot tell the harness what it
|
|
559
|
+
* did, so the gate never sees the work and the run loops forever on a task
|
|
560
|
+
* that is actually finished. Cheap to ask, and the answer decides whether
|
|
561
|
+
* the session has to be restarted with an explicit `-e`.
|
|
562
|
+
*/
|
|
563
|
+
async hasHarnessTools(): Promise<boolean> {
|
|
564
|
+
const res = await this.send({ type: "get_commands" });
|
|
565
|
+
if (res.success !== true) return true; // cannot tell — assume the normal case
|
|
566
|
+
const data = res.data as { commands?: Array<{ name?: string }> } | undefined;
|
|
567
|
+
const names = (data?.commands ?? []).map((c) => String(c.name ?? ""));
|
|
568
|
+
return names.some((n) => n.startsWith("infinity:"));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Wait until the child has answered anything at all, so a dead pi fails fast. */
|
|
572
|
+
async ready(timeoutMs = START_TIMEOUT_MS): Promise<boolean> {
|
|
573
|
+
if (this.startError) return false;
|
|
574
|
+
const res = await this.send({ type: "get_session_stats" }, timeoutMs);
|
|
575
|
+
if (res.success === true) {
|
|
576
|
+
const data = res.data as { sessionId?: string } | undefined;
|
|
577
|
+
if (data?.sessionId) this.sessionId = data.sessionId;
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Context pressure inside the worker, 0..1, or null when pi cannot say. */
|
|
584
|
+
async contextRatio(): Promise<number | null> {
|
|
585
|
+
const res = await this.send({ type: "get_session_stats" });
|
|
586
|
+
if (res.success !== true) return null;
|
|
587
|
+
const data = res.data as { contextUsage?: { percent?: number | null }; sessionId?: string } | undefined;
|
|
588
|
+
if (data?.sessionId) this.sessionId = data.sessionId;
|
|
589
|
+
const pct = data?.contextUsage?.percent;
|
|
590
|
+
if (typeof pct !== "number") return null;
|
|
591
|
+
return pct > 1 ? pct / 100 : pct;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Send one prompt and wait for the session to settle. Never throws. */
|
|
595
|
+
async prompt(text: string, timeoutMs = this.spec.turnTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS): Promise<TurnResult> {
|
|
596
|
+
this.tools = [];
|
|
597
|
+
this.lastText = "";
|
|
598
|
+
if (this.exited) {
|
|
599
|
+
return { summary: "", tools: [], usage: { ...this.usage }, contextRatio: null, aborted: true, error: this.startError ?? "worker exited" };
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
writeFileSync(join(this.spec.attemptDir, PROMPT_FILE), text, "utf-8");
|
|
603
|
+
} catch {
|
|
604
|
+
/* the transcript is a convenience */
|
|
605
|
+
}
|
|
606
|
+
let releaseSettled: () => void = () => {};
|
|
607
|
+
const settled = new Promise<void>((resolveP) => {
|
|
608
|
+
releaseSettled = resolveP;
|
|
609
|
+
this.settledWaiters.push(resolveP);
|
|
610
|
+
});
|
|
611
|
+
const res = await this.send({ type: "prompt", message: text });
|
|
612
|
+
if (res.success !== true) {
|
|
613
|
+
// Drop the waiter we just queued, or it leaks for the life of the run.
|
|
614
|
+
const i = this.settledWaiters.indexOf(releaseSettled);
|
|
615
|
+
if (i >= 0) this.settledWaiters.splice(i, 1);
|
|
616
|
+
return {
|
|
617
|
+
summary: "",
|
|
618
|
+
tools: [],
|
|
619
|
+
usage: { ...this.usage },
|
|
620
|
+
contextRatio: null,
|
|
621
|
+
aborted: this.exited,
|
|
622
|
+
error: String(res.error ?? "prompt rejected"),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
let timedOut = false;
|
|
626
|
+
await Promise.race([
|
|
627
|
+
settled,
|
|
628
|
+
new Promise<void>((resolveP) => {
|
|
629
|
+
const t = setTimeout(() => {
|
|
630
|
+
timedOut = true;
|
|
631
|
+
resolveP();
|
|
632
|
+
}, timeoutMs);
|
|
633
|
+
t.unref?.();
|
|
634
|
+
}),
|
|
635
|
+
]);
|
|
636
|
+
if (timedOut) {
|
|
637
|
+
// A turn that will not settle is a wedged worker. Abort the turn, and
|
|
638
|
+
// let the supervisor decide whether to keep the session.
|
|
639
|
+
await this.send({ type: "abort" }, 5000);
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
summary: this.lastText,
|
|
643
|
+
tools: [...this.tools],
|
|
644
|
+
usage: { ...this.usage },
|
|
645
|
+
contextRatio: await this.contextRatio(),
|
|
646
|
+
aborted: timedOut || this.exited,
|
|
647
|
+
error: timedOut ? "turn timed out" : null,
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Stop the child. Resolves once it is actually gone. */
|
|
652
|
+
async close(): Promise<void> {
|
|
653
|
+
if (this.exited || !this.child) {
|
|
654
|
+
this.exited = true;
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
const gone = new Promise<void>((resolveP) => this.exitWaiters.push(() => resolveP()));
|
|
658
|
+
try {
|
|
659
|
+
this.child.stdin?.end();
|
|
660
|
+
} catch {
|
|
661
|
+
/* already closed */
|
|
662
|
+
}
|
|
663
|
+
try {
|
|
664
|
+
this.child.kill("SIGTERM");
|
|
665
|
+
} catch {
|
|
666
|
+
/* already gone */
|
|
667
|
+
}
|
|
668
|
+
const killer = setTimeout(() => {
|
|
669
|
+
try {
|
|
670
|
+
this.child?.kill("SIGKILL");
|
|
671
|
+
} catch {
|
|
672
|
+
/* already gone */
|
|
673
|
+
}
|
|
674
|
+
}, KILL_GRACE_MS);
|
|
675
|
+
killer.unref?.();
|
|
676
|
+
await Promise.race([
|
|
677
|
+
gone,
|
|
678
|
+
new Promise<void>((resolveP) => {
|
|
679
|
+
const t = setTimeout(resolveP, KILL_GRACE_MS * 2);
|
|
680
|
+
t.unref?.();
|
|
681
|
+
}),
|
|
682
|
+
]);
|
|
683
|
+
clearTimeout(killer);
|
|
684
|
+
this.exited = true;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
get pid(): number | undefined {
|
|
688
|
+
return this.child?.pid;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
get tail(): string {
|
|
692
|
+
return this.output;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Start a worker and wait for it to answer, or return null with the reason on the session. */
|
|
697
|
+
export async function startWorkerSession(spec: WorkerSpec): Promise<WorkerSession> {
|
|
698
|
+
const session = new WorkerSession(spec);
|
|
699
|
+
session.start();
|
|
700
|
+
await session.ready();
|
|
701
|
+
return session;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Where a worker's session files live, so `/resume` can reach them. */
|
|
705
|
+
export function workerSessionDir(projectDir: string): string {
|
|
706
|
+
return resolve(projectDir, "tmp", "infinity-harness", "sessions");
|
|
707
|
+
}
|