Add channel health diagnostics and gateway recovery fixes (#855)

This commit is contained in:
Lingxuan Zuo
2026-04-15 13:51:02 +08:00
committed by GitHub
Unverified
parent 6acd8acf5a
commit 1f39d1a8a7
22 changed files with 1868 additions and 52 deletions

View File

@@ -175,6 +175,7 @@ describe('handleChannelRoutes', () => {
gatewayManager: {
rpc,
getStatus: () => ({ state: 'running' }),
getDiagnostics: () => ({ consecutiveHeartbeatMisses: 0, consecutiveRpcFailures: 0 }),
debouncedReload: vi.fn(),
debouncedRestart: vi.fn(),
},
@@ -921,6 +922,145 @@ describe('handleChannelRoutes', () => {
expect(feishu?.accounts.map((entry) => entry.accountId)).toEqual(['default']);
});
it('returns degraded channel health when channels.status times out while gateway is still running', async () => {
listConfiguredChannelsMock.mockResolvedValue(['feishu']);
listConfiguredChannelAccountsMock.mockResolvedValue({
feishu: {
defaultAccountId: 'default',
accountIds: ['default'],
},
});
readOpenClawConfigMock.mockResolvedValue({
channels: {
feishu: {
defaultAccount: 'default',
},
},
});
const rpc = vi.fn().mockRejectedValue(new Error('RPC timeout: channels.status'));
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
await handleChannelRoutes(
{ method: 'GET' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:13210/api/channels/accounts'),
{
gatewayManager: {
rpc,
getStatus: () => ({ state: 'running' }),
getDiagnostics: () => ({ consecutiveHeartbeatMisses: 0, consecutiveRpcFailures: 0 }),
debouncedReload: vi.fn(),
debouncedRestart: vi.fn(),
},
} as never,
);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({
success: true,
gatewayHealth: expect.objectContaining({
state: 'degraded',
reasons: expect.arrayContaining(['channels_status_timeout']),
}),
channels: [
expect.objectContaining({
channelType: 'feishu',
status: 'degraded',
statusReason: 'channels_status_timeout',
accounts: [
expect.objectContaining({
accountId: 'default',
status: 'degraded',
}),
],
}),
],
}),
);
});
it('keeps channel degraded when only filtered stale runtime accounts carry lastError', async () => {
listConfiguredChannelsMock.mockResolvedValue(['feishu']);
listConfiguredChannelAccountsMock.mockResolvedValue({
feishu: {
defaultAccountId: 'default',
accountIds: ['default'],
},
});
readOpenClawConfigMock.mockResolvedValue({
channels: {
feishu: {
defaultAccount: 'default',
},
},
});
const rpc = vi.fn().mockResolvedValue({
channels: {
feishu: {
configured: true,
},
},
channelAccounts: {
feishu: [
{
accountId: 'default',
configured: true,
connected: true,
running: true,
linked: false,
},
{
accountId: '2',
configured: false,
connected: false,
running: false,
lastError: 'stale runtime session',
},
],
},
channelDefaultAccountId: {
feishu: 'default',
},
});
const { handleChannelRoutes } = await import('@electron/api/routes/channels');
await handleChannelRoutes(
{ method: 'GET' } as IncomingMessage,
{} as ServerResponse,
new URL('http://127.0.0.1:13210/api/channels/accounts'),
{
gatewayManager: {
rpc,
getStatus: () => ({ state: 'running' }),
getDiagnostics: () => ({ consecutiveHeartbeatMisses: 1, consecutiveRpcFailures: 0 }),
debouncedReload: vi.fn(),
debouncedRestart: vi.fn(),
},
} as never,
);
expect(sendJsonMock).toHaveBeenCalledWith(
expect.anything(),
200,
expect.objectContaining({
success: true,
channels: [
expect.objectContaining({
channelType: 'feishu',
status: 'degraded',
accounts: [
expect.objectContaining({ accountId: 'default', status: 'degraded' }),
],
}),
],
}),
);
});
it('lists known QQ Bot targets for a configured account', async () => {
const knownUsersPath = join(testOpenClawConfigDir, 'qqbot', 'data');
mkdirSync(knownUsersPath, { recursive: true });