Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Repo: https://github.com/openclaw/acpx

### Changes

- Runtime/embedding: expose optional process lifecycle hooks around ACP agent spawn, failure, and exit for durable host-owned launch tracking. Thanks @MertBasar0.

### Breaking

### Fixes
Expand Down
170 changes: 154 additions & 16 deletions src/acp/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { spawn, type ChildProcess, type ChildProcessByStdio } from "node:child_process";
import { randomUUID } from "node:crypto";
import { Readable, Writable } from "node:stream";
import {
PROTOCOL_VERSION,
Expand Down Expand Up @@ -74,6 +75,9 @@ import type {
AcpClientOptions,
AcpElicitationHandler,
AcpElicitationMode,
AcpProcessLaunch,
AcpProcessLaunchScope,
AcpProcessStarted,
NonInteractivePermissionPolicy,
PermissionMode,
PermissionStats,
Expand Down Expand Up @@ -419,6 +423,18 @@ function snapshotPermissionPolicy(
};
}

function snapshotProcessLaunchScope(
scope: AcpProcessLaunchScope | undefined,
): AcpProcessLaunchScope {
if (!scope || scope.kind === "client") {
return Object.freeze({ kind: "client" });
}
if (scope.kind === "runtime-session") {
return Object.freeze({ kind: "runtime-session", sessionKey: scope.sessionKey });
}
return Object.freeze({ kind: "runtime-probe", agent: scope.agent });
}

type AuthSelection = {
methodId: string;
credential?: string;
Expand All @@ -439,6 +455,7 @@ type AgentLaunchPlan = {
type StartupFailureWatcher = {
promise: Promise<never>;
dispose: () => void;
getError: () => AgentStartupError | undefined;
};

type SessionUpdateSuppressionState = {
Expand Down Expand Up @@ -815,12 +832,11 @@ export class AcpClient {
const launch = await this.resolveAgentLaunchPlan();
this.logAgentLaunch(launch);
await this.ensureLaunchSupport(launch);
const child = await this.spawnAgentProcess(launch);
const { child, process: startedProcess } = await this.spawnAgentProcess(launch);
this.closing = false;
this.agentStartedAt = isoNow();
this.agentStartedAt = startedProcess.startedAt;
this.lastAgentExit = undefined;
this.lastKnownPid = child.pid ?? undefined;
this.attachAgentLifecycleObservers(child);
this.lastKnownPid = startedProcess.pid;
const startupStderr: string[] = [];

child.stderr.on("data", (chunk: Buffer | string) => {
Expand All @@ -830,6 +846,17 @@ export class AcpClient {
}
process.stderr.write(chunk);
});
const startupFailure = this.createStartupFailureWatcher(child, startupStderr);
try {
await this.admitAndObserveSpawnedProcess(child, startedProcess);
const admissionExit = startupFailure.getError();
if (admissionExit) {
throw admissionExit;
}
} catch (error) {
startupFailure.dispose();
throw error;
}

const input = Writable.toWeb(child.stdin);
const output = Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>;
Expand All @@ -845,8 +872,6 @@ export class AcpClient {
},
{ once: true },
);
const startupFailure = this.createStartupFailureWatcher(child, startupStderr);

await this.initializeAgentConnection({
child,
connection,
Expand Down Expand Up @@ -915,25 +940,76 @@ export class AcpClient {
}
}

private async spawnAgentProcess(
plan: AgentLaunchPlan,
): Promise<ChildProcessByStdio<Writable, Readable, Readable>> {
private async spawnAgentProcess(plan: AgentLaunchPlan): Promise<{
child: ChildProcessByStdio<Writable, Readable, Readable>;
process: AcpProcessStarted;
}> {
const spawnCommand = buildAgentSpawnCommand(
plan.spawnCommand,
plan.args,
process.platform,
plan.spawnOptions.env,
);
const spawnedChild = spawn(spawnCommand.command, spawnCommand.args, {
...plan.spawnOptions,
windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments,
}) as ChildProcessByStdio<Writable, Readable, Readable>;
const launch: AcpProcessLaunch = Object.freeze({
launchId: randomUUID(),
scope: snapshotProcessLaunchScope(this.options.processLaunchScope),
command: spawnCommand.command,
args: Object.freeze([...spawnCommand.args]),
cwd: this.options.cwd,
});
await this.options.processLifecycle?.onBeforeSpawn?.(launch);

let spawnedChild: ChildProcessByStdio<Writable, Readable, Readable>;
try {
spawnedChild = spawn(spawnCommand.command, spawnCommand.args, {
...plan.spawnOptions,
windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments,
});
await waitForSpawn(spawnedChild);
} catch (error) {
throw new AgentSpawnError(this.options.agentCommand, error);
const spawnError = new AgentSpawnError(this.options.agentCommand, error);
this.notifyProcessSpawnFailure(launch, spawnError);
throw spawnError;
}

const child = requireAgentStdio(spawnedChild);
const pid = child.pid;
if (pid === undefined) {
const spawnError = new AgentSpawnError(
this.options.agentCommand,
new Error("spawned agent process did not expose a PID"),
);
this.notifyProcessSpawnFailure(launch, spawnError);
await this.terminateAgentProcess(child);
throw spawnError;
}
return {
child,
process: Object.freeze({
...launch,
pid,
startedAt: isoNow(),
}),
};
}

private async admitAndObserveSpawnedProcess(
child: ChildProcessByStdio<Writable, Readable, Readable>,
process: AcpProcessStarted,
): Promise<void> {
let releaseExitNotification = () => {};
const exitNotificationBarrier = new Promise<void>((resolve) => {
releaseExitNotification = resolve;
});
this.attachAgentLifecycleObservers(child, process, exitNotificationBarrier);
try {
await this.options.processLifecycle?.onSpawned?.(process);
} catch (error) {
await this.terminateAgentProcess(child);
throw error;
} finally {
releaseExitNotification();
}
return requireAgentStdio(spawnedChild);
}

private createConnection(
Expand Down Expand Up @@ -1783,6 +1859,7 @@ export class AcpClient {
startupStderr: string[],
): StartupFailureWatcher {
let settled = false;
let failure: AgentStartupError | undefined;
let rejectPromise: (error: unknown) => void;

const cleanup = () => {
Expand All @@ -1791,13 +1868,14 @@ export class AcpClient {
child.off("close", onClose);
};

const finish = (error?: unknown) => {
const finish = (error?: AgentStartupError) => {
if (settled) {
return;
}
settled = true;
cleanup();
if (error) {
failure = error;
rejectPromise(error);
}
};
Expand Down Expand Up @@ -1832,11 +1910,16 @@ export class AcpClient {
child.once("error", onError);
child.once("exit", onExit);
child.once("close", onClose);
if (child.exitCode !== null || child.signalCode !== null) {
onExit(child.exitCode, child.signalCode);
}
});
void promise.catch(() => {});

return {
promise,
dispose: () => finish(),
getError: () => failure,
};
}

Expand Down Expand Up @@ -2168,9 +2251,15 @@ export class AcpClient {

private attachAgentLifecycleObservers(
child: ChildProcessByStdio<Writable, Readable, Readable>,
startedProcess: AcpProcessStarted,
exitNotificationBarrier: Promise<void>,
): void {
child.once("exit", (exitCode, signal) => {
const exitedAt = isoNow();
this.recordAgentExit("process_exit", exitCode, signal);
void exitNotificationBarrier.then(() => {
this.notifyProcessExit(startedProcess, exitCode, signal, exitedAt);
});
});

child.once("close", (exitCode, signal) => {
Expand All @@ -2182,6 +2271,55 @@ export class AcpClient {
});
}

private notifyProcessSpawnFailure(launch: AcpProcessLaunch, error: unknown): void {
const handler = this.options.processLifecycle?.onSpawnFailed;
if (!handler) {
return;
}
const event = Object.freeze({
...launch,
error,
failedAt: isoNow(),
});
try {
void Promise.resolve(handler(event)).catch((observerError: unknown) => {
this.logProcessLifecycleError("onSpawnFailed", observerError);
});
} catch (observerError) {
this.logProcessLifecycleError("onSpawnFailed", observerError);
}
}

private notifyProcessExit(
startedProcess: AcpProcessStarted,
exitCode: number | null,
signal: NodeJS.Signals | null,
exitedAt: string,
): void {
const handler = this.options.processLifecycle?.onExit;
if (!handler) {
return;
}
const event = Object.freeze({
...startedProcess,
exitCode,
signal,
exitedAt,
});
try {
void Promise.resolve(handler(event)).catch((error: unknown) => {
this.logProcessLifecycleError("onExit", error);
});
} catch (error) {
this.logProcessLifecycleError("onExit", error);
}
}

private logProcessLifecycleError(hook: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
this.log(`process lifecycle ${hook} hook failed: ${message}`);
}

private recordAgentExit(
reason: AgentDisconnectReason,
exitCode: number | null,
Expand Down
6 changes: 6 additions & 0 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ export type {
AcpFileSessionStoreOptions,
AcpPermissionDecision,
AcpPermissionRequest,
AcpProcessExit,
AcpProcessLaunch,
AcpProcessLaunchScope,
AcpProcessLifecycle,
AcpProcessSpawnFailure,
AcpProcessStarted,
AcpRuntime,
AcpRuntimeAvailableCommand,
AcpRuntimeCapabilities,
Expand Down
22 changes: 21 additions & 1 deletion src/runtime/engine/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -895,7 +895,15 @@ export class AcpRuntimeManager {
sessionRecordId: record.acpxRecordId,
loadRecord: async (sessionRecordId) => await this.requireRecord(sessionRecordId),
saveRecord: async (connectedRecord) => await this.options.sessionStore.save(connectedRecord),
createClient: (options) => this.createClient(options),
createClient: (options) =>
this.createClient({
...options,
processLifecycle: this.options.processLifecycle,
processLaunchScope: {
kind: "runtime-session",
sessionKey: record.name ?? record.acpxRecordId,
},
}),
mcpServers: [...(this.options.mcpServers ?? [])],
permissionMode: this.options.permissionMode,
nonInteractivePermissions: this.options.nonInteractivePermissions,
Expand Down Expand Up @@ -1070,6 +1078,8 @@ export class AcpRuntimeManager {
permissionPolicy: this.options.permissionPolicy,
onPermissionRequest: this.options.onPermissionRequest,
elicitationModes: this.options.elicitationModes,
processLifecycle: this.options.processLifecycle,
processLaunchScope: { kind: "runtime-session", sessionKey: input.sessionKey },
verbose: this.options.verbose,
sessionOptions: input.sessionOptions,
});
Expand Down Expand Up @@ -1493,6 +1503,11 @@ export class AcpRuntimeManager {
permissionPolicy: this.options.permissionPolicy,
onPermissionRequest: this.options.onPermissionRequest,
elicitationModes: this.options.elicitationModes,
processLifecycle: this.options.processLifecycle,
processLaunchScope: {
kind: "runtime-session",
sessionKey: record.name ?? record.acpxRecordId,
},
verbose: this.options.verbose,
sessionOptions: sessionOptionsFromRecord(record),
});
Expand Down Expand Up @@ -2043,6 +2058,11 @@ export class AcpRuntimeManager {
permissionPolicy: this.options.permissionPolicy,
onPermissionRequest: this.options.onPermissionRequest,
elicitationModes: this.options.elicitationModes,
processLifecycle: this.options.processLifecycle,
processLaunchScope: {
kind: "runtime-session",
sessionKey: record.name ?? record.acpxRecordId,
},
verbose: this.options.verbose,
}),
};
Expand Down
9 changes: 9 additions & 0 deletions src/runtime/public/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
AcpElicitationMode,
AcpPermissionDecision,
AcpPermissionRequest,
AcpProcessLifecycle,
McpServer,
NonInteractivePermissionPolicy,
PermissionMode,
Expand All @@ -22,6 +23,12 @@ export type {
AcpElicitationResponse,
AcpPermissionDecision,
AcpPermissionRequest,
AcpProcessExit,
AcpProcessLaunch,
AcpProcessLaunchScope,
AcpProcessLifecycle,
AcpProcessSpawnFailure,
AcpProcessStarted,
PermissionPolicy,
} from "../../types.js";

Expand Down Expand Up @@ -354,6 +361,8 @@ export type AcpRuntimeOptions = {
verbose?: boolean;
/** ACP elicitation modes the embedding host can render for prompt turns. */
elicitationModes?: readonly AcpElicitationMode[];
/** Optional lifecycle observer for ACP agent processes owned by this runtime. */
processLifecycle?: AcpProcessLifecycle;
onPermissionRequest?: (
req: AcpPermissionRequest,
ctx: { signal: AbortSignal },
Expand Down
Loading