Skip to content

Commit 7269c9f

Browse files
committed
feat(cli/logs): announce --follow gateway reconnect and add JSON notice parity
1 parent 058b625 commit 7269c9f

4 files changed

Lines changed: 60 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,7 @@ Docs: https://docs.openclaw.ai
787787
- 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.
788788
- 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.
789789
- 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.
790+
- 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.
790791
- 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.
791792
- 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.
792793
- 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.

docs/cli/logs.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ openclaw logs --url ws://127.0.0.1:18789 --token "$OPENCLAW_GATEWAY_TOKEN"
5757

5858
- Use `--local-time` to render timestamps in your local timezone.
5959
- 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.
60-
- 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.
60+
- 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.
6161

6262
## Related
6363

src/cli/logs-cli.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,10 +369,52 @@ describe("logs cli", () => {
369369

370370
expect(readConfiguredLogTail).not.toHaveBeenCalled();
371371
expect(stderrWrites.join("")).toContain("gateway disconnected");
372+
expect(stderrWrites.join("")).toContain("gateway reconnected");
372373
expect(stdoutWrites.join("")).toContain("line from remote");
373374
expect(exitSpy).toHaveBeenCalledWith(1);
374375
});
375376

377+
it("emits notice JSON records for retry and reconnect in --follow --json mode", async () => {
378+
callGatewayFromCli
379+
.mockRejectedValueOnce(
380+
new GatewayTransportError({
381+
kind: "closed",
382+
code: 1006,
383+
reason: "abnormal closure",
384+
connectionDetails: {
385+
url: "ws://remote.example.com:18789",
386+
urlSource: "cli",
387+
message: "",
388+
},
389+
message: "gateway closed (1006 abnormal closure): abnormal closure",
390+
}),
391+
)
392+
.mockResolvedValueOnce({
393+
file: "/tmp/openclaw.log",
394+
cursor: 10,
395+
lines: [],
396+
});
397+
398+
const stderrWrites = captureStderrWrites();
399+
const stdoutWrites = captureStdoutWrites();
400+
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
401+
402+
await runLogsCli(["logs", "--follow", "--json", "--url", "ws://remote.example.com:18789"]);
403+
404+
const stderr = stderrWrites.join("");
405+
const noticeRecords = stderr
406+
.split("\n")
407+
.filter((line) => line.length > 0)
408+
.map((line) => JSON.parse(line) as { type: string; message?: string });
409+
const messages = noticeRecords
410+
.filter((record) => record.type === "notice")
411+
.map((record) => record.message ?? "");
412+
expect(messages.some((message) => message.includes("gateway disconnected"))).toBe(true);
413+
expect(messages.some((message) => message.includes("gateway reconnected"))).toBe(true);
414+
expect(stdoutWrites.join("")).toContain('"type":"meta"');
415+
expect(exitSpy).toHaveBeenCalledWith(1);
416+
});
417+
376418
it("exits immediately on pairing-required close errors in --follow mode with explicit URL", async () => {
377419
callGatewayFromCli.mockRejectedValueOnce(
378420
new GatewayTransportError({

src/cli/logs-cli.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -341,15 +341,12 @@ export function registerLogsCli(program: Command) {
341341
if (opts.follow && followRetryAttempt < MAX_FOLLOW_RETRIES && isTransientFollowError(err)) {
342342
followRetryAttempt += 1;
343343
const backoffMs = computeBackoff(FOLLOW_BACKOFF_POLICY, followRetryAttempt);
344-
if (
345-
!errorLine(
346-
colorize(
347-
rich,
348-
theme.warn,
349-
`[logs] gateway disconnected, reconnecting in ${Math.round(backoffMs / 1_000)}s...`,
350-
),
351-
)
352-
) {
344+
const message = `[logs] gateway disconnected, reconnecting in ${Math.round(backoffMs / 1_000)}s...`;
345+
if (jsonMode) {
346+
if (!emitJsonLine({ type: "notice", message }, true)) {
347+
return;
348+
}
349+
} else if (!errorLine(colorize(rich, theme.warn, message))) {
353350
return;
354351
}
355352
await delay(backoffMs);
@@ -366,6 +363,16 @@ export function registerLogsCli(program: Command) {
366363
process.exit(1);
367364
return;
368365
}
366+
if (followRetryAttempt > 0) {
367+
const message = "[logs] gateway reconnected";
368+
if (jsonMode) {
369+
if (!emitJsonLine({ type: "notice", message }, true)) {
370+
return;
371+
}
372+
} else if (!errorLine(colorize(rich, theme.muted, message))) {
373+
return;
374+
}
375+
}
369376
followRetryAttempt = 0;
370377
const lines = Array.isArray(payload.lines) ? payload.lines : [];
371378
if (jsonMode) {

0 commit comments

Comments
 (0)