From 4cca1b2399f12b81ae03e36d37aa763ab1cfd6a6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 05:05:02 +0100 Subject: [PATCH] fix: preserve formatted channel startup logs --- CHANGELOG.md | 1 + extensions/voice-call/src/runtime.ts | 1 + extensions/voice-call/src/webhook.ts | 18 ++++++++++++++++-- src/gateway/server-channels.test.ts | 20 ++++++++++++++++++++ src/gateway/server-channels.ts | 24 +++++++++++++++++------- 5 files changed, 55 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf5cdd23d04..e022aafff39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/logging: keep deferred channel startup logs on the subsystem logger, so Slack, Discord, Telegram, and voice-call startup messages keep timestamped prefixes. Thanks @vincentkoc. - Discord/reactions: skip reaction listener registration when DMs and group DMs are disabled and every configured guild has `reactionNotifications: "off"`, avoiding needless reaction-event queue work. Fixes #47516. Thanks @x4v13r1120. - CLI sessions: preserve explicit manual-attach reuse bindings so trusted CLI sessions are not invalidated on the first turn when auth, prompt, or MCP fingerprints drift. Fixes #75849. Thanks @alfredjbclaw. - Telegram/streaming: keep partial preview streaming enabled for plain reply-to replies, disabling drafts only for real native quote excerpts that require Telegram quote parameters. Fixes #73505. Thanks @choury. diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index 9cc65799b4c..9e5fa7b7f60 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -307,6 +307,7 @@ export async function createVoiceCallRuntime(params: { coreConfig, fullConfig ?? (coreConfig as OpenClawConfig), agentRuntime, + log, ); if (realtimeProvider) { const { RealtimeCallHandler } = await loadRealtimeHandler(); diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index ff8119a760f..c746fcccd5b 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -35,6 +35,12 @@ const TRANSCRIPT_LOG_MAX_CHARS = 200; type RealtimeTranscriptionRuntime = typeof import("./realtime-transcription.runtime.js"); type ResponseGeneratorModule = typeof import("./response-generator.js"); +type Logger = { + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string) => void; + debug?: (message: string) => void; +}; let realtimeTranscriptionRuntimePromise: Promise | undefined; let responseGeneratorModulePromise: Promise | undefined; @@ -158,6 +164,7 @@ export class VoiceCallWebhookServer { private coreConfig: CoreConfig | null; private fullConfig: OpenClawConfig | null; private agentRuntime: CoreAgentDeps | null; + private logger: Logger; private stopStaleCallReaper: (() => void) | null = null; private readonly webhookInFlightLimiter = createWebhookInFlightLimiter(); @@ -175,6 +182,7 @@ export class VoiceCallWebhookServer { coreConfig?: CoreConfig, fullConfig?: OpenClawConfig, agentRuntime?: CoreAgentDeps, + logger?: Logger, ) { this.config = normalizeVoiceCallConfig(config); this.manager = manager; @@ -182,6 +190,12 @@ export class VoiceCallWebhookServer { this.coreConfig = coreConfig ?? null; this.fullConfig = fullConfig ?? null; this.agentRuntime = agentRuntime ?? null; + this.logger = logger ?? { + info: console.log, + warn: console.warn, + error: console.error, + debug: console.debug, + }; } /** @@ -492,12 +506,12 @@ export class VoiceCallWebhookServer { const url = this.resolveListeningUrl(bind, webhookPath); this.listeningUrl = url; this.startPromise = null; - console.log(`[voice-call] Webhook server listening on ${url}`); + this.logger.info(`[voice-call] Webhook server listening on ${url}`); if (this.mediaStreamHandler) { const address = this.server?.address(); const actualPort = address && typeof address === "object" ? address.port : this.config.serve.port; - console.log( + this.logger.info( `[voice-call] Media stream WebSocket on ws://${bind}:${actualPort}${streamPath}`, ); } diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index 2e45d6b87de..60b7773e9ad 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -354,6 +354,26 @@ describe("server-channels auto restart", () => { expect(ctx?.channelRuntime).not.toBe(channelRuntime); }); + it("creates formatted runtime and log sinks for channels loaded after manager construction", async () => { + const startAccount = vi.fn(async (_ctx: ChannelGatewayContext) => {}); + installTestRegistry(createTestPlugin({ id: "slack", startAccount })); + const channelLogs = {} as Record; + const channelRuntimeEnvs = {} as Record; + const manager = createChannelManager({ + getRuntimeConfig: () => ({}), + channelLogs, + channelRuntimeEnvs, + }); + + await manager.startChannel("slack"); + + expect(startAccount).toHaveBeenCalledTimes(1); + const [ctx] = startAccount.mock.calls[0] ?? []; + expect(ctx?.log).toBe(channelLogs.slack); + expect(ctx?.runtime).toBe(channelRuntimeEnvs.slack); + expect((ctx?.log as SubsystemLogger | undefined)?.subsystem).toBe("channels/slack"); + }); + it("deduplicates concurrent start requests for the same account", async () => { const startupGate = createDeferred(); const isConfigured = vi.fn(async () => { diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index c6d3ccfaa21..2c550789f26 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -13,7 +13,7 @@ import { type BackoffPolicy, computeBackoff, sleepWithAbort } from "../infra/bac import { createTaskScopedChannelRuntime } from "../infra/channel-runtime-context.js"; import { formatErrorMessage } from "../infra/errors.js"; import { resetDirectoryCache } from "../infra/outbound/target-resolver.js"; -import type { createSubsystemLogger } from "../logging/subsystem.js"; +import { createSubsystemLogger, runtimeForLogger } from "../logging/subsystem.js"; import { resolveAccountEntry, resolveNormalizedAccountEntry } from "../routing/account-lookup.js"; import { DEFAULT_ACCOUNT_ID, @@ -214,6 +214,14 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage const manuallyStopped = new Set(); const restartKey = (channelId: ChannelId, accountId: string) => `${channelId}:${accountId}`; + const ensureChannelLog = (channelId: ChannelId): SubsystemLogger => { + channelLogs[channelId] ??= createSubsystemLogger("channels").child(channelId); + return channelLogs[channelId]; + }; + const ensureChannelRuntime = (channelId: ChannelId): RuntimeEnv => { + channelRuntimeEnvs[channelId] ??= runtimeForLogger(ensureChannelLog(channelId)); + return channelRuntimeEnvs[channelId]; + }; const resolveAccountHealthMonitorOverride = ( channelConfig: ChannelHealthMonitorConfig | undefined, @@ -265,7 +273,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage // This call exists solely to fail closed if resolver-side config loading is broken. plugin.config.resolveAccount(cfg, accountId); } catch (err) { - channelLogs[channelId].warn?.( + ensureChannelLog(channelId).warn?.( `[${channelId}:${accountId}] health-monitor: failed to resolve account; skipping monitor (${formatErrorMessage(err)})`, ); return false; @@ -388,7 +396,8 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage const abort = new AbortController(); store.aborts.set(id, abort); let handedOffTask = false; - const log = channelLogs[channelId]; + const log = ensureChannelLog(channelId); + const runtime = ensureChannelRuntime(channelId); let scopedChannelRuntime: ReturnType | null = null; let channelRuntimeForTask: ChannelRuntimeSurface | undefined; let stopApprovalBootstrap: () => Promise = async () => {}; @@ -499,7 +508,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage cfg, accountId: id, account, - runtime: channelRuntimeEnvs[channelId], + runtime, abortSignal: abort.signal, log, getStatus: () => getRuntime(channelId, id), @@ -641,16 +650,17 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage } manuallyStopped.add(restartKey(channelId, id)); abort?.abort(); - const log = channelLogs[channelId]; + const log = ensureChannelLog(channelId); + const runtime = ensureChannelRuntime(channelId); if (plugin?.gateway?.stopAccount) { const account = plugin.config.resolveAccount(cfg, id); await plugin.gateway.stopAccount({ cfg, accountId: id, account, - runtime: channelRuntimeEnvs[channelId], + runtime, abortSignal: abort?.signal ?? new AbortController().signal, - log: channelLogs[channelId], + log, getStatus: () => getRuntime(channelId, id), setStatus: (next) => setRuntime(channelId, id, next), });