diff --git a/src/channels/status/account-state.test.ts b/src/channels/status/account-state.test.ts index 662ecc8faf21..2db7556242f8 100644 --- a/src/channels/status/account-state.test.ts +++ b/src/channels/status/account-state.test.ts @@ -54,6 +54,11 @@ describe("resolveChannelAccountState", () => { { runtime: { running: true, connected: true, lastError: "not linked" } }, { kind: "running", linked: true, connected: true, failure: "not linked" }, ], + [ + "running keeps connectivity absent when the transport publishes none", + { runtime: { running: true } }, + { kind: "running", linked: true, connected: undefined, failure: null }, + ], [ "stopped owns linkage, connectivity, and failure", {}, @@ -120,6 +125,13 @@ describe("projectChannelAccountState", () => { { kind: "running", linked: true, connected: false, failure: null }, { configured: true, linked: true, running: true, connected: false, lastError: null }, ], + [ + // Socketless channels never publish connectivity; a manufactured + // `connected: false` here reads as a transport disconnect and makes the + // gateway health monitor restart them every cooldown window. + { kind: "running", linked: true, failure: null }, + { configured: true, linked: true, running: true, lastError: null }, + ], [ { kind: "stopped", connected: true, failure: "transport failed" }, { configured: true, running: false, connected: true, lastError: "transport failed" }, diff --git a/src/channels/status/account-state.ts b/src/channels/status/account-state.ts index cdee6bad2b29..082b5a15d6e8 100644 --- a/src/channels/status/account-state.ts +++ b/src/channels/status/account-state.ts @@ -17,7 +17,7 @@ type ChannelAccountState = } | { kind: "unconfigured"; reason: string; failure: string | null } | { kind: "unlinked"; reason: string; failure: string | null } - | { kind: "running"; linked?: true; connected: boolean; failure: string | null } + | { kind: "running"; linked?: true; connected?: boolean; failure: string | null } | { kind: "stopped"; linked?: true; connected?: boolean; failure: string | null }; type ChannelAccountStateInput = { @@ -59,7 +59,11 @@ export function resolveChannelAccountState(input: ChannelAccountStateInput): Cha return { kind: "running", linked: input.linked, - connected: input.runtime.connected ?? false, + // Connectivity is tri-state: absent means the transport publishes none at + // all (imessage, signal, sms, ...), which is not a reported disconnect. + // Defaulting to false makes `evaluateChannelHealth` return "disconnected" + // and the health monitor restart every socketless channel per cooldown. + connected: input.runtime.connected, failure, }; } @@ -115,7 +119,7 @@ function projectChannelAccountState(state: ChannelAccountState): { configured: true, ...(state.linked ? { linked: true } : {}), running: true, - connected: state.connected, + ...(typeof state.connected === "boolean" ? { connected: state.connected } : {}), lastError: state.failure, }; case "stopped": diff --git a/src/gateway/server-channels.test.ts b/src/gateway/server-channels.test.ts index c24d4929d71f..d888a1078872 100644 --- a/src/gateway/server-channels.test.ts +++ b/src/gateway/server-channels.test.ts @@ -23,6 +23,7 @@ import { listActiveDegradedSecretOwners, setActiveDegradedSecretOwners, } from "../secrets/runtime-degraded-state.js"; +import { evaluateChannelHealth } from "./channel-health-policy.js"; import { createChannelManager, type ChannelManager } from "./server-channels.js"; const hoisted = vi.hoisted(() => { @@ -483,6 +484,35 @@ describe("server-channels auto restart", () => { expect(account?.lastError).toBeNull(); }); + it("keeps a running channel without transport reporting free of a synthetic disconnect", async () => { + // Socketless channels (imessage, signal, sms, ...) never publish `connected`. + // Projecting a synthetic `false` made the health monitor read them as + // disconnected and restart them once per cooldown window forever. + const startAccount = vi.fn(async (ctx: ChannelGatewayContext) => { + ctx.setStatus({ accountId: DEFAULT_ACCOUNT_ID, running: true }); + await new Promise((resolve) => { + ctx.abortSignal.addEventListener("abort", () => resolve(), { once: true }); + }); + }); + installTestRegistry(createTestPlugin({ startAccount })); + const manager = createManager(); + + await manager.startChannels(); + await flushMicrotasks(); + + const account = manager.getRuntimeSnapshot().channelAccounts.discord?.[DEFAULT_ACCOUNT_ID]; + expect(account?.running).toBe(true); + expect(account).not.toHaveProperty("connected"); + expect( + evaluateChannelHealth(account ?? {}, { + channelId: "discord", + now: Date.now() + 60 * 60_000, + channelConnectGraceMs: 120_000, + staleEventThresholdMs: 30 * 60_000, + }), + ).toEqual({ healthy: true, reason: "healthy" }); + }); + it("settles every account before surfacing a stop hook failure", async () => { const accountIds = ["broken", "healthy"]; const taskReleases = new Map(accountIds.map((accountId) => [accountId, createDeferred()]));