mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 06:01:41 +00:00
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:
committed by
GitHub
parent
54309a832a
commit
30fda068aa
@@ -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" },
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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()]));
|
||||
|
||||
Reference in New Issue
Block a user