Add ACP-backed role sessions
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { AcpConfig } from "../config.js";
|
||||
import type { ResolvedRole } from "../roles/role-registry.js";
|
||||
import { AcpClient } from "./client.js";
|
||||
import type { AcpBackendSpec } from "./types.js";
|
||||
|
||||
export class AcpWorker {
|
||||
private child?: ChildProcessWithoutNullStreams;
|
||||
private client?: AcpClient;
|
||||
private abort?: AbortController;
|
||||
private exited = false;
|
||||
private stopping = false;
|
||||
private stderrBytes = 0;
|
||||
nativeSessionId?: string;
|
||||
lastUsedAt = Date.now();
|
||||
inFlight = false;
|
||||
|
||||
constructor(
|
||||
readonly backend: AcpBackendSpec,
|
||||
readonly role: ResolvedRole,
|
||||
private readonly config: AcpConfig,
|
||||
private readonly onCrash: (worker: AcpWorker, error: Error) => void
|
||||
) {}
|
||||
|
||||
async start(nativeSessionId?: string): Promise<string> {
|
||||
this.child = spawn(this.backend.command, this.backend.args, {
|
||||
cwd: this.role.workspace,
|
||||
env: { ...process.env, ...this.backend.env },
|
||||
shell: false,
|
||||
stdio: ["pipe", "pipe", "pipe"]
|
||||
});
|
||||
this.child.stderr.on("data", (chunk: Buffer) => this.logStderr(chunk));
|
||||
this.child.once("error", (error) => this.crashed(error));
|
||||
this.child.once("close", (code, signal) => {
|
||||
this.exited = true;
|
||||
if (!this.stopping) this.crashed(new Error(`ACP worker exited (code=${code ?? "null"}, signal=${signal ?? "null"})`));
|
||||
});
|
||||
this.client = new AcpClient(this.child, { initializeTimeoutMs: this.config.initializeTimeoutMs, policy: this.role.policy });
|
||||
try {
|
||||
await this.client.initialize();
|
||||
if (nativeSessionId) {
|
||||
await this.client.resumeSession(nativeSessionId, this.role.workspace);
|
||||
this.nativeSessionId = nativeSessionId;
|
||||
} else {
|
||||
this.nativeSessionId = await this.client.newSession(this.role.workspace);
|
||||
}
|
||||
return this.nativeSessionId;
|
||||
} catch (error) {
|
||||
await this.terminate();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(text: string): Promise<string> {
|
||||
if (!this.client || !this.nativeSessionId || this.exited) throw new Error("ACP worker is not available");
|
||||
if (this.inFlight) throw new Error("ACP worker already has an in-flight turn");
|
||||
this.inFlight = true;
|
||||
this.lastUsedAt = Date.now();
|
||||
this.abort = new AbortController();
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
void this.client?.cancel();
|
||||
this.abort?.abort();
|
||||
reject(new Error(`ACP prompt timed out after ${this.config.promptTimeoutMs}ms`));
|
||||
setTimeout(() => { if (!this.exited) void this.terminate(); }, this.config.cancelGraceMs).unref();
|
||||
}, this.config.promptTimeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([this.client.prompt(text, this.abort.signal), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
this.inFlight = false;
|
||||
this.abort = undefined;
|
||||
this.lastUsedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(): Promise<boolean> {
|
||||
if (!this.inFlight || !this.client) return false;
|
||||
await this.client.cancel();
|
||||
this.abort?.abort();
|
||||
setTimeout(() => { if (this.inFlight) void this.terminate(); }, this.config.cancelGraceMs).unref();
|
||||
return true;
|
||||
}
|
||||
|
||||
async terminate(): Promise<void> {
|
||||
if (this.stopping) return;
|
||||
this.stopping = true;
|
||||
if (this.client && !this.exited) {
|
||||
await Promise.race([
|
||||
this.client.closeSession().catch(() => undefined),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, this.config.cancelGraceMs))
|
||||
]);
|
||||
}
|
||||
this.client?.close();
|
||||
if (this.child && !this.exited) {
|
||||
this.child.kill("SIGTERM");
|
||||
await waitForExit(this.child, this.config.cancelGraceMs);
|
||||
if (!this.exited) {
|
||||
this.child.kill("SIGKILL");
|
||||
await waitForExit(this.child, this.config.cancelGraceMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private logStderr(chunk: Buffer): void {
|
||||
const remaining = Math.max(0, 16_384 - this.stderrBytes);
|
||||
if (!remaining) return;
|
||||
const text = chunk.subarray(0, remaining).toString("utf8").trimEnd();
|
||||
this.stderrBytes += Buffer.byteLength(text);
|
||||
if (text) console.error(`[acp:${this.backend.id}] ${text}`);
|
||||
}
|
||||
|
||||
private crashed(error: Error): void {
|
||||
if (this.stopping) return;
|
||||
this.stopping = true;
|
||||
this.onCrash(this, error);
|
||||
}
|
||||
}
|
||||
|
||||
function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, timeoutMs);
|
||||
child.once("close", () => { clearTimeout(timer); resolve(); });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user