diff --git a/src/renderer/components/team/useTeamAgentRuntimeWatcher.test.tsx b/src/renderer/components/team/useTeamAgentRuntimeWatcher.test.tsx new file mode 100644 index 00000000..d9bd186e --- /dev/null +++ b/src/renderer/components/team/useTeamAgentRuntimeWatcher.test.tsx @@ -0,0 +1,180 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const hoisted = vi.hoisted(() => ({ + state: { + leadActivityByTeam: {} as Record, + teamDataByName: {} as Record, + provisioningActiveByTeam: {} as Record, + fetchTeamAgentRuntime: vi.fn(async (_teamName: string): Promise => undefined), + }, +})); + +vi.mock('@renderer/store', () => ({ + useStore: (selector: (state: typeof hoisted.state) => T): T => selector(hoisted.state), +})); + +vi.mock('@renderer/store/slices/teamSlice', () => ({ + isTeamProvisioningActive: (state: typeof hoisted.state, teamName: string): boolean => + state.provisioningActiveByTeam[teamName] === true, + selectTeamDataForName: ( + state: typeof hoisted.state, + teamName: string + ): { isAlive?: boolean } | undefined => state.teamDataByName[teamName], +})); + +import { + __resetTeamAgentRuntimeWatcherForTests, + useTeamAgentRuntimeWatcher, +} from './useTeamAgentRuntimeWatcher'; + +interface HookProbeProps { + teamName: string; + enabled: boolean; + isTeamProvisioning?: boolean; + isTeamAlive?: boolean; +} + +const HookProbe = (props: HookProbeProps): null => { + useTeamAgentRuntimeWatcher(props); + return null; +}; + +const mountedRoots: Root[] = []; + +async function renderWatcher(props: HookProbeProps): Promise { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + mountedRoots.push(root); + + await act(async () => { + root.render(React.createElement(HookProbe, props)); + await Promise.resolve(); + }); + + return root; +} + +describe('useTeamAgentRuntimeWatcher', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.useFakeTimers(); + vi.setSystemTime(0); + __resetTeamAgentRuntimeWatcherForTests(); + hoisted.state.leadActivityByTeam = {}; + hoisted.state.teamDataByName = {}; + hoisted.state.provisioningActiveByTeam = {}; + hoisted.state.fetchTeamAgentRuntime.mockReset(); + hoisted.state.fetchTeamAgentRuntime.mockResolvedValue(undefined); + }); + + afterEach(() => { + for (const root of mountedRoots.splice(0)) { + act(() => { + root.unmount(); + }); + } + document.body.innerHTML = ''; + __resetTeamAgentRuntimeWatcherForTests(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('backs off polling for an alive idle team', async () => { + hoisted.state.teamDataByName['team-a'] = { isAlive: true }; + + await renderWatcher({ teamName: 'team-a', enabled: true }); + + expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1); + expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledWith('team-a'); + + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(10_000); + await Promise.resolve(); + }); + + expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(2); + }); + + it('keeps active teams on the fast polling cadence', async () => { + hoisted.state.leadActivityByTeam['team-a'] = 'active'; + + await renderWatcher({ teamName: 'team-a', enabled: true }); + + expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(2); + }); + + it('does not overlap runtime refreshes for the same team', async () => { + hoisted.state.leadActivityByTeam['team-a'] = 'active'; + let resolveFirstRefresh: () => void = () => undefined; + const firstRefresh = new Promise((resolve) => { + resolveFirstRefresh = resolve; + }); + hoisted.state.fetchTeamAgentRuntime + .mockImplementationOnce(() => firstRefresh) + .mockResolvedValue(undefined); + + await renderWatcher({ teamName: 'team-a', enabled: true }); + + expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveFirstRefresh(); + await firstRefresh; + await Promise.resolve(); + }); + + await act(async () => { + vi.advanceTimersByTime(5_000); + await Promise.resolve(); + }); + + expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(2); + }); + + it('coalesces multiple mounted watchers for one team', async () => { + hoisted.state.leadActivityByTeam['team-a'] = 'active'; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + mountedRoots.push(root); + + await act(async () => { + root.render( + React.createElement( + React.Fragment, + null, + React.createElement(HookProbe, { teamName: 'team-a', enabled: true }), + React.createElement(HookProbe, { teamName: 'team-a', enabled: true }) + ) + ); + await Promise.resolve(); + }); + + expect(hoisted.state.fetchTeamAgentRuntime).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/renderer/components/team/useTeamAgentRuntimeWatcher.ts b/src/renderer/components/team/useTeamAgentRuntimeWatcher.ts index e65cbcfb..e293d1bb 100644 --- a/src/renderer/components/team/useTeamAgentRuntimeWatcher.ts +++ b/src/renderer/components/team/useTeamAgentRuntimeWatcher.ts @@ -4,7 +4,66 @@ import { useStore } from '@renderer/store'; import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store/slices/teamSlice'; import { useShallow } from 'zustand/react/shallow'; -const TEAM_AGENT_RUNTIME_REFRESH_MS = 5_000; +const TEAM_AGENT_RUNTIME_ACTIVE_REFRESH_MS = 5_000; +const TEAM_AGENT_RUNTIME_IDLE_REFRESH_MS = 15_000; +const TEAM_AGENT_RUNTIME_REFRESH_GRACE_MS = 500; + +type FetchTeamAgentRuntime = (teamName: string) => Promise; + +const runtimeRefreshInFlightByTeam = new Map>(); +const runtimeRefreshLastStartedAtByTeam = new Map(); + +function getTeamAgentRuntimeRefreshMs({ + isTeamProvisioning, + leadActivity, +}: { + isTeamProvisioning: boolean | undefined; + leadActivity: string | undefined; +}): number { + return isTeamProvisioning || leadActivity === 'active' + ? TEAM_AGENT_RUNTIME_ACTIVE_REFRESH_MS + : TEAM_AGENT_RUNTIME_IDLE_REFRESH_MS; +} + +function requestTeamAgentRuntimeRefresh( + teamName: string, + fetchTeamAgentRuntime: FetchTeamAgentRuntime, + minRefreshGapMs: number +): Promise { + const inFlight = runtimeRefreshInFlightByTeam.get(teamName); + if (inFlight) { + return inFlight; + } + + const now = Date.now(); + const lastStartedAt = runtimeRefreshLastStartedAtByTeam.get(teamName); + if (lastStartedAt !== undefined && now - lastStartedAt < minRefreshGapMs) { + return Promise.resolve(); + } + + runtimeRefreshLastStartedAtByTeam.set(teamName, now); + + let refreshPromise: Promise; + try { + refreshPromise = fetchTeamAgentRuntime(teamName); + } catch (error) { + runtimeRefreshLastStartedAtByTeam.delete(teamName); + return Promise.reject(error); + } + + const trackedPromise = refreshPromise.finally(() => { + if (runtimeRefreshInFlightByTeam.get(teamName) === trackedPromise) { + runtimeRefreshInFlightByTeam.delete(teamName); + } + }); + runtimeRefreshInFlightByTeam.set(teamName, trackedPromise); + return trackedPromise; +} + +export function __resetTeamAgentRuntimeWatcherForTests(): void { + runtimeRefreshInFlightByTeam.clear(); + runtimeRefreshLastStartedAtByTeam.clear(); +} interface TeamAgentRuntimeWatcherOptions { teamName: string; @@ -41,10 +100,16 @@ export function useTeamAgentRuntimeWatcher({ leadActivity === 'idle'; if (!shouldWatch) return; - void fetchTeamAgentRuntime(teamName); + const refreshMs = getTeamAgentRuntimeRefreshMs({ + isTeamProvisioning: effectiveIsTeamProvisioning, + leadActivity, + }); + const minRefreshGapMs = Math.max(0, refreshMs - TEAM_AGENT_RUNTIME_REFRESH_GRACE_MS); + + void requestTeamAgentRuntimeRefresh(teamName, fetchTeamAgentRuntime, minRefreshGapMs); const timer = window.setInterval(() => { - void fetchTeamAgentRuntime(teamName); - }, TEAM_AGENT_RUNTIME_REFRESH_MS); + void requestTeamAgentRuntimeRefresh(teamName, fetchTeamAgentRuntime, minRefreshGapMs); + }, refreshMs); return () => { window.clearInterval(timer); };