diff --git a/CHANGELOG.md b/CHANGELOG.md index a36c6b69310..775088a1301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Docs: https://docs.openclaw.ai - Gateway: scope `sessions.resolve` sessionId and label store loads to the requested agent so large unrelated agent stores are not parsed for scoped lookups. Fixes #51264. (#79474) Thanks @samzong. - Gateway: share serialized streaming event envelopes across eligible WebSocket and node subscribers while preserving per-client sequence numbers. (#80299) Thanks @samzong. - Browser: report Chrome MCP existing-session page readiness in browser status without letting status probes exceed the client timeout. Fixes #80268. (#80280) Thanks @ai-hpc. +- WhatsApp: route opening-phase Baileys 428 connectionClosed through the WhatsApp reconnect policy and keep post-open 428 closes retryable, so transient setup socket closes retry with WhatsApp diagnostics instead of escaping as a bare `channel exited` error. Fixes #75736; mitigates #77443. Thanks @dataCenter430. - Providers/self-hosted: read model-scoped llama.cpp runtime context from `/props.default_generation_settings.n_ctx` while keeping top-level `n_ctx` as a fallback, so session budgeting reflects the loaded context window. Fixes #73664. (#74057) Thanks @brokemac79. - Memory: reject symlinked directory components in configured extra memory paths before reading Markdown files. (#80331) Thanks @samzong. - Sessions/transcripts: replace whole-file `readFile` scans with shared streaming helpers (`streamSessionTranscriptLines` and `streamSessionTranscriptLinesReverse`) for idempotency lookup, latest/tail assistant text reads, delivery-mirror dedupe, and compaction fork loading, so long-running sessions no longer materialize the full transcript in memory. Forward scans use `readline` over a bounded `createReadStream`; reverse scans read bounded chunks from the file end and decode complete JSONL lines newest-first without a fixed tail cap. Synthetic 200 MiB transcript: peak RSS delta drops from +252 MiB to +27 MiB while preserving malformed-line tolerance and idempotency-key return semantics. Fixes #54296. Thanks @jack-stormentswe. diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts index 9fedd82f892..0af08a47d19 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts @@ -169,6 +169,75 @@ describe("web auto-reply connection", () => { } }); + it("retries opening-phase Boom 428 through the reconnect policy", async () => { + const boom428 = { + output: { + statusCode: 428, + payload: { error: "Precondition Required", message: "Connection Terminated" }, + }, + }; + const listenerFactory = vi.fn(async () => { + throw boom428; + }); + + const sleep = vi.fn(async () => {}); + const { runtime, run } = startWebAutoReplyMonitor({ + monitorWebChannelFn: monitorWebChannel as never, + listenerFactory, + sleep, + reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 2, factor: 1.1 }, + }); + + await run; + + expect(listenerFactory).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalled(); + expectErrorContaining(runtime.error, "status 428"); + expectErrorContaining(runtime.error, "Retry 1/2"); + expectErrorContaining(runtime.error, "2/2 attempts"); + }); + + it("keeps post-open Baileys 428 on the reconnect path", async () => { + const sleep = vi.fn(async () => {}); + const scripted = createScriptedWebListenerFactory(); + const { controller, run } = startWebAutoReplyMonitor({ + monitorWebChannelFn: monitorWebChannel as never, + listenerFactory: scripted.listenerFactory, + sleep, + reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 }, + }); + + await vi.waitFor( + () => { + expect(scripted.getListenerCount()).toBe(1); + }, + { timeout: 250, interval: 2 }, + ); + scripted.resolveClose(0, { + status: 428, + isLoggedOut: false, + error: "Connection Terminated", + }); + + await vi.waitFor( + () => { + expect(scripted.getListenerCount()).toBeGreaterThanOrEqual(2); + }, + { timeout: 250, interval: 2 }, + ); + + controller.abort(); + scripted.resolveClose(scripted.getListenerCount() - 1, { + status: 499, + isLoggedOut: false, + error: "aborted", + }); + await run; + + expect(scripted.getListenerCount()).toBeGreaterThanOrEqual(2); + expect(sleep).toHaveBeenCalled(); + }); + it("treats status 440 as non-retryable and stops without retrying", async () => { const sleep = vi.fn(async () => {}); const scripted = createScriptedWebListenerFactory(); diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts index 0a6e544aeed..1990fd89bf1 100644 --- a/extensions/whatsapp/src/auto-reply/monitor.ts +++ b/extensions/whatsapp/src/auto-reply/monitor.ts @@ -29,7 +29,13 @@ import { resolveReconnectPolicy, sleepWithAbort, } from "../reconnect.js"; -import { formatError, getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../session.js"; +import { + formatError, + getStatusCode, + getWebAuthAgeMs, + logoutWeb, + readWebSelfId, +} from "../session.js"; import { resolveWhatsAppSocketTiming } from "../socket-timing.js"; import { getRuntimeConfig, getRuntimeConfigSourceSnapshot } from "./config.runtime.js"; import { whatsappHeartbeatLog, whatsappLog } from "./loggers.js"; @@ -43,6 +49,8 @@ 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. + // Baileys 428 = DisconnectReason.connectionClosed, a generic WebSocket close + // that is often transient and must stay on the reconnect path. return statusCode === 440; } @@ -395,6 +403,50 @@ export async function monitorWebChannel( }, }); } catch (error) { + if (getStatusCode(error) === 428) { + const retryDecision = controller.consumeReconnectAttempt(); + statusController.noteReconnectAttempts(retryDecision.reconnectAttempts); + statusController.noteClose({ + statusCode: 428, + error: formatError(error), + reconnectAttempts: retryDecision.reconnectAttempts, + healthState: retryDecision.healthState, + }); + if (retryDecision.action === "stop") { + reconnectLogger.warn( + { + connectionId, + status: 428, + reconnectAttempts: retryDecision.reconnectAttempts, + maxAttempts: reconnectPolicy.maxAttempts, + }, + "web reconnect: 428 during opening; max attempts reached", + ); + runtime.error( + `WhatsApp Web connection closed during setup (status 428) after ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts} attempts. Relink with \`${formatCliCommand("openclaw channels login --channel whatsapp")}\` if the issue persists.`, + ); + await controller.shutdown(); + break; + } + reconnectLogger.info( + { + connectionId, + status: 428, + reconnectAttempts: retryDecision.reconnectAttempts, + delayMs: retryDecision.delayMs, + }, + "web reconnect: 428 during opening; retrying", + ); + runtime.error( + `WhatsApp Web connection closed during setup (status 428). Retry ${retryDecision.reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDurationPrecise(retryDecision.delayMs ?? 0)}.`, + ); + try { + await controller.waitBeforeRetry(retryDecision.delayMs ?? 0); + } catch { + break; + } + continue; + } if (!isRetryableAuthUnstableError(error)) { throw error; }