mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
Merge pull request #646 from AutoMaker-Org/fix/excessive-api-polling
fix: excessive api pooling
This commit is contained in:
@@ -1,6 +1,15 @@
|
||||
export { useAutoMode } from './use-auto-mode';
|
||||
export { useBoardBackgroundSettings } from './use-board-background-settings';
|
||||
export { useElectronAgent } from './use-electron-agent';
|
||||
export {
|
||||
useEventRecorder,
|
||||
useEventRecency,
|
||||
useEventRecencyStore,
|
||||
getGlobalEventsRecent,
|
||||
getEventsRecent,
|
||||
createSmartPollingInterval,
|
||||
EVENT_RECENCY_THRESHOLD,
|
||||
} from './use-event-recency';
|
||||
export { useGuidedPrompts } from './use-guided-prompts';
|
||||
export { useKeyboardShortcuts } from './use-keyboard-shortcuts';
|
||||
export { useMessageQueue } from './use-message-queue';
|
||||
|
||||
@@ -10,10 +10,13 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { STALE_TIMES } from '@/lib/query-client';
|
||||
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
|
||||
import type { Feature } from '@/store/app-store';
|
||||
|
||||
const FEATURES_REFETCH_ON_FOCUS = false;
|
||||
const FEATURES_REFETCH_ON_RECONNECT = false;
|
||||
/** Default polling interval for agent output when WebSocket is inactive */
|
||||
const AGENT_OUTPUT_POLLING_INTERVAL = 5000;
|
||||
|
||||
/**
|
||||
* Fetch all features for a project
|
||||
@@ -79,7 +82,11 @@ export function useFeature(
|
||||
},
|
||||
enabled: !!projectPath && !!featureId && enabled,
|
||||
staleTime: STALE_TIMES.FEATURES,
|
||||
refetchInterval: pollingInterval,
|
||||
// When a polling interval is specified, disable it if WebSocket events are recent
|
||||
refetchInterval:
|
||||
pollingInterval === false || pollingInterval === undefined
|
||||
? pollingInterval
|
||||
: () => (getGlobalEventsRecent() ? false : pollingInterval),
|
||||
refetchOnWindowFocus: FEATURES_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: FEATURES_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
@@ -119,14 +126,19 @@ export function useAgentOutput(
|
||||
},
|
||||
enabled: !!projectPath && !!featureId && enabled,
|
||||
staleTime: STALE_TIMES.AGENT_OUTPUT,
|
||||
// Use provided polling interval or default behavior
|
||||
// Use provided polling interval or default smart behavior
|
||||
refetchInterval:
|
||||
pollingInterval !== undefined
|
||||
? pollingInterval
|
||||
: (query) => {
|
||||
// Disable polling when WebSocket events are recent (within 5s)
|
||||
// WebSocket invalidation handles updates in real-time
|
||||
if (getGlobalEventsRecent()) {
|
||||
return false;
|
||||
}
|
||||
// Only poll if we have data and it's not empty (indicating active task)
|
||||
if (query.state.data && query.state.data.length > 0) {
|
||||
return 5000; // 5 seconds
|
||||
return AGENT_OUTPUT_POLLING_INTERVAL;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { STALE_TIMES } from '@/lib/query-client';
|
||||
import { getGlobalEventsRecent } from '@/hooks/use-event-recency';
|
||||
|
||||
interface SpecFileResult {
|
||||
content: string;
|
||||
@@ -98,6 +99,8 @@ export function useSpecRegenerationStatus(projectPath: string | undefined, enabl
|
||||
},
|
||||
enabled: !!projectPath && enabled,
|
||||
staleTime: 5000, // Check every 5 seconds when active
|
||||
refetchInterval: enabled ? 5000 : false,
|
||||
// Disable polling when WebSocket events are recent (within 5s)
|
||||
// WebSocket invalidation handles updates in real-time
|
||||
refetchInterval: enabled ? () => (getGlobalEventsRecent() ? false : 5000) : false,
|
||||
});
|
||||
}
|
||||
|
||||
176
apps/ui/src/hooks/use-event-recency.ts
Normal file
176
apps/ui/src/hooks/use-event-recency.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Event Recency Hook
|
||||
*
|
||||
* Tracks the timestamp of the last WebSocket event received.
|
||||
* Used to conditionally disable polling when events are flowing
|
||||
* through WebSocket (indicating the connection is healthy).
|
||||
*/
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Time threshold (ms) to consider events as "recent"
|
||||
* If an event was received within this time, WebSocket is considered healthy
|
||||
* and polling can be safely disabled.
|
||||
*/
|
||||
export const EVENT_RECENCY_THRESHOLD = 5000; // 5 seconds
|
||||
|
||||
/**
|
||||
* Store for tracking event timestamps per query key
|
||||
* This allows fine-grained control over which queries have received recent events
|
||||
*/
|
||||
interface EventRecencyState {
|
||||
/** Map of query key (stringified) -> last event timestamp */
|
||||
eventTimestamps: Record<string, number>;
|
||||
/** Global last event timestamp (for any event) */
|
||||
lastGlobalEventTimestamp: number;
|
||||
/** Record an event for a specific query key */
|
||||
recordEvent: (queryKey: string) => void;
|
||||
/** Record a global event (useful for general WebSocket health) */
|
||||
recordGlobalEvent: () => void;
|
||||
/** Check if events are recent for a specific query key */
|
||||
areEventsRecent: (queryKey: string) => boolean;
|
||||
/** Check if any global events are recent */
|
||||
areGlobalEventsRecent: () => boolean;
|
||||
}
|
||||
|
||||
export const useEventRecencyStore = create<EventRecencyState>((set, get) => ({
|
||||
eventTimestamps: {},
|
||||
lastGlobalEventTimestamp: 0,
|
||||
|
||||
recordEvent: (queryKey: string) => {
|
||||
const now = Date.now();
|
||||
set((state) => ({
|
||||
eventTimestamps: {
|
||||
...state.eventTimestamps,
|
||||
[queryKey]: now,
|
||||
},
|
||||
lastGlobalEventTimestamp: now,
|
||||
}));
|
||||
},
|
||||
|
||||
recordGlobalEvent: () => {
|
||||
set({ lastGlobalEventTimestamp: Date.now() });
|
||||
},
|
||||
|
||||
areEventsRecent: (queryKey: string) => {
|
||||
const { eventTimestamps } = get();
|
||||
const lastEventTime = eventTimestamps[queryKey];
|
||||
if (!lastEventTime) return false;
|
||||
return Date.now() - lastEventTime < EVENT_RECENCY_THRESHOLD;
|
||||
},
|
||||
|
||||
areGlobalEventsRecent: () => {
|
||||
const { lastGlobalEventTimestamp } = get();
|
||||
if (!lastGlobalEventTimestamp) return false;
|
||||
return Date.now() - lastGlobalEventTimestamp < EVENT_RECENCY_THRESHOLD;
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Hook to record event timestamps when WebSocket events are received.
|
||||
* Should be called from WebSocket event handlers.
|
||||
*
|
||||
* @returns Functions to record events
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { recordEvent, recordGlobalEvent } = useEventRecorder();
|
||||
*
|
||||
* // In WebSocket event handler:
|
||||
* api.autoMode.onEvent((event) => {
|
||||
* recordGlobalEvent();
|
||||
* if (event.featureId) {
|
||||
* recordEvent(`features:${event.featureId}`);
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function useEventRecorder() {
|
||||
const recordEvent = useEventRecencyStore((state) => state.recordEvent);
|
||||
const recordGlobalEvent = useEventRecencyStore((state) => state.recordGlobalEvent);
|
||||
|
||||
return { recordEvent, recordGlobalEvent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to check if WebSocket events are recent, used by queries
|
||||
* to decide whether to enable/disable polling.
|
||||
*
|
||||
* @param queryKey - Optional specific query key to check
|
||||
* @returns Object with recency check result and timestamp
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { areEventsRecent, areGlobalEventsRecent } = useEventRecency();
|
||||
*
|
||||
* // In query options:
|
||||
* refetchInterval: areGlobalEventsRecent() ? false : 5000,
|
||||
* ```
|
||||
*/
|
||||
export function useEventRecency(queryKey?: string) {
|
||||
const areEventsRecent = useEventRecencyStore((state) => state.areEventsRecent);
|
||||
const areGlobalEventsRecent = useEventRecencyStore((state) => state.areGlobalEventsRecent);
|
||||
const lastGlobalEventTimestamp = useEventRecencyStore((state) => state.lastGlobalEventTimestamp);
|
||||
|
||||
const checkRecency = useCallback(
|
||||
(key?: string) => {
|
||||
if (key) {
|
||||
return areEventsRecent(key);
|
||||
}
|
||||
return areGlobalEventsRecent();
|
||||
},
|
||||
[areEventsRecent, areGlobalEventsRecent]
|
||||
);
|
||||
|
||||
return {
|
||||
areEventsRecent: queryKey ? () => areEventsRecent(queryKey) : areEventsRecent,
|
||||
areGlobalEventsRecent,
|
||||
checkRecency,
|
||||
lastGlobalEventTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to create a refetchInterval that respects event recency.
|
||||
* Returns false (no polling) if events are recent, otherwise returns the interval.
|
||||
*
|
||||
* @param defaultInterval - The polling interval to use when events aren't recent
|
||||
* @returns A function suitable for React Query's refetchInterval option
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data } = useQuery({
|
||||
* queryKey: ['features'],
|
||||
* queryFn: fetchFeatures,
|
||||
* refetchInterval: createSmartPollingInterval(5000),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export function createSmartPollingInterval(defaultInterval: number) {
|
||||
return () => {
|
||||
const { areGlobalEventsRecent } = useEventRecencyStore.getState();
|
||||
return areGlobalEventsRecent() ? false : defaultInterval;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get current event recency state (for use outside React)
|
||||
* Useful in query configurations where hooks can't be used directly.
|
||||
*
|
||||
* @returns Whether global events are recent
|
||||
*/
|
||||
export function getGlobalEventsRecent(): boolean {
|
||||
return useEventRecencyStore.getState().areGlobalEventsRecent();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to get event recency for a specific query key (for use outside React)
|
||||
*
|
||||
* @param queryKey - The query key to check
|
||||
* @returns Whether events for that query key are recent
|
||||
*/
|
||||
export function getEventsRecent(queryKey: string): boolean {
|
||||
return useEventRecencyStore.getState().areEventsRecent(queryKey);
|
||||
}
|
||||
@@ -5,12 +5,48 @@
|
||||
* ensuring the UI stays in sync with server-side changes without manual refetching.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQueryClient, QueryClient } from '@tanstack/react-query';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import type { AutoModeEvent, SpecRegenerationEvent } from '@/types/electron';
|
||||
import type { IssueValidationEvent } from '@automaker/types';
|
||||
import { debounce, type DebouncedFunction } from '@automaker/utils/debounce';
|
||||
import { useEventRecencyStore } from './use-event-recency';
|
||||
|
||||
/**
|
||||
* Debounce configuration for auto_mode_progress invalidations
|
||||
* - wait: 150ms delay to batch rapid consecutive progress events
|
||||
* - maxWait: 2000ms ensures UI updates at least every 2 seconds during streaming
|
||||
*/
|
||||
const PROGRESS_DEBOUNCE_WAIT = 150;
|
||||
const PROGRESS_DEBOUNCE_MAX_WAIT = 2000;
|
||||
|
||||
/**
|
||||
* Creates a unique key for per-feature debounce tracking
|
||||
*/
|
||||
function getFeatureKey(projectPath: string, featureId: string): string {
|
||||
return `${projectPath}:${featureId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a debounced invalidation function for a specific feature's agent output
|
||||
*/
|
||||
function createDebouncedInvalidation(
|
||||
queryClient: QueryClient,
|
||||
projectPath: string,
|
||||
featureId: string
|
||||
): DebouncedFunction<() => void> {
|
||||
return debounce(
|
||||
() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.agentOutput(projectPath, featureId),
|
||||
});
|
||||
},
|
||||
PROGRESS_DEBOUNCE_WAIT,
|
||||
{ maxWait: PROGRESS_DEBOUNCE_MAX_WAIT }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate queries based on auto mode events
|
||||
@@ -31,12 +67,54 @@ import type { IssueValidationEvent } from '@automaker/types';
|
||||
*/
|
||||
export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
const recordGlobalEvent = useEventRecencyStore((state) => state.recordGlobalEvent);
|
||||
|
||||
// Store per-feature debounced invalidation functions
|
||||
// Using a ref to persist across renders without causing re-subscriptions
|
||||
const debouncedInvalidationsRef = useRef<Map<string, DebouncedFunction<() => void>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectPath) return;
|
||||
|
||||
// Capture projectPath in a const to satisfy TypeScript's type narrowing
|
||||
const currentProjectPath = projectPath;
|
||||
const debouncedInvalidations = debouncedInvalidationsRef.current;
|
||||
|
||||
/**
|
||||
* Get or create a debounced invalidation function for a specific feature
|
||||
*/
|
||||
function getDebouncedInvalidation(featureId: string): DebouncedFunction<() => void> {
|
||||
const key = getFeatureKey(currentProjectPath, featureId);
|
||||
let debouncedFn = debouncedInvalidations.get(key);
|
||||
|
||||
if (!debouncedFn) {
|
||||
debouncedFn = createDebouncedInvalidation(queryClient, currentProjectPath, featureId);
|
||||
debouncedInvalidations.set(key, debouncedFn);
|
||||
}
|
||||
|
||||
return debouncedFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up debounced function for a feature (flush pending and remove)
|
||||
*/
|
||||
function cleanupFeatureDebounce(featureId: string): void {
|
||||
const key = getFeatureKey(currentProjectPath, featureId);
|
||||
const debouncedFn = debouncedInvalidations.get(key);
|
||||
|
||||
if (debouncedFn) {
|
||||
// Flush any pending invalidation before cleanup
|
||||
debouncedFn.flush();
|
||||
debouncedInvalidations.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const api = getElectronAPI();
|
||||
const unsubscribe = api.autoMode.onEvent((event: AutoModeEvent) => {
|
||||
// Record that we received a WebSocket event (for event recency tracking)
|
||||
// This allows polling to be disabled when WebSocket events are flowing
|
||||
recordGlobalEvent();
|
||||
|
||||
// Invalidate features when agent completes, errors, or receives plan approval
|
||||
if (
|
||||
event.type === 'auto_mode_feature_complete' ||
|
||||
@@ -47,7 +125,7 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
|
||||
event.type === 'pipeline_step_complete'
|
||||
) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.all(projectPath),
|
||||
queryKey: queryKeys.features.all(currentProjectPath),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,30 +150,49 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
|
||||
'featureId' in event
|
||||
) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.single(projectPath, event.featureId),
|
||||
queryKey: queryKeys.features.single(currentProjectPath, event.featureId),
|
||||
});
|
||||
}
|
||||
|
||||
// Invalidate agent output during progress updates
|
||||
// Invalidate agent output during progress updates (DEBOUNCED)
|
||||
// Uses per-feature debouncing to batch rapid progress events during streaming
|
||||
if (event.type === 'auto_mode_progress' && 'featureId' in event) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.features.agentOutput(projectPath, event.featureId),
|
||||
});
|
||||
const debouncedInvalidation = getDebouncedInvalidation(event.featureId);
|
||||
debouncedInvalidation();
|
||||
}
|
||||
|
||||
// Clean up debounced functions when feature completes or errors
|
||||
// This ensures we flush any pending invalidations and free memory
|
||||
if (
|
||||
(event.type === 'auto_mode_feature_complete' || event.type === 'auto_mode_error') &&
|
||||
'featureId' in event &&
|
||||
event.featureId
|
||||
) {
|
||||
cleanupFeatureDebounce(event.featureId);
|
||||
}
|
||||
|
||||
// Invalidate worktree queries when feature completes (may have created worktree)
|
||||
if (event.type === 'auto_mode_feature_complete' && 'featureId' in event) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.worktrees.all(projectPath),
|
||||
queryKey: queryKeys.worktrees.all(currentProjectPath),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.worktrees.single(projectPath, event.featureId),
|
||||
queryKey: queryKeys.worktrees.single(currentProjectPath, event.featureId),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [projectPath, queryClient]);
|
||||
// Cleanup on unmount: flush and clear all debounced functions
|
||||
return () => {
|
||||
unsubscribe();
|
||||
|
||||
// Flush all pending invalidations before cleanup
|
||||
for (const debouncedFn of debouncedInvalidations.values()) {
|
||||
debouncedFn.flush();
|
||||
}
|
||||
debouncedInvalidations.clear();
|
||||
};
|
||||
}, [projectPath, queryClient, recordGlobalEvent]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +202,7 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
|
||||
*/
|
||||
export function useSpecRegenerationQueryInvalidation(projectPath: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
const recordGlobalEvent = useEventRecencyStore((state) => state.recordGlobalEvent);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectPath) return;
|
||||
@@ -114,6 +212,9 @@ export function useSpecRegenerationQueryInvalidation(projectPath: string | undef
|
||||
// Only handle events for the current project
|
||||
if (event.projectPath !== projectPath) return;
|
||||
|
||||
// Record that we received a WebSocket event
|
||||
recordGlobalEvent();
|
||||
|
||||
if (event.type === 'spec_regeneration_complete') {
|
||||
// Invalidate features as new ones may have been generated
|
||||
queryClient.invalidateQueries({
|
||||
@@ -128,7 +229,7 @@ export function useSpecRegenerationQueryInvalidation(projectPath: string | undef
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [projectPath, queryClient]);
|
||||
}, [projectPath, queryClient, recordGlobalEvent]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +239,7 @@ export function useSpecRegenerationQueryInvalidation(projectPath: string | undef
|
||||
*/
|
||||
export function useGitHubValidationQueryInvalidation(projectPath: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
const recordGlobalEvent = useEventRecencyStore((state) => state.recordGlobalEvent);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectPath) return;
|
||||
@@ -150,6 +252,9 @@ export function useGitHubValidationQueryInvalidation(projectPath: string | undef
|
||||
}
|
||||
|
||||
const unsubscribe = api.github.onValidationEvent((event: IssueValidationEvent) => {
|
||||
// Record that we received a WebSocket event
|
||||
recordGlobalEvent();
|
||||
|
||||
if (event.type === 'validation_complete' || event.type === 'validation_error') {
|
||||
// Invalidate all validations for this project
|
||||
queryClient.invalidateQueries({
|
||||
@@ -166,7 +271,7 @@ export function useGitHubValidationQueryInvalidation(projectPath: string | undef
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [projectPath, queryClient]);
|
||||
}, [projectPath, queryClient, recordGlobalEvent]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,6 +281,7 @@ export function useGitHubValidationQueryInvalidation(projectPath: string | undef
|
||||
*/
|
||||
export function useSessionQueryInvalidation(sessionId: string | undefined) {
|
||||
const queryClient = useQueryClient();
|
||||
const recordGlobalEvent = useEventRecencyStore((state) => state.recordGlobalEvent);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
@@ -185,6 +291,9 @@ export function useSessionQueryInvalidation(sessionId: string | undefined) {
|
||||
// Only handle events for the current session
|
||||
if ('sessionId' in event && event.sessionId !== sessionId) return;
|
||||
|
||||
// Record that we received a WebSocket event
|
||||
recordGlobalEvent();
|
||||
|
||||
// Invalidate session history when a message is complete
|
||||
if (event.type === 'complete' || event.type === 'message') {
|
||||
queryClient.invalidateQueries({
|
||||
@@ -201,7 +310,7 @@ export function useSessionQueryInvalidation(sessionId: string | undefined) {
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [sessionId, queryClient]);
|
||||
}, [sessionId, queryClient, recordGlobalEvent]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user