fix(channels): keep running-channel connectivity tri-state (#114970)

e0a119dabf (#114775) collapsed the account-status `connected` tri-state into a
boolean for running accounts: `resolveChannelAccountState` resolved
`input.runtime.connected ?? false` and the projection emitted the field
unconditionally. Every channel that never publishes connectivity (17 of 27
channel plugins, including imessage, signal, sms, googlechat, line, msteams)
therefore reported `connected: false` while running.

`evaluateChannelHealth` reads `connected === false` on a running account as
`disconnected`, so the gateway health monitor stopped and started every
socketless channel once per cooldown window, forever.

Restores absent connectivity as "no transport signal" in the one place it was
lost. Explicit `true`/`false` from socket channels is unchanged.
This commit is contained in:
Peter Steinberger
2026-07-28 02:22:02 -04:00
committed by GitHub
parent 54309a832a
commit 30fda068aa
3 changed files with 49 additions and 3 deletions

View File

@@ -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" },

View File

@@ -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":

View File

@@ -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<TestAccount>) => {
ctx.setStatus({ accountId: DEFAULT_ACCOUNT_ID, running: true });
await new Promise<void>((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()]));