car-multi 0.22.1

Multi-agent coordination patterns for Common Agent Runtime
Documentation
//! 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.
    pub base: PathBuf,
    pub mode: WorkspaceMode,
}

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

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

/// 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_root = git_repo_root(&config.base).ok_or_else(|| {
                    format!(
                        "git_worktree workspace requires {} to be inside a git repo",
                        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 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);
    }
}