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:
Haze
2026-04-14 15:42:37 +08:00
committed by GitHub
Unverified
parent 758a8f8c94
commit 30bd8c08f9
14 changed files with 626 additions and 69 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
})();
}

View File

@@ -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;
}
/**