perf(startup): defer provider status checks

This commit is contained in:
777genius 2026-05-23 14:23:57 +03:00
parent 64fdfd2422
commit a4861fa77d
15 changed files with 471 additions and 87 deletions

View file

@ -916,7 +916,9 @@ let shutdownComplete = false;
const startupTimers = new Set<ReturnType<typeof setTimeout>>(); const startupTimers = new Set<ReturnType<typeof setTimeout>>();
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000; const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
const STARTUP_RECOVERY_DELAY_MS = 10_000; const STARTUP_RECOVERY_DELAY_MS = 60_000;
const STARTUP_CLI_WARMUP_DELAY_MS = 90_000;
const STARTUP_BACKGROUND_SERVICE_DELAY_MS = 5_000;
const STARTUP_RECOVERY_CONCURRENCY = 1; const STARTUP_RECOVERY_CONCURRENCY = 1;
const appStartupStartedAt = Date.now(); const appStartupStartedAt = Date.now();
let appStartupSteps: AppStartupStep[] = [ let appStartupSteps: AppStartupStep[] = [
@ -2456,10 +2458,12 @@ function runPostRendererStartupTasks(): void {
); );
scheduleStartupTask(() => { scheduleStartupTask(() => {
void teamProvisioningService.warmup();
teamDataService.startProcessHealthPolling(); teamDataService.startProcessHealthPolling();
void schedulerService?.start(); void schedulerService?.start();
}, 5000); }, STARTUP_BACKGROUND_SERVICE_DELAY_MS);
scheduleStartupTask(() => {
void teamProvisioningService.warmup();
}, STARTUP_CLI_WARMUP_DELAY_MS);
} }
function scheduleRendererRecovery(win: BrowserWindow): void { function scheduleRendererRecovery(win: BrowserWindow): void {

View file

@ -24,6 +24,8 @@ import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
import type { CliInstallerService } from '../services'; import type { CliInstallerService } from '../services';
import type { import type {
CliInstallationStatus, CliInstallationStatus,
CliInstallerGetStatusOptions,
CliInstallerProviderStatusMode,
CliProviderId, CliProviderId,
CliProviderStatus, CliProviderStatus,
IpcResult, IpcResult,
@ -33,12 +35,16 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const logger = createLogger('IPC:cliInstaller'); const logger = createLogger('IPC:cliInstaller');
let service: CliInstallerService; let service: CliInstallerService;
let statusInFlight: Promise<CliInstallationStatus> | null = null; const statusInFlight = new Map<CliInstallerProviderStatusMode, Promise<CliInstallationStatus>>();
const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>(); const providerStatusInFlight = new Map<CliProviderId, Promise<CliProviderStatus | null>>();
let cachedStatus: { value: CliInstallationStatus; at: number } | null = null; const cachedStatus = new Map<
CliInstallerProviderStatusMode,
{ value: CliInstallationStatus; at: number }
>();
let statusCacheGeneration = 0; let statusCacheGeneration = 0;
const STATUS_CACHE_TTL_MS = 5_000; const STATUS_CACHE_TTL_MS = 5_000;
const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set<CliProviderId>(['anthropic', 'codex', 'opencode']); const FRONTEND_MULTIMODEL_PROVIDER_IDS = new Set<CliProviderId>(['anthropic', 'codex', 'opencode']);
const DEFERRED_PROVIDER_STATUS_MESSAGE = 'Provider status will refresh when needed.';
function isFrontendMultimodelProviderId(providerId: CliProviderId): boolean { function isFrontendMultimodelProviderId(providerId: CliProviderId): boolean {
return FRONTEND_MULTIMODEL_PROVIDER_IDS.has(providerId); return FRONTEND_MULTIMODEL_PROVIDER_IDS.has(providerId);
@ -54,6 +60,39 @@ function getCachedStatusAuthenticatedProvider(
); );
} }
function normalizeGetStatusOptions(options: unknown): Required<CliInstallerGetStatusOptions> {
if (
typeof options === 'object' &&
options !== null &&
(options as CliInstallerGetStatusOptions).providerStatusMode === 'defer'
) {
return { providerStatusMode: 'defer' };
}
return { providerStatusMode: 'full' };
}
function isDeferredProviderStatusSnapshot(status: CliInstallationStatus): boolean {
return (
status.flavor === 'agent_teams_orchestrator' &&
status.providers.length > 0 &&
status.providers.every(
(provider) =>
provider.supported === false &&
provider.authenticated === false &&
provider.verificationState === 'unknown' &&
provider.statusMessage === DEFERRED_PROVIDER_STATUS_MESSAGE
)
);
}
function canUseLatestSnapshotForCacheKey(
cacheKey: CliInstallerProviderStatusMode,
status: CliInstallationStatus
): boolean {
return cacheKey === 'defer' || !isDeferredProviderStatusSnapshot(status);
}
/** /**
* Initializes CLI installer handlers with the service instance. * Initializes CLI installer handlers with the service instance.
*/ */
@ -92,32 +131,36 @@ export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
// ============================================================================= // =============================================================================
async function handleGetStatus( async function handleGetStatus(
_event: IpcMainInvokeEvent _event: IpcMainInvokeEvent,
options?: CliInstallerGetStatusOptions
): Promise<IpcResult<CliInstallationStatus>> { ): Promise<IpcResult<CliInstallationStatus>> {
try { try {
const normalizedOptions = normalizeGetStatusOptions(options);
const cacheKey = normalizedOptions.providerStatusMode;
const latestSnapshot = service.getLatestStatusSnapshot(); const latestSnapshot = service.getLatestStatusSnapshot();
if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) { const cached = cachedStatus.get(cacheKey);
if (latestSnapshot) { if (cached && Date.now() - cached.at < STATUS_CACHE_TTL_MS) {
cachedStatus = { value: latestSnapshot, at: Date.now() }; if (latestSnapshot && canUseLatestSnapshotForCacheKey(cacheKey, latestSnapshot)) {
cachedStatus.set(cacheKey, { value: latestSnapshot, at: Date.now() });
return { success: true, data: latestSnapshot }; return { success: true, data: latestSnapshot };
} }
return { success: true, data: cachedStatus.value }; return { success: true, data: cached.value };
} }
if (!statusInFlight) { if (!statusInFlight.has(cacheKey)) {
const startedAt = Date.now(); const startedAt = Date.now();
const generation = statusCacheGeneration; const generation = statusCacheGeneration;
const request = service const request = service
.getStatus() .getStatus(normalizedOptions)
.then((status) => { .then((status) => {
if (generation === statusCacheGeneration) { if (generation === statusCacheGeneration) {
cachedStatus = { value: status, at: Date.now() }; cachedStatus.set(cacheKey, { value: status, at: Date.now() });
} }
return status; return status;
}) })
.catch((err) => { .catch((err) => {
if (generation === statusCacheGeneration) { if (generation === statusCacheGeneration) {
cachedStatus = null; cachedStatus.delete(cacheKey);
} }
throw err; throw err;
}) })
@ -126,14 +169,14 @@ async function handleGetStatus(
if (ms >= 2000) { if (ms >= 2000) {
logger.warn(`cliInstaller:getStatus slow ms=${ms}`); logger.warn(`cliInstaller:getStatus slow ms=${ms}`);
} }
if (statusInFlight === request) { if (statusInFlight.get(cacheKey) === request) {
statusInFlight = null; statusInFlight.delete(cacheKey);
} }
}); });
statusInFlight = request; statusInFlight.set(cacheKey, request);
} }
const status = await statusInFlight; const status = await statusInFlight.get(cacheKey)!;
return { success: true, data: status }; return { success: true, data: status };
} catch (error) { } catch (error) {
const msg = getErrorMessage(error); const msg = getErrorMessage(error);
@ -143,42 +186,44 @@ async function handleGetStatus(
} }
function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): void { function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): void {
if (!cachedStatus || !providerStatus) { if (!providerStatus) {
return; return;
} }
if ( for (const [cacheKey, cached] of cachedStatus) {
cachedStatus.value.flavor === 'agent_teams_orchestrator' && if (
!isFrontendMultimodelProviderId(providerStatus.providerId) cached.value.flavor === 'agent_teams_orchestrator' &&
) { !isFrontendMultimodelProviderId(providerStatus.providerId)
return; ) {
continue;
}
const hasProvider = cached.value.providers.some(
(provider) => provider.providerId === providerStatus.providerId
);
const nextProviders = hasProvider
? cached.value.providers.map((provider) =>
provider.providerId === providerStatus.providerId ? providerStatus : provider
)
: [...cached.value.providers, providerStatus];
const authenticatedProvider =
cached.value.flavor === 'agent_teams_orchestrator'
? getCachedStatusAuthenticatedProvider(nextProviders)
: (nextProviders.find((provider) => provider.authenticated) ?? null);
cachedStatus.set(cacheKey, {
value: {
...cached.value,
providers: nextProviders,
authLoggedIn:
cached.value.flavor === 'agent_teams_orchestrator'
? authenticatedProvider !== null
: nextProviders.some((provider) => provider.authenticated),
authMethod: authenticatedProvider?.authMethod ?? null,
},
at: Date.now(),
});
} }
const hasProvider = cachedStatus.value.providers.some(
(provider) => provider.providerId === providerStatus.providerId
);
const nextProviders = hasProvider
? cachedStatus.value.providers.map((provider) =>
provider.providerId === providerStatus.providerId ? providerStatus : provider
)
: [...cachedStatus.value.providers, providerStatus];
const authenticatedProvider =
cachedStatus.value.flavor === 'agent_teams_orchestrator'
? getCachedStatusAuthenticatedProvider(nextProviders)
: (nextProviders.find((provider) => provider.authenticated) ?? null);
cachedStatus = {
value: {
...cachedStatus.value,
providers: nextProviders,
authLoggedIn:
cachedStatus.value.flavor === 'agent_teams_orchestrator'
? authenticatedProvider !== null
: nextProviders.some((provider) => provider.authenticated),
authMethod: authenticatedProvider?.authMethod ?? null,
},
at: Date.now(),
};
} }
async function handleGetProviderStatus( async function handleGetProviderStatus(
@ -248,8 +293,8 @@ async function handleVerifyProviderModels(
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> { function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
statusCacheGeneration += 1; statusCacheGeneration += 1;
cachedStatus = null; cachedStatus.clear();
statusInFlight = null; statusInFlight.clear();
providerStatusInFlight.clear(); providerStatusInFlight.clear();
ClaudeBinaryResolver.clearCache(); ClaudeBinaryResolver.clearCache();
CodexBinaryResolver.clearCache(); CodexBinaryResolver.clearCache();

View file

@ -49,7 +49,9 @@ import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor
import type { import type {
CliInstallationStatus, CliInstallationStatus,
CliInstallerGetStatusOptions,
CliInstallerProgress, CliInstallerProgress,
CliInstallerProviderStatusMode,
CliPlatform, CliPlatform,
CliProviderId, CliProviderId,
CliProviderModelAvailability, CliProviderModelAvailability,
@ -868,8 +870,9 @@ export class CliInstallerService {
// Public: getStatus // Public: getStatus
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async getStatus(): Promise<CliInstallationStatus> { async getStatus(options: CliInstallerGetStatusOptions = {}): Promise<CliInstallationStatus> {
const statusStartedAt = Date.now(); const statusStartedAt = Date.now();
const providerStatusMode: CliInstallerProviderStatusMode = options.providerStatusMode ?? 'full';
const generation = ++this.statusGatherGeneration; const generation = ++this.statusGatherGeneration;
const result = this.createInitialStatus(); const result = this.createInitialStatus();
this.latestProviderSignatures.clear(); this.latestProviderSignatures.clear();
@ -882,7 +885,7 @@ export class CliInstallerService {
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
try { try {
await Promise.race([ await Promise.race([
this.gatherStatus(ref, runDiag, generation), this.gatherStatus(ref, runDiag, generation, providerStatusMode),
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
timer = setTimeout(() => { timer = setTimeout(() => {
logger.warn( logger.warn(
@ -1023,7 +1026,8 @@ export class CliInstallerService {
private async gatherStatus( private async gatherStatus(
ref: { current: CliInstallationStatus }, ref: { current: CliInstallationStatus },
diag: CliInstallerStatusRunDiag, diag: CliInstallerStatusRunDiag,
generation: number generation: number,
providerStatusMode: CliInstallerProviderStatusMode
): Promise<void> { ): Promise<void> {
resetGatherDiag(diag); resetGatherDiag(diag);
const shellEnvStartedAt = Date.now(); const shellEnvStartedAt = Date.now();
@ -1048,6 +1052,14 @@ export class CliInstallerService {
r.installedVersion = versionProbe.version; r.installedVersion = versionProbe.version;
r.launchError = null; r.launchError = null;
r.authStatusChecking = true; r.authStatusChecking = true;
if (r.flavor === 'agent_teams_orchestrator' && providerStatusMode === 'defer') {
r.authStatusChecking = false;
this.markProvidersDeferred(r);
this.publishStatusSnapshotIfCurrent(r, generation);
return;
}
this.rememberHealthyStatus(r); this.rememberHealthyStatus(r);
this.publishStatusSnapshotIfCurrent(r, generation); this.publishStatusSnapshotIfCurrent(r, generation);
@ -1186,6 +1198,28 @@ export class CliInstallerService {
result.authMethod = null; result.authMethod = null;
} }
private markProvidersDeferred(result: CliInstallationStatus): void {
if (result.flavor !== 'agent_teams_orchestrator') {
return;
}
result.providers = result.providers.map((provider) => ({
...provider,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
modelVerificationState: 'idle',
modelCatalogRefreshState: 'idle',
statusMessage: 'Provider status will refresh when needed.',
detailMessage: null,
models: [],
modelAvailability: [],
backend: null,
}));
result.authLoggedIn = false;
result.authMethod = null;
}
/** /**
* Check auth status with retry covers stale lock files after Ctrl+C interruption. * Check auth status with retry covers stale lock files after Ctrl+C interruption.
* Wrapped in its own timeout to prevent slow auth from blocking the overall status. * Wrapped in its own timeout to prevent slow auth from blocking the overall status.

View file

@ -273,6 +273,7 @@ import type {
ClaudeRootFolderSelection, ClaudeRootFolderSelection,
ClaudeRootInfo, ClaudeRootInfo,
CliInstallationStatus, CliInstallationStatus,
CliInstallerGetStatusOptions,
CliInstallerProgress, CliInstallerProgress,
CliProviderId, CliProviderId,
ConflictCheckResult, ConflictCheckResult,
@ -1554,8 +1555,8 @@ const electronAPI: ElectronAPI = {
// ===== CLI Installer API ===== // ===== CLI Installer API =====
cliInstaller: { cliInstaller: {
getStatus: async (): Promise<CliInstallationStatus> => { getStatus: async (options?: CliInstallerGetStatusOptions): Promise<CliInstallationStatus> => {
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS); return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS, options);
}, },
getProviderStatus: async (providerId: CliProviderId) => { getProviderStatus: async (providerId: CliProviderId) => {
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId); return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);

View file

@ -260,6 +260,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
useEffect(() => { useEffect(() => {
void refreshCliStatusForCurrentMode({ void refreshCliStatusForCurrentMode({
multimodelEnabled, multimodelEnabled,
providerStatusMode: 'defer',
bootstrapCliStatus, bootstrapCliStatus,
fetchCliStatus, fetchCliStatus,
}); });

View file

@ -9,7 +9,12 @@ import { useStore } from '@renderer/store';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts'; import type { CodexRuntimeStatus } from '@features/codex-runtime-installer/contracts';
import type { CliInstallationStatus, CliProviderId, OpenCodeRuntimeStatus } from '@shared/types'; import type {
CliInstallationStatus,
CliInstallerProviderStatusMode,
CliProviderId,
OpenCodeRuntimeStatus,
} from '@shared/types';
export function useCliInstaller(): { export function useCliInstaller(): {
cliStatus: CliInstallationStatus | null; cliStatus: CliInstallationStatus | null;
@ -37,7 +42,10 @@ export function useCliInstaller(): {
codexRuntimeStatus: CodexRuntimeStatus | null; codexRuntimeStatus: CodexRuntimeStatus | null;
codexRuntimeStatusLoading: boolean; codexRuntimeStatusLoading: boolean;
codexRuntimeError: string | null; codexRuntimeError: string | null;
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>; bootstrapCliStatus: (options?: {
multimodelEnabled?: boolean;
providerStatusMode?: CliInstallerProviderStatusMode;
}) => Promise<void>;
fetchCliStatus: () => Promise<void>; fetchCliStatus: () => Promise<void>;
fetchCliProviderStatus: ( fetchCliProviderStatus: (
providerId: CliProviderId, providerId: CliProviderId,

View file

@ -95,6 +95,7 @@ const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000; const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000; const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
const TASK_LOG_ACTIVITY_PULSE_MS = 3_500; const TASK_LOG_ACTIVITY_PULSE_MS = 3_500;
const STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS = 30_000;
const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> = const ACTIVE_PROVISIONING_STATES_FOR_PROCESS_LITE: ReadonlySet<TeamProvisioningProgress['state']> =
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']); new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout'; export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
@ -209,6 +210,7 @@ export function initializeNotificationListeners(): () => void {
const cleanupFns: (() => void)[] = []; const cleanupFns: (() => void)[] = [];
cleanupFns.push(installTeamRefreshFanoutDebugBridge()); cleanupFns.push(installTeamRefreshFanoutDebugBridge());
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null; let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
let runtimeStatusTimer: ReturnType<typeof setTimeout> | null = null;
useStore.getState().subscribeProvisioningProgress(); useStore.getState().subscribeProvisioningProgress();
cleanupFns.push(() => { cleanupFns.push(() => {
useStore.getState().unsubscribeProvisioningProgress(); useStore.getState().unsubscribeProvisioningProgress();
@ -241,19 +243,24 @@ export function initializeNotificationListeners(): () => void {
cliStatusTimer = setTimeout(() => { cliStatusTimer = setTimeout(() => {
const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true; const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true;
if (multimodelEnabled) { if (multimodelEnabled) {
void useStore.getState().bootstrapCliStatus({ multimodelEnabled: true }); void useStore
.getState()
.bootstrapCliStatus({ multimodelEnabled: true, providerStatusMode: 'defer' });
} else { } else {
void useStore.getState().fetchCliStatus(); void useStore.getState().fetchCliStatus();
} }
cliStatusTimer = null; cliStatusTimer = null;
}, delayMs); }, delayMs);
} }
if (api.openCodeRuntime) { runtimeStatusTimer = setTimeout(() => {
void useStore.getState().fetchOpenCodeRuntimeStatus(); if (api.openCodeRuntime) {
} void useStore.getState().fetchOpenCodeRuntimeStatus();
if (api.codexRuntime) { }
void useStore.getState().fetchCodexRuntimeStatus(); if (api.codexRuntime) {
} void useStore.getState().fetchCodexRuntimeStatus();
}
runtimeStatusTimer = null;
}, STARTUP_RUNTIME_STATUS_IDLE_DELAY_MS);
// Remaining fetches have no data dependency on each other — run in parallel // Remaining fetches have no data dependency on each other — run in parallel
// to avoid blocking teams/notifications behind a slow repository scan. // to avoid blocking teams/notifications behind a slow repository scan.
@ -267,6 +274,7 @@ export function initializeNotificationListeners(): () => void {
})(); })();
cleanupFns.push(() => { cleanupFns.push(() => {
if (cliStatusTimer) clearTimeout(cliStatusTimer); if (cliStatusTimer) clearTimeout(cliStatusTimer);
if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer);
}); });
// TODO(task-change-presence): re-enable this only after the board uses a bounded // TODO(task-change-presence): re-enable this only after the board uses a bounded
// batch/priority presence pipeline. The old one-task-per-tick poll was accurate // batch/priority presence pipeline. The old one-task-per-tick poll was accurate

View file

@ -659,7 +659,10 @@ export interface CliInstallerSlice {
codexRuntimeError: string | null; codexRuntimeError: string | null;
// Actions // Actions
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>; bootstrapCliStatus: (options?: {
multimodelEnabled?: boolean;
providerStatusMode?: 'full' | 'defer';
}) => Promise<void>;
fetchCliStatus: () => Promise<void>; fetchCliStatus: () => Promise<void>;
fetchCliProviderStatus: ( fetchCliProviderStatus: (
providerId: CliProviderId, providerId: CliProviderId,
@ -714,13 +717,15 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
bootstrapCliStatus: async (options) => { bootstrapCliStatus: async (options) => {
if (!api.cliInstaller) return; if (!api.cliInstaller) return;
const multimodelEnabled = options?.multimodelEnabled ?? true; const multimodelEnabled = options?.multimodelEnabled ?? true;
const providerStatusMode = options?.providerStatusMode ?? 'full';
const hydrateProviders = providerStatusMode !== 'defer';
if (!multimodelEnabled) { if (!multimodelEnabled) {
return get().fetchCliStatus(); return get().fetchCliStatus();
} }
const epoch = ++cliStatusEpoch; const epoch = ++cliStatusEpoch;
const providerLoading = Object.fromEntries( const providerLoading = Object.fromEntries(
MULTIMODEL_PROVIDER_IDS.map((providerId) => [providerId, true]) MULTIMODEL_PROVIDER_IDS.map((providerId) => [providerId, hydrateProviders])
) as Partial<Record<CliProviderId, boolean>>; ) as Partial<Record<CliProviderId, boolean>>;
set({ set({
@ -731,7 +736,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
}); });
try { try {
const metadata = await api.cliInstaller.getStatus(); const metadata = await api.cliInstaller.getStatus(
providerStatusMode === 'defer' ? { providerStatusMode } : undefined
);
if (metadata.flavor !== 'agent_teams_orchestrator') { if (metadata.flavor !== 'agent_teams_orchestrator') {
set((state) => { set((state) => {
if (epoch !== cliStatusEpoch) { if (epoch !== cliStatusEpoch) {
@ -756,9 +763,10 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
const nextProviderLoading = Object.fromEntries( const nextProviderLoading = Object.fromEntries(
MULTIMODEL_PROVIDER_IDS.map((providerId) => [ MULTIMODEL_PROVIDER_IDS.map((providerId) => [
providerId, providerId,
!isHydratedMultimodelProviderStatus( hydrateProviders &&
metadata.providers.find((provider) => provider.providerId === providerId) !isHydratedMultimodelProviderStatus(
), metadata.providers.find((provider) => provider.providerId === providerId)
),
]) ])
) as Partial<Record<CliProviderId, boolean>>; ) as Partial<Record<CliProviderId, boolean>>;
const pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter( const pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter(
@ -800,7 +808,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
return; return;
} }
if (pendingProviderIds.length === 0) { if (!hydrateProviders || pendingProviderIds.length === 0) {
return; return;
} }
@ -818,14 +826,16 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
} }
try { try {
await Promise.allSettled( if (hydrateProviders) {
MULTIMODEL_PROVIDER_IDS.map((providerId) => await Promise.allSettled(
get().fetchCliProviderStatus(providerId, { MULTIMODEL_PROVIDER_IDS.map((providerId) =>
silent: false, get().fetchCliProviderStatus(providerId, {
epoch, silent: false,
}) epoch,
) })
); )
);
}
} finally { } finally {
if (epoch === cliStatusEpoch) { if (epoch === cliStatusEpoch) {
set({ cliStatusLoading: false }); set({ cliStatusLoading: false });

View file

@ -1,16 +1,27 @@
import type { CliInstallerProviderStatusMode } from '@shared/types';
interface RefreshCliStatusOptions { interface RefreshCliStatusOptions {
multimodelEnabled: boolean; multimodelEnabled: boolean;
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>; providerStatusMode?: CliInstallerProviderStatusMode;
bootstrapCliStatus: (options?: {
multimodelEnabled?: boolean;
providerStatusMode?: CliInstallerProviderStatusMode;
}) => Promise<void>;
fetchCliStatus: () => Promise<void>; fetchCliStatus: () => Promise<void>;
} }
export function refreshCliStatusForCurrentMode({ export function refreshCliStatusForCurrentMode({
multimodelEnabled, multimodelEnabled,
providerStatusMode,
bootstrapCliStatus, bootstrapCliStatus,
fetchCliStatus, fetchCliStatus,
}: RefreshCliStatusOptions): Promise<void> { }: RefreshCliStatusOptions): Promise<void> {
if (multimodelEnabled) { if (multimodelEnabled) {
return bootstrapCliStatus({ multimodelEnabled: true }); return bootstrapCliStatus(
providerStatusMode
? { multimodelEnabled: true, providerStatusMode }
: { multimodelEnabled: true }
);
} }
return fetchCliStatus(); return fetchCliStatus();

View file

@ -351,6 +351,16 @@ export interface CliInstallerProgress {
status?: CliInstallationStatus; status?: CliInstallationStatus;
} }
export type CliInstallerProviderStatusMode = 'full' | 'defer';
export interface CliInstallerGetStatusOptions {
/**
* `defer` keeps startup lightweight by checking only the runtime binary/version.
* Explicit refreshes should keep the default `full` mode.
*/
providerStatusMode?: CliInstallerProviderStatusMode;
}
// ============================================================================= // =============================================================================
// Preload API // Preload API
// ============================================================================= // =============================================================================
@ -360,7 +370,7 @@ export interface CliInstallerProgress {
*/ */
export interface CliInstallerAPI { export interface CliInstallerAPI {
/** Get current CLI installation status */ /** Get current CLI installation status */
getStatus: () => Promise<CliInstallationStatus>; getStatus: (options?: CliInstallerGetStatusOptions) => Promise<CliInstallationStatus>;
/** Get current runtime/auth status for a single provider */ /** Get current runtime/auth status for a single provider */
getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>; getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
/** Start on-demand model verification for a single runtime provider */ /** Start on-demand model verification for a single runtime provider */

View file

@ -275,6 +275,112 @@ describe('cliInstaller IPC handlers', () => {
expect(cached.data?.providers[0]?.statusMessage).toBe('ChatGPT account ready'); expect(cached.data?.providers[0]?.statusMessage).toBe('ChatGPT account ready');
}); });
it('keeps lightweight startup status cache separate from full provider status cache', async () => {
const deferredStartupStatus = status([
provider({
providerId: 'anthropic',
supported: false,
statusMessage: 'Provider status will refresh when needed.',
}),
provider({
providerId: 'codex',
supported: false,
statusMessage: 'Provider status will refresh when needed.',
}),
]);
const fullStatus = status([
provider({
providerId: 'anthropic',
authenticated: true,
authMethod: 'oauth_token',
verificationState: 'verified',
statusMessage: 'Connected',
}),
provider({
providerId: 'codex',
authenticated: true,
authMethod: 'chatgpt',
verificationState: 'verified',
statusMessage: 'ChatGPT account ready',
}),
]);
const startupRequest = deferred<CliInstallationStatus>();
const fullRequest = deferred<CliInstallationStatus>();
service.getStatus.mockImplementation((options?: { providerStatusMode?: string }) =>
options?.providerStatusMode === 'defer' ? startupRequest.promise : fullRequest.promise
);
const startupInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS, {
providerStatusMode: 'defer',
}) as Promise<IpcResult<CliInstallationStatus>>;
const fullInvoke = ipcMain.invoke(CLI_INSTALLER_GET_STATUS) as Promise<
IpcResult<CliInstallationStatus>
>;
await vi.waitFor(() => expect(service.getStatus).toHaveBeenCalledTimes(2));
startupRequest.resolve(deferredStartupStatus);
fullRequest.resolve(fullStatus);
await expect(startupInvoke).resolves.toMatchObject({
success: true,
data: { authLoggedIn: false },
});
await expect(fullInvoke).resolves.toMatchObject({
success: true,
data: { authLoggedIn: true, authMethod: 'oauth_token' },
});
const cachedStartup = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS, {
providerStatusMode: 'defer',
})) as IpcResult<CliInstallationStatus>;
const cachedFull = (await ipcMain.invoke(
CLI_INSTALLER_GET_STATUS
)) as IpcResult<CliInstallationStatus>;
expect(service.getStatus).toHaveBeenCalledTimes(2);
expect(cachedStartup.data?.authLoggedIn).toBe(false);
expect(cachedStartup.data?.providers[0]?.statusMessage).toBe(
'Provider status will refresh when needed.'
);
expect(cachedFull.data?.authLoggedIn).toBe(true);
expect(cachedFull.data?.providers[1]?.statusMessage).toBe('ChatGPT account ready');
});
it('does not replace a cached full provider status with a deferred startup snapshot', async () => {
const fullStatus = status([
provider({
providerId: 'anthropic',
authenticated: true,
authMethod: 'oauth_token',
verificationState: 'verified',
statusMessage: 'Connected',
}),
]);
const deferredStartupStatus = status([
provider({
providerId: 'anthropic',
supported: false,
verificationState: 'unknown',
statusMessage: 'Provider status will refresh when needed.',
}),
]);
service.getStatus.mockResolvedValueOnce(fullStatus);
const first = (await ipcMain.invoke(CLI_INSTALLER_GET_STATUS)) as IpcResult<CliInstallationStatus>;
expect(first.success).toBe(true);
expect(first.data?.providers[0]?.statusMessage).toBe('Connected');
service.getLatestStatusSnapshot.mockReturnValue(deferredStartupStatus);
const cached = (await ipcMain.invoke(
CLI_INSTALLER_GET_STATUS
)) as IpcResult<CliInstallationStatus>;
expect(service.getStatus).toHaveBeenCalledTimes(1);
expect(cached.success).toBe(true);
expect(cached.data?.authLoggedIn).toBe(true);
expect(cached.data?.providers[0]?.statusMessage).toBe('Connected');
});
it('does not let a stale in-flight provider refresh patch the cache after invalidation', async () => { it('does not let a stale in-flight provider refresh patch the cache after invalidation', async () => {
const staleProviderRequest = deferred<CliProviderStatus | null>(); const staleProviderRequest = deferred<CliProviderStatus | null>();
service.getStatus service.getStatus

View file

@ -325,6 +325,70 @@ describe('CliInstallerService', () => {
expect(service.getLatestStatusSnapshot()?.authLoggedIn).toBe(false); expect(service.getLatestStatusSnapshot()?.authLoggedIn).toBe(false);
}); });
it('defers multimodel provider status probes during lightweight startup status checks', async () => {
allowConsoleLogs();
vi.mocked(getConfiguredCliFlavor).mockReturnValue('agent_teams_orchestrator');
vi.mocked(getCliFlavorUiOptions).mockReturnValue({
displayName: 'agent_teams_orchestrator',
supportsSelfUpdate: false,
showVersionDetails: false,
showBinaryPath: false,
});
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/agent_teams_orchestrator');
vi.mocked(execCli).mockResolvedValueOnce({ stdout: '0.0.46', stderr: '' });
const getProviderStatusesSpy = vi
.spyOn(ClaudeMultimodelBridgeService.prototype, 'getProviderStatuses')
.mockResolvedValue([
createTestProviderStatus('anthropic', true, 'oauth_token'),
createTestProviderStatus('codex', true, 'chatgpt'),
createTestProviderStatus('opencode', true, 'opencode_managed'),
]);
const mockWindow = {
isDestroyed: () => false,
webContents: { send: vi.fn(), isDestroyed: () => false },
};
service.setMainWindow(mockWindow as unknown as import('electron').BrowserWindow);
const status = await service.getStatus({ providerStatusMode: 'defer' });
const statusEvents = mockWindow.webContents.send.mock.calls
.filter((call: unknown[]) => call[0] === 'cliInstaller:progress')
.map((call: unknown[]) => call[1] as { type?: string; status?: { providers?: unknown[] } })
.filter((event) => event.type === 'status');
expect(status.installed).toBe(true);
expect(status.authStatusChecking).toBe(false);
expect(status.authLoggedIn).toBe(false);
expect(status.providers).toHaveLength(3);
expect(
status.providers.every(
(provider) => provider.statusMessage === 'Provider status will refresh when needed.'
)
).toBe(true);
expect(statusEvents.length).toBeGreaterThan(0);
expect(
statusEvents.every((event) =>
event.status?.providers?.every(
(provider) =>
typeof provider === 'object' &&
provider !== null &&
'statusMessage' in provider &&
'models' in provider &&
(provider as { statusMessage?: string }).statusMessage ===
'Provider status will refresh when needed.' &&
Array.isArray((provider as { models?: unknown[] }).models) &&
(provider as { models?: unknown[] }).models?.length === 0
)
)
).toBe(true);
expect(getProviderStatusesSpy).not.toHaveBeenCalled();
expect(execCli).toHaveBeenCalledTimes(1);
expect(execCli).toHaveBeenCalledWith(
'/mock/agent_teams_orchestrator',
['--version'],
expect.objectContaining({ timeout: expect.any(Number) })
);
});
it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => { it('does not mark the CLI installed when the version probe cannot confirm the binary', async () => {
allowConsoleLogs(); allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude'); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');

View file

@ -1,5 +1,6 @@
import React, { act } from 'react'; import React, { act } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts'; import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
@ -346,7 +347,10 @@ describe('ExtensionStoreView provider loading placeholders', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({ multimodelEnabled: true }); expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({
multimodelEnabled: true,
providerStatusMode: 'defer',
});
expect(storeState.fetchCliStatus).not.toHaveBeenCalled(); expect(storeState.fetchCliStatus).not.toHaveBeenCalled();
expect(storeState.fetchApiKeys).not.toHaveBeenCalled(); expect(storeState.fetchApiKeys).not.toHaveBeenCalled();

View file

@ -888,6 +888,67 @@ describe('cliInstallerSlice', () => {
expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled(); expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled();
}); });
it('does not hydrate pending providers when startup asks to defer provider status checks', async () => {
const mockStatus = createMultimodelStatus([
createMultimodelProvider({
providerId: 'anthropic',
displayName: 'Anthropic',
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
statusMessage: 'Provider status will refresh when needed.',
models: [],
backend: null,
availableBackends: [],
}),
createMultimodelProvider({
providerId: 'codex',
displayName: 'Codex',
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
statusMessage: 'Provider status will refresh when needed.',
models: [],
backend: null,
availableBackends: [],
}),
createMultimodelProvider({
providerId: 'opencode',
displayName: 'OpenCode',
supported: false,
authenticated: false,
authMethod: null,
verificationState: 'unknown',
statusMessage: 'Provider status will refresh when needed.',
models: [],
backend: null,
availableBackends: [],
canLoginFromUi: false,
}),
]);
vi.mocked(api.cliInstaller.getStatus).mockResolvedValue(mockStatus);
await useStore
.getState()
.bootstrapCliStatus({ multimodelEnabled: true, providerStatusMode: 'defer' });
expect(api.cliInstaller.getStatus).toHaveBeenCalledWith({ providerStatusMode: 'defer' });
expect(api.cliInstaller.getProviderStatus).not.toHaveBeenCalled();
expect(useStore.getState().cliStatusLoading).toBe(false);
expect(useStore.getState().cliProviderStatusLoading).toEqual({
anthropic: false,
codex: false,
opencode: false,
});
expect(useStore.getState().cliStatus?.providers.map((provider) => provider.statusMessage)).toEqual([
'Provider status will refresh when needed.',
'Provider status will refresh when needed.',
'Provider status will refresh when needed.',
]);
});
it('drops global loading once metadata is ready and keeps only unresolved providers loading', async () => { it('drops global loading once metadata is ready and keeps only unresolved providers loading', async () => {
let resolveCodexStatus!: (value: CliInstallationStatus['providers'][number]) => void; let resolveCodexStatus!: (value: CliInstallationStatus['providers'][number]) => void;
const pendingCodexStatus = new Promise<CliInstallationStatus['providers'][number]>( const pendingCodexStatus = new Promise<CliInstallationStatus['providers'][number]>(

View file

@ -1,6 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus'; import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
import { describe, expect, it, vi } from 'vitest';
describe('refreshCliStatusForCurrentMode', () => { describe('refreshCliStatusForCurrentMode', () => {
it('uses provider-first bootstrap when multimodel is enabled', async () => { it('uses provider-first bootstrap when multimodel is enabled', async () => {
@ -17,6 +16,24 @@ describe('refreshCliStatusForCurrentMode', () => {
expect(fetchCliStatus).not.toHaveBeenCalled(); expect(fetchCliStatus).not.toHaveBeenCalled();
}); });
it('passes deferred provider status mode to multimodel bootstrap when requested', async () => {
const bootstrapCliStatus = vi.fn().mockResolvedValue(undefined);
const fetchCliStatus = vi.fn().mockResolvedValue(undefined);
await refreshCliStatusForCurrentMode({
multimodelEnabled: true,
providerStatusMode: 'defer',
bootstrapCliStatus,
fetchCliStatus,
});
expect(bootstrapCliStatus).toHaveBeenCalledWith({
multimodelEnabled: true,
providerStatusMode: 'defer',
});
expect(fetchCliStatus).not.toHaveBeenCalled();
});
it('falls back to legacy status fetch when multimodel is disabled', async () => { it('falls back to legacy status fetch when multimodel is disabled', async () => {
const bootstrapCliStatus = vi.fn().mockResolvedValue(undefined); const bootstrapCliStatus = vi.fn().mockResolvedValue(undefined);
const fetchCliStatus = vi.fn().mockResolvedValue(undefined); const fetchCliStatus = vi.fn().mockResolvedValue(undefined);