From 9b0afd81413a28d43faf1b9f1382be433140ef44 Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 5 May 2026 07:49:33 +0100 Subject: [PATCH] feat: show restart handoffs in gateway status --- CHANGELOG.md | 1 + docs/cli/gateway.md | 1 + src/cli/daemon-cli/status.gather.test.ts | 52 ++++++++++++++++++++++++ src/cli/daemon-cli/status.gather.ts | 7 ++++ src/cli/daemon-cli/status.print.test.ts | 35 ++++++++++++++++ src/cli/daemon-cli/status.print.ts | 4 ++ 6 files changed, 100 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a0d49a919..2202f7c6c6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/status: show recent supervisor restart handoffs in `openclaw gateway status --deep`, including JSON details, so clean service-managed restarts are reported as restart handoffs instead of opaque stopped-service diagnostics. - Video generation: wait up to 20 minutes for slow fal/MiniMax queue-backed jobs, stop forwarding unsupported Google Veo generated-audio options, and normalize MiniMax `720P` requests to its supported `768P` resolution with the usual override warning/details instead of failing fallback. - Video generation: accept provider-specific aspect-ratio and resolution hints at the tool boundary, normalize `720P` to MiniMax's supported `768P`, and stop sending Google `generateAudio` on Gemini video requests so provider fallback can recover from model-specific parameter differences. Thanks @vincentkoc. - OpenAI/Google Meet: fail realtime voice connection attempts when the socket closes before `session.updated`, avoiding stuck Meet joins waiting on a bridge that never became ready. Thanks @vincentkoc. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index b5b3edd8127..e2896d3a27b 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -295,6 +295,7 @@ openclaw gateway status --require-rpc - If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives. - Use `--require-rpc` in scripts and automation when a listening service is not enough and you need read-scope RPC calls to be healthy too. - `--deep` adds a best-effort scan for extra launchd/systemd/schtasks installs. When multiple gateway-like services are detected, human output prints cleanup hints and warns that most setups should run one gateway per machine. + - `--deep` also reports a recent Gateway supervisor restart handoff when the service process exited cleanly for an external supervisor restart. - Human output includes the resolved file log path plus the CLI-vs-service config paths/validity snapshot to help diagnose profile or state-dir drift. diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index d63c7befde8..0357c7163bb 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createMockGatewayService } from "../../daemon/service.test-helpers.js"; +import type { GatewayRestartHandoff } from "../../infra/restart-handoff.js"; import { captureEnv } from "../../test-utils/env.js"; import type { GatewayRestartSnapshot } from "./restart-health.js"; import { gatherDaemonStatus } from "./status.gather.js"; @@ -27,6 +28,9 @@ const inspectPortUsage = vi.fn(async (port: number) => ({ hints: [], })); const readLastGatewayErrorLine = vi.fn(async (_env?: NodeJS.ProcessEnv) => null); +const readGatewayRestartHandoffSync = vi.fn< + (_env?: NodeJS.ProcessEnv) => GatewayRestartHandoff | null +>(() => null); const auditGatewayServiceConfig = vi.fn(async (_opts?: unknown) => undefined); const serviceIsLoaded = vi.fn(async (_opts?: unknown) => true); const serviceReadRuntime = vi.fn(async (_env?: NodeJS.ProcessEnv) => ({ status: "running" })); @@ -136,6 +140,10 @@ vi.mock("../../infra/ports.js", () => ({ formatPortDiagnostics: () => [], })); +vi.mock("../../infra/restart-handoff.js", () => ({ + readGatewayRestartHandoffSync: (env?: NodeJS.ProcessEnv) => readGatewayRestartHandoffSync(env), +})); + vi.mock("../../infra/tailnet.js", () => ({ pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(), })); @@ -173,6 +181,7 @@ describe("gatherDaemonStatus", () => { callGatewayStatusProbe.mockClear(); loadGatewayTlsRuntime.mockClear(); inspectGatewayRestart.mockClear(); + readGatewayRestartHandoffSync.mockClear(); readConfigFileSnapshotCalls.mockClear(); loadConfigCalls.mockClear(); daemonLoadedConfig = { @@ -369,6 +378,49 @@ describe("gatherDaemonStatus", () => { }); }); + it("surfaces recent service restart handoffs only during deep status", async () => { + readGatewayRestartHandoffSync.mockReturnValueOnce({ + kind: "gateway-supervisor-restart-handoff", + version: 1, + intentId: "intent-1", + pid: 12_345, + createdAt: 10_000, + expiresAt: 70_000, + reason: "plugin source changed", + source: "plugin-change", + restartKind: "full-process", + supervisorMode: "launchd", + }); + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: false, + deep: true, + }); + + expect(readGatewayRestartHandoffSync).toHaveBeenCalledWith( + expect.objectContaining({ + OPENCLAW_STATE_DIR: "/tmp/openclaw-daemon", + OPENCLAW_CONFIG_PATH: "/tmp/openclaw-daemon/openclaw.json", + }), + ); + expect(status.service.restartHandoff).toMatchObject({ + reason: "plugin source changed", + restartKind: "full-process", + supervisorMode: "launchd", + }); + }); + + it("does not read restart handoffs during normal status", async () => { + await gatherDaemonStatus({ + rpc: {}, + probe: false, + deep: false, + }); + + expect(readGatewayRestartHandoffSync).not.toHaveBeenCalled(); + }); + it("uses the fast config path for plain same-file status reads", async () => { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-status-config-")); const configPath = path.join(tmp, "openclaw.json"); diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 9968cdcd0ae..be892c87c1b 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -29,6 +29,10 @@ import { type PortListener, type PortUsageStatus, } from "../../infra/ports.js"; +import { + readGatewayRestartHandoffSync, + type GatewayRestartHandoff, +} from "../../infra/restart-handoff.js"; import { resolveConfiguredLogFilePath } from "../../logging/log-file-path.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import { normalizeListenerAddress, parsePortFromArgs, pickProbeHostForBind } from "./shared.js"; @@ -247,6 +251,7 @@ export type DaemonStatus = { } | null; runtime?: GatewayServiceRuntime; configAudit?: ServiceConfigAudit; + restartHandoff?: GatewayRestartHandoff; }; config?: { cli: ConfigSummary; @@ -437,6 +442,7 @@ export async function gatherDaemonStatus( service.isLoaded({ env: serviceEnv }).catch(() => false), service.readRuntime(serviceEnv).catch((err) => ({ status: "unknown", detail: String(err) })), ]); + const restartHandoff = opts.deep ? readGatewayRestartHandoffSync(serviceEnv) : null; const configAudit = command ? await loadServiceAuditModule().then(({ auditGatewayServiceConfig }) => auditGatewayServiceConfig({ @@ -556,6 +562,7 @@ export async function gatherDaemonStatus( command, runtime, configAudit, + ...(restartHandoff ? { restartHandoff } : {}), }, config: { cli: cliConfigSummary, diff --git a/src/cli/daemon-cli/status.print.test.ts b/src/cli/daemon-cli/status.print.test.ts index 6e6fd5862d9..bec6682229b 100644 --- a/src/cli/daemon-cli/status.print.test.ts +++ b/src/cli/daemon-cli/status.print.test.ts @@ -157,6 +157,41 @@ describe("printDaemonStatus", () => { expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("Capability: write-capable")); }); + it("prints restart handoff diagnostics when deep status gathered one", () => { + printDaemonStatus( + { + service: { + label: "LaunchAgent", + loaded: true, + loadedText: "loaded", + notLoadedText: "not loaded", + runtime: { status: "stopped" }, + restartHandoff: { + kind: "gateway-supervisor-restart-handoff", + version: 1, + intentId: "intent-1", + pid: 12_345, + createdAt: 10_000, + expiresAt: 70_000, + reason: "plugin source changed", + source: "plugin-change", + restartKind: "full-process", + supervisorMode: "launchd", + }, + }, + extraServices: [], + }, + { json: false }, + ); + + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("Recent restart handoff: full-process via launchd"), + ); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("reason=plugin source changed"), + ); + }); + it("passes daemon TLS state to dashboard link rendering", () => { printDaemonStatus( { diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index cb37808a363..4bba4ed8bef 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -11,6 +11,7 @@ import { } from "../../daemon/systemd-hints.js"; import { classifySystemdUnavailableDetail } from "../../daemon/systemd-unavailable.js"; import { resolveControlUiLinks } from "../../gateway/control-ui-links.js"; +import { formatGatewayRestartHandoffDiagnostic } from "../../infra/restart-handoff.js"; import { isWSLEnv } from "../../infra/wsl.js"; import { defaultRuntime } from "../../runtime.js"; import { colorize } from "../../terminal/theme.js"; @@ -180,6 +181,9 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) const runtimeColor = resolveRuntimeStatusColor(service.runtime?.status); defaultRuntime.log(`${label("Runtime:")} ${colorize(rich, runtimeColor, runtimeLine)}`); } + if (service.restartHandoff) { + defaultRuntime.log(infoText(formatGatewayRestartHandoffDiagnostic(service.restartHandoff))); + } if (rpc && !rpc.ok && service.loaded && service.runtime?.status === "running") { defaultRuntime.log(