From 6d485a9f366d9aaae1d643fa5802a2e765dd58ed Mon Sep 17 00:00:00 2001 From: Shakker Date: Tue, 5 May 2026 07:50:18 +0100 Subject: [PATCH] feat: show restart handoffs in doctor --- CHANGELOG.md | 1 + docs/cli/doctor.md | 2 +- .../doctor-gateway-daemon-flow.test.ts | 74 +++++++++++++++++++ src/commands/doctor-gateway-daemon-flow.ts | 21 +++++- 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2202f7c6c6d..89a8f4729bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Doctor/gateway: report recent supervisor restart handoffs in `openclaw doctor --deep`, using the installed service environment when available so service-managed clean exits are visible in guided diagnostics. - 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. diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index a240aa11c12..8d6f5d182ad 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -34,7 +34,7 @@ openclaw doctor --generate-gateway-token - `--force`: apply aggressive repairs, including overwriting custom service config when needed - `--non-interactive`: run without prompts; safe migrations and non-service repairs only - `--generate-gateway-token`: generate and configure a gateway token -- `--deep`: scan system services for extra gateway installs +- `--deep`: scan system services for extra gateway installs and report recent Gateway supervisor restart handoffs Notes: diff --git a/src/commands/doctor-gateway-daemon-flow.test.ts b/src/commands/doctor-gateway-daemon-flow.test.ts index b3c8292a32b..4ecbaf331fc 100644 --- a/src/commands/doctor-gateway-daemon-flow.test.ts +++ b/src/commands/doctor-gateway-daemon-flow.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ExtraGatewayService } from "../daemon/inspect.js"; import * as launchd from "../daemon/launchd.js"; +import type { GatewayRestartHandoff } from "../infra/restart-handoff.js"; import { withEnvAsync } from "../test-utils/env.js"; import { createDoctorPrompter } from "./doctor-prompter.js"; import { EXTERNAL_SERVICE_REPAIR_NOTE } from "./doctor-service-repair-policy.js"; @@ -18,6 +19,9 @@ const sleep = vi.hoisted(() => vi.fn(async () => {})); const healthCommand = vi.hoisted(() => vi.fn(async () => {})); const inspectPortUsage = vi.hoisted(() => vi.fn()); const readLastGatewayErrorLine = vi.hoisted(() => vi.fn(async () => null)); +const readGatewayRestartHandoffSync = vi.hoisted(() => + vi.fn<() => GatewayRestartHandoff | null>(() => null), +); const findSystemGatewayServices = vi.hoisted(() => vi.fn<() => Promise>(async () => []), ); @@ -82,6 +86,16 @@ vi.mock("../infra/ports.js", () => ({ formatPortDiagnostics: vi.fn(() => []), })); +vi.mock("../infra/restart-handoff.js", async () => { + const actual = await vi.importActual( + "../infra/restart-handoff.js", + ); + return { + ...actual, + readGatewayRestartHandoffSync, + }; +}); + vi.mock("../infra/wsl.js", () => ({ isWSL: vi.fn(async () => false), })); @@ -133,7 +147,9 @@ describe("maybeRepairGatewayDaemon", () => { vi.clearAllMocks(); service.isLoaded.mockResolvedValue(true); service.readRuntime.mockResolvedValue({ status: "running" }); + service.readCommand.mockResolvedValue(null); service.restart.mockResolvedValue({ outcome: "completed" }); + readGatewayRestartHandoffSync.mockReturnValue(null); findSystemGatewayServices.mockResolvedValue([]); inspectPortUsage.mockResolvedValue({ port: 18789, @@ -245,6 +261,64 @@ describe("maybeRepairGatewayDaemon", () => { await runScheduledGatewayRepair("Restart gateway service now?"); }); + it("reports recent restart handoffs during deep doctor", async () => { + setPlatform("linux"); + service.readCommand.mockResolvedValueOnce({ + programArguments: ["/bin/node", "cli", "gateway"], + environment: { + OPENCLAW_STATE_DIR: "/tmp/openclaw-service", + OPENCLAW_CONFIG_PATH: "/tmp/openclaw-service/openclaw.json", + }, + }); + 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: "systemd", + }); + + await maybeRepairGatewayDaemon({ + cfg: { gateway: {} }, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + prompter: createDoctorPrompter({ + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + options: { deep: true, nonInteractive: true }, + }), + options: { deep: true, nonInteractive: true }, + gatewayDetailsMessage: "details", + healthOk: false, + }); + + expect(readGatewayRestartHandoffSync).toHaveBeenCalledWith( + expect.objectContaining({ + OPENCLAW_STATE_DIR: "/tmp/openclaw-service", + OPENCLAW_CONFIG_PATH: "/tmp/openclaw-service/openclaw.json", + }), + ); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("Recent restart handoff: full-process via systemd"), + "Gateway", + ); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("reason=plugin source changed"), + "Gateway", + ); + }); + + it("does not read restart handoffs during normal doctor", async () => { + setPlatform("linux"); + + await runNonInteractiveRepair(); + + expect(readGatewayRestartHandoffSync).not.toHaveBeenCalled(); + }); + it("skips start verification when a stopped service start is only scheduled", async () => { service.readRuntime.mockResolvedValue({ status: "stopped" }); await runScheduledGatewayRepair("Start gateway service now?"); diff --git a/src/commands/doctor-gateway-daemon-flow.ts b/src/commands/doctor-gateway-daemon-flow.ts index 26a3e46e4ed..fb06b995044 100644 --- a/src/commands/doctor-gateway-daemon-flow.ts +++ b/src/commands/doctor-gateway-daemon-flow.ts @@ -16,6 +16,10 @@ import { describeGatewayServiceRestart, resolveGatewayService } from "../daemon/ import { renderSystemdUnavailableHints } from "../daemon/systemd-hints.js"; import { isSystemdUserServiceAvailable } from "../daemon/systemd.js"; import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; +import { + formatGatewayRestartHandoffDiagnostic, + readGatewayRestartHandoffSync, +} from "../infra/restart-handoff.js"; import { isWSL } from "../infra/wsl.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; @@ -126,8 +130,23 @@ export async function maybeRepairGatewayDaemon(params: { loaded = false; } let serviceRuntime: Awaited> | undefined; + const command = params.options.deep + ? await Promise.resolve(service.readCommand(process.env)).catch(() => null) + : null; + const serviceEnv = command?.environment + ? ({ + ...process.env, + ...command.environment, + } satisfies NodeJS.ProcessEnv) + : process.env; if (loaded) { - serviceRuntime = await service.readRuntime(process.env).catch(() => undefined); + serviceRuntime = await service.readRuntime(serviceEnv).catch(() => undefined); + } + if (params.options.deep) { + const handoff = readGatewayRestartHandoffSync(serviceEnv); + if (handoff) { + note(formatGatewayRestartHandoffDiagnostic(handoff), "Gateway"); + } } if (process.platform === "darwin" && params.cfg.gateway?.mode !== "remote") {