From 71cd3371372763e1f6c3d1fa7c11cb302285f619 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 3 Mar 2026 01:17:07 +0000 Subject: [PATCH] fix(gateway): harden message action channel fallback and startup grace Take the safe, tested subset from #32367:\n- per-channel startup connect grace in health monitor\n- tool-context channel-provider fallback for message actions\n\nCo-authored-by: Munem Hashmi --- CHANGELOG.md | 1 + src/gateway/channel-health-monitor.test.ts | 37 +++++++++++++++++++ src/gateway/channel-health-monitor.ts | 13 ++++++- .../outbound/message-action-runner.test.ts | 31 ++++++++++++++++ src/infra/outbound/message-action-runner.ts | 31 ++++++++++++---- 5 files changed, 103 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3fbae28e31..c017c9dbcc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Gateway/message tool reliability: avoid false `Unknown channel` failures when `message.*` actions receive platform-specific channel ids by falling back to `toolContext.currentChannelProvider`, and prevent health-monitor restart thrash for channels that just (re)started by adding a per-channel startup-connect grace window. (from #32367) Thanks @MunemHashmi. - WebChat/markdown tables: ensure GitHub-flavored markdown table parsing is explicitly enabled at render time and add horizontal overflow handling for wide tables, with regression coverage for table-only and mixed text+table content. (#32365) Thanks @BlueBirdBack. - Feishu/default account resolution: always honor explicit `channels.feishu.defaultAccount` during outbound account selection (including top-level-credential setups where the preferred id is not present in `accounts`), instead of silently falling back to another account id. (#32253) Thanks @bmendonca3. - Gemini schema sanitization: coerce malformed JSON Schema `properties` values (`null`, arrays, primitives) to `{}` before provider validation, preventing downstream strict-validator crashes on invalid plugin/tool schemas. (#32332) Thanks @webdevtodayjason. diff --git a/src/gateway/channel-health-monitor.test.ts b/src/gateway/channel-health-monitor.test.ts index becbdf82efd..97c5337132a 100644 --- a/src/gateway/channel-health-monitor.test.ts +++ b/src/gateway/channel-health-monitor.test.ts @@ -201,6 +201,7 @@ describe("channel-health-monitor", () => { }); it("restarts a stuck channel (running but not connected)", async () => { + const now = Date.now(); const manager = createSnapshotManager({ whatsapp: { default: { @@ -209,6 +210,7 @@ describe("channel-health-monitor", () => { enabled: true, configured: true, linked: true, + lastStartAt: now - 300_000, }, }, }); @@ -219,6 +221,41 @@ describe("channel-health-monitor", () => { monitor.stop(); }); + it("skips recently-started channels while they are still connecting", async () => { + const now = Date.now(); + const manager = createSnapshotManager({ + discord: { + default: { + running: true, + connected: false, + enabled: true, + configured: true, + lastStartAt: now - 5_000, + }, + }, + }); + await expectNoRestart(manager); + }); + + it("respects custom per-channel startup grace", async () => { + const now = Date.now(); + const manager = createSnapshotManager({ + discord: { + default: { + running: true, + connected: false, + enabled: true, + configured: true, + lastStartAt: now - 30_000, + }, + }, + }); + const monitor = await startAndRunCheck(manager, { channelStartupGraceMs: 60_000 }); + expect(manager.stopChannel).not.toHaveBeenCalled(); + expect(manager.startChannel).not.toHaveBeenCalled(); + monitor.stop(); + }); + it("restarts a stopped channel that gave up (reconnectAttempts >= 10)", async () => { const manager = createSnapshotManager({ discord: { diff --git a/src/gateway/channel-health-monitor.ts b/src/gateway/channel-health-monitor.ts index 5f8dc498682..b0e2313c16e 100644 --- a/src/gateway/channel-health-monitor.ts +++ b/src/gateway/channel-health-monitor.ts @@ -17,6 +17,7 @@ const ONE_HOUR_MS = 60 * 60_000; * alive (health checks pass) but Slack silently stops delivering events. */ const DEFAULT_STALE_EVENT_THRESHOLD_MS = 30 * 60_000; +const DEFAULT_CHANNEL_STARTUP_GRACE_MS = 120_000; export type ChannelHealthMonitorDeps = { channelManager: ChannelManager; @@ -25,6 +26,7 @@ export type ChannelHealthMonitorDeps = { cooldownCycles?: number; maxRestartsPerHour?: number; staleEventThresholdMs?: number; + channelStartupGraceMs?: number; abortSignal?: AbortSignal; }; @@ -50,7 +52,7 @@ function isChannelHealthy( lastEventAt?: number | null; lastStartAt?: number | null; }, - opts: { now: number; staleEventThresholdMs: number }, + opts: { now: number; staleEventThresholdMs: number; channelStartupGraceMs: number }, ): boolean { if (!isManagedAccount(snapshot)) { return true; @@ -58,6 +60,12 @@ function isChannelHealthy( if (!snapshot.running) { return false; } + if (snapshot.lastStartAt != null) { + const upDuration = opts.now - snapshot.lastStartAt; + if (upDuration < opts.channelStartupGraceMs) { + return true; + } + } if (snapshot.connected === false) { return false; } @@ -88,6 +96,7 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann cooldownCycles = DEFAULT_COOLDOWN_CYCLES, maxRestartsPerHour = DEFAULT_MAX_RESTARTS_PER_HOUR, staleEventThresholdMs = DEFAULT_STALE_EVENT_THRESHOLD_MS, + channelStartupGraceMs = DEFAULT_CHANNEL_STARTUP_GRACE_MS, abortSignal, } = deps; @@ -132,7 +141,7 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann if (channelManager.isManuallyStopped(channelId as ChannelId, accountId)) { continue; } - if (isChannelHealthy(status, { now, staleEventThresholdMs })) { + if (isChannelHealthy(status, { now, staleEventThresholdMs, channelStartupGraceMs })) { continue; } diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index cf3ddabcead..d2db2a60b2d 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -349,6 +349,37 @@ describe("runMessageAction context isolation", () => { expect(result.channel).toBe("slack"); }); + it("falls back to tool-context provider when channel param is an id", async () => { + const result = await runDrySend({ + cfg: slackConfig, + actionParams: { + channel: "C12345678", + target: "#C12345678", + message: "hi", + }, + toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" }, + }); + + expect(result.kind).toBe("send"); + expect(result.channel).toBe("slack"); + }); + + it("falls back to tool-context provider for broadcast channel ids", async () => { + const result = await runDryAction({ + cfg: slackConfig, + action: "broadcast", + actionParams: { + targets: ["channel:C12345678"], + channel: "C12345678", + message: "hi", + }, + toolContext: { currentChannelProvider: "slack" }, + }); + + expect(result.kind).toBe("broadcast"); + expect(result.channel).toBe("slack"); + }); + it("blocks cross-provider sends by default", async () => { await expect( runDrySend({ diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 2693d110306..0336db6f233 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -217,13 +217,28 @@ async function maybeApplyCrossContextMarker(params: { }); } -async function resolveChannel(cfg: OpenClawConfig, params: Record) { +async function resolveChannel( + cfg: OpenClawConfig, + params: Record, + toolContext?: { currentChannelProvider?: string }, +) { const channelHint = readStringParam(params, "channel"); - const selection = await resolveMessageChannelSelection({ - cfg, - channel: channelHint, - }); - return selection.channel; + try { + const selection = await resolveMessageChannelSelection({ + cfg, + channel: channelHint, + }); + return selection.channel; + } catch (error) { + if (channelHint && toolContext?.currentChannelProvider) { + const fallback = normalizeMessageChannel(toolContext.currentChannelProvider); + if (fallback && isDeliverableMessageChannel(fallback)) { + params.channel = fallback; + return fallback; + } + } + throw error; + } } async function resolveActionTarget(params: { @@ -317,7 +332,7 @@ async function handleBroadcastAction( } const targetChannels = channelHint && channelHint.trim().toLowerCase() !== "all" - ? [await resolveChannel(input.cfg, { channel: channelHint })] + ? [await resolveChannel(input.cfg, { channel: channelHint }, input.toolContext)] : configured; const results: Array<{ channel: ChannelId; @@ -754,7 +769,7 @@ export async function runMessageAction( } } - const channel = await resolveChannel(cfg, params); + const channel = await resolveChannel(cfg, params, input.toolContext); let accountId = readStringParam(params, "accountId") ?? input.defaultAccountId; if (!accountId && resolvedAgentId) { const byAgent = buildChannelAccountBindings(cfg).get(channel);