diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eff14ac382..5aaad5d0e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ Docs: https://docs.openclaw.ai - Hooks/doctor: warn when `hooks.transformsDir` points outside the canonical hooks transform directory, so invalid workspace skill paths get a direct recovery hint before the Gateway crash-loops. Fixes #75853. Thanks @midobk. - Proxy/audio: convert standard `FormData` bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send `[object FormData]` when `HTTP_PROXY` or `HTTPS_PROXY` is configured. Fixes #48554. Thanks @dco5. - Discord: allow explicitly configured ack reactions in tool-only guild channels while keeping automatic lifecycle/status reactions suppressed. Fixes #74922. Thanks @samvilian and @BlueBirdBack. +- Discord: treat abort-time Carbon reconnect-exhausted events as expected shutdown during stale-socket restarts, so health-monitor restarts no longer reject the monitor lifecycle. Carries forward #58216; supersedes #73949. Thanks @Perttulands. - Discord/native commands: return an explicit warning when slash command dispatch or direct plugin execution produces no visible reply instead of a success-style completion ack. Fixes #58986; supersedes #62057. Thanks @jb510. - Discord: keep typing indicators alive during long tool runs and auto-compaction while keepalive ticks continue, so active sessions do not appear stalled before the final reply. Thanks @Squirbie. - Discord: preserve multipart Content-Type headers for attachment uploads across REST fetch paths, so generated images and other media no longer fail delivery with `CONTENT_TYPE_INVALID`. Thanks @FunJim. diff --git a/extensions/discord/src/monitor/provider.lifecycle.test.ts b/extensions/discord/src/monitor/provider.lifecycle.test.ts index 095334b02e6..9bacc3a38e3 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.test.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.test.ts @@ -175,12 +175,13 @@ describe("runDiscordGatewayLifecycle", () => { threadStop: ReturnType; waitCalls: number; gatewaySupervisor: { detachLifecycle: ReturnType }; + detachCalls?: number; }) { expect(waitForDiscordGatewayStopMock).toHaveBeenCalledTimes(params.waitCalls); expect(unregisterGatewayMock).toHaveBeenCalledWith("default"); expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1); expect(params.threadStop).toHaveBeenCalledTimes(1); - expect(params.gatewaySupervisor.detachLifecycle).toHaveBeenCalledTimes(1); + expect(params.gatewaySupervisor.detachLifecycle).toHaveBeenCalledTimes(params.detachCalls ?? 1); } it("resolves gateway READY timeouts from config, env, then defaults", () => { @@ -509,6 +510,60 @@ describe("runDiscordGatewayLifecycle", () => { }); }); + it("treats abort-time live reconnect exhaustion as expected shutdown", async () => { + const abortController = new AbortController(); + let liveGatewayHandler: ((event: DiscordGatewayEvent) => void) | undefined; + const { lifecycleParams, threadStop, runtimeLog, runtimeError, gatewaySupervisor } = + createLifecycleHarness(); + lifecycleParams.abortSignal = abortController.signal; + gatewaySupervisor.attachLifecycle.mockImplementation( + (handler: (event: DiscordGatewayEvent) => void) => { + liveGatewayHandler = handler; + }, + ); + abortController.signal.addEventListener( + "abort", + () => { + if (!liveGatewayHandler) { + throw new Error("discord gateway lifecycle handler was not attached"); + } + liveGatewayHandler( + createGatewayEvent( + "reconnect-exhausted", + "Max reconnect attempts (50) reached after close code 1005", + ), + ); + }, + { once: true }, + ); + waitForDiscordGatewayStopMock.mockImplementationOnce(async (waitParams) => { + const actual = + await vi.importActual("../monitor.gateway.js"); + const waitPromise = actual.waitForDiscordGatewayStop(waitParams); + abortController.abort(new Error("shutdown")); + return await waitPromise; + }); + + await expect(runDiscordGatewayLifecycle(lifecycleParams)).resolves.toBeUndefined(); + + expect(gatewaySupervisor.attachLifecycle).toHaveBeenCalledTimes(1); + expect(runtimeLog).toHaveBeenCalledWith( + expect.stringContaining("treating reconnect-exhausted during expected shutdown as clean"), + ); + expect(runtimeLog).toHaveBeenCalledWith( + expect.stringContaining("Max reconnect attempts (50) reached after close code 1005"), + ); + expect(runtimeError).not.toHaveBeenCalledWith( + expect.stringContaining("discord gateway reconnect-exhausted"), + ); + expectLifecycleCleanup({ + threadStop, + waitCalls: 1, + gatewaySupervisor, + detachCalls: 2, + }); + }); + it("surfaces fatal startup gateway errors while waiting for READY", async () => { vi.useFakeTimers(); try { diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts index d0720a5eddf..af7223c4da0 100644 --- a/extensions/discord/src/monitor/provider.lifecycle.ts +++ b/extensions/discord/src/monitor/provider.lifecycle.ts @@ -457,6 +457,13 @@ export async function runDiscordGatewayLifecycle(params: { let sawDisallowedIntents = false; const handleGatewayEvent = (event: DiscordGatewayEvent): "continue" | "stop" => { + if (params.abortSignal?.aborted && event.type === "reconnect-exhausted") { + lifecycleStopping = true; + params.runtime.log?.( + `discord: treating reconnect-exhausted during expected shutdown as clean: ${event.message}`, + ); + return "continue"; + } if (event.type === "disallowed-intents") { lifecycleStopping = true; sawDisallowedIntents = true;