Skip to content

Commit 83e19ca

Browse files
authored
fix: keep ACP turns on OpenClaw timeouts (#82997)
1 parent e4ec1b3 commit 83e19ca

6 files changed

Lines changed: 128 additions & 11 deletions

File tree

docs/tools/acp-agents-setup.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -274,18 +274,19 @@ What this does:
274274
- Exposes selected built-in OpenClaw tools. The initial server exposes `cron`.
275275
- Keeps core-tool exposure explicit and default-off.
276276

277-
### Runtime timeout configuration
277+
### Runtime operation timeout configuration
278278

279-
The `acpx` plugin defaults embedded runtime turns to a 120-second
280-
timeout. This gives slower harnesses such as Gemini CLI enough time to complete
281-
ACP startup and initialization. Override it if your host needs a different
282-
runtime limit:
279+
The `acpx` plugin gives embedded runtime startup and control operations 120
280+
seconds by default. This gives slower harnesses such as Gemini CLI enough time
281+
to complete ACP startup and initialization. Override it if your host needs a
282+
different operation limit:
283283

284284
```bash
285285
openclaw config set plugins.entries.acpx.config.timeoutSeconds 180
286286
```
287287

288-
Restart the gateway after changing this value.
288+
Runtime turns use OpenClaw agent/run timeouts, including `/acp timeout` and
289+
`sessions_spawn.timeoutSeconds`. Restart the gateway after changing this value.
289290

290291
### Health probe agent configuration
291292

extensions/acpx/openclaw.plugin.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,8 +129,8 @@
129129
"advanced": true
130130
},
131131
"timeoutSeconds": {
132-
"label": "Prompt Timeout Seconds",
133-
"help": "Timeout for each embedded runtime turn. Defaults to 120 seconds so slower Gemini CLI ACP startups have room to initialize.",
132+
"label": "Runtime Operation Timeout Seconds",
133+
"help": "Timeout for embedded ACP runtime startup and control operations. ACP turns use OpenClaw agent/run timeouts.",
134134
"advanced": true
135135
},
136136
"queueOwnerTtlSeconds": {

extensions/acpx/src/runtime.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,82 @@ describe("AcpxRuntime fresh reset wrapper", () => {
535535
});
536536
});
537537

538+
it("disables delegate prompt timeout for OpenClaw-managed turns", async () => {
539+
const baseStore: TestSessionStore = {
540+
load: vi.fn(async () => ({
541+
acpxRecordId: "agent:codex:acp:test",
542+
agentCommand: CODEX_ACP_COMMAND,
543+
})),
544+
save: vi.fn(async () => {}),
545+
};
546+
const { runtime, delegate } = makeRuntime(baseStore, {
547+
timeoutMs: 1,
548+
agentRegistry: {
549+
resolve: (agentName: string) => (agentName === "codex" ? CODEX_ACP_COMMAND : agentName),
550+
list: () => ["codex"],
551+
},
552+
});
553+
const runTurn = vi.spyOn(delegate, "runTurn").mockImplementation(async function* () {
554+
yield { type: "done" };
555+
});
556+
const startTurn = vi.spyOn(delegate, "startTurn").mockImplementation(
557+
(input): AcpRuntimeTurn => ({
558+
requestId: input.requestId,
559+
events: (async function* () {
560+
yield { type: "done" as const, stopReason: "end_turn" };
561+
})(),
562+
result: Promise.resolve({
563+
status: "completed" as const,
564+
stopReason: "end_turn",
565+
}),
566+
cancel: vi.fn(async () => {}),
567+
closeStream: vi.fn(async () => {}),
568+
}),
569+
);
570+
571+
for await (const _event of runtime.runTurn({
572+
handle: {
573+
sessionKey: "agent:codex:acp:test",
574+
backend: "acpx",
575+
runtimeSessionName: "agent:codex:acp:test",
576+
acpxRecordId: "agent:codex:acp:test",
577+
},
578+
text: "Reply exactly OK",
579+
mode: "prompt",
580+
requestId: "turn-1",
581+
})) {
582+
// no-op
583+
}
584+
585+
expect(runTurn).toHaveBeenCalledWith(
586+
expect.objectContaining({
587+
timeoutMs: 0,
588+
}),
589+
);
590+
591+
const turn = runtime.startTurn({
592+
handle: {
593+
sessionKey: "agent:codex:acp:test",
594+
backend: "acpx",
595+
runtimeSessionName: "agent:codex:acp:test",
596+
acpxRecordId: "agent:codex:acp:test",
597+
},
598+
text: "Reply exactly OK",
599+
mode: "prompt",
600+
requestId: "turn-2",
601+
});
602+
for await (const _event of turn.events) {
603+
// no-op
604+
}
605+
await turn.result;
606+
607+
expect(startTurn).toHaveBeenCalledWith(
608+
expect.objectContaining({
609+
timeoutMs: 0,
610+
}),
611+
);
612+
});
613+
538614
it("does not normalize model startup for non-Codex ACP agents", async () => {
539615
const baseStore: TestSessionStore = {
540616
load: vi.fn(async () => undefined),

extensions/acpx/src/runtime.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ type ResetAwareSessionStore = AcpSessionStore & {
5151
markFresh: (sessionKey: string) => void;
5252
};
5353

54+
function withOpenClawManagedTurnTimeout<T extends object>(input: T): T & { timeoutMs: 0 } {
55+
// OpenClaw owns ACP turn deadlines. acpx treats timeout after partial agent
56+
// output as a completed turn, which can mark background work done early.
57+
return {
58+
...input,
59+
timeoutMs: 0,
60+
};
61+
}
62+
5463
type AcpxLaunchLeaseContext = {
5564
leaseId: string;
5665
gatewayInstanceId: string;
@@ -989,7 +998,7 @@ export class AcpxRuntime implements AcpRuntime {
989998
const command = await this.resolveCommandForHandle(input.handle);
990999
const delegate = await this.resolveDelegateForHandle(input.handle);
9911000
try {
992-
for await (const event of delegate.runTurn(input)) {
1001+
for await (const event of delegate.runTurn(withOpenClawManagedTurnTimeout(input))) {
9931002
if (
9941003
event.type !== "error" ||
9951004
!isCodexAcpCommand(command) ||
@@ -1035,7 +1044,7 @@ export class AcpxRuntime implements AcpRuntime {
10351044
try {
10361045
return {
10371046
command,
1038-
turn: delegate.startTurn(input),
1047+
turn: delegate.startTurn(withOpenClawManagedTurnTimeout(input)),
10391048
};
10401049
} catch (error) {
10411050
if (!isCodexAcpCommand(command) || !isGenericInternalAcpError(error)) {

extensions/acpx/src/service.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,36 @@ describe("createAcpxRuntimeService", () => {
483483
await service.stop?.(ctx);
484484
});
485485

486+
it("passes the plugin timeout to the default acpx runtime constructor", async () => {
487+
process.env.OPENCLAW_ACPX_RUNTIME_STARTUP_PROBE = "0";
488+
const workspaceDir = await makeTempDir();
489+
const ctx = createServiceContext(workspaceDir);
490+
const service = createAcpxRuntimeService({
491+
pluginConfig: { timeoutSeconds: 0.001 },
492+
});
493+
494+
await service.start(ctx);
495+
496+
const backend = getAcpRuntimeBackend("acpx");
497+
if (!backend) {
498+
throw new Error("expected ACPX runtime backend");
499+
}
500+
const backendRuntime = backend.runtime as {
501+
ensureSession(input: { agent: string; mode: string; sessionKey: string }): Promise<unknown>;
502+
};
503+
504+
await backendRuntime.ensureSession({
505+
agent: "codex",
506+
mode: "oneshot",
507+
sessionKey: "agent:codex:acp:test",
508+
});
509+
510+
const [options] = acpxRuntimeConstructorMock.mock.calls[0] ?? [];
511+
expect(options).toHaveProperty("timeoutMs", 1);
512+
513+
await service.stop?.(ctx);
514+
});
515+
486516
it("runs the embedded runtime probe at startup by default and reports health", async () => {
487517
const workspaceDir = await makeTempDir();
488518
const ctx = createServiceContext(workspaceDir);

src/acp/control-plane/manager.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -679,7 +679,7 @@ describe("AcpSessionManager", () => {
679679
expect(runtimeState.runTurn).toHaveBeenCalledTimes(1);
680680
});
681681

682-
it("times out a hung persistent turn without closing the session and lets queued work continue", async () => {
682+
it("times out a hung persistent turn after partial progress without closing the session and lets queued work continue", async () => {
683683
vi.useFakeTimers();
684684
try {
685685
const runtimeState = createRuntime();
@@ -697,6 +697,7 @@ describe("AcpSessionManager", () => {
697697
runtimeState.runTurn.mockImplementation(async function* (input: { requestId: string }) {
698698
if (input.requestId === "r1") {
699699
firstTurnStarted = true;
700+
yield { type: "text_delta" as const, text: "Working on it..." };
700701
await new Promise(() => {});
701702
}
702703
yield { type: "done" as const };

0 commit comments

Comments
 (0)