feat: show restart handoffs in gateway status

This commit is contained in:
Shakker
2026-05-05 07:49:33 +01:00
parent 3e53580d63
commit 9b0afd8141
6 changed files with 100 additions and 0 deletions

View File

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

View File

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

View File

@@ -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");

View File

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

View File

@@ -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(
{

View File

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