Add ACP-backed role sessions

This commit is contained in:
zenord
2026-08-16 10:08:03 +08:00
parent d59c4a654f
commit a80fe68d57
34 changed files with 1683 additions and 887 deletions
+128
View File
@@ -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(); });
});
}