feat: add process runner for subagents
This commit is contained in:
113
src/process-runner.test.ts
Normal file
113
src/process-runner.test.ts
Normal 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
84
src/process-runner.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user