feat: show restart handoffs in doctor

This commit is contained in:
Shakker
2026-05-05 07:50:18 +01:00
parent 9b0afd8141
commit 6d485a9f36
4 changed files with 96 additions and 2 deletions

View File

@@ -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.

View File

@@ -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:

View File

@@ -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<ExtraGatewayService[]>>(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<typeof import("../infra/restart-handoff.js")>(
"../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?");

View File

@@ -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<ReturnType<typeof service.readRuntime>> | 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") {