feat: add process runner for subagents

This commit is contained in:
pi
2026-04-10 23:56:18 +01:00
parent b4ed6886c4
commit e864c3fe52
3 changed files with 199 additions and 0 deletions

113
src/process-runner.test.ts Normal file
View File

@@ -0,0 +1,113 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createRunArtifacts } from "./artifacts.ts";
import { monitorRun } from "./monitor.ts";
import { createProcessSingleRunner } from "./process-runner.ts";
class FakeChild extends EventEmitter {}
test("createProcessSingleRunner launches wrapper without tmux and returns monitored result", async () => {
const cwd = await mkdtemp(join(tmpdir(), "pi-subagents-process-"));
let metaPathSeen = "";
const runSingleTask = createProcessSingleRunner({
createArtifacts: createRunArtifacts,
buildWrapperSpawn(metaPath: string) {
metaPathSeen = metaPath;
return { command: process.execPath, args: ["-e", "process.exit(0)"] };
},
spawnChild() {
const child = new FakeChild() as any;
process.nextTick(async () => {
const meta = JSON.parse(await readFile(metaPathSeen, "utf8"));
await writeFile(
meta.resultPath,
JSON.stringify(
{
runId: meta.runId,
mode: meta.mode,
agent: meta.agent,
agentSource: meta.agentSource,
task: meta.task,
requestedModel: meta.requestedModel,
resolvedModel: meta.resolvedModel,
sessionPath: meta.sessionPath,
exitCode: 0,
finalText: "done",
stdoutPath: meta.stdoutPath,
stderrPath: meta.stderrPath,
transcriptPath: meta.transcriptPath,
resultPath: meta.resultPath,
eventsPath: meta.eventsPath,
},
null,
2,
),
"utf8",
);
child.emit("close", 0);
});
return child;
},
monitorRun: (input) => monitorRun({ ...input, pollMs: 1 }),
});
const result = await runSingleTask({
cwd,
meta: {
mode: "single",
agent: "scout",
agentSource: "builtin",
task: "inspect auth",
requestedModel: "openai/gpt-5",
resolvedModel: "openai/gpt-5",
},
});
assert.equal(result.finalText, "done");
assert.equal(result.exitCode, 0);
assert.match(result.resultPath ?? "", /\.pi\/subagents\/runs\//);
});
test("createProcessSingleRunner writes error result.json when wrapper launch fails", async () => {
const cwd = await mkdtemp(join(tmpdir(), "pi-subagents-process-"));
const runSingleTask = createProcessSingleRunner({
createArtifacts: createRunArtifacts,
buildWrapperSpawn() {
return { command: process.execPath, args: ["-e", "process.exit(0)"] };
},
spawnChild() {
const child = new FakeChild() as any;
process.nextTick(() => {
child.emit("error", new Error("spawn boom"));
});
return child;
},
monitorRun: (input) => monitorRun({ ...input, pollMs: 1 }),
});
const result = await runSingleTask({
cwd,
meta: {
mode: "single",
agent: "scout",
agentSource: "builtin",
task: "inspect auth",
requestedModel: "openai/gpt-5",
resolvedModel: "openai/gpt-5",
},
});
assert.equal(result.exitCode, 1);
assert.equal(result.stopReason, "error");
assert.match(result.errorMessage ?? "", /spawn boom/);
const saved = JSON.parse(await readFile(result.resultPath!, "utf8"));
assert.equal(saved.exitCode, 1);
assert.match(saved.errorMessage ?? "", /spawn boom/);
});

84
src/process-runner.ts Normal file
View File

@@ -0,0 +1,84 @@
import { spawn } from "node:child_process";
import { writeFile } from "node:fs/promises";
import type { RunSingleTask } from "./runner.ts";
function makeLaunchFailureResult(artifacts: any, meta: Record<string, unknown>, cwd: string, error: unknown) {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
const startedAt = new Date().toISOString();
return {
runId: artifacts.runId,
mode: meta.mode,
taskIndex: meta.taskIndex,
step: meta.step,
agent: meta.agent,
agentSource: meta.agentSource,
task: meta.task,
cwd,
requestedModel: meta.requestedModel,
resolvedModel: meta.resolvedModel,
sessionPath: artifacts.sessionPath,
startedAt,
finishedAt: new Date().toISOString(),
exitCode: 1,
stopReason: "error",
finalText: "",
stdoutPath: artifacts.stdoutPath,
stderrPath: artifacts.stderrPath,
transcriptPath: artifacts.transcriptPath,
resultPath: artifacts.resultPath,
eventsPath: artifacts.eventsPath,
errorMessage: message,
};
}
export function createProcessSingleRunner(deps: {
createArtifacts: (cwd: string, meta: Record<string, unknown>) => Promise<any>;
buildWrapperSpawn: (metaPath: string) => { command: string; args: string[]; env?: NodeJS.ProcessEnv };
spawnChild?: typeof spawn;
monitorRun: (input: { eventsPath: string; resultPath: string; onEvent?: (event: any) => void }) => Promise<any>;
}): RunSingleTask {
const spawnChild = deps.spawnChild ?? spawn;
return async function runSingleTask(input) {
const artifacts = await deps.createArtifacts(input.cwd, input.meta);
const spawnSpec = deps.buildWrapperSpawn(artifacts.metaPath);
const writeLaunchFailure = async (error: unknown) => {
const result = makeLaunchFailureResult(artifacts, input.meta, input.cwd, error);
await writeFile(artifacts.resultPath, JSON.stringify(result, null, 2), "utf8");
return result;
};
try {
const child = spawnChild(spawnSpec.command, spawnSpec.args, {
cwd: input.cwd,
env: { ...process.env, ...(spawnSpec.env ?? {}) },
stdio: ["ignore", "ignore", "ignore"],
});
child.once("error", (error) => {
void writeLaunchFailure(error);
});
} catch (error) {
return writeLaunchFailure(error);
}
const result = await deps.monitorRun({
eventsPath: artifacts.eventsPath,
resultPath: artifacts.resultPath,
onEvent: input.onEvent,
});
return {
...result,
runId: result.runId ?? artifacts.runId,
sessionPath: result.sessionPath ?? artifacts.sessionPath,
stdoutPath: result.stdoutPath ?? artifacts.stdoutPath,
stderrPath: result.stderrPath ?? artifacts.stderrPath,
transcriptPath: result.transcriptPath ?? artifacts.transcriptPath,
resultPath: artifacts.resultPath,
eventsPath: artifacts.eventsPath,
};
};
}

View File

@@ -72,8 +72,10 @@ export interface SubagentRunResult {
finalText: string; finalText: string;
stdoutPath?: string; stdoutPath?: string;
stderrPath?: string; stderrPath?: string;
transcriptPath?: string;
resultPath?: string; resultPath?: string;
eventsPath?: string; eventsPath?: string;
errorMessage?: string;
} }
export interface SubagentToolDetails { export interface SubagentToolDetails {