Skip to content

Commit a187437

Browse files
committed
fix(whatsapp): handle opening-phase 428 and lock in post-open retry for #77443
- Add post-open Baileys 428 retry regression test covering #77443: on Windows the DefaultResourceLoader blocks the event loop during first agent startup, causing Baileys to fire connectionClosed (428). Channel must retry rather than stop so it recovers once the stall clears. - Update CHANGELOG to reference both #75736 and #77443.
1 parent fe6b834 commit a187437

2 files changed

Lines changed: 48 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1155,7 +1155,7 @@ Docs: https://docs.openclaw.ai
11551155
- Agents/replies: defer implicit image model discovery and keep OAuth auth-store adoption on persisted profiles during reply startup, cutting OCM MarCodex warm prep to sub-second in live checks. Thanks @shakkernerd.
11561156
- Plugins/tools: enforce `contracts.tools` as the manifest ownership contract for plugin tool registration, rejecting undeclared runtime tool names and adding bundled plugin drift coverage. Thanks @shakkernerd.
11571157
- Agents/Codex: stop prompting message-tool-only source turns to finish with `NO_REPLY`, so quiet turns are represented by not calling the visible message tool instead of conflicting final-text instructions. Thanks @pashpashpash.
1158-
- WhatsApp/Web: route opening-phase Baileys 428 connectionClosed through the WhatsApp reconnect policy instead of rethrowing to the generic channel manager, so transient socket closes during the handshake are retried with proper diagnostics rather than surfacing as a bare `channel exited` error. Fixes #75736. Thanks @dataCenter430.
1158+
- WhatsApp/Web: route opening-phase Baileys 428 connectionClosed through the WhatsApp reconnect policy instead of rethrowing to the generic channel manager, and keep post-open 428 closes retryable so the channel recovers automatically after Windows event-loop stalls during first agent startup rather than dying with a bare `channel exited` error. Fixes #75736 and #77443. Thanks @dataCenter430.
11591159
- Gateway/config: report failed backup restores as failed in logs and config observe audit records instead of marking them valid. (#70515) Thanks @davidangularme.
11601160
- Compaction: use the active session model fallback chain for implicit summarization failures without persisting fallback model selection, so Azure content-filter 400s can recover. Fixes #64960. (#74470) Thanks @jalehman and @OpenCodeEngineer.
11611161
- Gateway/config: allow `gateway config.patch` to update documented subagent thinking defaults. Fixes #75764. (#75802) Thanks @kAIborg24.

extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,53 @@ describe("web auto-reply connection", () => {
170170
expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("2/2 attempts"));
171171
});
172172

173+
it("retries post-open Baileys 428 (event-loop-stall close) instead of stopping", async () => {
174+
// Regression for #77443: on Windows 11, DefaultResourceLoader.reload() uses
175+
// synchronous fs ops that block the event loop for 10+ seconds on first agent
176+
// startup. Baileys fires DisconnectReason.connectionClosed (428) when its
177+
// keepalive timers can't run during the stall. The channel must retry rather
178+
// than stop, so it recovers automatically once the stall clears.
179+
const sleep = vi.fn(async () => {});
180+
const scripted = createScriptedWebListenerFactory();
181+
const { controller, run } = startWebAutoReplyMonitor({
182+
monitorWebChannelFn: monitorWebChannel as never,
183+
listenerFactory: scripted.listenerFactory,
184+
sleep,
185+
reconnect: { initialMs: 10, maxMs: 10, maxAttempts: 3, factor: 1.1 },
186+
});
187+
188+
await vi.waitFor(
189+
() => {
190+
expect(scripted.getListenerCount()).toBe(1);
191+
},
192+
{ timeout: 250, interval: 2 },
193+
);
194+
scripted.resolveClose(0, {
195+
status: 428,
196+
isLoggedOut: false,
197+
error: "Connection Terminated",
198+
});
199+
200+
// Must NOT stop immediately — 428 is retryable, so a second listener is created.
201+
await vi.waitFor(
202+
() => {
203+
expect(scripted.getListenerCount()).toBeGreaterThanOrEqual(2);
204+
},
205+
{ timeout: 250, interval: 2 },
206+
);
207+
208+
controller.abort();
209+
scripted.resolveClose(scripted.getListenerCount() - 1, {
210+
status: 499,
211+
isLoggedOut: false,
212+
error: "aborted",
213+
});
214+
await run;
215+
216+
expect(scripted.getListenerCount()).toBeGreaterThanOrEqual(2);
217+
expect(sleep).toHaveBeenCalled();
218+
});
219+
173220
it("treats status 440 as non-retryable and stops without retrying", async () => {
174221
const sleep = vi.fn(async () => {});
175222
const scripted = createScriptedWebListenerFactory();

0 commit comments

Comments
 (0)