diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b25a147e16..d8f9888f254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob. - Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142) - Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411) +- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc. ## 2026.3.13 diff --git a/ui/src/ui/app-gateway.node.test.ts b/ui/src/ui/app-gateway.node.test.ts index 471a719c603..20e68318bd2 100644 --- a/ui/src/ui/app-gateway.node.test.ts +++ b/ui/src/ui/app-gateway.node.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { GATEWAY_EVENT_UPDATE_AVAILABLE } from "../../../src/gateway/events.js"; import { ConnectErrorDetailCodes } from "../../../src/gateway/protocol/connect-error-details.js"; import { connectGateway, resolveControlUiClientVersion } from "./app-gateway.ts"; +import type { GatewayHelloOk } from "./gateway.ts"; const loadChatHistoryMock = vi.hoisted(() => vi.fn(async () => undefined)); @@ -9,6 +10,7 @@ type GatewayClientMock = { start: ReturnType; stop: ReturnType; options: { clientVersion?: string }; + emitHello: (hello?: GatewayHelloOk) => void; emitClose: (info: { code: number; reason?: string; @@ -39,6 +41,7 @@ vi.mock("./gateway.ts", () => { constructor( private opts: { clientVersion?: string; + onHello?: (hello: GatewayHelloOk) => void; onClose?: (info: { code: number; reason: string; @@ -52,6 +55,15 @@ vi.mock("./gateway.ts", () => { start: this.start, stop: this.stop, options: { clientVersion: this.opts.clientVersion }, + emitHello: (hello) => { + this.opts.onHello?.( + hello ?? { + type: "hello-ok", + protocol: 3, + snapshot: {}, + }, + ); + }, emitClose: (info) => { this.opts.onClose?.({ code: info.code, @@ -356,6 +368,93 @@ describe("connectGateway", () => { expect(host.lastErrorCode).toBe("AUTH_TOKEN_MISMATCH"); }); + it("surfaces shutdown restart reasons before the socket closes", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitEvent({ + event: "shutdown", + payload: { + reason: "config change requires gateway restart (plugins.installs)", + restartExpectedMs: 1500, + }, + }); + client.emitClose({ code: 1006 }); + + expect(host.lastError).toBe( + "Restarting: config change requires gateway restart (plugins.installs)", + ); + expect(host.lastErrorCode).toBeNull(); + }); + + it("clears pending shutdown messages on successful hello after reconnect", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitEvent({ + event: "shutdown", + payload: { + reason: "config change", + restartExpectedMs: 1500, + }, + }); + client.emitClose({ code: 1006 }); + + expect(host.lastError).toBe("Restarting: config change"); + + client.emitHello(); + expect(host.lastError).toBeNull(); + + client.emitClose({ code: 1006 }); + expect(host.lastError).toBe("disconnected (1006): no reason"); + }); + + it("keeps shutdown restart reasons on service restart closes", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitEvent({ + event: "shutdown", + payload: { + reason: "gateway restarting", + restartExpectedMs: 1500, + }, + }); + client.emitClose({ code: 1012, reason: "service restart" }); + + expect(host.lastError).toBe("Restarting: gateway restarting"); + expect(host.lastErrorCode).toBeNull(); + }); + + it("prefers shutdown restart reasons over non-1012 close reasons", () => { + const host = createHost(); + + connectGateway(host); + const client = gatewayClientInstances[0]; + expect(client).toBeDefined(); + + client.emitEvent({ + event: "shutdown", + payload: { + reason: "gateway restarting", + restartExpectedMs: 1500, + }, + }); + client.emitClose({ code: 1001, reason: "going away" }); + + expect(host.lastError).toBe("Restarting: gateway restarting"); + expect(host.lastErrorCode).toBeNull(); + }); + it("does not reload chat history for each live tool result event", () => { const host = createHost(); diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index bcd8a866e4e..1a4206a7f8c 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -91,6 +91,10 @@ type SessionDefaultsSnapshot = { scope?: string; }; +type GatewayHostWithShutdownMessage = GatewayHost & { + pendingShutdownMessage?: string | null; +}; + export function resolveControlUiClientVersion(params: { gatewayUrl: string; serverVersion: string | null; @@ -171,6 +175,8 @@ function applySessionDefaults(host: GatewayHost, defaults?: SessionDefaultsSnaps } export function connectGateway(host: GatewayHost) { + const shutdownHost = host as GatewayHostWithShutdownMessage; + shutdownHost.pendingShutdownMessage = null; host.lastError = null; host.lastErrorCode = null; host.hello = null; @@ -195,6 +201,7 @@ export function connectGateway(host: GatewayHost) { if (host.client !== client) { return; } + shutdownHost.pendingShutdownMessage = null; host.connected = true; host.lastError = null; host.lastErrorCode = null; @@ -234,9 +241,10 @@ export function connectGateway(host: GatewayHost) { : error.message; return; } - host.lastError = `disconnected (${code}): ${reason || "no reason"}`; + host.lastError = + shutdownHost.pendingShutdownMessage ?? `disconnected (${code}): ${reason || "no reason"}`; } else { - host.lastError = null; + host.lastError = shutdownHost.pendingShutdownMessage ?? null; host.lastErrorCode = null; } }, @@ -347,6 +355,22 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) { return; } + if (evt.event === "shutdown") { + const payload = evt.payload as { reason?: unknown; restartExpectedMs?: unknown } | undefined; + const reason = + payload && typeof payload.reason === "string" && payload.reason.trim() + ? payload.reason.trim() + : "gateway stopping"; + const shutdownMessage = + typeof payload?.restartExpectedMs === "number" + ? `Restarting: ${reason}` + : `Disconnected: ${reason}`; + (host as GatewayHostWithShutdownMessage).pendingShutdownMessage = shutdownMessage; + host.lastError = shutdownMessage; + host.lastErrorCode = null; + return; + } + if (evt.event === "cron" && host.tab === "cron") { void loadCron(host as unknown as Parameters[0]); }