From c266e6d3e056ba5066055b9529a1bf42ddcc6086 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Fri, 14 Aug 2026 14:35:47 +0200 Subject: [PATCH] perf(cli): stagger console polling --- .../tui/console/hooks/pollSchedule.test.ts | 73 +++++++++++++++++++ .../cli/src/tui/console/hooks/pollSchedule.ts | 33 +++++++++ .../tui/console/hooks/useAgentConversation.ts | 15 +++- .../cli/src/tui/console/hooks/useAgentList.ts | 15 +++- .../src/tui/console/hooks/useChannelState.ts | 30 ++++++-- 5 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/__tests__/tui/console/hooks/pollSchedule.test.ts create mode 100644 packages/cli/src/tui/console/hooks/pollSchedule.ts diff --git a/packages/cli/src/__tests__/tui/console/hooks/pollSchedule.test.ts b/packages/cli/src/__tests__/tui/console/hooks/pollSchedule.test.ts new file mode 100644 index 00000000..458ceea3 --- /dev/null +++ b/packages/cli/src/__tests__/tui/console/hooks/pollSchedule.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + CONSOLE_POLL_INTERVAL_MS, + CONSOLE_POLL_PHASE_MS, + schedulePeriodicRefresh, +} from '../../../../tui/console/hooks/pollSchedule.js'; + +describe('console poll schedule', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('separates recurring refresh phases while preserving each source cadence', async () => { + const calls: Array<{ source: string; time: number }> = []; + const cleanups = Object.entries(CONSOLE_POLL_PHASE_MS).map(([source, phaseMs]) => ( + schedulePeriodicRefresh( + () => { calls.push({ source, time: Date.now() }); }, + CONSOLE_POLL_INTERVAL_MS, + phaseMs, + ) + )); + + await vi.advanceTimersByTimeAsync(CONSOLE_POLL_INTERVAL_MS * 2); + + expect(calls).toEqual([ + { source: 'selectedAgentPreview', time: 750 }, + { source: 'channelStatus', time: 1500 }, + { source: 'configuredChannels', time: 2250 }, + { source: 'agentList', time: 3000 }, + { source: 'selectedAgentPreview', time: 3750 }, + { source: 'channelStatus', time: 4500 }, + { source: 'configuredChannels', time: 5250 }, + { source: 'agentList', time: 6000 }, + ]); + + for (const source of Object.keys(CONSOLE_POLL_PHASE_MS)) { + const times = calls.filter(call => call.source === source).map(call => call.time); + expect(times[1] - times[0]).toBe(CONSOLE_POLL_INTERVAL_MS); + } + + cleanups.forEach(cleanup => cleanup()); + }); + + it('cancels both pending phase starts and active intervals', async () => { + const beforeStart = vi.fn(); + const stopBeforeStart = schedulePeriodicRefresh( + beforeStart, + CONSOLE_POLL_INTERVAL_MS, + CONSOLE_POLL_PHASE_MS.configuredChannels, + ); + stopBeforeStart(); + + const afterStart = vi.fn(); + const stopAfterStart = schedulePeriodicRefresh( + afterStart, + CONSOLE_POLL_INTERVAL_MS, + CONSOLE_POLL_PHASE_MS.selectedAgentPreview, + ); + await vi.advanceTimersByTimeAsync(CONSOLE_POLL_PHASE_MS.selectedAgentPreview); + expect(afterStart).toHaveBeenCalledTimes(1); + stopAfterStart(); + + await vi.advanceTimersByTimeAsync(CONSOLE_POLL_INTERVAL_MS * 2); + + expect(beforeStart).not.toHaveBeenCalled(); + expect(afterStart).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/tui/console/hooks/pollSchedule.ts b/packages/cli/src/tui/console/hooks/pollSchedule.ts new file mode 100644 index 00000000..6ee933be --- /dev/null +++ b/packages/cli/src/tui/console/hooks/pollSchedule.ts @@ -0,0 +1,33 @@ +export const CONSOLE_POLL_INTERVAL_MS = 3000; + +export const CONSOLE_POLL_PHASE_MS = { + agentList: 0, + selectedAgentPreview: 750, + channelStatus: 1500, + configuredChannels: 2250, +} as const; + +export function schedulePeriodicRefresh( + refresh: () => void, + intervalMs: number, + phaseMs: number, +): () => void { + const normalizedPhaseMs = ((phaseMs % intervalMs) + intervalMs) % intervalMs; + const elapsedInPeriodMs = Date.now() % intervalMs; + const timeUntilPhaseMs = (normalizedPhaseMs - elapsedInPeriodMs + intervalMs) % intervalMs; + const initialDelayMs = timeUntilPhaseMs === 0 ? intervalMs : timeUntilPhaseMs; + let intervalHandle: ReturnType | undefined; + let stopped = false; + + const timeoutHandle = setTimeout(() => { + if (stopped) return; + refresh(); + intervalHandle = setInterval(refresh, intervalMs); + }, initialDelayMs); + + return () => { + stopped = true; + clearTimeout(timeoutHandle); + if (intervalHandle !== undefined) clearInterval(intervalHandle); + }; +} diff --git a/packages/cli/src/tui/console/hooks/useAgentConversation.ts b/packages/cli/src/tui/console/hooks/useAgentConversation.ts index 1aa95c55..3d433614 100644 --- a/packages/cli/src/tui/console/hooks/useAgentConversation.ts +++ b/packages/cli/src/tui/console/hooks/useAgentConversation.ts @@ -1,6 +1,11 @@ import fs from 'fs'; import { useEffect, useRef, useState } from 'react'; import type { AgentInfo, AgentManager, ConversationMessage } from '@ai-devkit/agent-manager'; +import { + CONSOLE_POLL_INTERVAL_MS, + CONSOLE_POLL_PHASE_MS, + schedulePeriodicRefresh, +} from './pollSchedule.js'; export interface ConversationFetchError { kind: 'no-session-file' | 'no-adapter' | 'parse-error' | 'agent-not-found'; @@ -14,7 +19,7 @@ export interface UseAgentConversationResult { isLoading: boolean; } -export const PREVIEW_POLL_INTERVAL_MS = 3000; +export const PREVIEW_POLL_INTERVAL_MS = CONSOLE_POLL_INTERVAL_MS; export const PREVIEW_TAIL = 20; export const SELECTION_DEBOUNCE_MS = 150; @@ -177,11 +182,15 @@ export function useAgentConversation({ }; } - const intervalHandle = setInterval(fetchOnce, intervalMs); + const stopPolling = schedulePeriodicRefresh( + fetchOnce, + intervalMs, + CONSOLE_POLL_PHASE_MS.selectedAgentPreview, + ); return () => { mountedRef.current = false; clearTimeout(debounceHandle); - clearInterval(intervalHandle); + stopPolling(); }; }, [manager, agent?.name, agent?.type, agent?.sessionFilePath, intervalMs, tail, paused]); diff --git a/packages/cli/src/tui/console/hooks/useAgentList.ts b/packages/cli/src/tui/console/hooks/useAgentList.ts index 22a13786..d26a5320 100644 --- a/packages/cli/src/tui/console/hooks/useAgentList.ts +++ b/packages/cli/src/tui/console/hooks/useAgentList.ts @@ -1,5 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { AgentInfo, AgentManager } from '@ai-devkit/agent-manager'; +import { + CONSOLE_POLL_INTERVAL_MS, + CONSOLE_POLL_PHASE_MS, + schedulePeriodicRefresh, +} from './pollSchedule.js'; export interface UseAgentListResult { agents: AgentInfo[]; @@ -11,7 +16,7 @@ export interface UseAgentListResult { type AgentListState = Omit; -export const LIST_POLL_INTERVAL_MS = 3000; +export const LIST_POLL_INTERVAL_MS = CONSOLE_POLL_INTERVAL_MS; export function agentsEqual(a: AgentInfo[], b: AgentInfo[]): boolean { if (a.length !== b.length) return false; @@ -91,11 +96,15 @@ export function useAgentList( return () => { mountedRef.current = false; }; } void refresh(); - const handle = setInterval(() => { void refresh(); }, intervalMs); + const stopPolling = schedulePeriodicRefresh( + () => { void refresh(); }, + intervalMs, + CONSOLE_POLL_PHASE_MS.agentList, + ); return () => { mountedRef.current = false; - clearInterval(handle); + stopPolling(); }; }, [intervalMs, paused, refresh]); diff --git a/packages/cli/src/tui/console/hooks/useChannelState.ts b/packages/cli/src/tui/console/hooks/useChannelState.ts index f2cf991d..3cebcfac 100644 --- a/packages/cli/src/tui/console/hooks/useChannelState.ts +++ b/packages/cli/src/tui/console/hooks/useChannelState.ts @@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { ConfigStore, type ChannelConfig, type TelegramConfig } from '@ai-devkit/channel-connector'; import { ChannelService, type ChannelBridgeProcess } from '../../../services/channel/channel.service.js'; import type { AgentChannelStatusMap, ConfiguredChannel } from '../types.js'; +import { + CONSOLE_POLL_INTERVAL_MS, + CONSOLE_POLL_PHASE_MS, + schedulePeriodicRefresh, +} from './pollSchedule.js'; export interface UseChannelStateResult { channelStatuses: AgentChannelStatusMap; @@ -70,7 +75,7 @@ export function configuredChannelsEqual(a: ConfiguredChannel[], b: ConfiguredCha export function useChannelState( channelService?: ChannelService, configStore?: ConfigStore, - intervalMs = 3000, + intervalMs = CONSOLE_POLL_INTERVAL_MS, paused = false, ): UseChannelStateResult { const serviceRef = useRef(channelService ?? new ChannelService()); @@ -93,18 +98,33 @@ export function useChannelState( useEffect(() => { if (paused) return undefined; - const refreshAll = (): void => { + const refreshChannelStatuses = (): void => { void refreshChannels().catch(() => { setChannelStatuses(prev => channelStatusesEqual(prev, {}) ? prev : {}); }); + }; + const refreshChannelConfig = (): void => { void refreshConfiguredChannels().catch(() => { setConfiguredChannels(prev => configuredChannelsEqual(prev, []) ? prev : []); }); }; - refreshAll(); - const handle = setInterval(refreshAll, intervalMs); - return () => clearInterval(handle); + refreshChannelStatuses(); + refreshChannelConfig(); + const stopStatusPolling = schedulePeriodicRefresh( + refreshChannelStatuses, + intervalMs, + CONSOLE_POLL_PHASE_MS.channelStatus, + ); + const stopConfigPolling = schedulePeriodicRefresh( + refreshChannelConfig, + intervalMs, + CONSOLE_POLL_PHASE_MS.configuredChannels, + ); + return () => { + stopStatusPolling(); + stopConfigPolling(); + }; }, [intervalMs, paused, refreshChannels, refreshConfiguredChannels]); return {