fix(cli): reuse websocket for logs --follow#56475
fix(cli): reuse websocket for logs --follow#56475huntharo wants to merge 13 commits intoopenclaw:mainfrom
logs --follow#56475Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0dfac15748
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Greptile SummaryThis PR replaces the previous per-poll websocket approach in Key changes:
Confidence Score: 5/5Safe to merge; all remaining findings are P2 style suggestions that do not affect correctness or reliability. The implementation is correct, backward-compatible, and thoroughly tested (streaming path, polling fallback, startup retry, stream-disconnect reconnect, capability negotiation, and unsubscribe race). The prior P1 concerns from earlier review threads — unhandled rejection on
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/gateway/call.ts
Line: 1811-1815
Comment:
**Dead CLI defaults in `resolveGatewayClientConnectionWithScopes`**
The `?? GATEWAY_CLIENT_NAMES.CLI` and `?? GATEWAY_CLIENT_MODES.CLI` fallbacks on lines 1811–1815 are now dead code for every existing internal caller:
- `callGatewayScoped`, `callGatewayLeastPrivilege`, and `callGateway` all call `applyBackendGatewayIdentityDefaults(opts)` before reaching this function, which fills in `GATEWAY_CLIENT` / `BACKEND` so the `??` branches never fire.
- `callGatewayCli` (and `createFollowLogsClient`) explicitly supply `GATEWAY_CLIENT_NAMES.CLI` / `GATEWAY_CLIENT_MODES.CLI`.
The risk is that the newly exported `resolveGatewayClientConnection` does **not** apply `applyBackendGatewayIdentityDefaults` internally. Any future caller that omits `clientName`/`mode` will silently receive CLI identity, while `resolveGatewayCallScopes` (which does default to BACKEND) would pick least-privilege scopes — an identity/scope mismatch. A brief comment, or applying the backend defaults inside `resolveGatewayClientConnection` itself, would prevent future surprises.
How can I resolve this? If you propose a fix, please make it concise.Reviews (2): Last reviewed commit: "Gateway: address remaining log follow re..." | Re-trigger Greptile |
| const resetReady = () => { | ||
| ready = createDeferred<void>(); | ||
| }; |
There was a problem hiding this comment.
Unhandled rejection on reconnect
onConnectError during poll delay
When the websocket drops and the follow loop is in the delay(interval) sleep between polls, onClose fires first and calls resetReady(), creating a fresh ready deferred. The client then schedules a reconnect (after backoffMs, starting at 1 000 ms). If that reconnect attempt fails quickly (e.g. "connection refused") before delay(interval) finishes, onConnectError rejects ready.promise, and then onClose calls resetReady() again — replacing ready with a new deferred.
Because waitUntilReady() hasn't been called yet (the follow loop is still sleeping), nothing ever awaits the rejected promise. In Node.js 15+ the process will crash with UnhandledPromiseRejection.
The simplest guard is to silence the old promise before replacing it:
const resetReady = () => {
ready.promise.catch(() => {}); // prevent unhandled rejection if nobody is awaiting it
ready = createDeferred<void>();
};Alternatively, store the last connect error and re-throw it on the next waitUntilReady() call so the caller still sees the error.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cli/logs-cli.ts
Line: 107-109
Comment:
**Unhandled rejection on reconnect `onConnectError` during poll delay**
When the websocket drops and the follow loop is in the `delay(interval)` sleep between polls, `onClose` fires first and calls `resetReady()`, creating a fresh `ready` deferred. The client then schedules a reconnect (after `backoffMs`, starting at 1 000 ms). If that reconnect attempt fails quickly (e.g. "connection refused") before `delay(interval)` finishes, `onConnectError` rejects `ready.promise`, and then `onClose` calls `resetReady()` again — replacing `ready` with a new deferred.
Because `waitUntilReady()` hasn't been called yet (the follow loop is still sleeping), nothing ever awaits the rejected promise. In Node.js 15+ the process will crash with `UnhandledPromiseRejection`.
The simplest guard is to silence the old promise before replacing it:
```typescript
const resetReady = () => {
ready.promise.catch(() => {}); // prevent unhandled rejection if nobody is awaiting it
ready = createDeferred<void>();
};
```
Alternatively, store the last connect error and re-throw it on the next `waitUntilReady()` call so the caller still sees the error.
How can I resolve this? If you propose a fix, please make it concise.| token: resolvedCredentials.token, | ||
| password: resolvedCredentials.password, | ||
| tlsFingerprint, | ||
| requestTimeoutMs: timeoutMs, |
There was a problem hiding this comment.
requestTimeoutMs now propagates into one-shot client options
Before this refactor, executeGatewayRequestWithScopes never set requestTimeoutMs when constructing the GatewayClient, so the client used its 30 s default as a fallback for any request that didn't supply its own timeoutMs. Now requestTimeoutMs: timeoutMs is included in clientOptions from resolveGatewayClientConnectionWithScopes, meaning one-shot callers that pass neither opts.timeoutMs to their client.request() call nor an explicit timeout to callGateway()/callGatewayCli() will now see a 10 s per-request fallback (the default from resolveGatewayCallTimeout) instead of 30 s.
In practice this is harmless because the safeTimerTimeoutMs outer timer already fires at 10 s, but it changes observable behavior for any caller that relied on the 30 s default per-request timeout. If this is intentional, a brief comment here would clarify it for future readers.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/gateway/call.ts
Line: 905
Comment:
**`requestTimeoutMs` now propagates into one-shot client options**
Before this refactor, `executeGatewayRequestWithScopes` never set `requestTimeoutMs` when constructing the `GatewayClient`, so the client used its 30 s default as a fallback for any request that didn't supply its own `timeoutMs`. Now `requestTimeoutMs: timeoutMs` is included in `clientOptions` from `resolveGatewayClientConnectionWithScopes`, meaning one-shot callers that pass neither `opts.timeoutMs` to their `client.request()` call nor an explicit timeout to `callGateway()`/`callGatewayCli()` will now see a 10 s per-request fallback (the default from `resolveGatewayCallTimeout`) instead of 30 s.
In practice this is harmless because the `safeTimerTimeoutMs` outer timer already fires at 10 s, but it changes observable behavior for any caller that relied on the 30 s default per-request timeout. If this is intentional, a brief comment here would clarify it for future readers.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 74c8efc97a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
03eba53 to
981c108
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 981c108227
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| function isRetryableFollowStartupError(err: unknown): boolean { | ||
| const message = (err instanceof Error ? err.message : String(err)).toLowerCase(); | ||
| return ( | ||
| message.includes("connect failed") || |
There was a problem hiding this comment.
Treat stream-disconnect errors as retryable in follow mode
createFollowLogsClient pushes "gateway log stream disconnected" into the follow queue on socket close, but isRetryableFollowStartupError does not match that message (nor "gateway not connected" from client.request). In logs --follow, a transient gateway restart therefore falls into the non-retryable path and exits with a fatal gateway error instead of re-waiting/re-subscribing. This breaks long-running follow sessions whenever the websocket drops briefly.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9787127265
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } catch { | ||
| unsubscribe(subscriber.connId); |
There was a problem hiding this comment.
Surface poll errors instead of silently dropping log subscribers
The catch-all in pollSubscriber unsubscribes on any exception without notifying the client or closing the socket. A transient file race (for example, ENOENT between stat and open during rotation) will permanently remove that subscriber while the websocket stays connected, leaving follow clients blocked waiting for logs.appended that never arrive. This should either retry on the next poll or close/error the socket so clients can recover.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7c20a5bb95
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
7c20a5b to
b7a4ca1
Compare
logs --follow
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b7a4ca1b7f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
b7a4ca1 to
8f24f3d
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8f24f3d286
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!result.reset && result.lines.length === 0) { | ||
| return; | ||
| } | ||
| subscriber.socket.send( |
There was a problem hiding this comment.
Re-check subscriber membership before sending appended logs
pollSubscriber can continue past await resolveLogFile(...) / await readLogSlice(...) and call socket.send(...) even after logs.unsubscribe removed that connId from subscribers. In that race (for example, unsubscribe during slow filesystem reads), the client can still receive a logs.appended event after unsubscribe has already succeeded, which violates the method contract and can produce confusing extra lines. Add a post-read guard (e.g., ensure the current map entry still matches this subscriber or mark inactive on unsubscribe) before sending.
Useful? React with 👍 / 👎.
8f24f3d to
fa050d3
Compare
|
@greptile-apps - Can you re-review latest? |
14f9116 to
e127e81
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e127e813af
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } catch { | ||
| unsubscribe(subscriber.connId); | ||
| try { | ||
| if (subscriber.socket.readyState === WebSocket.OPEN) { | ||
| subscriber.socket.close(1011, "log stream error"); |
There was a problem hiding this comment.
Skip socket close for stale unsubscribed pollers
pollSubscriber now checks subscriber identity before sending events, but the catch path still unconditionally closes subscriber.socket. If logs.unsubscribe (or a replacement logs.subscribe) wins while a poll is in flight and that stale poll later throws (for example during log-file rotation races), this closes the still-active websocket unexpectedly and drops unrelated RPC traffic on that connection. Gate the close path on subscribers.get(subscriber.connId) === subscriber (and active status) before closing.
Useful? React with 👍 / 👎.
e127e81 to
22946ea
Compare
🔒 Aisle Security AnalysisWe found 4 potential security issue(s) in this PR:
1. 🟠 Terminal escape injection via unsanitized gateway-provided filename and error reason in logs CLI
DescriptionThe logs CLI prints some gateway-controlled strings to the terminal without applying Affected paths:
Vulnerable code (text mode): if (first && payload.file) {
const prefix = pretty ? colorize(rich, theme.muted, "Log file:") : "Log file:";
if (!logLine(`${prefix} ${payload.file}`)) {
return false;
}
}
...
if (errorText.trim()) {
if (!errorLine(colorize(rich, theme.muted, `Reason: ${summarizeRetryErrorText(err)}`))) {
return;
}
}Even though individual log lines are sanitized via RecommendationSanitize all gateway-controlled strings before writing to TTY output in text mode. Suggested fix (apply import { sanitizeForLog } from "../terminal/ansi.js";
...
if (first && payload.file) {
const safeFile = sanitizeForLog(payload.file);
const prefix = pretty ? colorize(rich, theme.muted, "Log file:") : "Log file:";
if (!logLine(`${prefix} ${safeFile}`)) return false;
}
...
if (errorText.trim()) {
const safeReason = sanitizeForLog(summarizeRetryErrorText(err));
if (!errorLine(colorize(rich, theme.muted, `Reason: ${safeReason}`))) return;
}Also consider applying similar sanitization to any other non-JSON notices that may include remote-provided data (e.g., close reasons, retry notices) and/or centralizing this in the writer layer for stdout/stderr when 2. 🟡 Potential ReDoS / CPU DoS in broad OSC-stripping regex used on untrusted log text
Description
This matters because Vulnerable code: const ANSI_OSC_PATTERN = "\\x1b\\][\\s\\S]*?(?:\\x07|\\x1b\\\\)";
const ANSI_OSC_REGEX = new RegExp(ANSI_OSC_PATTERN, "g");
export function stripAnsi(input: string): string {
return input.replace(ANSI_OSC_REGEX, "").replace(ANSI_CSI_REGEX, "");
}RecommendationAvoid regex constructs that can rescan large tails for each potential start. Safer options:
// Limit OSC payload to e.g. 8KB to avoid pathological scans
const ANSI_OSC_REGEX = /\x1b\][\s\S]{0,8192}?(?:\x07|\x1b\\)/g;
export function stripAnsi(input: string): string {
return input.replace(ANSI_OSC_REGEX, "").replace(ANSI_CSI_REGEX, "");
}
Also consider adding a unit/perf test that feeds a long string containing many 3. 🟡 Per-IP log stream subscription limit bypass via untrusted proxy headers (clientIp becomes undefined)
DescriptionThe new live log streaming implementation attempts to cap subscriptions per IP ( In the websocket handshake,
Impact:
Vulnerable code paths:
RecommendationEnsure an IP (or other stable identifier) is always available for rate limiting, even when proxy headers are untrusted. Suggested approach:
Example (conceptual): // during handshake
const rateLimitIp = remoteAddr; // or resolveClientIp({remoteAddr, forwardedFor: undefined, realIp: undefined, ...})
nextClient.clientIp = rateLimitIp;Or change const clientIp = (gatewayClient.clientIp ?? gatewayClient.socket._socket?.remoteAddress)?.trim();
if (clientIp) {
// enforce per-IP cap
}Additionally, consider enforcing per-connection and per-auth-identity limits (device/client id) to reduce reliance on IP address alone. 4. 🔵 Information disclosure: absolute log file path returned to clients in logs.subscribe and logs.appended events
DescriptionThe gateway exposes the server's resolved log file path (likely an absolute path under the host filesystem) to remote clients.
This discloses host filesystem layout and potentially sensitive installation/user information (e.g., usernames, container mount points). While not always critical, it can materially aid attackers in follow-on attacks and is generally unnecessary for functionality (clients can treat the Vulnerable code: respond(true, { subscribed: true, file, ...result }, undefined);and params.broadcastToConnIds("logs.appended", { file, ...result }, ...);RecommendationDo not expose real filesystem paths to clients. Options:
Example: // server-side
const file = await resolveLogFile(configuredFile);
const fileId = createHash('sha256').update(file).digest('hex');
respond(true, { subscribed: true, fileId, ...resultWithoutPath }, undefined);
// when comparing previous file:
previousFileId: p.fileIdAlso update the protocol schemas to reflect the new Analyzed PR: #56475 at commit Last updated on: 2026-04-09T01:14:25Z |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 22946ea45c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!payload || typeof payload !== "object") { | ||
| throw new Error("Unexpected logs.tail response"); | ||
| } | ||
| } |
There was a problem hiding this comment.
Return logs.tail payload from fetchLogs
fetchLogs validates the logs.tail response but never returns it, so callers receive undefined. In normal openclaw logs (non-follow) flow this makes emitLogsPayload crash when it reads payload.lines, and the command falls into the generic gateway-error path even when the gateway is healthy. Return the validated payload so non-follow log tailing still works.
Useful? React with 👍 / 👎.
| params.logStreamStop?.(); | ||
| } catch { |
There was a problem hiding this comment.
Keep task-registry shutdown hook in close handler
This shutdown section now invokes logStreamStop but no longer calls stopTaskRegistryMaintenance, even though that callback is still passed by startGatewayServer. That leaves task-registry maintenance running after gateway shutdown/restart in-process, so background sweeps can continue mutating task state outside the server lifecycle. Keep the existing task-maintenance stop call alongside the new log-stream stop.
Useful? React with 👍 / 👎.
| LogsSubscribeParams, | ||
| LogsSubscribeResult, |
There was a problem hiding this comment.
Re-export exec-approval param types from protocol index
The protocol/index.ts type export list dropped ExecApprovalGetParams, ExecApprovalRequestParams, and ExecApprovalResolveParams while adding log-subscribe types. That creates an unintended breaking change for downstream code importing those exec-approval types from this public barrel. Keep the log-subscribe exports additive and restore the removed exec-approval type exports.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
The handler responds with result only, but LogsTailResult requires a file field and callers rely on that value to track log-file rollovers across cursored polls. Dropping file is a wire-contract regression that can break downstream clients and makes rotation detection unreliable when the caller cannot echo previousFile back on the next request.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| limit: p.limit, | ||
| maxBytes: p.maxBytes, |
There was a problem hiding this comment.
Default logs.tail limits before calling readLogSlice
logs.tail now forwards p.limit/p.maxBytes directly even though both params are optional, so valid calls like logs.tail {} pass undefined into readLogSlice. That function clamps numeric inputs and eventually computes a NaN buffer length, which throws and returns UNAVAILABLE instead of tail data for clients that omit these optional fields. Preserve the old behavior by applying defaults (like DEFAULT_LIMIT/DEFAULT_MAX_BYTES) before invoking readLogSlice.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5a65fad828
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| limit, | ||
| maxBytes, | ||
| }); | ||
| respond(true, result, undefined); |
There was a problem hiding this comment.
Include resolved file in logs.tail responses
logs.tail now responds with result from readLogSlice, but readLogSlice does not include a file field, so successful responses violate the declared LogsTailResult contract and can break typed clients that decode file as required (including the updated Swift model) or depend on it for file-rotation state. Returning { file, ...result } is needed to keep the wire payload backward-compatible.
Useful? React with 👍 / 👎.
| const payload = await callGatewayFromCli( | ||
| "logs.tail", | ||
| opts, | ||
| { file, cursor, limit, maxBytes }, | ||
| { progress: showProgress }, |
There was a problem hiding this comment.
Restore loopback pairing fallback for logs.tail fetches
fetchLogs now directly awaits callGatewayFromCli("logs.tail", ...) without the previous pairing-required fallback path, so when a local loopback gateway requests pairing, openclaw logs exits through the generic gateway error flow instead of reading the configured local log file. This is a user-facing regression for unpaired local setups where logs were previously still available.
Useful? React with 👍 / 👎.
Summary
Describe the problem and fix in 2–5 bullets:
openclaw logs --followreused the one-shot CLI gateway helper, so each poll opened a new websocket, sentlogs.tail, and closed immediately.logs.subscribe/logs.unsubscribegateway capability with livelogs.appendedevents, bounded in-memory buffering, and slow-subscriber disconnect protection.hello-okand uses streamed follow when available, with the persistent-websocketlogs.tailpolling path kept as fallback for older gateways.logs.tailintact for compatibility and bootstrap/fallback behavior.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
Root Cause / Regression History (if applicable)
For bug fixes or regressions, explain why this happened, not just what changed. Otherwise write
N/A. If the cause is unclear, writeUnknown.callGatewayFromCli()inside its polling loop, and that helper creates a freshGatewayClientfor one request and then stops it. The gateway also had no log subscription capability, so the CLI could not switch to a push model even when keeping a socket open.git blame, prior PR, issue, or refactor if known): existing CLI behavior, not a newly introduced refactor in this PR.Regression Test Plan (if applicable)
For bug fixes or regressions, name the smallest reliable test coverage that should have caught this. Otherwise write
N/A.src/cli/logs-cli.test.ts,src/gateway/log-stream.test.tslogs.appendedevents.User-visible / Behavior Changes
openclaw logs --follownow prefers a streamed websocket follow mode on gateways that advertiselogs.subscribe. Older gateways keep working through the fallback persistent-websocket polling path.Diagram (if applicable)
Security Impact (required)
Yes/No) NoYes/No) NoYes/No) YesYes/No) NoYes/No) NoYes, explain risk + mitigation: I added new gateway RPC methods and a new websocket event, but gated client usage on advertised method discovery and keptlogs.tailas the compatibility fallback. Live delivery uses bounded queues and disconnects slow subscribers instead of blocking log writes.Repro + Verification
Environment
Steps
hello-okgateway methods/events.openclaw logs --follow.logs.subscribeand receivelogs.appended, while older gateways continue on the polling fallback.Expected
Actual
Evidence
Attach at least one:
Human Verification (required)
What you personally verified (not just CI), and how:
pnpm test -- src/cli/logs-cli.test.ts,pnpm test -- src/gateway/log-stream.test.ts,pnpm test -- src/gateway/method-scopes.test.ts,pnpm test -- src/gateway/call.test.ts,pnpm test -- src/gateway/server-methods/server-methods.test.ts, andpnpm build.hello-ok,logs.subscribereturns the initial tail plus live events,logs.unsubscribestops delivery, and the CLI falls back when streaming is not advertised.Review Conversations
If a bot review conversation is addressed by this PR, resolve that conversation yourself. Do not leave bot review conversation cleanup for maintainers.
Compatibility / Migration
Yes/No) YesYes/No) NoYes/No) NoRisks and Mitigations
List only real risks for this PR. Add/remove entries as needed. If none, write
None.logs.subscribe, andlogs.tailremains unchanged as the fallback path.