diff --git a/extensions/bluebubbles/src/types.ts b/extensions/bluebubbles/src/types.ts index 43e8c739775..11a1d486652 100644 --- a/extensions/bluebubbles/src/types.ts +++ b/extensions/bluebubbles/src/types.ts @@ -57,6 +57,10 @@ export type BlueBubblesAccountConfig = { allowPrivateNetwork?: boolean; /** Per-group configuration keyed by chat GUID or identifier. */ groups?: Record; + /** Channel health monitor overrides for this channel/account. */ + healthMonitor?: { + enabled?: boolean; + }; }; export type BlueBubblesActionConfig = { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 7fbfdec76d8..97b2ef02e5c 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -102,6 +102,8 @@ export const FIELD_HELP: Record = { "Explicit gateway-level tool denylist to block risky tools even if lower-level policies allow them. Use deny rules for emergency response and defense-in-depth hardening.", "gateway.channelHealthCheckMinutes": "Interval in minutes for automatic channel health probing and status updates. Use lower intervals for faster detection, or higher intervals to reduce periodic probe noise.", + "gateway.channelHealthMonitorEnabled": + "Global enable switch for the gateway channel health monitor. Set false to disable all health-monitor-initiated channel restarts; per-channel healthMonitor.enabled overrides can further disable individual channels or accounts when the global monitor stays on.", "gateway.channelStaleEventThresholdMinutes": "How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.", "gateway.channelMaxRestartsPerHour": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index e700f2329b4..394f2095880 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -83,6 +83,7 @@ export const FIELD_LABELS: Record = { "gateway.tools": "Gateway Tool Exposure Policy", "gateway.tools.allow": "Gateway Tool Allowlist", "gateway.tools.deny": "Gateway Tool Denylist", + "gateway.channelHealthMonitorEnabled": "Gateway Channel Health Monitor Enabled", "gateway.channelHealthCheckMinutes": "Gateway Channel Health Check Interval (min)", "gateway.channelStaleEventThresholdMinutes": "Gateway Channel Stale Event Threshold (min)", "gateway.channelMaxRestartsPerHour": "Gateway Channel Max Restarts Per Hour", diff --git a/src/config/types.channel-messaging-common.ts b/src/config/types.channel-messaging-common.ts index 5d927884bd6..f918557aad6 100644 --- a/src/config/types.channel-messaging-common.ts +++ b/src/config/types.channel-messaging-common.ts @@ -4,7 +4,10 @@ import type { GroupPolicy, MarkdownConfig, } from "./types.base.js"; -import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; +import type { + ChannelHealthMonitorConfig, + ChannelHeartbeatVisibilityConfig, +} from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; export type CommonChannelMessagingConfig = { @@ -43,6 +46,8 @@ export type CommonChannelMessagingConfig = { blockStreamingCoalesce?: BlockStreamingCoalesceConfig; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Channel health monitor overrides for this channel/account. */ + healthMonitor?: ChannelHealthMonitorConfig; /** Outbound response prefix override for this channel/account. */ responsePrefix?: string; /** Max outbound media size in MB. */ diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index caa33631bb1..96d8efddac6 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -18,6 +18,14 @@ export type ChannelHeartbeatVisibilityConfig = { useIndicator?: boolean; }; +export type ChannelHealthMonitorConfig = { + /** + * Enable channel-health-monitor restarts for this channel or account. + * Inherits the global gateway setting when omitted. + */ + enabled?: boolean; +}; + export type ChannelDefaultsConfig = { groupPolicy?: GroupPolicy; /** Default heartbeat visibility for all channels. */ @@ -39,6 +47,7 @@ export type ExtensionChannelConfig = { defaultAccount?: string; dmPolicy?: string; groupPolicy?: GroupPolicy; + healthMonitor?: ChannelHealthMonitorConfig; accounts?: Record; [key: string]: unknown; }; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index e25f7c5f592..a27fd3f8b45 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -8,7 +8,10 @@ import type { OutboundRetryConfig, ReplyToMode, } from "./types.base.js"; -import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; +import type { + ChannelHealthMonitorConfig, + ChannelHeartbeatVisibilityConfig, +} from "./types.channels.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; import type { SecretInput } from "./types.secrets.js"; import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; @@ -297,6 +300,8 @@ export type DiscordAccountConfig = { guilds?: Record; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Channel health monitor overrides for this channel/account. */ + healthMonitor?: ChannelHealthMonitorConfig; /** Exec approval forwarding configuration. */ execApprovals?: DiscordExecApprovalConfig; /** Agent-controlled interactive components (buttons, select menus). */ diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index 88a5350ab1d..be21668112d 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -425,6 +425,12 @@ export type GatewayConfig = { allowRealIpFallback?: boolean; /** Tool access restrictions for HTTP /tools/invoke endpoint. */ tools?: GatewayToolsConfig; + /** + * Global enable switch for the channel health monitor. + * Set to false to disable health-monitor-driven channel restarts entirely. + * Default: true. + */ + channelHealthMonitorEnabled?: boolean; /** * Channel health monitor interval in minutes. * Periodically checks channel health and restarts unhealthy channels. diff --git a/src/config/types.googlechat.ts b/src/config/types.googlechat.ts index 091c4f0f271..fdfc23fd866 100644 --- a/src/config/types.googlechat.ts +++ b/src/config/types.googlechat.ts @@ -4,6 +4,7 @@ import type { GroupPolicy, ReplyToMode, } from "./types.base.js"; +import type { ChannelHealthMonitorConfig } from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; import type { SecretRef } from "./types.secrets.js"; @@ -99,6 +100,8 @@ export type GoogleChatAccountConfig = { /** Per-action tool gating (default: true for all). */ actions?: GoogleChatActionConfig; dm?: GoogleChatDmConfig; + /** Channel health monitor overrides for this channel/account. */ + healthMonitor?: ChannelHealthMonitorConfig; /** * Typing indicator mode (default: "message"). * - "none": No indicator diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index 9fe1b96fef2..4d63965586b 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -4,7 +4,10 @@ import type { GroupPolicy, MarkdownConfig, } from "./types.base.js"; -import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; +import type { + ChannelHealthMonitorConfig, + ChannelHeartbeatVisibilityConfig, +} from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; @@ -77,6 +80,8 @@ export type IMessageAccountConfig = { >; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Channel health monitor overrides for this channel/account. */ + healthMonitor?: ChannelHealthMonitorConfig; /** Outbound response prefix override for this channel/account. */ responsePrefix?: string; }; diff --git a/src/config/types.msteams.ts b/src/config/types.msteams.ts index 35470a56178..83195f03a40 100644 --- a/src/config/types.msteams.ts +++ b/src/config/types.msteams.ts @@ -4,7 +4,10 @@ import type { GroupPolicy, MarkdownConfig, } from "./types.base.js"; -import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; +import type { + ChannelHealthMonitorConfig, + ChannelHeartbeatVisibilityConfig, +} from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; import type { SecretInput } from "./types.secrets.js"; import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; @@ -114,6 +117,8 @@ export type MSTeamsConfig = { sharePointSiteId?: string; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Channel health monitor overrides for this channel. */ + healthMonitor?: ChannelHealthMonitorConfig; /** Outbound response prefix override for this channel/account. */ responsePrefix?: string; }; diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index a90f1ed5020..c62e3b03e64 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -5,7 +5,10 @@ import type { MarkdownConfig, ReplyToMode, } from "./types.base.js"; -import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; +import type { + ChannelHealthMonitorConfig, + ChannelHeartbeatVisibilityConfig, +} from "./types.channels.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; @@ -185,6 +188,8 @@ export type SlackAccountConfig = { channels?: Record; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Channel health monitor overrides for this channel/account. */ + healthMonitor?: ChannelHealthMonitorConfig; /** Outbound response prefix override for this channel/account. */ responsePrefix?: string; /** diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 45eac2fb310..252f66740b2 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -8,7 +8,10 @@ import type { ReplyToMode, SessionThreadBindingsConfig, } from "./types.base.js"; -import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; +import type { + ChannelHealthMonitorConfig, + ChannelHeartbeatVisibilityConfig, +} from "./types.channels.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; @@ -179,6 +182,8 @@ export type TelegramAccountConfig = { reactionLevel?: "off" | "ack" | "minimal" | "extensive"; /** Heartbeat visibility settings for this channel. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Channel health monitor overrides for this channel/account. */ + healthMonitor?: ChannelHealthMonitorConfig; /** Controls whether link previews are shown in outbound messages. Default: true. */ linkPreview?: boolean; /** diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index a39a5c28e1f..29ae866956a 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -4,7 +4,10 @@ import type { GroupPolicy, MarkdownConfig, } from "./types.base.js"; -import type { ChannelHeartbeatVisibilityConfig } from "./types.channels.js"; +import type { + ChannelHealthMonitorConfig, + ChannelHeartbeatVisibilityConfig, +} from "./types.channels.js"; import type { DmConfig } from "./types.messages.js"; import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./types.tools.js"; @@ -78,6 +81,8 @@ type WhatsAppSharedConfig = { debounceMs?: number; /** Heartbeat visibility settings. */ heartbeat?: ChannelHeartbeatVisibilityConfig; + /** Channel health monitor overrides for this channel/account. */ + healthMonitor?: ChannelHealthMonitorConfig; }; type WhatsAppConfigCore = { diff --git a/src/config/zod-schema.channels.ts b/src/config/zod-schema.channels.ts index ebabe1bae94..94d6d24caed 100644 --- a/src/config/zod-schema.channels.ts +++ b/src/config/zod-schema.channels.ts @@ -8,3 +8,10 @@ export const ChannelHeartbeatVisibilitySchema = z }) .strict() .optional(); + +export const ChannelHealthMonitorSchema = z + .object({ + enabled: z.boolean().optional(), + }) + .strict() + .optional(); diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index ced89bd8512..e6e4a3aacd2 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -13,7 +13,10 @@ import { resolveTelegramCustomCommands, } from "./telegram-custom-commands.js"; import { ToolPolicySchema } from "./zod-schema.agent-runtime.js"; -import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js"; +import { + ChannelHealthMonitorSchema, + ChannelHeartbeatVisibilitySchema, +} from "./zod-schema.channels.js"; import { BlockStreamingChunkSchema, BlockStreamingCoalesceSchema, @@ -271,6 +274,7 @@ export const TelegramAccountSchemaBase = z reactionNotifications: z.enum(["off", "own", "all"]).optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + healthMonitor: ChannelHealthMonitorSchema, linkPreview: z.boolean().optional(), responsePrefix: z.string().optional(), ackReaction: z.string().optional(), @@ -511,6 +515,7 @@ export const DiscordAccountSchema = z dm: DiscordDmSchema.optional(), guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + healthMonitor: ChannelHealthMonitorSchema, execApprovals: z .object({ enabled: z.boolean().optional(), @@ -782,6 +787,7 @@ export const GoogleChatAccountSchema = z .strict() .optional(), dm: GoogleChatDmSchema.optional(), + healthMonitor: ChannelHealthMonitorSchema, typingIndicator: z.enum(["none", "message", "reaction"]).optional(), responsePrefix: z.string().optional(), }) @@ -898,6 +904,7 @@ export const SlackAccountSchema = z dm: SlackDmSchema.optional(), channels: z.record(z.string(), SlackChannelSchema.optional()).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + healthMonitor: ChannelHealthMonitorSchema, responsePrefix: z.string().optional(), ackReaction: z.string().optional(), typingReaction: z.string().optional(), @@ -1032,6 +1039,7 @@ export const SignalAccountSchemaBase = z .optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + healthMonitor: ChannelHealthMonitorSchema, responsePrefix: z.string().optional(), }) .strict(); @@ -1145,6 +1153,7 @@ export const IrcAccountSchemaBase = z blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), mediaMaxMb: z.number().positive().optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + healthMonitor: ChannelHealthMonitorSchema, responsePrefix: z.string().optional(), }) .strict(); @@ -1272,6 +1281,7 @@ export const IMessageAccountSchemaBase = z ) .optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + healthMonitor: ChannelHealthMonitorSchema, responsePrefix: z.string().optional(), }) .strict(); @@ -1383,6 +1393,7 @@ export const BlueBubblesAccountSchemaBase = z blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), groups: z.record(z.string(), BlueBubblesGroupConfigSchema.optional()).optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + healthMonitor: ChannelHealthMonitorSchema, responsePrefix: z.string().optional(), }) .strict(); @@ -1499,6 +1510,7 @@ export const MSTeamsConfigSchema = z /** SharePoint site ID for file uploads in group chats/channels (e.g., "contoso.sharepoint.com,guid1,guid2") */ sharePointSiteId: z.string().optional(), heartbeat: ChannelHeartbeatVisibilitySchema, + healthMonitor: ChannelHealthMonitorSchema, responsePrefix: z.string().optional(), }) .strict() diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 2faba715bad..26b7c476c53 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -1,6 +1,9 @@ import { z } from "zod"; import { ToolPolicySchema } from "./zod-schema.agent-runtime.js"; -import { ChannelHeartbeatVisibilitySchema } from "./zod-schema.channels.js"; +import { + ChannelHealthMonitorSchema, + ChannelHeartbeatVisibilitySchema, +} from "./zod-schema.channels.js"; import { BlockStreamingCoalesceSchema, DmConfigSchema, @@ -56,6 +59,7 @@ const WhatsAppSharedSchema = z.object({ ackReaction: WhatsAppAckReactionSchema, debounceMs: z.number().int().nonnegative().optional().default(0), heartbeat: ChannelHeartbeatVisibilitySchema, + healthMonitor: ChannelHealthMonitorSchema, }); function enforceOpenDmPolicyAllowFromStar(params: { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index dac3e61f94c..2e7eb8d151b 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -695,6 +695,7 @@ export const OpenClawSchema = z }) .strict() .optional(), + channelHealthMonitorEnabled: z.boolean().optional(), channelHealthCheckMinutes: z.number().int().min(0).optional(), channelStaleEventThresholdMinutes: z.number().int().min(1).optional(), channelMaxRestartsPerHour: z.number().int().min(1).optional(), diff --git a/src/gateway/channel-health-monitor.test.ts b/src/gateway/channel-health-monitor.test.ts index 32052af5745..efc392f8ee0 100644 --- a/src/gateway/channel-health-monitor.test.ts +++ b/src/gateway/channel-health-monitor.test.ts @@ -11,6 +11,7 @@ function createMockChannelManager(overrides?: Partial): ChannelM startChannel: vi.fn(async () => {}), stopChannel: vi.fn(async () => {}), markChannelLoggedOut: vi.fn(), + isHealthMonitorEnabled: vi.fn(() => true), isManuallyStopped: vi.fn(() => false), resetRestartAttempts: vi.fn(), ...overrides, @@ -226,6 +227,53 @@ describe("channel-health-monitor", () => { await expectNoStart(manager); }); + it("skips channels with health monitor disabled globally for that account", async () => { + const manager = createSnapshotManager( + { + discord: { + default: { running: false, enabled: true, configured: true }, + }, + }, + { isHealthMonitorEnabled: vi.fn(() => false) }, + ); + await expectNoStart(manager); + }); + + it("still restarts enabled accounts when another account on the same channel is disabled", async () => { + const now = Date.now(); + const manager = createSnapshotManager( + { + discord: { + default: { + running: true, + connected: false, + enabled: true, + configured: true, + lastStartAt: now - 300_000, + }, + quiet: { + running: true, + connected: false, + enabled: true, + configured: true, + lastStartAt: now - 300_000, + }, + }, + }, + { + isHealthMonitorEnabled: vi.fn((channelId: ChannelId, accountId: string) => { + return !(channelId === "discord" && accountId === "quiet"); + }), + }, + ); + const monitor = await startAndRunCheck(manager); + expect(manager.stopChannel).toHaveBeenCalledWith("discord", "default"); + expect(manager.startChannel).toHaveBeenCalledWith("discord", "default"); + expect(manager.stopChannel).not.toHaveBeenCalledWith("discord", "quiet"); + expect(manager.startChannel).not.toHaveBeenCalledWith("discord", "quiet"); + monitor.stop(); + }); + it("restarts a stuck channel (running but not connected)", async () => { const now = Date.now(); const manager = createSnapshotManager({ diff --git a/src/gateway/channel-health-monitor.ts b/src/gateway/channel-health-monitor.ts index fb8715a12f1..809beb1abb8 100644 --- a/src/gateway/channel-health-monitor.ts +++ b/src/gateway/channel-health-monitor.ts @@ -118,6 +118,9 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann if (!status) { continue; } + if (!channelManager.isHealthMonitorEnabled(channelId as ChannelId, accountId)) { + continue; + } if (channelManager.isManuallyStopped(channelId as ChannelId, accountId)) { continue; } diff --git a/src/gateway/config-reload-plan.ts b/src/gateway/config-reload-plan.ts index 63eddd31c54..238d4431fc2 100644 --- a/src/gateway/config-reload-plan.ts +++ b/src/gateway/config-reload-plan.ts @@ -36,6 +36,11 @@ type ReloadAction = const BASE_RELOAD_RULES: ReloadRule[] = [ { prefix: "gateway.remote", kind: "none" }, { prefix: "gateway.reload", kind: "none" }, + { + prefix: "gateway.channelHealthMonitorEnabled", + kind: "hot", + actions: ["restart-health-monitor"], + }, { prefix: "gateway.channelHealthCheckMinutes", kind: "hot", diff --git a/src/gateway/server-channels.ts b/src/gateway/server-channels.ts index 4090791d285..4ce992fca3d 100644 --- a/src/gateway/server-channels.ts +++ b/src/gateway/server-channels.ts @@ -31,6 +31,16 @@ type ChannelRuntimeStore = { runtimes: Map; }; +type RawHealthMonitorEntry = { + healthMonitor?: { + enabled?: boolean; + }; +}; + +type RawChannelConfig = RawHealthMonitorEntry & { + accounts?: Record; +}; + function createRuntimeStore(): ChannelRuntimeStore { return { aborts: new Map(), @@ -105,6 +115,7 @@ export type ChannelManager = { markChannelLoggedOut: (channelId: ChannelId, cleared: boolean, accountId?: string) => void; isManuallyStopped: (channelId: ChannelId, accountId: string) => boolean; resetRestartAttempts: (channelId: ChannelId, accountId: string) => void; + isHealthMonitorEnabled: (channelId: ChannelId, accountId: string) => boolean; }; // Channel docking: lifecycle hooks (`plugin.gateway`) flow through this manager. @@ -119,6 +130,26 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage const restartKey = (channelId: ChannelId, accountId: string) => `${channelId}:${accountId}`; + const isHealthMonitorEnabled = (channelId: ChannelId, accountId: string): boolean => { + const cfg = loadConfig(); + if (cfg.gateway?.channelHealthMonitorEnabled === false) { + return false; + } + + const channelConfig = cfg.channels?.[channelId] as RawChannelConfig | undefined; + const accountOverride = channelConfig?.accounts?.[accountId]?.healthMonitor?.enabled; + if (typeof accountOverride === "boolean") { + return accountOverride; + } + + const channelOverride = channelConfig?.healthMonitor?.enabled; + if (typeof channelOverride === "boolean") { + return channelOverride; + } + + return true; + }; + const getStore = (channelId: ChannelId): ChannelRuntimeStore => { const existing = channelStores.get(channelId); if (existing) { @@ -453,5 +484,6 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage markChannelLoggedOut, isManuallyStopped: isManuallyStopped_, resetRestartAttempts: resetRestartAttempts_, + isHealthMonitorEnabled, }; } diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 008f0977d37..1bd810907b5 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -104,10 +104,11 @@ export function createGatewayReloadHandlers(params: { if (plan.restartHealthMonitor) { state.channelHealthMonitor?.stop(); + const enabled = nextConfig.gateway?.channelHealthMonitorEnabled !== false; const minutes = nextConfig.gateway?.channelHealthCheckMinutes; const staleMinutes = nextConfig.gateway?.channelStaleEventThresholdMinutes; nextState.channelHealthMonitor = - minutes === 0 + !enabled || minutes === 0 ? null : params.createHealthMonitor({ checkIntervalMs: (minutes ?? 5) * 60_000, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 5453ff8fcee..1d7e38b8cea 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -755,8 +755,9 @@ export async function startGatewayServer( } : startHeartbeatRunner({ cfg: cfgAtStart }); + const healthMonitorEnabled = cfgAtStart.gateway?.channelHealthMonitorEnabled !== false; const healthCheckMinutes = cfgAtStart.gateway?.channelHealthCheckMinutes; - const healthCheckDisabled = healthCheckMinutes === 0; + const healthCheckDisabled = !healthMonitorEnabled || healthCheckMinutes === 0; const staleEventThresholdMinutes = cfgAtStart.gateway?.channelStaleEventThresholdMinutes; const maxRestartsPerHour = cfgAtStart.gateway?.channelMaxRestartsPerHour; let channelHealthMonitor = healthCheckDisabled diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index da749fc6501..e16dcd3f35c 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -109,6 +109,9 @@ const hoisted = vi.hoisted(() => { startChannel: vi.fn(async () => {}), stopChannel: vi.fn(async () => {}), markChannelLoggedOut: vi.fn(), + isHealthMonitorEnabled: vi.fn(() => true), + isManuallyStopped: vi.fn(() => false), + resetRestartAttempts: vi.fn(), }; const createChannelManager = vi.fn(() => providerManager); diff --git a/src/gateway/server/readiness.test.ts b/src/gateway/server/readiness.test.ts index b333277f158..f41373dab7e 100644 --- a/src/gateway/server/readiness.test.ts +++ b/src/gateway/server/readiness.test.ts @@ -26,6 +26,7 @@ function createManager(snapshot: ChannelRuntimeSnapshot): ChannelManager { startChannel: vi.fn(), stopChannel: vi.fn(), markChannelLoggedOut: vi.fn(), + isHealthMonitorEnabled: vi.fn(() => true), isManuallyStopped: vi.fn(() => false), resetRestartAttempts: vi.fn(), };