fix: keep ACP turns on OpenClaw timeouts (#82997)

This commit is contained in:
Peter Steinberger
2026-05-17 09:10:42 +01:00
committed by GitHub
parent e4ec1b3de8
commit 83e19ca469
6 changed files with 128 additions and 11 deletions
+7 -6
View File
@@ -274,18 +274,19 @@ What this does:
- Exposes selected built-in OpenClaw tools. The initial server exposes `cron`.
- Keeps core-tool exposure explicit and default-off.
### Runtime timeout configuration
### Runtime operation timeout configuration
The `acpx` plugin defaults embedded runtime turns to a 120-second
timeout. This gives slower harnesses such as Gemini CLI enough time to complete
ACP startup and initialization. Override it if your host needs a different
runtime limit:
The `acpx` plugin gives embedded runtime startup and control operations 120
seconds by default. This gives slower harnesses such as Gemini CLI enough time
to complete ACP startup and initialization. Override it if your host needs a
different operation limit:
```bash
openclaw config set plugins.entries.acpx.config.timeoutSeconds 180
```
Restart the gateway after changing this value.
Runtime turns use OpenClaw agent/run timeouts, including `/acp timeout` and
`sessions_spawn.timeoutSeconds`. Restart the gateway after changing this value.
### Health probe agent configuration
+2 -2
View File
@@ -129,8 +129,8 @@
"advanced": true
},
"timeoutSeconds": {
"label": "Prompt Timeout Seconds",
"help": "Timeout for each embedded runtime turn. Defaults to 120 seconds so slower Gemini CLI ACP startups have room to initialize.",
"label": "Runtime Operation Timeout Seconds",
"help": "Timeout for embedded ACP runtime startup and control operations. ACP turns use OpenClaw agent/run timeouts.",
"advanced": true
},
"queueOwnerTtlSeconds": {
+76
View File
@@ -535,6 +535,82 @@ describe("AcpxRuntime fresh reset wrapper", () => {
});
});
it("disables delegate prompt timeout for OpenClaw-managed turns", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => ({
acpxRecordId: "agent:codex:acp:test",
agentCommand: CODEX_ACP_COMMAND,
})),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore, {
timeoutMs: 1,
agentRegistry: {
resolve: (agentName: string) => (agentName === "codex" ? CODEX_ACP_COMMAND : agentName),
list: () => ["codex"],
},
});
const runTurn = vi.spyOn(delegate, "runTurn").mockImplementation(async function* () {
yield { type: "done" };
});
const startTurn = vi.spyOn(delegate, "startTurn").mockImplementation(
(input): AcpRuntimeTurn => ({
requestId: input.requestId,
events: (async function* () {
yield { type: "done" as const, stopReason: "end_turn" };
})(),
result: Promise.resolve({
status: "completed" as const,
stopReason: "end_turn",
}),
cancel: vi.fn(async () => {}),
closeStream: vi.fn(async () => {}),
}),
);
for await (const _event of runtime.runTurn({
handle: {
sessionKey: "agent:codex:acp:test",
backend: "acpx",
runtimeSessionName: "agent:codex:acp:test",
acpxRecordId: "agent:codex:acp:test",
},
text: "Reply exactly OK",
mode: "prompt",
requestId: "turn-1",
})) {
// no-op
}
expect(runTurn).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: 0,
}),
);
const turn = runtime.startTurn({
handle: {
sessionKey: "agent:codex:acp:test",
backend: "acpx",
runtimeSessionName: "agent:codex:acp:test",
acpxRecordId: "agent:codex:acp:test",
},
text: "Reply exactly OK",
mode: "prompt",
requestId: "turn-2",
});
for await (const _event of turn.events) {
// no-op
}
await turn.result;
expect(startTurn).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: 0,
}),
);
});
it("does not normalize model startup for non-Codex ACP agents", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
+11 -2
View File
@@ -51,6 +51,15 @@ type ResetAwareSessionStore = AcpSessionStore & {
markFresh: (sessionKey: string) => void;
};
function withOpenClawManagedTurnTimeout<T extends object>(input: T): T & { timeoutMs: 0 } {
// OpenClaw owns ACP turn deadlines. acpx treats timeout after partial agent
// output as a completed turn, which can mark background work done early.
return {
...input,
timeoutMs: 0,
};
}
type AcpxLaunchLeaseContext = {
leaseId: string;
gatewayInstanceId: string;
@@ -989,7 +998,7 @@ export class AcpxRuntime implements AcpRuntime {
const command = await this.resolveCommandForHandle(input.handle);
const delegate = await this.resolveDelegateForHandle(input.handle);
try {
for await (const event of delegate.runTurn(input)) {
for await (const event of delegate.runTurn(withOpenClawManagedTurnTimeout(input))) {
if (
event.type !== "error" ||
!isCodexAcpCommand(command) ||
@@ -1035,7 +1044,7 @@ export class AcpxRuntime implements AcpRuntime {
try {
return {
command,
turn: delegate.startTurn(input),
turn: delegate.startTurn(withOpenClawManagedTurnTimeout(input)),
};
} catch (error) {
if (!isCodexAcpCommand(command) || !isGenericInternalAcpError(error)) {
+30
View File
@@ -483,6 +483,36 @@ describe("createAcpxRuntimeService", () => {
await service.stop?.(ctx);
});
it("passes the plugin timeout to the default acpx runtime constructor", async () => {
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
const service = createAcpxRuntimeService({
pluginConfig: { timeoutSeconds: 0.001 },
});
await service.start(ctx);
const backend = getAcpRuntimeBackend("acpx");
if (!backend) {
throw new Error("expected ACPX runtime backend");
}
const backendRuntime = backend.runtime as {
ensureSession(input: { agent: string; mode: string; sessionKey: string }): Promise<unknown>;
};
await backendRuntime.ensureSession({
agent: "codex",
mode: "oneshot",
sessionKey: "agent:codex:acp:test",
});
const [options] = acpxRuntimeConstructorMock.mock.calls[0] ?? [];
expect(options).toHaveProperty("timeoutMs", 1);
await service.stop?.(ctx);
});
it("runs the embedded runtime probe at startup by default and reports health", async () => {
const workspaceDir = await makeTempDir();
const ctx = createServiceContext(workspaceDir);
+2 -1
View File
@@ -679,7 +679,7 @@ describe("AcpSessionManager", () => {
expect(runtimeState.runTurn).toHaveBeenCalledTimes(1);
});
it("times out a hung persistent turn without closing the session and lets queued work continue", async () => {
it("times out a hung persistent turn after partial progress without closing the session and lets queued work continue", async () => {
vi.useFakeTimers();
try {
const runtimeState = createRuntime();
@@ -697,6 +697,7 @@ describe("AcpSessionManager", () => {
runtimeState.runTurn.mockImplementation(async function* (input: { requestId: string }) {
if (input.requestId === "r1") {
firstTurnStarted = true;
yield { type: "text_delta" as const, text: "Working on it..." };
await new Promise(() => {});
}
yield { type: "done" as const };