From 52d2bd5cc6fe70a4bea994f585893a3de2cb3ced Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 2 Apr 2026 15:30:49 +0530 Subject: [PATCH] fix: reject stale ACP reconnect prompts --- CHANGELOG.md | 1 + src/acp/translator.stop-reason.test.ts | 18 ++++++------------ src/acp/translator.ts | 19 +++++++++++++------ 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56a73ce552f..7287de1711b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - MS Teams/streaming: strip already-streamed text from fallback block delivery when replies exceed the 4000-character streaming limit so long responses stop duplicating content. (#59297) Thanks @bradgroux. - MS Teams/logging: format non-`Error` failures with the shared unknown-error helper so logs stop collapsing caught SDK or Axios objects into `[object Object]`. (#59321) Thanks @bradgroux. - Slack/thread context: filter thread starter and history by the effective conversation allowlist without dropping valid open-room, DM, or group DM context. (#58380) +- ACP/gateway reconnects: reject stale pre-ack ACP prompts after reconnect grace expiry so callers fail cleanly instead of hanging indefinitely when the gateway never confirms the run. ## 2026.4.1-beta.1 diff --git a/src/acp/translator.stop-reason.test.ts b/src/acp/translator.stop-reason.test.ts index 8eaa133dcee..ea3e08c9e6f 100644 --- a/src/acp/translator.stop-reason.test.ts +++ b/src/acp/translator.stop-reason.test.ts @@ -402,7 +402,7 @@ describe("acp translator stop reason mapping", () => { } }); - it("keeps pre-ack prompts pending after reconnect timeout", async () => { + it("rejects pre-ack prompts when reconnect timeout still finds no run", async () => { vi.useFakeTimers(); try { const sessionId = "session-1"; @@ -442,9 +442,7 @@ describe("acp translator stop reason mapping", () => { ); await vi.advanceTimersByTimeAsync(5_000); - await expect(Promise.race([promptPromise, Promise.resolve("pending")])).resolves.toBe( - "pending", - ); + await expect(promptPromise).rejects.toThrow("Gateway disconnected: 1006: connection lost"); } finally { vi.useRealTimers(); } @@ -491,7 +489,7 @@ describe("acp translator stop reason mapping", () => { await expect(Promise.race([secondPrompt, Promise.resolve("pending")])).resolves.toBe("pending"); }); - it("keeps disconnect deadline when a superseded send resolves late", async () => { + it("rejects stale pre-ack prompts when a superseded send resolves late", async () => { vi.useFakeTimers(); try { const sessionId = "session-1"; @@ -548,15 +546,13 @@ describe("acp translator stop reason mapping", () => { agent.handleGatewayReconnect(); await vi.advanceTimersByTimeAsync(5_000); - await expect(Promise.race([secondPrompt, Promise.resolve("pending")])).resolves.toBe( - "pending", - ); + await expect(secondPrompt).rejects.toThrow("Gateway disconnected: 1006: connection lost"); } finally { vi.useRealTimers(); } }); - it("finishes terminal prompts without rejecting other reconnecting prompts", async () => { + it("finishes terminal prompts while rejecting stale pre-ack prompts", async () => { vi.useFakeTimers(); try { let acceptedRunId: string | undefined; @@ -621,9 +617,7 @@ describe("acp translator stop reason mapping", () => { await vi.advanceTimersByTimeAsync(5_000); await expect(acceptedPrompt).resolves.toEqual({ stopReason: "end_turn" }); - await expect(Promise.race([preAckPrompt, Promise.resolve("pending")])).resolves.toBe( - "pending", - ); + await expect(preAckPrompt).rejects.toThrow("Gateway disconnected: 1006: connection lost"); } finally { vi.useRealTimers(); } diff --git a/src/acp/translator.ts b/src/acp/translator.ts index 7d68576e6d3..98fbafea411 100644 --- a/src/acp/translator.ts +++ b/src/acp/translator.ts @@ -1088,6 +1088,17 @@ export class AcpGatewayAgent implements Agent { pending.disconnectContext = undefined; } + private shouldRejectPendingAtDisconnectDeadline( + pending: PendingPrompt, + disconnectContext: DisconnectContext, + ): boolean { + return ( + pending.disconnectContext === disconnectContext && + (!pending.sendAccepted || + this.activeDisconnectContext?.generation === disconnectContext.generation) + ); + } + private async reconcilePendingPrompts( observedDisconnectGeneration: number, deadlineExpired: boolean, @@ -1101,8 +1112,6 @@ export class AcpGatewayAgent implements Agent { const pendingEntries = [...this.pendingPrompts.entries()]; let keepDisconnectTimer = false; - const shouldRejectPending = - deadlineExpired && this.activeDisconnectContext?.generation === observedDisconnectGeneration; for (const [sessionId, pending] of pendingEntries) { if (this.pendingPrompts.get(sessionId) !== pending) { continue; @@ -1114,7 +1123,6 @@ export class AcpGatewayAgent implements Agent { sessionId, pending, deadlineExpired, - shouldRejectPending, ); if (shouldKeepPending) { keepDisconnectTimer = true; @@ -1130,7 +1138,6 @@ export class AcpGatewayAgent implements Agent { sessionId: string, pending: PendingPrompt, deadlineExpired: boolean, - shouldRejectPending: boolean, ): Promise { const disconnectContext = pending.disconnectContext; if (!disconnectContext) { @@ -1149,7 +1156,7 @@ export class AcpGatewayAgent implements Agent { } catch (err) { this.log(`agent.wait reconcile failed for ${pending.idempotencyKey}: ${String(err)}`); if (deadlineExpired) { - if (shouldRejectPending) { + if (this.shouldRejectPendingAtDisconnectDeadline(pending, disconnectContext)) { this.rejectPendingPrompt( pending, new Error(`Gateway disconnected: ${disconnectContext.reason}`), @@ -1175,7 +1182,7 @@ export class AcpGatewayAgent implements Agent { return false; } if (deadlineExpired) { - if (shouldRejectPending) { + if (this.shouldRejectPendingAtDisconnectDeadline(currentPending, disconnectContext)) { const currentDisconnectContext = currentPending.disconnectContext; if (!currentDisconnectContext) { return false;