diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index bd9a05fea10..b463a6b50a3 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -212,6 +212,37 @@ describe("gateway.channelHealthCheckMinutes", () => { expect(res.issues[0]?.path).toBe("gateway.channelHealthCheckMinutes"); } }); + + it("rejects stale thresholds shorter than the health check interval", () => { + const res = validateConfigObject({ + gateway: { + channelHealthCheckMinutes: 5, + channelStaleEventThresholdMinutes: 4, + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("gateway.channelStaleEventThresholdMinutes"); + } + }); + + it("accepts stale thresholds that match or exceed the health check interval", () => { + const equal = validateConfigObject({ + gateway: { + channelHealthCheckMinutes: 5, + channelStaleEventThresholdMinutes: 5, + }, + }); + expect(equal.ok).toBe(true); + + const greater = validateConfigObject({ + gateway: { + channelHealthCheckMinutes: 5, + channelStaleEventThresholdMinutes: 6, + }, + }); + expect(greater.ok).toBe(true); + }); }); describe("cron webhook schema", () => { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index dac3e61f94c..e37132e5480 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -835,6 +835,21 @@ export const OpenClawSchema = z .optional(), }) .strict() + .superRefine((gateway, ctx) => { + if ( + gateway.channelStaleEventThresholdMinutes != null && + gateway.channelHealthCheckMinutes != null && + gateway.channelHealthCheckMinutes !== 0 && + gateway.channelStaleEventThresholdMinutes < gateway.channelHealthCheckMinutes + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["channelStaleEventThresholdMinutes"], + message: + "channelStaleEventThresholdMinutes should be >= channelHealthCheckMinutes to avoid delayed stale detection", + }); + } + }) .optional(), memory: MemorySchema, skills: z diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index 4627dc681fa..9d65dac1a18 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -235,4 +235,27 @@ describe("server-channels auto restart", () => { expect(manager.isHealthMonitorEnabled("discord", "router-d")).toBe(false); }); + + it("falls back to channel-level health monitor overrides when account resolution omits them", () => { + installTestRegistry( + createTestPlugin({ + resolveAccount: () => ({ + enabled: true, + configured: true, + }), + }), + ); + + const manager = createManager({ + loadConfig: () => ({ + channels: { + discord: { + healthMonitor: { enabled: false }, + }, + }, + }), + }); + + expect(manager.isHealthMonitorEnabled("discord", DEFAULT_ACCOUNT_ID)).toBe(false); + }); }); diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index 853a7ea551c..dc6127687d8 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -131,11 +131,24 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage } | undefined; const accountOverride = resolvedAccount?.healthMonitor?.enabled; + const channelOverride = ( + cfg.channels?.[channelId] as + | { + healthMonitor?: { + enabled?: boolean; + }; + } + | undefined + )?.healthMonitor?.enabled; if (typeof accountOverride === "boolean") { return accountOverride; } + if (typeof channelOverride === "boolean") { + return channelOverride; + } + return true; };