perf(startup): defer provider status checks
This commit is contained in:
parent
64fdfd2422
commit
a4861fa77d
15 changed files with 471 additions and 87 deletions
|
|
@ -916,7 +916,9 @@ let shutdownComplete = false;
|
|||
const startupTimers = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
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 appStartupStartedAt = Date.now();
|
||||
let appStartupSteps: AppStartupStep[] = [
|
||||
|
|
@ -2456,10 +2458,12 @@ function runPostRendererStartupTasks(): void {
|
|||
);
|
||||
|
||||
scheduleStartupTask(() => {
|
||||
void teamProvisioningService.warmup();
|
||||
teamDataService.startProcessHealthPolling();
|
||||
void schedulerService?.start();
|
||||
}, 5000);
|
||||
}, STARTUP_BACKGROUND_SERVICE_DELAY_MS);
|
||||
scheduleStartupTask(() => {
|
||||
void teamProvisioningService.warmup();
|
||||
}, STARTUP_CLI_WARMUP_DELAY_MS);
|
||||
}
|
||||
|
||||
function scheduleRendererRecovery(win: BrowserWindow): void {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import { ClaudeBinaryResolver } from '../services/team/ClaudeBinaryResolver';
|
|||
import type { CliInstallerService } from '../services';
|
||||
import type {
|
||||
CliInstallationStatus,
|
||||
CliInstallerGetStatusOptions,
|
||||
CliInstallerProviderStatusMode,
|
||||
CliProviderId,
|
||||
CliProviderStatus,
|
||||
IpcResult,
|
||||
|
|
@ -33,12 +35,16 @@ import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
|||
const logger = createLogger('IPC:cliInstaller');
|
||||
|
||||
let service: CliInstallerService;
|
||||
let statusInFlight: Promise<CliInstallationStatus> | null = null;
|
||||
const statusInFlight = new Map<CliInstallerProviderStatusMode, Promise<CliInstallationStatus>>();
|
||||
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;
|
||||
const STATUS_CACHE_TTL_MS = 5_000;
|
||||
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 {
|
||||
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.
|
||||
*/
|
||||
|
|
@ -92,32 +131,36 @@ export function removeCliInstallerHandlers(ipcMain: IpcMain): void {
|
|||
// =============================================================================
|
||||
|
||||
async function handleGetStatus(
|
||||
_event: IpcMainInvokeEvent
|
||||
_event: IpcMainInvokeEvent,
|
||||
options?: CliInstallerGetStatusOptions
|
||||
): Promise<IpcResult<CliInstallationStatus>> {
|
||||
try {
|
||||
const normalizedOptions = normalizeGetStatusOptions(options);
|
||||
const cacheKey = normalizedOptions.providerStatusMode;
|
||||
const latestSnapshot = service.getLatestStatusSnapshot();
|
||||
if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) {
|
||||
if (latestSnapshot) {
|
||||
cachedStatus = { value: latestSnapshot, at: Date.now() };
|
||||
const cached = cachedStatus.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < STATUS_CACHE_TTL_MS) {
|
||||
if (latestSnapshot && canUseLatestSnapshotForCacheKey(cacheKey, latestSnapshot)) {
|
||||
cachedStatus.set(cacheKey, { value: latestSnapshot, at: Date.now() });
|
||||
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 generation = statusCacheGeneration;
|
||||
const request = service
|
||||
.getStatus()
|
||||
.getStatus(normalizedOptions)
|
||||
.then((status) => {
|
||||
if (generation === statusCacheGeneration) {
|
||||
cachedStatus = { value: status, at: Date.now() };
|
||||
cachedStatus.set(cacheKey, { value: status, at: Date.now() });
|
||||
}
|
||||
return status;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (generation === statusCacheGeneration) {
|
||||
cachedStatus = null;
|
||||
cachedStatus.delete(cacheKey);
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
|
|
@ -126,14 +169,14 @@ async function handleGetStatus(
|
|||
if (ms >= 2000) {
|
||||
logger.warn(`cliInstaller:getStatus slow ms=${ms}`);
|
||||
}
|
||||
if (statusInFlight === request) {
|
||||
statusInFlight = null;
|
||||
if (statusInFlight.get(cacheKey) === request) {
|
||||
statusInFlight.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
statusInFlight = request;
|
||||
statusInFlight.set(cacheKey, request);
|
||||
}
|
||||
|
||||
const status = await statusInFlight;
|
||||
const status = await statusInFlight.get(cacheKey)!;
|
||||
return { success: true, data: status };
|
||||
} catch (error) {
|
||||
const msg = getErrorMessage(error);
|
||||
|
|
@ -143,42 +186,44 @@ async function handleGetStatus(
|
|||
}
|
||||
|
||||
function patchCachedProviderStatus(providerStatus: CliProviderStatus | null): void {
|
||||
if (!cachedStatus || !providerStatus) {
|
||||
if (!providerStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
cachedStatus.value.flavor === 'agent_teams_orchestrator' &&
|
||||
!isFrontendMultimodelProviderId(providerStatus.providerId)
|
||||
) {
|
||||
return;
|
||||
for (const [cacheKey, cached] of cachedStatus) {
|
||||
if (
|
||||
cached.value.flavor === 'agent_teams_orchestrator' &&
|
||||
!isFrontendMultimodelProviderId(providerStatus.providerId)
|
||||
) {
|
||||
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(
|
||||
|
|
@ -248,8 +293,8 @@ async function handleVerifyProviderModels(
|
|||
|
||||
function handleInvalidateStatus(_event: IpcMainInvokeEvent): IpcResult<void> {
|
||||
statusCacheGeneration += 1;
|
||||
cachedStatus = null;
|
||||
statusInFlight = null;
|
||||
cachedStatus.clear();
|
||||
statusInFlight.clear();
|
||||
providerStatusInFlight.clear();
|
||||
ClaudeBinaryResolver.clearCache();
|
||||
CodexBinaryResolver.clearCache();
|
||||
|
|
|
|||
|
|
@ -49,7 +49,9 @@ import { getCliFlavorUiOptions, getConfiguredCliFlavor } from '../team/cliFlavor
|
|||
|
||||
import type {
|
||||
CliInstallationStatus,
|
||||
CliInstallerGetStatusOptions,
|
||||
CliInstallerProgress,
|
||||
CliInstallerProviderStatusMode,
|
||||
CliPlatform,
|
||||
CliProviderId,
|
||||
CliProviderModelAvailability,
|
||||
|
|
@ -868,8 +870,9 @@ export class CliInstallerService {
|
|||
// Public: getStatus
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getStatus(): Promise<CliInstallationStatus> {
|
||||
async getStatus(options: CliInstallerGetStatusOptions = {}): Promise<CliInstallationStatus> {
|
||||
const statusStartedAt = Date.now();
|
||||
const providerStatusMode: CliInstallerProviderStatusMode = options.providerStatusMode ?? 'full';
|
||||
const generation = ++this.statusGatherGeneration;
|
||||
const result = this.createInitialStatus();
|
||||
this.latestProviderSignatures.clear();
|
||||
|
|
@ -882,7 +885,7 @@ export class CliInstallerService {
|
|||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
try {
|
||||
await Promise.race([
|
||||
this.gatherStatus(ref, runDiag, generation),
|
||||
this.gatherStatus(ref, runDiag, generation, providerStatusMode),
|
||||
new Promise<void>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
logger.warn(
|
||||
|
|
@ -1023,7 +1026,8 @@ export class CliInstallerService {
|
|||
private async gatherStatus(
|
||||
ref: { current: CliInstallationStatus },
|
||||
diag: CliInstallerStatusRunDiag,
|
||||
generation: number
|
||||
generation: number,
|
||||
providerStatusMode: CliInstallerProviderStatusMode
|
||||
): Promise<void> {
|
||||
resetGatherDiag(diag);
|
||||
const shellEnvStartedAt = Date.now();
|
||||
|
|
@ -1048,6 +1052,14 @@ export class CliInstallerService {
|
|||
r.installedVersion = versionProbe.version;
|
||||
r.launchError = null;
|
||||
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.publishStatusSnapshotIfCurrent(r, generation);
|
||||
|
||||
|
|
@ -1186,6 +1198,28 @@ export class CliInstallerService {
|
|||
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.
|
||||
* Wrapped in its own timeout to prevent slow auth from blocking the overall status.
|
||||
|
|
|
|||
|
|
@ -273,6 +273,7 @@ import type {
|
|||
ClaudeRootFolderSelection,
|
||||
ClaudeRootInfo,
|
||||
CliInstallationStatus,
|
||||
CliInstallerGetStatusOptions,
|
||||
CliInstallerProgress,
|
||||
CliProviderId,
|
||||
ConflictCheckResult,
|
||||
|
|
@ -1554,8 +1555,8 @@ const electronAPI: ElectronAPI = {
|
|||
|
||||
// ===== CLI Installer API =====
|
||||
cliInstaller: {
|
||||
getStatus: async (): Promise<CliInstallationStatus> => {
|
||||
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS);
|
||||
getStatus: async (options?: CliInstallerGetStatusOptions): Promise<CliInstallationStatus> => {
|
||||
return invokeIpcWithResult<CliInstallationStatus>(CLI_INSTALLER_GET_STATUS, options);
|
||||
},
|
||||
getProviderStatus: async (providerId: CliProviderId) => {
|
||||
return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId);
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ export const ExtensionStoreView = (): React.JSX.Element => {
|
|||
useEffect(() => {
|
||||
void refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
providerStatusMode: 'defer',
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,12 @@ import { useStore } from '@renderer/store';
|
|||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
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(): {
|
||||
cliStatus: CliInstallationStatus | null;
|
||||
|
|
@ -37,7 +42,10 @@ export function useCliInstaller(): {
|
|||
codexRuntimeStatus: CodexRuntimeStatus | null;
|
||||
codexRuntimeStatusLoading: boolean;
|
||||
codexRuntimeError: string | null;
|
||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||
bootstrapCliStatus: (options?: {
|
||||
multimodelEnabled?: boolean;
|
||||
providerStatusMode?: CliInstallerProviderStatusMode;
|
||||
}) => Promise<void>;
|
||||
fetchCliStatus: () => Promise<void>;
|
||||
fetchCliProviderStatus: (
|
||||
providerId: CliProviderId,
|
||||
|
|
|
|||
|
|
@ -95,6 +95,7 @@ const TEAM_VISIBLE_IDLE_WATCHDOG_POLL_MS = 10_000;
|
|||
const TEAM_VISIBLE_IDLE_WATCHDOG_STALE_MS = 30_000;
|
||||
const TEAM_MESSAGE_FALLBACK_POLL_MS = 10_000;
|
||||
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']> =
|
||||
new Set(['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying']);
|
||||
export const TEAM_PROCESS_LITE_FANOUT_STORAGE_KEY = 'team:processLiteFanout';
|
||||
|
|
@ -209,6 +210,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
const cleanupFns: (() => void)[] = [];
|
||||
cleanupFns.push(installTeamRefreshFanoutDebugBridge());
|
||||
let cliStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let runtimeStatusTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
useStore.getState().subscribeProvisioningProgress();
|
||||
cleanupFns.push(() => {
|
||||
useStore.getState().unsubscribeProvisioningProgress();
|
||||
|
|
@ -241,19 +243,24 @@ export function initializeNotificationListeners(): () => void {
|
|||
cliStatusTimer = setTimeout(() => {
|
||||
const multimodelEnabled = useStore.getState().appConfig?.general?.multimodelEnabled ?? true;
|
||||
if (multimodelEnabled) {
|
||||
void useStore.getState().bootstrapCliStatus({ multimodelEnabled: true });
|
||||
void useStore
|
||||
.getState()
|
||||
.bootstrapCliStatus({ multimodelEnabled: true, providerStatusMode: 'defer' });
|
||||
} else {
|
||||
void useStore.getState().fetchCliStatus();
|
||||
}
|
||||
cliStatusTimer = null;
|
||||
}, delayMs);
|
||||
}
|
||||
if (api.openCodeRuntime) {
|
||||
void useStore.getState().fetchOpenCodeRuntimeStatus();
|
||||
}
|
||||
if (api.codexRuntime) {
|
||||
void useStore.getState().fetchCodexRuntimeStatus();
|
||||
}
|
||||
runtimeStatusTimer = setTimeout(() => {
|
||||
if (api.openCodeRuntime) {
|
||||
void useStore.getState().fetchOpenCodeRuntimeStatus();
|
||||
}
|
||||
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
|
||||
// to avoid blocking teams/notifications behind a slow repository scan.
|
||||
|
|
@ -267,6 +274,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
})();
|
||||
cleanupFns.push(() => {
|
||||
if (cliStatusTimer) clearTimeout(cliStatusTimer);
|
||||
if (runtimeStatusTimer) clearTimeout(runtimeStatusTimer);
|
||||
});
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -659,7 +659,10 @@ export interface CliInstallerSlice {
|
|||
codexRuntimeError: string | null;
|
||||
|
||||
// Actions
|
||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||
bootstrapCliStatus: (options?: {
|
||||
multimodelEnabled?: boolean;
|
||||
providerStatusMode?: 'full' | 'defer';
|
||||
}) => Promise<void>;
|
||||
fetchCliStatus: () => Promise<void>;
|
||||
fetchCliProviderStatus: (
|
||||
providerId: CliProviderId,
|
||||
|
|
@ -714,13 +717,15 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
bootstrapCliStatus: async (options) => {
|
||||
if (!api.cliInstaller) return;
|
||||
const multimodelEnabled = options?.multimodelEnabled ?? true;
|
||||
const providerStatusMode = options?.providerStatusMode ?? 'full';
|
||||
const hydrateProviders = providerStatusMode !== 'defer';
|
||||
if (!multimodelEnabled) {
|
||||
return get().fetchCliStatus();
|
||||
}
|
||||
|
||||
const epoch = ++cliStatusEpoch;
|
||||
const providerLoading = Object.fromEntries(
|
||||
MULTIMODEL_PROVIDER_IDS.map((providerId) => [providerId, true])
|
||||
MULTIMODEL_PROVIDER_IDS.map((providerId) => [providerId, hydrateProviders])
|
||||
) as Partial<Record<CliProviderId, boolean>>;
|
||||
|
||||
set({
|
||||
|
|
@ -731,7 +736,9 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
});
|
||||
|
||||
try {
|
||||
const metadata = await api.cliInstaller.getStatus();
|
||||
const metadata = await api.cliInstaller.getStatus(
|
||||
providerStatusMode === 'defer' ? { providerStatusMode } : undefined
|
||||
);
|
||||
if (metadata.flavor !== 'agent_teams_orchestrator') {
|
||||
set((state) => {
|
||||
if (epoch !== cliStatusEpoch) {
|
||||
|
|
@ -756,9 +763,10 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
const nextProviderLoading = Object.fromEntries(
|
||||
MULTIMODEL_PROVIDER_IDS.map((providerId) => [
|
||||
providerId,
|
||||
!isHydratedMultimodelProviderStatus(
|
||||
metadata.providers.find((provider) => provider.providerId === providerId)
|
||||
),
|
||||
hydrateProviders &&
|
||||
!isHydratedMultimodelProviderStatus(
|
||||
metadata.providers.find((provider) => provider.providerId === providerId)
|
||||
),
|
||||
])
|
||||
) as Partial<Record<CliProviderId, boolean>>;
|
||||
const pendingProviderIds = MULTIMODEL_PROVIDER_IDS.filter(
|
||||
|
|
@ -800,7 +808,7 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
return;
|
||||
}
|
||||
|
||||
if (pendingProviderIds.length === 0) {
|
||||
if (!hydrateProviders || pendingProviderIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -818,14 +826,16 @@ export const createCliInstallerSlice: StateCreator<AppState, [], [], CliInstalle
|
|||
}
|
||||
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
MULTIMODEL_PROVIDER_IDS.map((providerId) =>
|
||||
get().fetchCliProviderStatus(providerId, {
|
||||
silent: false,
|
||||
epoch,
|
||||
})
|
||||
)
|
||||
);
|
||||
if (hydrateProviders) {
|
||||
await Promise.allSettled(
|
||||
MULTIMODEL_PROVIDER_IDS.map((providerId) =>
|
||||
get().fetchCliProviderStatus(providerId, {
|
||||
silent: false,
|
||||
epoch,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (epoch === cliStatusEpoch) {
|
||||
set({ cliStatusLoading: false });
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
import type { CliInstallerProviderStatusMode } from '@shared/types';
|
||||
|
||||
interface RefreshCliStatusOptions {
|
||||
multimodelEnabled: boolean;
|
||||
bootstrapCliStatus: (options?: { multimodelEnabled?: boolean }) => Promise<void>;
|
||||
providerStatusMode?: CliInstallerProviderStatusMode;
|
||||
bootstrapCliStatus: (options?: {
|
||||
multimodelEnabled?: boolean;
|
||||
providerStatusMode?: CliInstallerProviderStatusMode;
|
||||
}) => Promise<void>;
|
||||
fetchCliStatus: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function refreshCliStatusForCurrentMode({
|
||||
multimodelEnabled,
|
||||
providerStatusMode,
|
||||
bootstrapCliStatus,
|
||||
fetchCliStatus,
|
||||
}: RefreshCliStatusOptions): Promise<void> {
|
||||
if (multimodelEnabled) {
|
||||
return bootstrapCliStatus({ multimodelEnabled: true });
|
||||
return bootstrapCliStatus(
|
||||
providerStatusMode
|
||||
? { multimodelEnabled: true, providerStatusMode }
|
||||
: { multimodelEnabled: true }
|
||||
);
|
||||
}
|
||||
|
||||
return fetchCliStatus();
|
||||
|
|
|
|||
|
|
@ -351,6 +351,16 @@ export interface CliInstallerProgress {
|
|||
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
|
||||
// =============================================================================
|
||||
|
|
@ -360,7 +370,7 @@ export interface CliInstallerProgress {
|
|||
*/
|
||||
export interface CliInstallerAPI {
|
||||
/** Get current CLI installation status */
|
||||
getStatus: () => Promise<CliInstallationStatus>;
|
||||
getStatus: (options?: CliInstallerGetStatusOptions) => Promise<CliInstallationStatus>;
|
||||
/** Get current runtime/auth status for a single provider */
|
||||
getProviderStatus: (providerId: CliProviderId) => Promise<CliProviderStatus | null>;
|
||||
/** Start on-demand model verification for a single runtime provider */
|
||||
|
|
|
|||
|
|
@ -275,6 +275,112 @@ describe('cliInstaller IPC handlers', () => {
|
|||
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 () => {
|
||||
const staleProviderRequest = deferred<CliProviderStatus | null>();
|
||||
service.getStatus
|
||||
|
|
|
|||
|
|
@ -325,6 +325,70 @@ describe('CliInstallerService', () => {
|
|||
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 () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CodexAccountSnapshotDto } from '@features/codex-account/contracts';
|
||||
|
|
@ -346,7 +347,10 @@ describe('ExtensionStoreView provider loading placeholders', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({ multimodelEnabled: true });
|
||||
expect(storeState.bootstrapCliStatus).toHaveBeenCalledWith({
|
||||
multimodelEnabled: true,
|
||||
providerStatusMode: 'defer',
|
||||
});
|
||||
expect(storeState.fetchCliStatus).not.toHaveBeenCalled();
|
||||
expect(storeState.fetchApiKeys).not.toHaveBeenCalled();
|
||||
|
||||
|
|
|
|||
|
|
@ -888,6 +888,67 @@ describe('cliInstallerSlice', () => {
|
|||
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 () => {
|
||||
let resolveCodexStatus!: (value: CliInstallationStatus['providers'][number]) => void;
|
||||
const pendingCodexStatus = new Promise<CliInstallationStatus['providers'][number]>(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
describe('refreshCliStatusForCurrentMode', () => {
|
||||
it('uses provider-first bootstrap when multimodel is enabled', async () => {
|
||||
|
|
@ -17,6 +16,24 @@ describe('refreshCliStatusForCurrentMode', () => {
|
|||
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 () => {
|
||||
const bootstrapCliStatus = vi.fn().mockResolvedValue(undefined);
|
||||
const fetchCliStatus = vi.fn().mockResolvedValue(undefined);
|
||||
|
|
|
|||
Loading…
Reference in a new issue