diff --git a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts index f6eca287621..678cf0d37c6 100644 --- a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts +++ b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts @@ -145,6 +145,56 @@ describe("web auto-reply", () => { expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining(scenario.expectedError)); } }); + + it("treats status 440 as non-retryable and stops without retrying", async () => { + const closeResolvers: Array<(reason?: unknown) => void> = []; + const sleep = vi.fn(async () => {}); + const listenerFactory = vi.fn(async () => { + const onClose = new Promise((res) => { + closeResolvers.push(res); + }); + return { close: vi.fn(), onClose }; + }); + const { runtime, controller, run } = startMonitorWebChannel({ + monitorWebChannelFn: monitorWebChannel as never, + listenerFactory, + sleep, + reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 }, + }); + + await Promise.resolve(); + expect(listenerFactory).toHaveBeenCalledTimes(1); + closeResolvers.shift()?.({ + status: 440, + isLoggedOut: false, + error: "Unknown Stream Errored (conflict)", + }); + + const completedQuickly = await Promise.race([ + run.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 60)), + ]); + + if (!completedQuickly) { + await vi.waitFor( + () => { + expect(listenerFactory).toHaveBeenCalledTimes(2); + }, + { timeout: 250, interval: 2 }, + ); + controller.abort(); + closeResolvers[1]?.({ status: 499, isLoggedOut: false, error: "aborted" }); + await run; + } + + expect(completedQuickly).toBe(true); + expect(listenerFactory).toHaveBeenCalledTimes(1); + expect(sleep).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("status 440")); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("session conflict")); + expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("Stopping web monitoring")); + }); + it("forces reconnect when watchdog closes without onClose", async () => { vi.useFakeTimers(); try { diff --git a/src/web/auto-reply/monitor.ts b/src/web/auto-reply/monitor.ts index cab3490fedd..b7e2bb2683f 100644 --- a/src/web/auto-reply/monitor.ts +++ b/src/web/auto-reply/monitor.ts @@ -31,6 +31,12 @@ import { createWebOnMessageHandler } from "./monitor/on-message.js"; import type { WebChannelStatus, WebInboundMsg, WebMonitorTuning } from "./types.js"; import { isLikelyWhatsAppCryptoError } from "./util.js"; +function isNonRetryableWebCloseStatus(statusCode: unknown): boolean { + // WhatsApp 440 = session conflict ("Unknown Stream Errored (conflict)"). + // This is persistent until the operator resolves the conflicting session. + return statusCode === 440; +} + export async function monitorWebChannel( verbose: boolean, listenerFactory: typeof monitorWebInbox | undefined = monitorWebInbox, @@ -402,6 +408,22 @@ export async function monitorWebChannel( break; } + if (isNonRetryableWebCloseStatus(statusCode)) { + reconnectLogger.warn( + { + connectionId, + status: statusCode, + error: errorStr, + }, + "web reconnect: non-retryable close status; stopping monitor", + ); + runtime.error( + `WhatsApp Web connection closed (status ${statusCode}: session conflict). Resolve conflicting WhatsApp Web sessions, then relink with \`${formatCliCommand("openclaw channels login --channel web")}\`. Stopping web monitoring.`, + ); + await closeListener(); + break; + } + reconnectAttempts += 1; status.reconnectAttempts = reconnectAttempts; emitStatus();