feat: add assistant proposal worker runtime

This commit is contained in:
zenord
2026-08-18 18:48:47 +08:00
parent 947f19e3cd
commit b4121c9fd6
32 changed files with 2827 additions and 986 deletions
+140 -30
View File
@@ -1,11 +1,26 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { AcpConfig } from "../config.js";
import crypto from "node:crypto";
import fs from "node:fs";
import type { AcpConfig, PermissionPolicy } from "../config.js";
import type { WorkerProcessGroup } from "../core/proposal-store.js";
import type { ResolvedBot } from "../roles/role-registry.js";
import { AcpClient } from "./client.js";
import { AcpClient, type SafeActivityCategory } from "./client.js";
export class AcpSessionRestoreError extends Error {}
export type AcpWorkerPhase = "idle" | "initializing" | "processing";
export type AcpWorkerKind = "assistant" | "worker";
export interface AcpWorkerOptions {
kind?: AcpWorkerKind;
cwd?: string;
env?: Record<string, string>;
policy?: PermissionPolicy;
onActivity?: (category?: SafeActivityCategory) => void;
onIsolationViolation?: (worker: AcpWorker) => void;
onSpawn?: (group: WorkerProcessGroup) => Promise<void>;
allowUnverifiedAssistantAgent?: boolean;
}
export class AcpWorker {
private child?: ChildProcessWithoutNullStreams;
@@ -13,8 +28,13 @@ export class AcpWorker {
private abort?: AbortController;
private exited = false;
private stopping = false;
private termination?: Promise<void>;
private stderrBytes = 0;
readonly kind: AcpWorkerKind;
readonly cwd: string;
nativeSessionId?: string;
processGroup?: WorkerProcessGroup;
isolationViolated = false;
lastUsedAt = Date.now();
turnStartedAt?: number;
lastActivityAt?: number;
@@ -24,14 +44,29 @@ export class AcpWorker {
constructor(
readonly bot: ResolvedBot,
private readonly config: AcpConfig,
private readonly onCrash: (worker: AcpWorker, error: Error) => void
) {}
private readonly onCrash: (worker: AcpWorker, error: Error) => void,
private readonly options: AcpWorkerOptions = {}
) {
this.kind = options.kind || "worker";
this.cwd = options.cwd || bot.workspace;
}
static async terminatePersistedGroup(group: WorkerProcessGroup, timeoutMs: number): Promise<void> {
if (!processGroupExists(group.pgid)) return;
if (!processGroupHasToken(group.pgid, group.token)) {
throw new Error(`Persisted ACP process group ${group.pgid} no longer matches its worker token`);
}
signalProcessGroup(group.pgid, "SIGKILL");
await waitForProcessGroupExit(group.pgid, timeoutMs);
}
async start(nativeSessionId?: string): Promise<string> {
const processToken = crypto.randomUUID();
this.child = spawn(this.bot.agent.command, this.bot.agent.args, {
cwd: this.bot.workspace,
env: { ...process.env, ...this.bot.agent.env },
cwd: this.cwd,
env: { ...process.env, ...this.bot.agent.env, ...this.options.env, GORI_AGENT_WORKER_TOKEN: processToken },
shell: false,
detached: true,
stdio: ["pipe", "pipe", "pipe"]
});
this.child.stderr.on("data", (chunk: Buffer) => this.logStderr(chunk));
@@ -42,16 +77,31 @@ export class AcpWorker {
});
this.client = new AcpClient(this.child, {
initializeTimeoutMs: this.config.initializeTimeoutMs,
policy: this.bot.permissions,
onSessionActivity: () => { this.lastActivityAt = Date.now(); }
policy: this.options.policy || this.bot.permissions,
forbidToolActivity: this.kind === "assistant",
onToolViolation: () => {
this.isolationViolated = true;
this.options.onIsolationViolation?.(this);
this.terminateImmediately();
},
onSessionActivity: (category) => {
this.lastActivityAt = Date.now();
this.options.onActivity?.(category);
}
});
try {
await this.client.initialize();
if (!this.child.pid) throw new Error("ACP worker has no process ID");
this.processGroup = { pgid: this.child.pid, token: processToken };
await this.options.onSpawn?.(this.processGroup);
const initialized = await this.client.initialize();
if (this.kind === "assistant" && !this.options.allowUnverifiedAssistantAgent && initialized.agentInfo?.name !== "Kimi Code CLI") {
throw new Error("Assistant sessions require Kimi Code ACP with execution-layer no-tool profiles");
}
if (nativeSessionId) {
await this.client.resumeSession(nativeSessionId, this.bot.workspace);
await this.client.resumeSession(nativeSessionId, this.cwd);
this.nativeSessionId = nativeSessionId;
} else {
this.nativeSessionId = await this.client.newSession(this.bot.workspace);
this.nativeSessionId = await this.client.newSession(this.cwd);
}
return this.nativeSessionId;
} catch (error) {
@@ -81,7 +131,9 @@ export class AcpWorker {
}, this.config.promptTimeoutMs);
});
try {
return await Promise.race([this.client.prompt(text, this.abort.signal), timeoutPromise]);
const result = await Promise.race([this.client.prompt(text, this.abort.signal), timeoutPromise]);
if (this.kind === "assistant") await this.client.settleIsolation();
return result;
} finally {
if (timeout) clearTimeout(timeout);
this.inFlight = false;
@@ -101,24 +153,31 @@ export class AcpWorker {
return true;
}
async terminate(): Promise<void> {
if (this.stopping) return;
terminateImmediately(): void {
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.abort?.abort();
this.client?.close(new Error("ACP worker terminated because workspace ownership was lost"));
if (this.child) killProcessGroup(this.child, "SIGKILL");
this.termination ||= this.finishTermination("SIGKILL");
}
terminate(): Promise<void> {
if (this.termination) return this.termination;
this.stopping = true;
this.abort?.abort();
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);
}
}
this.termination = this.finishTermination("SIGTERM");
return this.termination;
}
private async finishTermination(initialSignal: NodeJS.Signals): Promise<void> {
if (!this.child?.pid) return;
const pid = this.child.pid;
killProcessGroup(this.child, initialSignal);
await waitForExit(this.child, this.config.cancelGraceMs);
killProcessGroup(this.child, "SIGKILL");
await waitForExit(this.child, this.config.cancelGraceMs);
await waitForProcessGroupExit(pid, this.config.cancelGraceMs);
}
private logStderr(chunk: Buffer): void {
@@ -126,16 +185,49 @@ export class AcpWorker {
if (!remaining) return;
const text = chunk.subarray(0, remaining).toString("utf8").trimEnd();
this.stderrBytes += Buffer.byteLength(text);
if (text) console.error(`[acp:${this.bot.agent.id}] ${text}`);
if (text) console.error(`[acp:${this.kind}:${this.bot.agent.id}] ${text}`);
}
private crashed(error: Error): void {
if (this.stopping) return;
this.stopping = true;
this.onCrash(this, error);
this.abort?.abort();
this.client?.close(error);
this.termination = this.finishTermination("SIGKILL");
void this.termination.then(
() => this.onCrash(this, error),
(cleanupError) => this.onCrash(this, new Error(`${error.message}; process-group cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`))
);
}
}
function killProcessGroup(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
if (child.pid) signalProcessGroup(child.pid, signal);
}
function signalProcessGroup(pgid: number, signal: NodeJS.Signals): void {
try { process.kill(-pgid, signal); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
}
function processGroupHasToken(pgid: number, token: string): boolean {
for (const entry of fs.readdirSync("/proc")) {
if (!/^\d+$/.test(entry)) continue;
try {
const stat = fs.readFileSync(`/proc/${entry}/stat`, "utf8");
const close = stat.lastIndexOf(")");
const fields = stat.slice(close + 2).split(" ");
if (Number(fields[2]) !== pgid) continue;
const environ = fs.readFileSync(`/proc/${entry}/environ`);
if (environ.toString("utf8").split("\0").includes(`GORI_AGENT_WORKER_TOKEN=${token}`)) return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT" && code !== "EACCES" && code !== "EPERM") throw error;
}
}
return false;
}
function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
return new Promise((resolve) => {
@@ -143,3 +235,21 @@ function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number):
child.once("close", () => { clearTimeout(timer); resolve(); });
});
}
async function waitForProcessGroupExit(pid: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (processGroupExists(pid) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
if (processGroupExists(pid)) throw new Error(`ACP process group ${pid} did not terminate`);
}
function processGroupExists(pid: number): boolean {
try { process.kill(-pid, 0); return true; }
catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ESRCH") return false;
if (code === "EPERM") return true;
throw error;
}
}