81 lines
3.5 KiB
TypeScript
81 lines
3.5 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import test from "node:test";
|
|
import { AcpWorker } from "../src/acp/worker.js";
|
|
import { parseConfig } from "../src/config.js";
|
|
import { BotProfileResolver } from "../src/roles/role-registry.js";
|
|
|
|
const fixture = path.resolve("test/fixtures/fake-acp-agent.mjs");
|
|
|
|
function makeWorker(promptTimeoutMs = 80, env: Record<string, string> = {}) {
|
|
const config = parseConfig({
|
|
configVersion: 3,
|
|
bot: { id: "test-bot", workspace: path.resolve("."), persona: "", agent: { id: "fake", command: process.execPath, args: [fixture], env }, permissions: { mode: "deny" } },
|
|
gateway: { platform: { type: "webhook", secret: "secret" } },
|
|
runtime: { acp: { promptTimeoutMs, cancelGraceMs: 30 } }
|
|
});
|
|
let crashes = 0;
|
|
const worker = new AcpWorker(new BotProfileResolver(config).bot, config.runtime.acp, () => { crashes++; });
|
|
return { worker, crashes: () => crashes };
|
|
}
|
|
|
|
test("worker times out a hung prompt and can be terminated", async () => {
|
|
const { worker } = makeWorker();
|
|
await worker.start();
|
|
await assert.rejects(worker.prompt("hang"), /timed out/);
|
|
await worker.terminate();
|
|
});
|
|
|
|
test("worker reports an unexpected ACP subprocess exit", async () => {
|
|
const current = makeWorker(500);
|
|
await current.worker.start();
|
|
await assert.rejects(current.worker.prompt("crash"));
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
assert.equal(current.crashes(), 1);
|
|
});
|
|
|
|
test("immediate termination kills ACP descendants in the worker process group", async (t) => {
|
|
await assertDescendantTermination(t, (worker) => { worker.terminateImmediately(); });
|
|
});
|
|
|
|
test("normal termination SIGKILLs descendants that ignore SIGTERM", async (t) => {
|
|
await assertDescendantTermination(t, (worker) => worker.terminate());
|
|
});
|
|
|
|
test("persisted worker token safely identifies and kills an orphaned process group", async () => {
|
|
const { worker } = makeWorker(2_000);
|
|
await worker.start();
|
|
assert.ok(worker.processGroup);
|
|
await AcpWorker.terminatePersistedGroup(worker.processGroup, 100);
|
|
assert.equal(processGroupExists(worker.processGroup.pgid), false);
|
|
});
|
|
|
|
async function assertDescendantTermination(t: test.TestContext, terminate: (worker: AcpWorker) => void | Promise<void>): Promise<void> {
|
|
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-acp-group-"));
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const pidFile = path.join(directory, "descendant.pid");
|
|
const { worker } = makeWorker(2_000, { FAKE_ACP_DESCENDANT_PID_FILE: pidFile });
|
|
await worker.start();
|
|
const prompt = worker.prompt("spawn descendant");
|
|
const promptRejected = assert.rejects(prompt);
|
|
for (let count = 0; count < 100 && !fs.existsSync(pidFile); count++) await new Promise((resolve) => setTimeout(resolve, 10));
|
|
const pid = Number(fs.readFileSync(pidFile, "utf8").trim());
|
|
assert.equal(processExists(pid), true);
|
|
await terminate(worker);
|
|
await promptRejected;
|
|
for (let count = 0; count < 100 && processExists(pid); count++) await new Promise((resolve) => setTimeout(resolve, 10));
|
|
assert.equal(processExists(pid), false);
|
|
}
|
|
|
|
function processExists(pid: number): boolean {
|
|
try { process.kill(pid, 0); return true; }
|
|
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
|
|
}
|
|
|
|
function processGroupExists(pgid: number): boolean {
|
|
try { process.kill(-pgid, 0); return true; }
|
|
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
|
|
}
|