Skip to content

Commit e29448d

Browse files
openperfsteipete
andauthored
fix(gateway): stop terminal WhatsApp restart loops (#78511)
* fix(gateway): prevent restart loops after terminal WhatsApp disconnects Track `terminalDisconnect` through the WhatsApp status controller, channel runtime snapshot, and `ChannelAccountSnapshot` so the health-monitor and the `ChannelManager` task-exit handler both skip auto-restart when Baileys signals a terminal session end (loggedOut / connectionReplaced). Adds a `terminal-disconnect` `ChannelHealthEvaluationReason` so the policy layer returns a stable, named reason rather than falling through to `not-running`, preventing unbounded WebSocket/heap growth on multi-tenant gateways. Fixes #78419. * fix(gateway): prioritize terminal disconnect recovery Co-authored-by: openperf <[email protected]> * docs(changelog): move WhatsApp restart fix to unreleased --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 66081c0 commit e29448d

17 files changed

Lines changed: 251 additions & 0 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
88

99
### Fixes
1010

11+
- **WhatsApp restart recovery:** stop automatic restart loops after logged-out or connection-replaced disconnects until the account reconnects. (#78511) Thanks @openperf.
1112
- **iMessage group warnings:** suppress the false drop-all startup warning when an effective group sender allowlist can admit groups, and point true empty-allowlist configurations at the correct remedy. (#100046)
1213

1314
## 2026.7.1

extensions/whatsapp/src/auto-reply/monitor-state.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,4 +133,47 @@ describe("createWebChannelStatusController", () => {
133133
loggedOut: false,
134134
});
135135
});
136+
137+
it.each([
138+
{ healthState: "logged-out", statusCode: 401, terminalDisconnect: true },
139+
{ healthState: "conflict", statusCode: 440, terminalDisconnect: true },
140+
{ healthState: "reconnecting", statusCode: 408, terminalDisconnect: false },
141+
] as const)(
142+
"sets terminalDisconnect=$terminalDisconnect after a $healthState stop",
143+
({ healthState, statusCode, terminalDisconnect }) => {
144+
const patches: Record<string, unknown>[] = [];
145+
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
146+
147+
controller.noteConnected(1000);
148+
controller.noteClose({
149+
at: 2000,
150+
statusCode,
151+
error: healthState,
152+
reconnectAttempts: healthState === "reconnecting" ? 1 : 0,
153+
healthState,
154+
});
155+
controller.markStopped(2100);
156+
157+
expect(patches.at(-1)!.terminalDisconnect).toBe(terminalDisconnect);
158+
},
159+
);
160+
161+
it("clears terminalDisconnect on noteConnected after a terminal stop", () => {
162+
const patches: Record<string, unknown>[] = [];
163+
const controller = createWebChannelStatusController((s) => patches.push({ ...s }));
164+
165+
controller.noteConnected(1000);
166+
controller.noteClose({
167+
at: 2000,
168+
statusCode: 401,
169+
error: "logged out",
170+
reconnectAttempts: 0,
171+
healthState: "logged-out",
172+
});
173+
controller.markStopped(2100);
174+
expect(patches.at(-1)!.terminalDisconnect).toBe(true);
175+
176+
controller.noteConnected(3000);
177+
expect(patches.at(-1)!.terminalDisconnect).toBeUndefined();
178+
});
136179
});

extensions/whatsapp/src/auto-reply/monitor-state.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export function createWebChannelStatusController(statusSink?: (status: WebChanne
5050
}
5151
status.lastError = null;
5252
status.healthState = "healthy";
53+
status.terminalDisconnect = undefined;
5354
emit();
5455
},
5556
noteInbound(at = Date.now()) {
@@ -119,6 +120,8 @@ export function createWebChannelStatusController(statusSink?: (status: WebChanne
119120
status.running = false;
120121
status.connected = false;
121122
status.lastEventAt = at;
123+
status.terminalDisconnect =
124+
status.healthState === "logged-out" || status.healthState === "conflict";
122125
if (!isTerminalHealthState(status.healthState)) {
123126
status.healthState = "stopped";
124127
}

extensions/whatsapp/src/auto-reply/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export type WebChannelStatus = {
3535
lastRunActivityAt?: number | null;
3636
lastError?: string | null;
3737
healthState?: WebChannelHealthState;
38+
terminalDisconnect?: boolean;
3839
};
3940

4041
export type WebMonitorTuning = {

extensions/whatsapp/src/channel.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,9 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
288288
lastRunActivityAt: snapshot.lastRunActivityAt ?? null,
289289
lastError: snapshot.lastError ?? null,
290290
healthState: snapshot.healthState ?? undefined,
291+
...(snapshot.terminalDisconnect
292+
? { terminalDisconnect: snapshot.terminalDisconnect }
293+
: {}),
291294
};
292295
},
293296
resolveAccountSnapshot: async ({ account, runtime }) => {
@@ -315,6 +318,9 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
315318
busy: runtime?.busy ?? false,
316319
lastRunActivityAt: runtime?.lastRunActivityAt ?? null,
317320
healthState: runtime?.healthState ?? undefined,
321+
...(runtime?.terminalDisconnect
322+
? { terminalDisconnect: runtime.terminalDisconnect }
323+
: {}),
318324
dmPolicy: account.dmPolicy,
319325
allowFrom: account.allowFrom,
320326
},

src/channels/account-snapshot-fields.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,15 @@ describe("projectSafeChannelAccountSnapshotFields", () => {
6060
lastTransportActivityAt: 456,
6161
});
6262
});
63+
64+
it("projects terminalDisconnect when present and omits it when absent", () => {
65+
const withFlag = projectSafeChannelAccountSnapshotFields({
66+
connected: false,
67+
terminalDisconnect: true,
68+
});
69+
expect(withFlag.terminalDisconnect).toBe(true);
70+
71+
const withoutFlag = projectSafeChannelAccountSnapshotFields({ connected: false });
72+
expect(withoutFlag).not.toHaveProperty("terminalDisconnect");
73+
});
6374
});

src/channels/account-snapshot-fields.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,9 @@ export function projectSafeChannelAccountSnapshotFields(
237237
: {}),
238238
...(statusState ? { statusState } : {}),
239239
...(healthState ? { healthState } : {}),
240+
...(readBoolean(record, "terminalDisconnect") !== undefined
241+
? { terminalDisconnect: readBoolean(record, "terminalDisconnect") }
242+
: {}),
240243
...(readBoolean(record, "busy") !== undefined ? { busy: readBoolean(record, "busy") } : {}),
241244
...(readNumber(record, "activeRuns") !== undefined
242245
? { activeRuns: readNumber(record, "activeRuns") }

src/channels/plugins/types.core.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export type ChannelAccountSnapshot = {
213213
lastTransportActivityAt?: number | null;
214214
lastError?: string | null;
215215
healthState?: string;
216+
terminalDisconnect?: boolean;
216217
lastStartAt?: number | null;
217218
lastStopAt?: number | null;
218219
lastInboundAt?: number | null;

src/gateway/channel-health-monitor.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,36 @@ describe("channel-health-monitor", () => {
274274
await expectNoStart(manager);
275275
});
276276

277+
it("does not restart a channel with terminalDisconnect set", async () => {
278+
const manager = createSnapshotManager({
279+
whatsapp: {
280+
default: {
281+
running: false,
282+
enabled: true,
283+
configured: true,
284+
terminalDisconnect: true,
285+
},
286+
},
287+
});
288+
await expectNoRestart(manager);
289+
});
290+
291+
it("restarts a stopped channel without terminalDisconnect", async () => {
292+
const manager = createSnapshotManager({
293+
whatsapp: {
294+
default: {
295+
running: false,
296+
enabled: true,
297+
configured: true,
298+
terminalDisconnect: false,
299+
},
300+
},
301+
});
302+
const monitor = await startAndRunCheck(manager);
303+
expect(manager.startChannel).toHaveBeenCalledWith("whatsapp", "default");
304+
monitor.stop();
305+
});
306+
277307
it("skips manually stopped channels", async () => {
278308
const manager = createSnapshotManager(
279309
{

src/gateway/channel-health-monitor.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,12 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
138138
if (health.healthy) {
139139
continue;
140140
}
141+
if (health.reason === "terminal-disconnect") {
142+
log.info?.(
143+
`[${channelId}:${accountId}] health-monitor: skipping restart, terminal disconnect`,
144+
);
145+
continue;
146+
}
141147

142148
const key = rKey(channelId, accountId);
143149
const record = restartRecords.get(key) ?? {

0 commit comments

Comments
 (0)