acp: Install new versions of agent binaries in the background (#37141)

Cole Miller and Conrad Irwin created

Release Notes:

- acp: New releases of external agents are now installed in the
background.

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/agent_servers/src/agent_servers.rs | 186 ++++++++++++++++++------
1 file changed, 139 insertions(+), 47 deletions(-)

Detailed changes

crates/agent_servers/src/agent_servers.rs đź”—

@@ -7,20 +7,24 @@ mod settings;
 #[cfg(any(test, feature = "test-support"))]
 pub mod e2e_tests;
 
+use anyhow::Context as _;
 pub use claude::*;
 pub use custom::*;
+use fs::Fs;
+use fs::RemoveOptions;
+use fs::RenameOptions;
+use futures::StreamExt as _;
 pub use gemini::*;
+use gpui::AppContext;
+use node_runtime::NodeRuntime;
 pub use settings::*;
 
 use acp_thread::AgentConnection;
 use acp_thread::LoadError;
 use anyhow::Result;
 use anyhow::anyhow;
-use anyhow::bail;
 use collections::HashMap;
-use gpui::AppContext as _;
 use gpui::{App, AsyncApp, Entity, SharedString, Task};
-use node_runtime::VersionStrategy;
 use project::Project;
 use schemars::JsonSchema;
 use semver::Version;
@@ -64,70 +68,158 @@ impl AgentServerDelegate {
         let project = self.project;
         let fs = project.read(cx).fs().clone();
         let Some(node_runtime) = project.read(cx).node_runtime().cloned() else {
-            return Task::ready(Err(anyhow!("Missing node runtime")));
+            return Task::ready(Err(anyhow!(
+                "External agents are not yet available in remote projects."
+            )));
         };
         let mut status_tx = self.status_tx;
 
         cx.spawn(async move |cx| {
             if !ignore_system_version {
                 if let Some(bin) = find_bin_in_path(binary_name.clone(), &project, cx).await {
-                    return Ok(AgentServerCommand { path: bin, args: Vec::new(), env: Default::default() })
+                    return Ok(AgentServerCommand {
+                        path: bin,
+                        args: Vec::new(),
+                        env: Default::default(),
+                    });
                 }
             }
 
-            cx.background_spawn(async move {
+            cx.spawn(async move |cx| {
                 let node_path = node_runtime.binary_path().await?;
-                let dir = paths::data_dir().join("external_agents").join(binary_name.as_str());
+                let dir = paths::data_dir()
+                    .join("external_agents")
+                    .join(binary_name.as_str());
                 fs.create_dir(&dir).await?;
-                let local_executable_path = dir.join(entrypoint_path);
-                let command = AgentServerCommand {
-                    path: node_path,
-                    args: vec![local_executable_path.to_string_lossy().to_string()],
-                    env: Default::default(),
-                };
 
-                let installed_version = node_runtime
-                    .npm_package_installed_version(&dir, &package_name)
-                    .await?
-                    .filter(|version| {
-                        Version::from_str(&version)
-                            .is_ok_and(|version| Some(version) >= minimum_version)
-                    });
+                let mut stream = fs.read_dir(&dir).await?;
+                let mut versions = Vec::new();
+                let mut to_delete = Vec::new();
+                while let Some(entry) = stream.next().await {
+                    let Ok(entry) = entry else { continue };
+                    let Some(file_name) = entry.file_name() else {
+                        continue;
+                    };
+
+                    if let Some(version) = file_name
+                        .to_str()
+                        .and_then(|name| semver::Version::from_str(&name).ok())
+                    {
+                        versions.push((file_name.to_owned(), version));
+                    } else {
+                        to_delete.push(file_name.to_owned())
+                    }
+                }
 
-                status_tx.send("Checking for latest version…".into())?;
-                let latest_version = match node_runtime.npm_package_latest_version(&package_name).await
+                versions.sort();
+                let newest_version = if let Some((file_name, version)) = versions.last().cloned()
+                    && minimum_version.is_none_or(|minimum_version| version > minimum_version)
                 {
-                    Ok(latest_version) => latest_version,
-                    Err(e) => {
-                        if let Some(installed_version) = installed_version {
-                            log::error!("{e}");
-                            log::warn!("failed to fetch latest version of {package_name}, falling back to cached version {installed_version}");
-                            return Ok(command);
-                        } else {
-                            bail!(e);
+                    versions.pop();
+                    Some(file_name)
+                } else {
+                    None
+                };
+                to_delete.extend(versions.into_iter().map(|(file_name, _)| file_name));
+
+                cx.background_spawn({
+                    let fs = fs.clone();
+                    let dir = dir.clone();
+                    async move {
+                        for file_name in to_delete {
+                            fs.remove_dir(
+                                &dir.join(file_name),
+                                RemoveOptions {
+                                    recursive: true,
+                                    ignore_if_not_exists: false,
+                                },
+                            )
+                            .await
+                            .ok();
                         }
                     }
+                })
+                .detach();
+
+                let version = if let Some(file_name) = newest_version {
+                    cx.background_spawn({
+                        let file_name = file_name.clone();
+                        let dir = dir.clone();
+                        async move {
+                            let latest_version =
+                                node_runtime.npm_package_latest_version(&package_name).await;
+                            if let Ok(latest_version) = latest_version
+                                && &latest_version != &file_name.to_string_lossy()
+                            {
+                                Self::download_latest_version(
+                                    fs,
+                                    dir.clone(),
+                                    node_runtime,
+                                    package_name,
+                                )
+                                .await
+                                .log_err();
+                            }
+                        }
+                    })
+                    .detach();
+                    file_name
+                } else {
+                    status_tx.send("Installing…".into()).ok();
+                    let dir = dir.clone();
+                    cx.background_spawn(Self::download_latest_version(
+                        fs,
+                        dir.clone(),
+                        node_runtime,
+                        package_name,
+                    ))
+                    .await?
+                    .into()
                 };
+                anyhow::Ok(AgentServerCommand {
+                    path: node_path,
+                    args: vec![
+                        dir.join(version)
+                            .join(entrypoint_path)
+                            .to_string_lossy()
+                            .to_string(),
+                    ],
+                    env: Default::default(),
+                })
+            })
+            .await
+            .map_err(|e| LoadError::FailedToInstall(e.to_string().into()).into())
+        })
+    }
 
-                let should_install = node_runtime
-                    .should_install_npm_package(
-                        &package_name,
-                        &local_executable_path,
-                        &dir,
-                        VersionStrategy::Latest(&latest_version),
-                    )
-                    .await;
+    async fn download_latest_version(
+        fs: Arc<dyn Fs>,
+        dir: PathBuf,
+        node_runtime: NodeRuntime,
+        package_name: SharedString,
+    ) -> Result<String> {
+        let tmp_dir = tempfile::tempdir_in(&dir)?;
 
-                if should_install {
-                    status_tx.send("Installing latest version…".into())?;
-                    node_runtime
-                        .npm_install_packages(&dir, &[(&package_name, &latest_version)])
-                        .await?;
-                }
+        node_runtime
+            .npm_install_packages(tmp_dir.path(), &[(&package_name, "latest")])
+            .await?;
 
-                Ok(command)
-            }).await.map_err(|e| LoadError::FailedToInstall(e.to_string().into()).into())
-        })
+        let version = node_runtime
+            .npm_package_installed_version(tmp_dir.path(), &package_name)
+            .await?
+            .context("expected package to be installed")?;
+
+        fs.rename(
+            &tmp_dir.keep(),
+            &dir.join(&version),
+            RenameOptions {
+                ignore_if_exists: true,
+                overwrite: false,
+            },
+        )
+        .await?;
+
+        anyhow::Ok(version)
     }
 }