diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 715bd71ad75..2f552d7ba51 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -10f07ebae3910cfe7639c54fb97ec4011c5f8be8a5444b86d23f075f9a49cc4c plugin-sdk-api-baseline.json -fdc165b2d06f00d195e326c2d28176da5cdeb8f8b05df4ec28466d384d57a07b plugin-sdk-api-baseline.jsonl +fc00be212cab9fa24cf625fd9afb8f6d0871509afcc42baa6653d3ef26a991d1 plugin-sdk-api-baseline.json +efe8884ee3a296ae77b80f1485d17744397c5868c110b23eb5cf99ce2587a03f plugin-sdk-api-baseline.jsonl diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 914a5944a29..701e953ff8c 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -760,6 +760,7 @@ export const telegramPlugin = createChatChannelPlugin({ actions: telegramMessageActions, status: createComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), + staleSocketHealthCheckModes: ["polling"], collectStatusIssues: collectTelegramStatusIssues, buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 68b3f85d6fa..e33be3cce7a 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -173,6 +173,8 @@ export type ChannelGroupAdapter = { export type ChannelStatusAdapter = { defaultRuntime?: ChannelAccountSnapshot; skipStaleSocketHealthCheck?: boolean; + /** Runtime `mode` values where `lastEventAt` can prove connected socket liveness. */ + staleSocketHealthCheckModes?: readonly string[]; buildChannelSummary?: BivariantCallback< (params: { account: ResolvedAccount; diff --git a/src/gateway/channel-health-monitor.ts b/src/gateway/channel-health-monitor.ts index cb489e204b5..fdd328f0106 100644 --- a/src/gateway/channel-health-monitor.ts +++ b/src/gateway/channel-health-monitor.ts @@ -125,12 +125,14 @@ export function startChannelHealthMonitor(deps: ChannelHealthMonitorDeps): Chann if (channelManager.isManuallyStopped(channelId as ChannelId, accountId)) { continue; } + const channelPluginStatus = getChannelPlugin(channelId)?.status; const healthPolicy: ChannelHealthPolicy = { channelId, now, staleEventThresholdMs: timing.staleEventThresholdMs, channelConnectGraceMs: timing.channelConnectGraceMs, - skipStaleSocketCheck: getChannelPlugin(channelId)?.status?.skipStaleSocketHealthCheck, + skipStaleSocketCheck: channelPluginStatus?.skipStaleSocketHealthCheck, + staleSocketHealthCheckModes: channelPluginStatus?.staleSocketHealthCheckModes, }; const health = evaluateChannelHealth(status, healthPolicy); if (health.healthy) { diff --git a/src/gateway/channel-health-policy.test.ts b/src/gateway/channel-health-policy.test.ts index 1656bb8c74e..72cf246c117 100644 --- a/src/gateway/channel-health-policy.test.ts +++ b/src/gateway/channel-health-policy.test.ts @@ -136,7 +136,7 @@ describe("evaluateChannelHealth", () => { expect(evaluation).toEqual({ healthy: false, reason: "stale-socket" }); }); - it("flags stale sockets for telegram polling channels", () => { + it("flags stale sockets for channels with an allowed health-check mode", () => { const evaluation = evaluateChannelHealth( { running: true, @@ -148,16 +148,17 @@ describe("evaluateChannelHealth", () => { mode: "polling", }, { - channelId: "telegram", + channelId: "example", now: 100_000, channelConnectGraceMs: 10_000, staleEventThresholdMs: 30_000, + staleSocketHealthCheckModes: ["polling"], }, ); expect(evaluation).toEqual({ healthy: false, reason: "stale-socket" }); }); - it("skips stale-socket detection for telegram accounts without explicit polling mode", () => { + it("skips stale-socket detection when an allowlisted health-check mode is missing", () => { const evaluation = evaluateChannelHealth( { running: true, @@ -168,16 +169,17 @@ describe("evaluateChannelHealth", () => { lastEventAt: 0, }, { - channelId: "telegram", + channelId: "example", now: 100_000, channelConnectGraceMs: 10_000, staleEventThresholdMs: 30_000, + staleSocketHealthCheckModes: ["polling"], }, ); expect(evaluation).toEqual({ healthy: true, reason: "healthy" }); }); - it("skips stale-socket detection for telegram accounts with malformed mode", () => { + it("skips stale-socket detection when the health-check mode is malformed", () => { const evaluation = evaluateChannelHealth( { running: true, @@ -189,10 +191,11 @@ describe("evaluateChannelHealth", () => { mode: { polling: true } as unknown as string, }, { - channelId: "telegram", + channelId: "example", now: 100_000, channelConnectGraceMs: 10_000, staleEventThresholdMs: 30_000, + staleSocketHealthCheckModes: ["polling"], }, ); expect(evaluation).toEqual({ healthy: true, reason: "healthy" }); diff --git a/src/gateway/channel-health-policy.ts b/src/gateway/channel-health-policy.ts index 58233036260..08049d9618e 100644 --- a/src/gateway/channel-health-policy.ts +++ b/src/gateway/channel-health-policy.ts @@ -36,6 +36,7 @@ export type ChannelHealthPolicy = { staleEventThresholdMs: number; channelConnectGraceMs: number; skipStaleSocketCheck?: boolean; + staleSocketHealthCheckModes?: readonly string[]; }; export type ChannelRestartReason = @@ -55,6 +56,19 @@ const BUSY_ACTIVITY_STALE_THRESHOLD_MS = 25 * 60_000; export const DEFAULT_CHANNEL_STALE_EVENT_THRESHOLD_MS = 30 * 60_000; export const DEFAULT_CHANNEL_CONNECT_GRACE_MS = 120_000; +function shouldCheckStaleSocketForMode( + mode: string | undefined, + healthCheckModes: readonly string[] | undefined, +): boolean { + if (healthCheckModes) { + const normalizedModes = new Set( + healthCheckModes.map((entry) => entry.trim().toLowerCase()).filter(Boolean), + ); + return Boolean(mode && normalizedModes.has(mode)); + } + return mode !== "webhook"; +} + export function evaluateChannelHealth( snapshot: ChannelHealthSnapshot, policy: ChannelHealthPolicy, @@ -112,14 +126,11 @@ export function evaluateChannelHealth( if (snapshot.connected === false) { return { healthy: false, reason: "disconnected" }; } - // Telegram only has reliable stale-socket liveness in explicit polling mode. - // Webhook accounts and malformed legacy mode values do not have a persistent - // outgoing socket to age-check. const shouldCheckStaleSocket = policy.skipStaleSocketCheck !== true && snapshot.connected === true && lastEventAt != null && - (policy.channelId === "telegram" ? mode === "polling" : mode !== "webhook"); + shouldCheckStaleSocketForMode(mode, policy.staleSocketHealthCheckModes); if (shouldCheckStaleSocket) { if (lastStartAt != null && lastEventAt < lastStartAt) { const lifecycleEventGap = Math.max(0, policy.now - lastStartAt); diff --git a/src/gateway/server/readiness.ts b/src/gateway/server/readiness.ts index f7eb873c2fb..f0901ffb026 100644 --- a/src/gateway/server/readiness.ts +++ b/src/gateway/server/readiness.ts @@ -64,12 +64,14 @@ export function createReadinessChecker(deps: { if (!accountSnapshot) { continue; } + const channelPluginStatus = getChannelPlugin(channelId)?.status; const policy: ChannelHealthPolicy = { now, staleEventThresholdMs: DEFAULT_CHANNEL_STALE_EVENT_THRESHOLD_MS, channelConnectGraceMs: DEFAULT_CHANNEL_CONNECT_GRACE_MS, channelId, - skipStaleSocketCheck: getChannelPlugin(channelId)?.status?.skipStaleSocketHealthCheck, + skipStaleSocketCheck: channelPluginStatus?.skipStaleSocketHealthCheck, + staleSocketHealthCheckModes: channelPluginStatus?.staleSocketHealthCheckModes, }; const health = evaluateChannelHealth(accountSnapshot, policy); if (!health.healthy && !shouldIgnoreReadinessFailure(accountSnapshot, health)) { diff --git a/src/secrets/channel-contract-surface-guardrails.test.ts b/src/secrets/channel-contract-surface-guardrails.test.ts index d145112d587..38a3f04824d 100644 --- a/src/secrets/channel-contract-surface-guardrails.test.ts +++ b/src/secrets/channel-contract-surface-guardrails.test.ts @@ -50,6 +50,10 @@ const CORE_SECRET_SURFACE_GUARDS = [ path: "src/plugin-sdk/command-auth.ts", forbiddenPatterns: [/\bpluginId:\s*"telegram"/], }, + { + path: "src/gateway/channel-health-policy.ts", + forbiddenPatterns: [/\btelegram\b/], + }, ] as const; describe("channel secret contract surface guardrails", () => {