Skip to content

Commit 71cd337

Browse files
committed
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 <[email protected]>
1 parent 4d04e1a commit 71cd337

File tree

5 files changed

+103
-10
lines changed

5 files changed

+103
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
3232

3333
### Fixes
3434

35+
- 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.
3536
- 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.
3637
- 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.
3738
- 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.

src/gateway/channel-health-monitor.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ describe("channel-health-monitor", () => {
201201
});
202202

203203
it("restarts a stuck channel (running but not connected)", async () => {
204+
const now = Date.now();
204205
const manager = createSnapshotManager({
205206
whatsapp: {
206207
default: {
@@ -209,6 +210,7 @@ describe("channel-health-monitor", () => {
209210
enabled: true,
210211
configured: true,
211212
linked: true,
213+
lastStartAt: now - 300_000,
212214
},
213215
},
214216
});
@@ -219,6 +221,41 @@ describe("channel-health-monitor", () => {
219221
monitor.stop();
220222
});
221223

224+
it("skips recently-started channels while they are still connecting", async () => {
225+
const now = Date.now();
226+
const manager = createSnapshotManager({
227+
discord: {
228+
default: {
229+
running: true,
230+
connected: false,
231+
enabled: true,
232+
configured: true,
233+
lastStartAt: now - 5_000,
234+
},
235+
},
236+
});
237+
await expectNoRestart(manager);
238+
});
239+
240+
it("respects custom per-channel startup grace", async () => {
241+
const now = Date.now();
242+
const manager = createSnapshotManager({
243+
discord: {
244+
default: {
245+
running: true,
246+
connected: false,
247+
enabled: true,
248+
configured: true,
249+
lastStartAt: now - 30_000,
250+
},
251+
},
252+
});
253+
const monitor = await startAndRunCheck(manager, { channelStartupGraceMs: 60_000 });
254+
expect(manager.stopChannel).not.toHaveBeenCalled();
255+
expect(manager.startChannel).not.toHaveBeenCalled();
256+
monitor.stop();
257+
});
258+
222259
it("restarts a stopped channel that gave up (reconnectAttempts >= 10)", async () => {
223260
const manager = createSnapshotManager({
224261
discord: {

src/gateway/channel-health-monitor.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const ONE_HOUR_MS = 60 * 60_000;
1717
* alive (health checks pass) but Slack silently stops delivering events.
1818
*/
1919
const DEFAULT_STALE_EVENT_THRESHOLD_MS = 30 * 60_000;
20+
const DEFAULT_CHANNEL_STARTUP_GRACE_MS = 120_000;
2021

2122
export type ChannelHealthMonitorDeps = {
2223
channelManager: ChannelManager;
@@ -25,6 +26,7 @@ export type ChannelHealthMonitorDeps = {
2526
cooldownCycles?: number;
2627
maxRestartsPerHour?: number;
2728
staleEventThresholdMs?: number;
29+
channelStartupGraceMs?: number;
2830
abortSignal?: AbortSignal;
2931
};
3032

@@ -50,14 +52,20 @@ function isChannelHealthy(
5052
lastEventAt?: number | null;
5153
lastStartAt?: number | null;
5254
},
53-
opts: { now: number; staleEventThresholdMs: number },
55+
opts: { now: number; staleEventThresholdMs: number; channelStartupGraceMs: number },
5456
): boolean {
5557
if (!isManagedAccount(snapshot)) {
5658
return true;
5759
}
5860
if (!snapshot.running) {
5961
return false;
6062
}
63+
if (snapshot.lastStartAt != null) {
64+
const upDuration = opts.now - snapshot.lastStartAt;
65+
if (upDuration < opts.channelStartupGraceMs) {
66+
return true;
67+
}
68+
}
6169
if (snapshot.connected === false) {
6270
return false;
6371
}
@@ -88,6 +96,7 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
8896
cooldownCycles = DEFAULT_COOLDOWN_CYCLES,
8997
maxRestartsPerHour = DEFAULT_MAX_RESTARTS_PER_HOUR,
9098
staleEventThresholdMs = DEFAULT_STALE_EVENT_THRESHOLD_MS,
99+
channelStartupGraceMs = DEFAULT_CHANNEL_STARTUP_GRACE_MS,
91100
abortSignal,
92101
} = deps;
93102

@@ -132,7 +141,7 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann
132141
if (channelManager.isManuallyStopped(channelId as ChannelId, accountId)) {
133142
continue;
134143
}
135-
if (isChannelHealthy(status, { now, staleEventThresholdMs })) {
144+
if (isChannelHealthy(status, { now, staleEventThresholdMs, channelStartupGraceMs })) {
136145
continue;
137146
}
138147

src/infra/outbound/message-action-runner.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,37 @@ describe("runMessageAction context isolation", () => {
349349
expect(result.channel).toBe("slack");
350350
});
351351

352+
it("falls back to tool-context provider when channel param is an id", async () => {
353+
const result = await runDrySend({
354+
cfg: slackConfig,
355+
actionParams: {
356+
channel: "C12345678",
357+
target: "#C12345678",
358+
message: "hi",
359+
},
360+
toolContext: { currentChannelId: "C12345678", currentChannelProvider: "slack" },
361+
});
362+
363+
expect(result.kind).toBe("send");
364+
expect(result.channel).toBe("slack");
365+
});
366+
367+
it("falls back to tool-context provider for broadcast channel ids", async () => {
368+
const result = await runDryAction({
369+
cfg: slackConfig,
370+
action: "broadcast",
371+
actionParams: {
372+
targets: ["channel:C12345678"],
373+
channel: "C12345678",
374+
message: "hi",
375+
},
376+
toolContext: { currentChannelProvider: "slack" },
377+
});
378+
379+
expect(result.kind).toBe("broadcast");
380+
expect(result.channel).toBe("slack");
381+
});
382+
352383
it("blocks cross-provider sends by default", async () => {
353384
await expect(
354385
runDrySend({

src/infra/outbound/message-action-runner.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -217,13 +217,28 @@ async function maybeApplyCrossContextMarker(params: {
217217
});
218218
}
219219

220-
async function resolveChannel(cfg: OpenClawConfig, params: Record<string, unknown>) {
220+
async function resolveChannel(
221+
cfg: OpenClawConfig,
222+
params: Record<string, unknown>,
223+
toolContext?: { currentChannelProvider?: string },
224+
) {
221225
const channelHint = readStringParam(params, "channel");
222-
const selection = await resolveMessageChannelSelection({
223-
cfg,
224-
channel: channelHint,
225-
});
226-
return selection.channel;
226+
try {
227+
const selection = await resolveMessageChannelSelection({
228+
cfg,
229+
channel: channelHint,
230+
});
231+
return selection.channel;
232+
} catch (error) {
233+
if (channelHint && toolContext?.currentChannelProvider) {
234+
const fallback = normalizeMessageChannel(toolContext.currentChannelProvider);
235+
if (fallback && isDeliverableMessageChannel(fallback)) {
236+
params.channel = fallback;
237+
return fallback;
238+
}
239+
}
240+
throw error;
241+
}
227242
}
228243

229244
async function resolveActionTarget(params: {
@@ -317,7 +332,7 @@ async function handleBroadcastAction(
317332
}
318333
const targetChannels =
319334
channelHint && channelHint.trim().toLowerCase() !== "all"
320-
? [await resolveChannel(input.cfg, { channel: channelHint })]
335+
? [await resolveChannel(input.cfg, { channel: channelHint }, input.toolContext)]
321336
: configured;
322337
const results: Array<{
323338
channel: ChannelId;
@@ -754,7 +769,7 @@ export async function runMessageAction(
754769
}
755770
}
756771

757-
const channel = await resolveChannel(cfg, params);
772+
const channel = await resolveChannel(cfg, params, input.toolContext);
758773
let accountId = readStringParam(params, "accountId") ?? input.defaultAccountId;
759774
if (!accountId && resolvedAgentId) {
760775
const byAgent = buildChannelAccountBindings(cfg).get(channel);

0 commit comments

Comments
 (0)