diff --git a/CHANGELOG.md b/CHANGELOG.md index 1511fc911e5..8a62bc0989d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -796,6 +796,7 @@ Docs: https://docs.openclaw.ai - Ollama: compose caller abort signals with guarded-fetch timeouts for native `/api/chat` streams, so `/stop` and early cancellation still interrupt local Ollama requests that also carry provider timeout budgets. Refs #74133. Thanks @obviyus. - Doctor/TTS: migrate legacy `messages.tts.enabled`, agent TTS, channel TTS, and voice-call plugin TTS toggles to `auto` mode during `openclaw doctor --fix`, matching the documented TTS config contract. 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. +- CLI/logs: announce `--follow` recovery with a `[logs] gateway reconnected` notice once a poll succeeds after a transient outage, and emit JSON `notice` records in `--json` mode for both the retry warning and the reconnect transition, so live monitoring scripts can react to the recovery. Carries forward #75059. Thanks @romneyda. - 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. - Active Memory/QMD: make gateway-start QMD refresh opt-in via `memory.qmd.update.startup`, keep normal memory access lazy, preserve interactive file watching, and align watcher dependency/build ignores with QMD's scanner so cold gateway startup no longer imports or initializes QMD by default. Thanks @codexGW. - 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. diff --git a/docs/cli/logs.md b/docs/cli/logs.md index 55155fc19ac..d342c0a819b 100644 --- a/docs/cli/logs.md +++ b/docs/cli/logs.md @@ -57,7 +57,7 @@ openclaw logs --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN" - Use `--local-time` to render timestamps in your local timezone. - 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. -- When using `--follow`, transient gateway disconnects (WebSocket close, timeout, connection drop) trigger automatic reconnection with exponential backoff (up to 8 retries, capped at 30 s between attempts). A warning is printed to stderr on each retry. Non-recoverable errors (auth failure, bad configuration) still exit immediately. +- When using `--follow`, transient gateway disconnects (WebSocket close, timeout, connection drop) trigger automatic reconnection with exponential backoff (up to 8 retries, capped at 30 s between attempts). A warning is printed to stderr on each retry, and a `[logs] gateway reconnected` notice is printed once a poll succeeds. In `--json` mode both the retry warning and the reconnect transition are emitted as `{"type":"notice"}` records on stderr. Non-recoverable errors (auth failure, bad configuration) still exit immediately. ## Related diff --git a/src/cli/logs-cli.test.ts b/src/cli/logs-cli.test.ts index fd9fe673e0d..ff20ffb9918 100644 --- a/src/cli/logs-cli.test.ts +++ b/src/cli/logs-cli.test.ts @@ -369,10 +369,52 @@ describe("logs cli", () => { expect(readConfiguredLogTail).not.toHaveBeenCalled(); expect(stderrWrites.join("")).toContain("gateway disconnected"); + expect(stderrWrites.join("")).toContain("gateway reconnected"); expect(stdoutWrites.join("")).toContain("line from remote"); expect(exitSpy).toHaveBeenCalledWith(1); }); + it("emits notice JSON records for retry and reconnect in --follow --json mode", async () => { + callGatewayFromCli + .mockRejectedValueOnce( + new GatewayTransportError({ + kind: "closed", + code: 1006, + reason: "abnormal closure", + connectionDetails: { + url: "ws://remote.example.com:18789", + urlSource: "cli", + message: "", + }, + message: "gateway closed (1006 abnormal closure): abnormal closure", + }), + ) + .mockResolvedValueOnce({ + file: "/tmp/openclaw.log", + cursor: 10, + lines: [], + }); + + const stderrWrites = captureStderrWrites(); + const stdoutWrites = captureStdoutWrites(); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + + await runLogsCli(["logs", "--follow", "--json", "--url", "ws://remote.example.com:18789"]); + + const stderr = stderrWrites.join(""); + const noticeRecords = stderr + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as { type: string; message?: string }); + const messages = noticeRecords + .filter((record) => record.type === "notice") + .map((record) => record.message ?? ""); + expect(messages.some((message) => message.includes("gateway disconnected"))).toBe(true); + expect(messages.some((message) => message.includes("gateway reconnected"))).toBe(true); + expect(stdoutWrites.join("")).toContain('"type":"meta"'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + it("exits immediately on pairing-required close errors in --follow mode with explicit URL", async () => { callGatewayFromCli.mockRejectedValueOnce( new GatewayTransportError({ diff --git a/src/cli/logs-cli.ts b/src/cli/logs-cli.ts index d2c3e539625..76060346473 100644 --- a/src/cli/logs-cli.ts +++ b/src/cli/logs-cli.ts @@ -341,15 +341,12 @@ export function registerLogsCli(program: Command) { if (opts.follow && followRetryAttempt < MAX_FOLLOW_RETRIES && isTransientFollowError(err)) { followRetryAttempt += 1; const backoffMs = computeBackoff(FOLLOW_BACKOFF_POLICY, followRetryAttempt); - if ( - !errorLine( - colorize( - rich, - theme.warn, - `[logs] gateway disconnected, reconnecting in ${Math.round(backoffMs / 1_000)}s...`, - ), - ) - ) { + const message = `[logs] gateway disconnected, reconnecting in ${Math.round(backoffMs / 1_000)}s...`; + if (jsonMode) { + if (!emitJsonLine({ type: "notice", message }, true)) { + return; + } + } else if (!errorLine(colorize(rich, theme.warn, message))) { return; } await delay(backoffMs); @@ -366,6 +363,16 @@ export function registerLogsCli(program: Command) { process.exit(1); return; } + if (followRetryAttempt > 0) { + const message = "[logs] gateway reconnected"; + if (jsonMode) { + if (!emitJsonLine({ type: "notice", message }, true)) { + return; + } + } else if (!errorLine(colorize(rich, theme.muted, message))) { + return; + } + } followRetryAttempt = 0; const lines = Array.isArray(payload.lines) ? payload.lines : []; if (jsonMode) {