car-multi 0.24.0

Multi-agent coordination patterns for Common Agent Runtime
//! Per-agent filesystem workspace isolation.
//!
//! [`task_context`](crate::task_context) isolates an agent's **state** (the
//! key/value store). This module isolates its **filesystem**: when parallel
//! agents mutate files, giving each its own working directory prevents them from
//! clobbering one another — the file-level analogue of the blog's
//! `isolation: 'worktree'`.
//!
//! ## What the runtime can and can't do
//!
//! CAR doesn't own process execution — the caller's `AgentRunner` runs the tools.
//! So the runtime *provisions* an isolated directory (or git worktree) and
//! *advertises* its path to the agent via `AgentSpec.metadata["workspace"]`; the
//! runner is responsible for actually running its file tools relative to that
//! path. The runtime guarantees provisioning and cleanup (RAII); honoring the
//! path is a cooperative contract with the runner. This is the honest boundary
//! for a runtime that validates and orchestrates but does not itself exec.

use crate::types::AgentSpec;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::{Path, PathBuf};

/// Metadata key under which a provisioned workspace path is advertised to the
/// agent runner.
pub const WORKSPACE_METADATA_KEY: &str = "workspace";

/// How to provision a per-agent workspace.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceMode {
    /// A plain empty directory per agent. No VCS; cheapest.
    Directory,
    /// A `git worktree` checked out at `base`'s HEAD, so each agent edits an
    /// isolated copy of the repository. Requires `base` to be inside a git repo
    /// and a `git` binary on PATH; falls back to an error if either is missing.
    GitWorktree,
}

/// Configuration for per-agent workspace provisioning.
#[derive(Debug, Clone)]
pub struct WorkspaceConfig {
    /// Base directory under which per-agent workspaces are created. For
    /// `GitWorktree` this must be inside (or be) a git working tree, unless
    /// `repo` names the repository explicitly.
    pub base: PathBuf,
    pub mode: WorkspaceMode,
    /// For `GitWorktree`: the repository to check worktrees out of, when it
    /// differs from `base`. `None` derives the repo from `base` (the original
    /// behavior, where worktrees land inside the repo itself). Setting this
    /// lets worktrees live *outside* the repository — e.g. under a state dir —
    /// so they never show up as untracked entries in the user's checkout.
    pub repo: Option<PathBuf>,
}

impl WorkspaceConfig {
    pub fn directory(base: impl Into<PathBuf>) -> Self {
        Self {
            base: base.into(),
            mode: WorkspaceMode::Directory,
            repo: None,
        }
    }

    pub fn git_worktree(base: impl Into<PathBuf>) -> Self {
        Self {
            base: base.into(),
            mode: WorkspaceMode::GitWorktree,
            repo: None,
        }
    }

    /// Git worktrees of `repo`, created under `base` (which may be anywhere on
    /// the filesystem, e.g. `~/.car/coder/worktrees`).
    pub fn git_worktree_at(repo: impl Into<PathBuf>, base: impl Into<PathBuf>) -> Self {
        Self {
            base: base.into(),
            mode: WorkspaceMode::GitWorktree,
            repo: Some(repo.into()),
        }
    }
}

/// Sanitize an agent name into a single safe path segment. Non-`[A-Za-z0-9_-]`
/// chars (including `.` and `/`) collapse to `-`, so no traversal or separator
/// can escape `base`. Note: distinct names can collide after sanitization (e.g.
/// `a/b` and `a-b`), sharing a workspace — keep agent names distinct under this
/// mapping when isolation matters.
fn sanitize(name: &str) -> String {
    let s: String = name
        .chars()
        .map(|c| {
            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
                c
            } else {
                '-'
            }
        })
        .collect();
    if s.is_empty() {
        "agent".to_string()
    } else {
        s
    }
}

/// An RAII handle to a provisioned per-agent workspace. The directory (or git
/// worktree) is removed when this is dropped.
#[derive(Debug)]
pub struct AgentWorkspace {
    path: PathBuf,
    mode: WorkspaceMode,
    /// The git repo root, for `git worktree remove` on drop (GitWorktree only).
    repo_root: Option<PathBuf>,
}

impl AgentWorkspace {
    /// Provision an isolated workspace for `agent_name` under `config.base`.
    pub fn provision(config: &WorkspaceConfig, agent_name: &str) -> Result<Self, String> {
        let path = config.base.join(sanitize(agent_name));
        match config.mode {
            WorkspaceMode::Directory => {
                std::fs::create_dir_all(&path)
                    .map_err(|e| format!("create workspace dir {}: {e}", path.display()))?;
                Ok(Self {
                    path,
                    mode: WorkspaceMode::Directory,
                    repo_root: None,
                })
            }
            WorkspaceMode::GitWorktree => {
                use std::ffi::OsStr;
                let repo_hint = config.repo.as_ref().unwrap_or(&config.base);
                let repo_root = git_repo_root(repo_hint).ok_or_else(|| {
                    format!(
                        "git_worktree workspace requires {} to be inside a git repo",
                        repo_hint.display()
                    )
                })?;
                std::fs::create_dir_all(&config.base)
                    .map_err(|e| format!("create workspace base {}: {e}", config.base.display()))?;
                // Self-heal against a worktree leaked by a prior run that didn't
                // get to clean up (process/runtime teardown): drop any stale
                // registration for this exact path, prune dangling entries, and
                // clear the directory before adding.
                let _ = run_git(
                    &repo_root,
                    &[
                        OsStr::new("worktree"),
                        OsStr::new("remove"),
                        OsStr::new("--force"),
                        path.as_os_str(),
                    ],
                );
                let _ = run_git(&repo_root, &[OsStr::new("worktree"), OsStr::new("prune")]);
                if path.exists() {
                    let _ = std::fs::remove_dir_all(&path);
                }
                run_git(
                    &repo_root,
                    &[
                        OsStr::new("worktree"),
                        OsStr::new("add"),
                        OsStr::new("--detach"),
                        path.as_os_str(),
                        OsStr::new("HEAD"),
                    ],
                )?;
                Ok(Self {
                    path,
                    mode: WorkspaceMode::GitWorktree,
                    repo_root: Some(repo_root),
                })
            }
        }
    }

    /// The provisioned workspace path.
    pub fn path(&self) -> &Path {
        &self.path
    }

    /// Return `spec` with this workspace's path advertised in its metadata.
    pub fn inject(&self, mut spec: AgentSpec) -> AgentSpec {
        spec.metadata.insert(
            WORKSPACE_METADATA_KEY.to_string(),
            Value::String(self.path.to_string_lossy().into_owned()),
        );
        spec
    }
}

impl Drop for AgentWorkspace {
    fn drop(&mut self) {
        match self.mode {
            WorkspaceMode::Directory => {
                let _ = std::fs::remove_dir_all(&self.path);
            }
            WorkspaceMode::GitWorktree => {
                if let Some(root) = &self.repo_root {
                    use std::ffi::OsStr;
                    // Best-effort: detach the worktree, then remove the dir.
                    let _ = run_git(
                        root,
                        &[
                            OsStr::new("worktree"),
                            OsStr::new("remove"),
                            OsStr::new("--force"),
                            self.path.as_os_str(),
                        ],
                    );
                    let _ = std::fs::remove_dir_all(&self.path);
                }
            }
        }
    }
}

/// Find the git working-tree root containing `dir`, if any.
fn git_repo_root(dir: &Path) -> Option<PathBuf> {
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(["rev-parse", "--show-toplevel"])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let root = String::from_utf8(out.stdout).ok()?.trim().to_string();
    if root.is_empty() {
        None
    } else {
        Some(PathBuf::from(root))
    }
}

fn run_git(repo_root: &Path, args: &[&std::ffi::OsStr]) -> Result<(), String> {
    let out = std::process::Command::new("git")
        .arg("-C")
        .arg(repo_root)
        .args(args)
        .output()
        .map_err(|e| format!("git {:?}: {e}", args))?;
    if out.status.success() {
        Ok(())
    } else {
        Err(format!(
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&out.stderr).trim()
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn unique_base(tag: &str) -> PathBuf {
        // Avoid Math.random/Date in tests; use pid + a static counter.
        use std::sync::atomic::{AtomicU64, Ordering};
        static N: AtomicU64 = AtomicU64::new(0);
        let n = N.fetch_add(1, Ordering::Relaxed);
        std::env::temp_dir().join(format!("car-ws-{tag}-{}-{n}", std::process::id()))
    }

    #[test]
    fn directory_workspace_is_created_injected_and_cleaned() {
        let base = unique_base("dir");
        let cfg = WorkspaceConfig::directory(&base);
        let path;
        {
            let ws = AgentWorkspace::provision(&cfg, "alice/../x").unwrap();
            path = ws.path().to_path_buf();
            assert!(path.exists() && path.is_dir());
            // Sanitized: no path traversal segments survive.
            assert_eq!(path.parent().unwrap(), base);
            assert!(!path.to_string_lossy().contains(".."));

            let spec = ws.inject(AgentSpec::new("alice", "sys"));
            assert_eq!(
                spec.metadata.get(WORKSPACE_METADATA_KEY).unwrap(),
                &Value::String(path.to_string_lossy().into_owned())
            );
        }
        // Dropped → cleaned up.
        assert!(!path.exists(), "workspace should be removed on drop");
        let _ = std::fs::remove_dir_all(&base);
    }

    #[test]
    fn git_worktree_at_provisions_outside_the_repo() {
        // Skip silently when git is unavailable (mirrors CI environments
        // without a git binary; the mode itself errors clearly there).
        if std::process::Command::new("git").arg("--version").output().is_err() {
            return;
        }
        let repo = unique_base("repo");
        std::fs::create_dir_all(&repo).unwrap();
        for args in [
            vec!["init", "-q"],
            vec!["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "init"],
        ] {
            let out = std::process::Command::new("git")
                .arg("-C")
                .arg(&repo)
                .args(&args)
                .output()
                .unwrap();
            assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
        }

        let base = unique_base("wt-base");
        let cfg = WorkspaceConfig::git_worktree_at(&repo, &base);
        let path;
        {
            let ws = AgentWorkspace::provision(&cfg, "session-1").unwrap();
            path = ws.path().to_path_buf();
            assert!(path.starts_with(&base), "worktree must live under base, not the repo");
            assert!(path.join(".git").exists(), "worktree checkout expected");
            // The repo's status stays clean — the worktree is elsewhere.
            let out = std::process::Command::new("git")
                .arg("-C")
                .arg(&repo)
                .args(["status", "--porcelain"])
                .output()
                .unwrap();
            assert!(out.stdout.is_empty(), "repo status must stay clean");
        }
        assert!(!path.exists(), "worktree removed on drop");
        let _ = std::fs::remove_dir_all(&base);
        let _ = std::fs::remove_dir_all(&repo);
    }

    #[test]
    fn distinct_agents_get_distinct_dirs() {
        let base = unique_base("distinct");
        let cfg = WorkspaceConfig::directory(&base);
        let a = AgentWorkspace::provision(&cfg, "a").unwrap();
        let b = AgentWorkspace::provision(&cfg, "b").unwrap();
        assert_ne!(a.path(), b.path());
        drop(a);
        drop(b);
        let _ = std::fs::remove_dir_all(&base);
    }
}