Skip to content

Commit 55265a9

Browse files
dutifulbobosolmaz
andauthored
fix: restore --version and staged adapter shutdown (#41)
Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
1 parent f35aaf9 commit 55265a9

4 files changed

Lines changed: 229 additions & 11 deletions

File tree

src/cli-core.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ import {
7474
type SessionAgentContent,
7575
type SessionUserContent,
7676
} from "./types.js";
77+
import { getAcpxVersion } from "./version.js";
7778
import { runQueueOwnerFromEnv } from "./queue-owner-env.js";
7879

7980
class NoSessionError extends Error {
@@ -1433,6 +1434,7 @@ export async function main(argv: string[] = process.argv): Promise<void> {
14331434
program
14341435
.name("acpx")
14351436
.description("Headless CLI client for the Agent Client Protocol")
1437+
.version(getAcpxVersion())
14361438
.enablePositionalOptions()
14371439
.showHelpAfterError();
14381440

src/client.ts

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ type CommandParts = {
4949
const REPLAY_IDLE_MS = 80;
5050
const REPLAY_DRAIN_TIMEOUT_MS = 5_000;
5151
const DRAIN_POLL_INTERVAL_MS = 20;
52+
const AGENT_CLOSE_AFTER_STDIN_END_MS = 100;
53+
const AGENT_CLOSE_TERM_GRACE_MS = 1_500;
54+
const AGENT_CLOSE_KILL_GRACE_MS = 1_000;
5255

5356
type LoadSessionOptions = {
5457
suppressReplayUpdates?: boolean;
@@ -134,6 +137,47 @@ function waitForSpawn(child: ChildProcess): Promise<void> {
134137
});
135138
}
136139

140+
function isChildProcessRunning(child: ChildProcess): boolean {
141+
return child.exitCode == null && child.signalCode == null;
142+
}
143+
144+
function waitForChildExit(
145+
child: ChildProcessByStdio<Writable, Readable, Readable>,
146+
timeoutMs: number,
147+
): Promise<boolean> {
148+
if (!isChildProcessRunning(child)) {
149+
return Promise.resolve(true);
150+
}
151+
152+
return new Promise<boolean>((resolve) => {
153+
let settled = false;
154+
const timer = setTimeout(
155+
() => {
156+
finish(false);
157+
},
158+
Math.max(0, timeoutMs),
159+
);
160+
161+
const finish = (value: boolean) => {
162+
if (settled) {
163+
return;
164+
}
165+
settled = true;
166+
child.off("close", onExitLike);
167+
child.off("exit", onExitLike);
168+
clearTimeout(timer);
169+
resolve(value);
170+
};
171+
172+
const onExitLike = () => {
173+
finish(true);
174+
};
175+
176+
child.once("close", onExitLike);
177+
child.once("exit", onExitLike);
178+
});
179+
}
180+
137181
function splitCommandLine(value: string): CommandParts {
138182
const parts: string[] = [];
139183
let current = "";
@@ -735,12 +779,7 @@ export class AcpClient {
735779

736780
const agent = this.agent;
737781
if (agent) {
738-
// Some adapters keep stdio handles alive after SIGTERM; explicitly
739-
// destroy/unref so CLI calls can exit deterministically.
740-
if (!agent.killed) {
741-
agent.kill();
742-
}
743-
this.detachAgentHandles(agent);
782+
await this.terminateAgentProcess(agent);
744783
}
745784

746785
this.sessionUpdateChain = Promise.resolve();
@@ -755,7 +794,45 @@ export class AcpClient {
755794
this.agent = undefined;
756795
}
757796

758-
private detachAgentHandles(agent: ChildProcess): void {
797+
private async terminateAgentProcess(
798+
child: ChildProcessByStdio<Writable, Readable, Readable>,
799+
): Promise<void> {
800+
// Closing stdin is the most graceful shutdown signal for stdio-based ACP agents.
801+
if (!child.stdin.destroyed) {
802+
try {
803+
child.stdin.end();
804+
} catch {
805+
// best effort
806+
}
807+
}
808+
809+
let exited = await waitForChildExit(child, AGENT_CLOSE_AFTER_STDIN_END_MS);
810+
if (!exited && isChildProcessRunning(child)) {
811+
try {
812+
child.kill("SIGTERM");
813+
} catch {
814+
// best effort
815+
}
816+
exited = await waitForChildExit(child, AGENT_CLOSE_TERM_GRACE_MS);
817+
}
818+
819+
if (!exited && isChildProcessRunning(child)) {
820+
this.log(
821+
`agent did not exit after ${AGENT_CLOSE_TERM_GRACE_MS}ms; forcing SIGKILL`,
822+
);
823+
try {
824+
child.kill("SIGKILL");
825+
} catch {
826+
// best effort
827+
}
828+
exited = await waitForChildExit(child, AGENT_CLOSE_KILL_GRACE_MS);
829+
}
830+
831+
// Ensure stdio handles don't keep this process alive after close() returns.
832+
this.detachAgentHandles(child, !exited);
833+
}
834+
835+
private detachAgentHandles(agent: ChildProcess, unref: boolean): void {
759836
const stdin = agent.stdin as Writable | null;
760837
const stdout = agent.stdout as Readable | null;
761838
const stderr = agent.stderr as Readable | null;
@@ -764,10 +841,12 @@ export class AcpClient {
764841
stdout?.destroy();
765842
stderr?.destroy();
766843

767-
try {
768-
agent.unref();
769-
} catch {
770-
// best effort
844+
if (unref) {
845+
try {
846+
agent.unref();
847+
} catch {
848+
// best effort
849+
}
771850
}
772851
}
773852

test/cli.test.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from "node:assert/strict";
22
import { spawn } from "node:child_process";
3+
import { readFileSync } from "node:fs";
34
import fs from "node:fs/promises";
45
import net from "node:net";
56
import os from "node:os";
@@ -22,7 +23,30 @@ import {
2223

2324
const CLI_PATH = fileURLToPath(new URL("../src/cli.js", import.meta.url));
2425
const MOCK_AGENT_PATH = fileURLToPath(new URL("./mock-agent.js", import.meta.url));
26+
function readPackageVersionForTest(): string {
27+
const candidates = [
28+
fileURLToPath(new URL("../package.json", import.meta.url)),
29+
fileURLToPath(new URL("../../package.json", import.meta.url)),
30+
path.join(process.cwd(), "package.json"),
31+
];
32+
for (const candidate of candidates) {
33+
try {
34+
const parsed = JSON.parse(readFileSync(candidate, "utf8")) as {
35+
version?: unknown;
36+
};
37+
if (typeof parsed.version === "string" && parsed.version.trim().length > 0) {
38+
return parsed.version;
39+
}
40+
} catch {
41+
// continue searching
42+
}
43+
}
44+
throw new Error("package.json version is missing");
45+
}
46+
47+
const PACKAGE_VERSION = readPackageVersionForTest();
2548
const MOCK_AGENT_COMMAND = `node ${JSON.stringify(MOCK_AGENT_PATH)}`;
49+
const MOCK_AGENT_IGNORING_SIGTERM = `${MOCK_AGENT_COMMAND} --ignore-sigterm`;
2650
const MOCK_CODEX_AGENT_WITH_RUNTIME_SESSION_ID = `${MOCK_AGENT_COMMAND} --codex-session-id codex-runtime-session`;
2751
const MOCK_CLAUDE_AGENT_WITH_RUNTIME_SESSION_ID = `${MOCK_AGENT_COMMAND} --claude-session-id claude-runtime-session`;
2852
const MOCK_AGENT_WITH_LOAD_RUNTIME_SESSION_ID = `${MOCK_AGENT_COMMAND} --supports-load-session --load-runtime-session-id loaded-runtime-session`;
@@ -44,6 +68,15 @@ type ParsedAcpError = {
4468
};
4569
};
4670

71+
test("CLI --version prints package version", async () => {
72+
await withTempHome(async (homeDir) => {
73+
const result = await runCli(["--version"], homeDir);
74+
assert.equal(result.code, 0, result.stderr);
75+
assert.equal(result.stderr.trim(), "");
76+
assert.equal(result.stdout.trim(), PACKAGE_VERSION);
77+
});
78+
});
79+
4780
function parseSingleAcpErrorLine(stdout: string): ParsedAcpError {
4881
const payload = JSON.parse(stdout.trim()) as {
4982
jsonrpc?: string;
@@ -204,6 +237,62 @@ test("sessions ensure creates when missing and returns existing on subsequent ca
204237
});
205238
});
206239

240+
test("sessions ensure exits even when agent ignores SIGTERM", async () => {
241+
await withTempHome(async (homeDir) => {
242+
const cwd = path.join(homeDir, "workspace");
243+
await fs.mkdir(cwd, { recursive: true });
244+
await fs.mkdir(path.join(homeDir, ".acpx"), { recursive: true });
245+
await fs.writeFile(
246+
path.join(homeDir, ".acpx", "config.json"),
247+
`${JSON.stringify(
248+
{
249+
agents: {
250+
codex: {
251+
command: MOCK_AGENT_IGNORING_SIGTERM,
252+
},
253+
},
254+
},
255+
null,
256+
2,
257+
)}\n`,
258+
"utf8",
259+
);
260+
261+
const result = await runCli(
262+
["--cwd", cwd, "--format", "json", "codex", "sessions", "ensure"],
263+
homeDir,
264+
{ timeoutMs: 8_000 },
265+
);
266+
assert.equal(result.code, 0, result.stderr);
267+
268+
const payload = JSON.parse(result.stdout.trim()) as {
269+
action?: unknown;
270+
created?: unknown;
271+
acpxRecordId?: unknown;
272+
};
273+
assert.equal(payload.action, "session_ensured");
274+
assert.equal(payload.created, true);
275+
assert.equal(typeof payload.acpxRecordId, "string");
276+
277+
const storedRecord = JSON.parse(
278+
await fs.readFile(
279+
path.join(
280+
homeDir,
281+
".acpx",
282+
"sessions",
283+
`${encodeURIComponent(payload.acpxRecordId as string)}.json`,
284+
),
285+
"utf8",
286+
),
287+
) as SessionRecord;
288+
289+
if (storedRecord.pid != null) {
290+
const exited = await waitForPidExit(storedRecord.pid, 2_000);
291+
assert.equal(exited, true);
292+
}
293+
});
294+
});
295+
207296
test("sessions ensure resolves existing session by directory walk", async () => {
208297
await withTempHome(async (homeDir) => {
209298
const root = path.join(homeDir, "workspace");
@@ -1170,6 +1259,7 @@ async function withTempHome(run: (homeDir: string) => Promise<void>): Promise<vo
11701259
type CliRunOptions = {
11711260
stdin?: string;
11721261
cwd?: string;
1262+
timeoutMs?: number;
11731263
};
11741264

11751265
async function runCli(
@@ -1206,12 +1296,44 @@ async function runCli(
12061296
child.stdin.end();
12071297
}
12081298

1299+
let timedOut = false;
1300+
let timeout: NodeJS.Timeout | undefined;
1301+
if (options.timeoutMs != null && options.timeoutMs > 0) {
1302+
timeout = setTimeout(() => {
1303+
timedOut = true;
1304+
if (child.exitCode == null && child.signalCode == null) {
1305+
child.kill("SIGKILL");
1306+
}
1307+
}, options.timeoutMs);
1308+
}
1309+
12091310
child.once("close", (code) => {
1311+
if (timeout) {
1312+
clearTimeout(timeout);
1313+
}
1314+
if (timedOut) {
1315+
stderr += `[test] timed out after ${options.timeoutMs}ms\n`;
1316+
}
12101317
resolve({ code, stdout, stderr });
12111318
});
12121319
});
12131320
}
12141321

1322+
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
1323+
const deadline = Date.now() + Math.max(0, timeoutMs);
1324+
while (Date.now() < deadline) {
1325+
try {
1326+
process.kill(pid, 0);
1327+
} catch {
1328+
return true;
1329+
}
1330+
await new Promise<void>((resolve) => {
1331+
setTimeout(resolve, 50);
1332+
});
1333+
}
1334+
return false;
1335+
}
1336+
12151337
function makeSessionRecord(
12161338
record: Partial<SessionRecord> & {
12171339
acpxRecordId: string;

test/mock-agent.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type MockAgentOptions = {
3030
loadSessionFailsOnEmpty: boolean;
3131
replayLoadSessionUpdates: boolean;
3232
loadReplayText: string;
33+
ignoreSigterm: boolean;
3334
};
3435

3536
type SessionState = {
@@ -257,6 +258,7 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions {
257258
let loadSessionFailsOnEmpty = false;
258259
let replayLoadSessionUpdates = false;
259260
let loadReplayText = "replayed load session update";
261+
let ignoreSigterm = false;
260262

261263
for (let index = 0; index < argv.length; index += 1) {
262264
const token = argv[index];
@@ -278,6 +280,11 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions {
278280
continue;
279281
}
280282

283+
if (token === "--ignore-sigterm") {
284+
ignoreSigterm = true;
285+
continue;
286+
}
287+
281288
if (token === "--load-replay-text") {
282289
supportsLoadSession = true;
283290
replayLoadSessionUpdates = true;
@@ -313,6 +320,7 @@ function parseMockAgentOptions(argv: string[]): MockAgentOptions {
313320
loadSessionFailsOnEmpty,
314321
replayLoadSessionUpdates,
315322
loadReplayText,
323+
ignoreSigterm,
316324
};
317325
}
318326

@@ -603,6 +611,13 @@ const output = Writable.toWeb(process.stdout);
603611
const input = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
604612
const stream = ndJsonStream(output, input);
605613
const mockAgentOptions = parseMockAgentOptions(process.argv.slice(2));
614+
615+
if (mockAgentOptions.ignoreSigterm) {
616+
process.on("SIGTERM", () => {
617+
// Intentionally ignore to exercise ACP client SIGKILL fallback behavior.
618+
});
619+
}
620+
606621
new AgentSideConnection(
607622
(connection) => new MockAgent(connection, mockAgentOptions),
608623
stream,

0 commit comments

Comments
 (0)