fix: reject stale ACP reconnect prompts

This commit is contained in:
Ayaan Zaidi 2026-04-02 15:30:49 +05:30
parent ac5bc4fb37
commit 52d2bd5cc6
No known key found for this signature in database
3 changed files with 20 additions and 18 deletions

View File

@ -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

View File

@ -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();
}

View File

@ -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<boolean> {
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;