feat(gateway): enhance gateway readiness handling and batch sync configuration (#851)
Co-authored-by: paisley <8197966+su8su@users.noreply.github.com>
This commit is contained in:
@@ -128,9 +128,10 @@ export function Sidebar() {
|
||||
|
||||
const gatewayStatus = useGatewayStore((s) => s.status);
|
||||
const isGatewayRunning = gatewayStatus.state === 'running';
|
||||
const isGatewayReady = isGatewayRunning && gatewayStatus.gatewayReady !== false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGatewayRunning) return;
|
||||
if (!isGatewayReady) return;
|
||||
let cancelled = false;
|
||||
const hasExistingMessages = useChatStore.getState().messages.length > 0;
|
||||
(async () => {
|
||||
@@ -141,7 +142,7 @@ export function Sidebar() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isGatewayRunning, loadHistory, loadSessions]);
|
||||
}, [isGatewayReady, loadHistory, loadSessions]);
|
||||
const agents = useAgentsStore((s) => s.agents);
|
||||
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
||||
|
||||
|
||||
@@ -89,6 +89,9 @@ export function Channels() {
|
||||
const [existingAccountIdsForModal, setExistingAccountIdsForModal] = useState<string[]>([]);
|
||||
const [initialConfigValuesForModal, setInitialConfigValuesForModal] = useState<Record<string, string> | undefined>(undefined);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
|
||||
const convergenceRefreshTimersRef = useRef<number[]>([]);
|
||||
const fetchInFlightRef = useRef(false);
|
||||
const queuedFetchOptionsRef = useRef<{ probe?: boolean } | null>(null);
|
||||
|
||||
const displayedChannelTypes = getPrimaryChannels();
|
||||
const visibleChannelGroups = channelGroups;
|
||||
@@ -104,7 +107,24 @@ export function Channels() {
|
||||
const agentsRef = useRef(agents);
|
||||
agentsRef.current = agents;
|
||||
|
||||
const fetchPageData = useCallback(async () => {
|
||||
const mergeFetchOptions = (
|
||||
base: { probe?: boolean } | null,
|
||||
incoming: { probe?: boolean } | undefined,
|
||||
): { probe?: boolean } => {
|
||||
return {
|
||||
probe: Boolean(base?.probe) || Boolean(incoming?.probe),
|
||||
};
|
||||
};
|
||||
|
||||
const fetchPageData = useCallback(async (options?: { probe?: boolean }) => {
|
||||
if (fetchInFlightRef.current) {
|
||||
queuedFetchOptionsRef.current = mergeFetchOptions(queuedFetchOptionsRef.current, options);
|
||||
return;
|
||||
}
|
||||
fetchInFlightRef.current = true;
|
||||
const startedAt = Date.now();
|
||||
const probe = options?.probe === true;
|
||||
console.info(`[channels-ui] fetch start probe=${probe ? '1' : '0'}`);
|
||||
// Only show loading spinner on first load (stale-while-revalidate).
|
||||
const hasData = channelGroupsRef.current.length > 0 || agentsRef.current.length > 0;
|
||||
if (!hasData) {
|
||||
@@ -113,7 +133,9 @@ export function Channels() {
|
||||
setError(null);
|
||||
try {
|
||||
const [channelsRes, agentsRes] = await Promise.all([
|
||||
hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[]; error?: string }>('/api/channels/accounts'),
|
||||
hostApiFetch<{ success: boolean; channels?: ChannelGroupItem[]; error?: string }>(
|
||||
options?.probe ? '/api/channels/accounts?probe=1' : '/api/channels/accounts'
|
||||
),
|
||||
hostApiFetch<{ success: boolean; agents?: AgentItem[]; error?: string }>('/api/agents'),
|
||||
]);
|
||||
|
||||
@@ -127,20 +149,64 @@ export function Channels() {
|
||||
|
||||
setChannelGroups(channelsRes.channels || []);
|
||||
setAgents(agentsRes.agents || []);
|
||||
console.info(
|
||||
`[channels-ui] fetch ok probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} view=${(channelsRes.channels || []).map((item) => `${item.channelType}:${item.status}`).join(',')}`
|
||||
);
|
||||
} catch (fetchError) {
|
||||
// Preserve previous data on error — don't clear channelGroups/agents.
|
||||
setError(String(fetchError));
|
||||
console.warn(
|
||||
`[channels-ui] fetch fail probe=${probe ? '1' : '0'} elapsedMs=${Date.now() - startedAt} error=${String(fetchError)}`
|
||||
);
|
||||
} finally {
|
||||
fetchInFlightRef.current = false;
|
||||
setLoading(false);
|
||||
const queued = queuedFetchOptionsRef.current;
|
||||
if (queued) {
|
||||
queuedFetchOptionsRef.current = null;
|
||||
void fetchPageData(queued);
|
||||
}
|
||||
}
|
||||
// Stable reference — reads state via refs, no deps needed.
|
||||
|
||||
}, []);
|
||||
|
||||
const clearConvergenceRefreshTimers = useCallback(() => {
|
||||
convergenceRefreshTimersRef.current.forEach((timerId) => {
|
||||
window.clearTimeout(timerId);
|
||||
});
|
||||
convergenceRefreshTimersRef.current = [];
|
||||
}, []);
|
||||
|
||||
const scheduleConvergenceRefresh = useCallback(() => {
|
||||
clearConvergenceRefreshTimers();
|
||||
// Channel adapters can take time to reconnect after gateway restart.
|
||||
// First few rounds use probe=true to force runtime connectivity checks,
|
||||
// then fall back to cached pulls to reduce load.
|
||||
[
|
||||
{ delay: 1200, probe: true },
|
||||
{ delay: 2600, probe: false },
|
||||
{ delay: 4500, probe: false },
|
||||
{ delay: 7000, probe: false },
|
||||
{ delay: 10500, probe: false },
|
||||
].forEach(({ delay, probe }) => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
void fetchPageData({ probe });
|
||||
}, delay);
|
||||
convergenceRefreshTimersRef.current.push(timerId);
|
||||
});
|
||||
}, [clearConvergenceRefreshTimers, fetchPageData]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPageData();
|
||||
}, [fetchPageData]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearConvergenceRefreshTimers();
|
||||
};
|
||||
}, [clearConvergenceRefreshTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
// Throttle channel-status events to avoid flooding fetchPageData during AI tasks.
|
||||
let throttleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -176,8 +242,9 @@ export function Channels() {
|
||||
|
||||
if (previousGatewayState !== 'running' && gatewayStatus.state === 'running') {
|
||||
void fetchPageData();
|
||||
scheduleConvergenceRefresh();
|
||||
}
|
||||
}, [fetchPageData, gatewayStatus.state]);
|
||||
}, [fetchPageData, gatewayStatus.state, scheduleConvergenceRefresh]);
|
||||
|
||||
const configuredTypes = useMemo(
|
||||
() => visibleChannelGroups.map((group) => group.channelType),
|
||||
@@ -199,7 +266,7 @@ export function Channels() {
|
||||
const unsupportedGroups = displayedChannelTypes.filter((type) => !configuredTypes.includes(type));
|
||||
|
||||
const handleRefresh = () => {
|
||||
void fetchPageData();
|
||||
void fetchPageData({ probe: true });
|
||||
};
|
||||
|
||||
const handleBindAgent = async (channelType: string, accountId: string, agentId: string) => {
|
||||
@@ -525,7 +592,8 @@ export function Channels() {
|
||||
setInitialConfigValuesForModal(undefined);
|
||||
}}
|
||||
onChannelSaved={async () => {
|
||||
await fetchPageData();
|
||||
await fetchPageData({ probe: true });
|
||||
scheduleConvergenceRefresh();
|
||||
setShowConfigModal(false);
|
||||
setSelectedChannelType(null);
|
||||
setSelectedAccountId(undefined);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { getCanonicalPrefixFromSessions, getMessageText, toMs } from './helpers';
|
||||
import { classifyHistoryStartupRetryError, sleep } from './history-startup-retry';
|
||||
import { DEFAULT_CANONICAL_PREFIX, DEFAULT_SESSION_KEY, type ChatSession, type RawMessage } from './types';
|
||||
import type { ChatGet, ChatSet, SessionHistoryActions } from './store-api';
|
||||
|
||||
const LABEL_FETCH_CONCURRENCY = 5;
|
||||
|
||||
function getAgentIdFromSessionKey(sessionKey: string): string {
|
||||
if (!sessionKey.startsWith('agent:')) return 'main';
|
||||
const [, agentId] = sessionKey.split(':');
|
||||
@@ -111,30 +112,24 @@ export function createSessionActions(
|
||||
get().loadHistory();
|
||||
}
|
||||
|
||||
// Background: fetch first user message for every non-main session to populate labels upfront.
|
||||
// Retries on "gateway startup" errors since the gateway may still be initializing.
|
||||
// Background: fetch first user message for every non-main session to populate labels.
|
||||
// Concurrency-limited to avoid flooding the gateway with parallel RPCs.
|
||||
// By the time this runs, the gateway should already be fully ready (Sidebar
|
||||
// gates on gatewayReady), so no startup-retry loop is needed.
|
||||
const sessionsToLabel = sessionsWithCurrent.filter((s) => !s.key.endsWith(':main'));
|
||||
if (sessionsToLabel.length > 0) {
|
||||
const LABEL_RETRY_DELAYS = [2_000, 5_000, 10_000];
|
||||
void (async () => {
|
||||
let pending = sessionsToLabel;
|
||||
for (let attempt = 0; attempt <= LABEL_RETRY_DELAYS.length; attempt += 1) {
|
||||
const failed: typeof pending = [];
|
||||
for (let i = 0; i < sessionsToLabel.length; i += LABEL_FETCH_CONCURRENCY) {
|
||||
const batch = sessionsToLabel.slice(i, i + LABEL_FETCH_CONCURRENCY);
|
||||
await Promise.all(
|
||||
pending.map(async (session) => {
|
||||
batch.map(async (session) => {
|
||||
try {
|
||||
const r = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'chat.history',
|
||||
{ sessionKey: session.key, limit: 1000 },
|
||||
) as { success: boolean; result?: Record<string, unknown>; error?: string };
|
||||
if (!r.success) {
|
||||
if (classifyHistoryStartupRetryError(r.error) === 'gateway_startup') {
|
||||
failed.push(session);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!r.result) return;
|
||||
if (!r.success || !r.result) return;
|
||||
const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : [];
|
||||
const firstUser = msgs.find((m) => m.role === 'user');
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
@@ -155,9 +150,6 @@ export function createSessionActions(
|
||||
} catch { /* ignore per-session errors */ }
|
||||
}),
|
||||
);
|
||||
if (failed.length === 0 || attempt >= LABEL_RETRY_DELAYS.length) break;
|
||||
await sleep(LABEL_RETRY_DELAYS[attempt]!);
|
||||
pending = failed;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface GatewayStatus {
|
||||
connectedAt?: number;
|
||||
version?: string;
|
||||
reconnectAttempts?: number;
|
||||
/** True once the gateway's internal subsystems (skills, plugins) are ready for RPC calls. */
|
||||
gatewayReady?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user