mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 15:50:46 +00:00
fix(acpx): lazy-load startup backend
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user