diff --git a/CHANGELOG.md b/CHANGELOG.md index fddc2b4bf25..2c182545ba4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ Docs: https://docs.openclaw.ai ### Fixes +- MCP/plugins: stringify non-array plugin tool results with chat-content coercion instead of default object stringification, so MCP callers receive useful JSON/text content from plugin tools. Thanks @vincentkoc. +- CLI/logs: fall back to the configured Gateway file log when implicit loopback Gateway connections close or time out before or during `logs.tail`, so `openclaw logs` still works while diagnosing local-model Gateway disconnects. Refs #74078. Thanks @sakalaboator. - MCP/plugins: stringify non-array plugin tool results with chat-content coercion instead of default object stringification, so MCP callers receive useful JSON/text content from plugin tools. Thanks @vincentkoc. - Channels/Discord: remove Discord-owned queued-run timeout replies through the shared channel lifecycle queue while preserving message ordering and compatibility timeout constants, so long Discord turns stay governed by session/tool/runtime lifecycle instead of channel fallback errors. Thanks @codexGW. - Agents/tools: clamp `process.poll` waits to 30 seconds, advertise that cap in the tool schema, and honor abort signals while waiting, so long command polls cannot pin agent responsiveness after cancellation. Thanks @vincentkoc. diff --git a/docs/cli/logs.md b/docs/cli/logs.md index 63acfb622f3..336aad31352 100644 --- a/docs/cli/logs.md +++ b/docs/cli/logs.md @@ -56,7 +56,7 @@ openclaw logs --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN" ## Notes - Use `--local-time` to render timestamps in your local timezone. -- If the local loopback Gateway asks for pairing, `openclaw logs` falls back to the configured local log file automatically. Explicit `--url` targets do not use this fallback. +- If the implicit local loopback Gateway asks for pairing, closes during connect, or times out before `logs.tail` answers, `openclaw logs` falls back to the configured Gateway file log automatically. Explicit `--url` targets do not use this fallback. ## Related diff --git a/docs/logging.md b/docs/logging.md index c6114386ea7..c12b2326225 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -73,9 +73,10 @@ In JSON mode, the CLI emits `type`-tagged objects: - `notice`: truncation / rotation hints - `raw`: unparsed log line -If the local loopback Gateway asks for pairing, `openclaw logs` falls back to -the configured local log file automatically. Explicit `--url` targets do not -use this fallback. +If the implicit local loopback Gateway asks for pairing, closes during connect, +or times out before `logs.tail` answers, `openclaw logs` falls back to the +configured Gateway file log automatically. Explicit `--url` targets do not use +this fallback. If the Gateway is unreachable, the CLI prints a short hint to run: diff --git a/src/cli/logs-cli.test.ts b/src/cli/logs-cli.test.ts index e383a16850a..f61c6673a4b 100644 --- a/src/cli/logs-cli.test.ts +++ b/src/cli/logs-cli.test.ts @@ -181,6 +181,46 @@ describe("logs cli", () => { expect(stderrWrites.join("")).toContain("reading local log file instead"); }); + it("falls back to the configured Gateway file log on loopback gateway close errors", async () => { + callGatewayFromCli.mockRejectedValueOnce( + new Error("gateway closed (1000 normal closure): no close reason"), + ); + readConfiguredLogTail.mockResolvedValueOnce({ + file: "/tmp/openclaw.log", + cursor: 5, + size: 5, + lines: ["local fallback line"], + truncated: false, + reset: false, + }); + + const stdoutWrites = captureStdoutWrites(); + const stderrWrites = captureStderrWrites(); + + await runLogsCli(["logs"]); + + expect(readConfiguredLogTail).toHaveBeenCalledTimes(1); + expect(stdoutWrites.join("")).toContain("local fallback line"); + expect(stderrWrites.join("")).toContain("Local Gateway RPC unavailable"); + }); + + it("does not use local fallback for explicit Gateway URLs", async () => { + callGatewayFromCli.mockRejectedValueOnce( + new Error("gateway closed (1000 normal closure): no close reason"), + ); + + const stdoutWrites = captureStdoutWrites(); + const stderrWrites = captureStderrWrites(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + + await runLogsCli(["logs", "--url", "ws://127.0.0.1:18789"]); + + expect(readConfiguredLogTail).not.toHaveBeenCalled(); + expect(stdoutWrites.join("")).not.toContain("local fallback line"); + expect(stderrWrites.join("")).toContain("Gateway not reachable"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + describe("formatLogTimestamp", () => { it("formats UTC timestamp in plain mode by default", () => { const result = formatLogTimestamp("2025-01-01T12:00:00.000Z"); diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index 19ceeb1d94f..94fd0b819ef 100644 --- a/src/cli/logs-cli.ts +++ b/src/cli/logs-cli.ts @@ -49,7 +49,7 @@ type LogsCliOptions = { expectFinal?: boolean; }; -const LOCAL_FALLBACK_NOTICE = "Gateway pairing required; reading local log file instead."; +const LOCAL_FALLBACK_NOTICE = "Local Gateway RPC unavailable; reading configured file log instead."; function parsePositiveInt(value: string | undefined, fallback: number): number { if (!value) { @@ -81,6 +81,7 @@ async function fetchLogs( if (!shouldUseLocalLogsFallback(opts, error)) { throw error; } + // Match the Gateway logs.tail source when implicit local RPC is unavailable. return { ...(await readConfiguredLogTail({ cursor, limit, maxBytes })), localFallback: true, @@ -97,7 +98,7 @@ function normalizeErrorMessage(error: unknown): string { function shouldUseLocalLogsFallback(opts: LogsCliOptions, error: unknown): boolean { const message = normalizeLowercaseStringOrEmpty(normalizeErrorMessage(error)); - if (!readConnectPairingRequiredMessage(message)) { + if (!isLocalGatewayRpcUnavailableError(message)) { return false; } if (typeof opts.url === "string" && opts.url.trim().length > 0) { @@ -114,6 +115,17 @@ function shouldUseLocalLogsFallback(opts: LogsCliOptions, error: unknown): boole } } +function isLocalGatewayRpcUnavailableError(message: string): boolean { + if (readConnectPairingRequiredMessage(message)) { + return true; + } + return ( + message.includes("gateway closed (") || + message.includes("gateway timeout after") || + message.includes("gateway connect failed:") + ); +} + export function formatLogTimestamp( value?: string, mode: "pretty" | "plain" = "plain",