fix(acpx): lazy-load startup backend

This commit is contained in:
Vincent Koc
2026-04-27 21:46:24 -07:00
parent 996818e6af
commit 6d7901f5c8
13 changed files with 426 additions and 135 deletions

View File

@@ -12,7 +12,7 @@ vi.mock("./register.runtime.js", () => ({
createAcpxRuntimeService: createAcpxRuntimeServiceMock,
}));
vi.mock("./runtime-api.js", () => ({
vi.mock("openclaw/plugin-sdk/acp-runtime-backend", () => ({
tryDispatchAcpReplyHook: tryDispatchAcpReplyHookMock,
}));

View File

@@ -1,12 +1,11 @@
import { tryDispatchAcpReplyHook } from "openclaw/plugin-sdk/acp-runtime-backend";
import { createAcpxRuntimeService } from "./register.runtime.js";
import { tryDispatchAcpReplyHook, type OpenClawPluginApi } from "./runtime-api.js";
import { createAcpxPluginConfigSchema } from "./src/config-schema.js";
import type { OpenClawPluginApi } from "./runtime-api.js";
const plugin = {
id: "acpx",
name: "ACPX Runtime",
description: "Embedded ACP runtime backend with plugin-owned session and transport management.",
configSchema: () => createAcpxPluginConfigSchema(),
register(api: OpenClawPluginApi) {
api.registerService(
createAcpxRuntimeService({

View File

@@ -1 +1,154 @@
export { createAcpxRuntimeService } from "./src/service.js";
import {
getAcpRuntimeBackend,
registerAcpRuntimeBackend,
unregisterAcpRuntimeBackend,
type AcpRuntime,
type AcpRuntimeCapabilities,
type AcpRuntimeDoctorReport,
type AcpRuntimeStatus,
} from "openclaw/plugin-sdk/acp-runtime-backend";
import type { OpenClawPluginService, OpenClawPluginServiceContext } from "openclaw/plugin-sdk/core";
const ACPX_BACKEND_ID = "acpx";
const ENABLE_STARTUP_PROBE_ENV = "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE";
type RealAcpxServiceModule = typeof import("./src/service.js");
type CreateAcpxRuntimeServiceParams = NonNullable<
Parameters<RealAcpxServiceModule["createAcpxRuntimeService"]>[0]
>;
type AcpxRuntimeLike = AcpRuntime & {
probeAvailability(): Promise<void>;
doctor?(): Promise<AcpRuntimeDoctorReport>;
isHealthy(): boolean;
};
type DeferredServiceState = {
ctx: OpenClawPluginServiceContext | null;
params: CreateAcpxRuntimeServiceParams;
realRuntime: AcpxRuntimeLike | null;
realService: OpenClawPluginService | null;
startPromise: Promise<AcpxRuntimeLike> | null;
};
let serviceModulePromise: Promise<RealAcpxServiceModule> | null = null;
function loadServiceModule(): Promise<RealAcpxServiceModule> {
serviceModulePromise ??= import("./src/service.js");
return serviceModulePromise;
}
function shouldRunStartupProbe(env: NodeJS.ProcessEnv = process.env): boolean {
return env[ENABLE_STARTUP_PROBE_ENV] === "1";
}
async function startRealService(state: DeferredServiceState): Promise<AcpxRuntimeLike> {
if (state.realRuntime) {
return state.realRuntime;
}
if (!state.ctx) {
throw new Error("ACPX runtime service is not started");
}
state.startPromise ??= (async () => {
const { createAcpxRuntimeService } = await loadServiceModule();
const service = createAcpxRuntimeService(state.params);
state.realService = service;
await service.start(state.ctx as OpenClawPluginServiceContext);
const backend = getAcpRuntimeBackend(ACPX_BACKEND_ID);
if (!backend?.runtime) {
throw new Error("ACPX runtime service did not register an ACP backend");
}
state.realRuntime = backend.runtime as AcpxRuntimeLike;
return state.realRuntime;
})();
return await state.startPromise;
}
function createDeferredRuntime(state: DeferredServiceState): AcpxRuntimeLike {
return {
async ensureSession(input) {
return await (await startRealService(state)).ensureSession(input);
},
async *runTurn(input) {
yield* (await startRealService(state)).runTurn(input);
},
async getCapabilities(input): Promise<AcpRuntimeCapabilities> {
const runtime = await startRealService(state);
return (await runtime.getCapabilities?.(input)) ?? { controls: [] };
},
async getStatus(input): Promise<AcpRuntimeStatus> {
const runtime = await startRealService(state);
return (await runtime.getStatus?.(input)) ?? {};
},
async setMode(input) {
await (await startRealService(state)).setMode?.(input);
},
async setConfigOption(input) {
await (await startRealService(state)).setConfigOption?.(input);
},
async doctor(): Promise<AcpRuntimeDoctorReport> {
const runtime = await startRealService(state);
return (await runtime.doctor?.()) ?? { ok: true, message: "ok" };
},
async prepareFreshSession(input) {
await (await startRealService(state)).prepareFreshSession?.(input);
},
async cancel(input) {
await (await startRealService(state)).cancel(input);
},
async close(input) {
await (await startRealService(state)).close(input);
},
async probeAvailability() {
await (await startRealService(state)).probeAvailability();
},
isHealthy() {
return state.realRuntime?.isHealthy() ?? false;
},
};
}
export function createAcpxRuntimeService(
params: CreateAcpxRuntimeServiceParams = {},
): OpenClawPluginService {
const state: DeferredServiceState = {
ctx: null,
params,
realRuntime: null,
realService: null,
startPromise: null,
};
return {
id: "acpx-runtime",
async start(ctx) {
if (process.env.OPENCLAW_SKIP_ACPX_RUNTIME === "1") {
ctx.logger.info("skipping embedded acpx runtime backend (OPENCLAW_SKIP_ACPX_RUNTIME=1)");
return;
}
state.ctx = ctx;
if (shouldRunStartupProbe()) {
await startRealService(state);
return;
}
registerAcpRuntimeBackend({
id: ACPX_BACKEND_ID,
runtime: createDeferredRuntime(state),
});
ctx.logger.info("embedded acpx runtime backend registered lazily");
},
async stop(ctx) {
if (state.realService) {
await state.realService.stop?.(ctx);
} else {
unregisterAcpRuntimeBackend(ACPX_BACKEND_ID);
}
state.ctx = null;
state.realRuntime = null;
state.realService = null;
state.startPromise = null;
},
};
}

View File

@@ -1,11 +1,11 @@
export type { AcpRuntimeErrorCode } from "openclaw/plugin-sdk/acp-runtime";
export type { AcpRuntimeErrorCode } from "openclaw/plugin-sdk/acp-runtime-backend";
export {
AcpRuntimeError,
getAcpRuntimeBackend,
tryDispatchAcpReplyHook,
registerAcpRuntimeBackend,
unregisterAcpRuntimeBackend,
} from "openclaw/plugin-sdk/acp-runtime";
} from "openclaw/plugin-sdk/acp-runtime-backend";
export type {
AcpRuntime,
AcpRuntimeCapabilities,
@@ -17,7 +17,7 @@ export type {
AcpRuntimeTurnAttachment,
AcpRuntimeTurnInput,
AcpSessionUpdateTag,
} from "openclaw/plugin-sdk/acp-runtime";
} from "openclaw/plugin-sdk/acp-runtime-backend";
export type {
OpenClawPluginApi,
OpenClawPluginConfigSchema,

View File

@@ -11,6 +11,32 @@ const { prepareAcpxCodexAuthConfigMock } = vi.hoisted(() => ({
async ({ pluginConfig }: { pluginConfig: unknown }) => pluginConfig,
),
}));
const { acpxRuntimeConstructorMock, createAgentRegistryMock, createFileSessionStoreMock } =
vi.hoisted(() => ({
acpxRuntimeConstructorMock: vi.fn(function AcpxRuntime(options: unknown) {
return {
cancel: vi.fn(async () => {}),
close: vi.fn(async () => {}),
doctor: vi.fn(async () => ({ ok: true, message: "ok" })),
ensureSession: vi.fn(async () => ({
backend: "acpx",
runtimeSessionName: "agent:codex:acp:test",
sessionKey: "agent:codex:acp:test",
})),
getCapabilities: vi.fn(async () => ({ controls: [] })),
getStatus: vi.fn(async () => ({ summary: "ready" })),
isHealthy: vi.fn(() => true),
prepareFreshSession: vi.fn(async () => {}),
probeAvailability: vi.fn(async () => {}),
runTurn: vi.fn(async function* () {}),
setConfigOption: vi.fn(async () => {}),
setMode: vi.fn(async () => {}),
__options: options,
};
}),
createAgentRegistryMock: vi.fn(() => ({})),
createFileSessionStoreMock: vi.fn(() => ({})),
}));
vi.mock("../runtime-api.js", () => ({
getAcpRuntimeBackend: (id: string) => runtimeRegistry.get(id),
@@ -24,9 +50,9 @@ vi.mock("../runtime-api.js", () => ({
vi.mock("./runtime.js", () => ({
ACPX_BACKEND_ID: "acpx",
AcpxRuntime: function AcpxRuntime() {},
createAgentRegistry: vi.fn(() => ({})),
createFileSessionStore: vi.fn(() => ({})),
AcpxRuntime: acpxRuntimeConstructorMock,
createAgentRegistry: createAgentRegistryMock,
createFileSessionStore: createFileSessionStoreMock,
}));
vi.mock("./codex-auth-bridge.js", () => ({
@@ -47,6 +73,9 @@ async function makeTempDir(): Promise<string> {
afterEach(async () => {
runtimeRegistry.clear();
prepareAcpxCodexAuthConfigMock.mockClear();
acpxRuntimeConstructorMock.mockClear();
createAgentRegistryMock.mockClear();
createFileSessionStoreMock.mockClear();
delete process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE;
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME;
delete process.env.OPENCLAW_SKIP_ACPX_RUNTIME_PROBE;
@@ -126,6 +155,28 @@ describe("createAcpxRuntimeService", () => {
await service.stop?.(ctx);
});
it("registers the default backend without importing ACPX runtime until first use", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const service = createAcpxRuntimeService();
await service.start(ctx);
const backend = getAcpRuntimeBackend("acpx");
expect(backend?.runtime).toBeDefined();
expect(acpxRuntimeConstructorMock).not.toHaveBeenCalled();
await backend?.runtime.ensureSession({
agent: "codex",
mode: "oneshot",
sessionKey: "agent:codex:acp:test",
});
expect(acpxRuntimeConstructorMock).toHaveBeenCalledOnce();
await service.stop?.(ctx);
});
it("can run the embedded runtime probe at startup when explicitly enabled", async () => {
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "1";
const workspaceDir = await makeTempDir();

View File

@@ -14,12 +14,6 @@ import {
toAcpMcpServers,
type ResolvedAcpxPluginConfig,
} from "./config.js";
import {
ACPX_BACKEND_ID,
AcpxRuntime,
createAgentRegistry,
createFileSessionStore,
} from "./runtime.js";
type AcpxRuntimeLike = AcpRuntime & {
probeAvailability(): Promise<void>;
@@ -32,6 +26,10 @@ type AcpxRuntimeLike = AcpRuntime & {
};
const ENABLE_STARTUP_PROBE_ENV = "OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE";
const ACPX_BACKEND_ID = "acpx";
type AcpxRuntimeModule = typeof import("./runtime.js");
let runtimeModulePromise: Promise<AcpxRuntimeModule> | null = null;
type AcpxRuntimeFactoryParams = {
pluginConfig: ResolvedAcpxPluginConfig;
@@ -40,27 +38,83 @@ type AcpxRuntimeFactoryParams = {
type CreateAcpxRuntimeServiceParams = {
pluginConfig?: unknown;
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike;
runtimeFactory?: (params: AcpxRuntimeFactoryParams) => AcpxRuntimeLike | Promise<AcpxRuntimeLike>;
};
function createDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
return new AcpxRuntime({
cwd: params.pluginConfig.cwd,
sessionStore: createFileSessionStore({
stateDir: params.pluginConfig.stateDir,
}),
agentRegistry: createAgentRegistry({
overrides: params.pluginConfig.agents,
}),
probeAgent: params.pluginConfig.probeAgent,
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
permissionMode: params.pluginConfig.permissionMode,
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
timeoutMs:
params.pluginConfig.timeoutSeconds != null
? params.pluginConfig.timeoutSeconds * 1_000
: undefined,
});
function loadRuntimeModule(): Promise<AcpxRuntimeModule> {
runtimeModulePromise ??= import("./runtime.js");
return runtimeModulePromise;
}
function createLazyDefaultRuntime(params: AcpxRuntimeFactoryParams): AcpxRuntimeLike {
let runtime: AcpxRuntimeLike | null = null;
let runtimePromise: Promise<AcpxRuntimeLike> | null = null;
async function resolveRuntime(): Promise<AcpxRuntimeLike> {
if (runtime) {
return runtime;
}
runtimePromise ??= loadRuntimeModule().then((module) => {
runtime = new module.AcpxRuntime({
cwd: params.pluginConfig.cwd,
sessionStore: module.createFileSessionStore({
stateDir: params.pluginConfig.stateDir,
}),
agentRegistry: module.createAgentRegistry({
overrides: params.pluginConfig.agents,
}),
probeAgent: params.pluginConfig.probeAgent,
mcpServers: toAcpMcpServers(params.pluginConfig.mcpServers),
permissionMode: params.pluginConfig.permissionMode,
nonInteractivePermissions: params.pluginConfig.nonInteractivePermissions,
timeoutMs:
params.pluginConfig.timeoutSeconds != null
? params.pluginConfig.timeoutSeconds * 1_000
: undefined,
}) as AcpxRuntimeLike;
return runtime;
});
return await runtimePromise;
}
return {
async ensureSession(input) {
return await (await resolveRuntime()).ensureSession(input);
},
async *runTurn(input) {
yield* (await resolveRuntime()).runTurn(input);
},
async getCapabilities(input) {
return (await (await resolveRuntime()).getCapabilities?.(input)) ?? { controls: [] };
},
async getStatus(input) {
return (await (await resolveRuntime()).getStatus?.(input)) ?? {};
},
async setMode(input) {
await (await resolveRuntime()).setMode?.(input);
},
async setConfigOption(input) {
await (await resolveRuntime()).setConfigOption?.(input);
},
async doctor() {
return (await (await resolveRuntime()).doctor?.()) ?? { ok: true, message: "ok" };
},
async prepareFreshSession(input) {
await (await resolveRuntime()).prepareFreshSession?.(input);
},
async cancel(input) {
await (await resolveRuntime()).cancel(input);
},
async close(input) {
await (await resolveRuntime()).close(input);
},
async probeAvailability() {
await (await resolveRuntime()).probeAvailability();
},
isHealthy() {
return runtime?.isHealthy() ?? false;
},
};
}
function warnOnIgnoredLegacyCompatibilityConfig(params: {
@@ -167,11 +221,15 @@ export function createAcpxRuntimeService(
logger: ctx.logger,
});
const runtimeFactory = params.runtimeFactory ?? createDefaultRuntime;
runtime = runtimeFactory({
pluginConfig,
logger: ctx.logger,
});
runtime = params.runtimeFactory
? await params.runtimeFactory({
pluginConfig,
logger: ctx.logger,
})
: createLazyDefaultRuntime({
pluginConfig,
logger: ctx.logger,
});
registerAcpRuntimeBackend({
id: ACPX_BACKEND_ID,