diff --git a/src/gateway/channel-health-policy.test.ts b/src/gateway/channel-health-policy.test.ts index a4645a13e75..1509bccc4ba 100644 --- a/src/gateway/channel-health-policy.test.ts +++ b/src/gateway/channel-health-policy.test.ts @@ -174,7 +174,7 @@ describe("evaluateChannelHealth", () => { }, { channelId: "slack", - now: 100_000, + now: 75_000, channelConnectGraceMs: 10_000, staleEventThresholdMs: 30_000, }, @@ -194,13 +194,33 @@ describe("evaluateChannelHealth", () => { }, { channelId: "slack", - now: 100_000, + now: 75_000, channelConnectGraceMs: 10_000, staleEventThresholdMs: 30_000, }, ); expect(evaluation).toEqual({ healthy: true, reason: "healthy" }); }); + + it("flags inherited event timestamps after the lifecycle exceeds the stale threshold", () => { + const evaluation = evaluateChannelHealth( + { + running: true, + connected: true, + enabled: true, + configured: true, + lastStartAt: 50_000, + lastEventAt: 10_000, + }, + { + channelId: "slack", + now: 140_000, + channelConnectGraceMs: 10_000, + staleEventThresholdMs: 30_000, + }, + ); + expect(evaluation).toEqual({ healthy: false, reason: "stale-socket" }); + }); }); describe("resolveChannelRestartReason", () => { diff --git a/src/gateway/channel-health-policy.ts b/src/gateway/channel-health-policy.ts index d8374d04ba8..b3bc74bfc4d 100644 --- a/src/gateway/channel-health-policy.ts +++ b/src/gateway/channel-health-policy.ts @@ -109,7 +109,11 @@ export function evaluateChannelHealth( snapshot.lastEventAt != null ) { if (lastStartAt != null && snapshot.lastEventAt < lastStartAt) { - return { healthy: true, reason: "healthy" }; + const lifecycleEventGap = Math.max(0, policy.now - lastStartAt); + if (lifecycleEventGap <= policy.staleEventThresholdMs) { + return { healthy: true, reason: "healthy" }; + } + return { healthy: false, reason: "stale-socket" }; } const eventAge = policy.now - snapshot.lastEventAt; if (eventAge > policy.staleEventThresholdMs) { diff --git a/src/slack/monitor/provider.reconnect.test.ts b/src/slack/monitor/provider.reconnect.test.ts index 10fbab031a0..81beaa59576 100644 --- a/src/slack/monitor/provider.reconnect.test.ts +++ b/src/slack/monitor/provider.reconnect.test.ts @@ -38,6 +38,38 @@ describe("slack socket reconnect helpers", () => { ); }); + it("clears connected state when socket mode disconnects", () => { + const setStatus = vi.fn(); + const err = new Error("dns down"); + + __testing.publishSlackDisconnectedStatus(setStatus, err); + + expect(setStatus).toHaveBeenCalledTimes(1); + expect(setStatus).toHaveBeenCalledWith({ + connected: false, + lastDisconnect: { + at: expect.any(Number), + error: "dns down", + }, + lastError: "dns down", + }); + }); + + it("clears connected state without error when socket mode disconnects cleanly", () => { + const setStatus = vi.fn(); + + __testing.publishSlackDisconnectedStatus(setStatus); + + expect(setStatus).toHaveBeenCalledTimes(1); + expect(setStatus).toHaveBeenCalledWith({ + connected: false, + lastDisconnect: { + at: expect.any(Number), + }, + lastError: null, + }); + }); + it("resolves disconnect waiter on socket disconnect event", async () => { const client = new FakeEmitter(); const app = { receiver: { client } }; diff --git a/src/slack/monitor/provider.ts b/src/slack/monitor/provider.ts index 12ba1020268..8686eb51367 100644 --- a/src/slack/monitor/provider.ts +++ b/src/slack/monitor/provider.ts @@ -77,6 +77,22 @@ function publishSlackConnectedStatus(setStatus?: (next: Record) }); } +function publishSlackDisconnectedStatus( + setStatus?: (next: Record) => void, + error?: unknown, +) { + if (!setStatus) { + return; + } + const at = Date.now(); + const message = error ? formatUnknownError(error) : undefined; + setStatus({ + connected: false, + lastDisconnect: message ? { at, error: message } : { at }, + lastError: message ?? null, + }); +} + export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { const cfg = opts.config ?? loadConfig(); const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime(); @@ -440,6 +456,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) { if (opts.abortSignal?.aborted) { break; } + publishSlackDisconnectedStatus(opts.setStatus, disconnect.error); // Bail immediately on non-recoverable auth errors during reconnect too. if (disconnect.error && isNonRecoverableSlackAuthError(disconnect.error)) { @@ -495,6 +512,7 @@ export { isNonRecoverableSlackAuthError } from "./reconnect-policy.js"; export const __testing = { publishSlackConnectedStatus, + publishSlackDisconnectedStatus, resolveSlackRuntimeGroupPolicy: resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, getSocketEmitter,