ai-dispatch 10.31.0

Multi-AI CLI team orchestrator
// Cargo target cleanup for `aid clean --worktrees` and custody GC.
// Exports task-owned target discovery and cleanup for terminal tasks.
// Deps: Store task records, agent target layout, build fallback paths, std fs.

use anyhow::Result;
use crate::store::Store;
use crate::types::{Task, TaskFilter};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};

const BASE_TARGET_DIR_NAME: &str = "_base";

#[derive(Debug, Clone, Copy)]
enum TargetKind {
    Branch,
    Fallback,
}

impl TargetKind {
    fn label(self) -> &'static str {
        match self {
            Self::Branch => "Cargo target",
            Self::Fallback => "fallback target",
        }
    }
}

fn cwd_no_longer_exists(cwd: &Path) -> bool {
    if !cwd.is_absolute() {
        return false;
    }
    match fs::symlink_metadata(cwd) {
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => true,
        _ => false,
    }
}

pub(crate) fn clean_orphaned_branch_targets(
    store: &Store,
    dry_run: bool,
    fallback_root: Option<&Path>,
    sizes: &mut crate::cmd::clean_size::SizeTracker,
) -> Result<u64> {
    let targets = owned_target_dirs(store, fallback_root)?;
    let (mut bytes, mut removed) = (0, 0);
    for (kind, target) in targets {
        if !is_safe_target_for_removal(&target) {
            continue;
        }
        let size = sizes.get_dir_size(&target)?;
        if dry_run {
            println!(
                "[dry-run] Would remove terminal task-owned {} {} ({})",
                kind.label(),
                target.display(),
                crate::cmd::clean::format_bytes(size)
            );
            bytes += size;
            removed += 1;
        } else if fs::remove_dir_all(&target).is_ok() {
            println!(
                "Removed terminal task-owned {} {} ({})",
                kind.label(),
                target.display(),
                crate::cmd::clean::format_bytes(size)
            );
            bytes += size;
            removed += 1;
        }
    }
    if removed > 0 || dry_run {
        println!(
            "{} {removed} terminal task-owned target dirs ({})",
            if dry_run { "[dry-run] Would remove" } else { "Removed" },
            crate::cmd::clean::format_bytes(bytes)
        );
    }
    let fallback_root_buf = fallback_root.map(Path::to_path_buf).unwrap_or_else(crate::cmd::build::build_fallback::fallback_target_root);
    let skipped_count = count_skipped_fallback_targets(store, &fallback_root_buf)?;
    if removed == 0 && skipped_count > 0 {
        println!(
            "{}Skipped {skipped_count} fallback target dirs (source cwd still exists)",
            if dry_run { "[dry-run] " } else { "" }
        );
    }
    Ok(bytes)
}

pub(crate) fn count_skipped_fallback_targets(store: &Store, fallback_root: &Path) -> Result<usize> {
    if !fallback_root.is_dir() {
        return Ok(0);
    }
    let tasks = store.list_tasks(TaskFilter::All)?;
    let mut skipped = HashSet::new();
    for task in &tasks {
        for (cwd, path) in task_fallback_target_paths(task, fallback_root) {
            if path.is_dir() && !cwd_no_longer_exists(&cwd) && is_safe_fallback_target_for_removal(&path, fallback_root) {
                skipped.insert(path);
            }
        }
    }
    Ok(skipped.len())
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ReclaimableSpaceEstimate {
    pub(crate) bytes: u64,
    pub(crate) truncated: bool,
}

pub(crate) fn has_reclaimable_space_above_threshold(store: &Store) -> Result<Option<ReclaimableSpaceEstimate>> {
    has_reclaimable_space_above_threshold_with_entry_limit(store, crate::cmd::clean_size::CLEANUP_HINT_ENTRY_LIMIT)
}

fn has_reclaimable_space_above_threshold_with_entry_limit(store: &Store, entry_limit: usize) -> Result<Option<ReclaimableSpaceEstimate>> {
    let mut sizes = crate::cmd::clean_size::SizeTracker::new();
    let (mut bytes, mut entries, mut truncated) = (0, 0, false);
    for path in owned_target_dirs(store, None)?.into_iter().map(|(_, p)| p).chain(terminal_task_homes(store)?) {
        if bytes >= crate::cmd::clean_size::CLEANUP_HINT_BYTE_LIMIT || entries >= entry_limit {
            truncated = true;
            break;
        }
        let scan = scan_hint_path(&mut sizes, &path, crate::cmd::clean_size::CLEANUP_HINT_BYTE_LIMIT - bytes, entry_limit - entries)?;
        bytes += scan.bytes;
        entries += scan.entries;
        if scan.truncated {
            truncated = true;
            break;
        }
    }
    if truncated || bytes >= crate::cmd::clean_size::CLEANUP_HINT_THRESHOLD_BYTES {
        Ok(Some(ReclaimableSpaceEstimate { bytes, truncated }))
    } else {
        Ok(None)
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct HintScan {
    bytes: u64,
    entries: usize,
    truncated: bool,
}

fn scan_hint_path(sizes: &mut crate::cmd::clean_size::SizeTracker, path: &Path, byte_limit: u64, entry_limit: usize) -> Result<HintScan> {
    let (bytes, entries) = sizes.get_dir_size_bounded(path, byte_limit, entry_limit)?;
    Ok(HintScan { bytes, entries, truncated: entries >= entry_limit || bytes >= byte_limit })
}

pub(crate) fn terminal_task_ids(store: &Store) -> Result<Vec<String>> {
    Ok(store.list_tasks(TaskFilter::All)?.into_iter().filter(|t| t.status.is_terminal()).map(|t| t.id.0).collect())
}

fn terminal_task_homes(store: &Store) -> Result<Vec<PathBuf>> {
    Ok(terminal_task_ids(store)?.into_iter().map(|id| crate::paths::task_dir(&id).join("home")).filter(|p| p.is_dir()).collect())
}

fn owned_target_dirs(store: &Store, fallback_root: Option<&Path>) -> Result<Vec<(TargetKind, PathBuf)>> {
    let tasks = store.list_tasks(TaskFilter::All)?;
    let mut terminal_candidates = Vec::new();
    let mut blocked = HashSet::new();
    let branch_root = crate::agent::env::branch_target_root();
    let fallback_root = fallback_root.map(Path::to_path_buf).unwrap_or_else(crate::cmd::build::build_fallback::fallback_target_root);

    for task in &tasks {
        let is_unblocked_terminal = task.status.is_terminal() && !has_live_worktree(task);
        if let (Some(root), Some(branch)) = (branch_root.as_deref(), task.worktree_branch.as_deref().filter(|b| !b.trim().is_empty())) {
            let name = crate::agent::env::branch_target_name(branch);
            if !is_reserved_target_dir_name(&name) {
                let path = root.join(name);
                if is_unblocked_terminal {
                    terminal_candidates.push((TargetKind::Branch, path));
                } else {
                    blocked.insert(path);
                }
            }
        }
        for (cwd, path) in task_fallback_target_paths(task, &fallback_root) {
            if is_unblocked_terminal && cwd_no_longer_exists(&cwd) {
                terminal_candidates.push((TargetKind::Fallback, path));
            } else {
                blocked.insert(path);
            }
        }
    }

    let mut result = Vec::new();
    let mut seen = HashSet::new();
    for (kind, path) in terminal_candidates {
        if blocked.contains(&path) || !seen.insert(path.clone()) || !path.is_dir() {
            continue;
        }
        let safe = match kind {
            TargetKind::Branch => is_safe_target_for_removal(&path),
            TargetKind::Fallback => is_safe_fallback_target_for_removal(&path, &fallback_root),
        };
        if safe {
            result.push((kind, path));
        }
    }
    Ok(result)
}

pub(crate) fn remove_task_fallback_target_dirs(store: &Store, task: &Task) -> Result<u64> {
    let fallback_root = crate::cmd::build::build_fallback::fallback_target_root();
    if !fallback_root.is_dir() {
        return Ok(0);
    }
    let tasks = store.list_tasks(TaskFilter::All)?;
    let mut blocked = HashSet::new();
    for other in &tasks {
        if other.id != task.id && (!other.status.is_terminal() || has_live_worktree(other)) {
            for (_, path) in task_fallback_target_paths(other, &fallback_root) {
                blocked.insert(path);
            }
        }
    }
    let mut reclaimed = 0;
    for (cwd, path) in task_fallback_target_paths(task, &fallback_root) {
        if cwd_no_longer_exists(&cwd) && is_safe_fallback_target_for_removal(&path, &fallback_root) && !blocked.contains(&path) {
            let size = crate::cmd::clean_size::get_dir_size(&path).unwrap_or(0);
            if fs::remove_dir_all(&path).is_ok() {
                reclaimed += size;
            }
        }
    }
    Ok(reclaimed)
}

fn task_fallback_target_paths(task: &Task, fallback_root: &Path) -> Vec<(PathBuf, PathBuf)> {
    let mut paths = Vec::new();
    for cwd in [task.worktree_path.as_deref(), task.repo_path.as_deref(), task.effective_dir.as_deref()].into_iter().flatten() {
        if !cwd.trim().is_empty() {
            let cwd_path = PathBuf::from(cwd);
            let target = fallback_root.join(crate::cmd::build::build_fallback::cwd_key(&cwd_path));
            paths.push((cwd_path, target));
        }
    }
    paths
}

fn is_safe_fallback_target_for_removal(path: &Path, fallback_root: &Path) -> bool {
    if !path.is_dir() || path.parent() != Some(fallback_root) {
        return false;
    }
    if fs::symlink_metadata(path).map(|m| m.file_type().is_symlink()).unwrap_or(true) {
        return false;
    }
    is_safe_target_for_removal(path)
}

fn is_safe_target_for_removal(target: &Path) -> bool {
    if crate::agent::env::branch_target_root().is_some_and(|root| root == target) {
        return false;
    }
    if std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).is_some_and(|root| root == target) {
        return false;
    }
    true
}

fn has_live_worktree(task: &Task) -> bool {
    task.worktree_branch.is_some() && task.worktree_path.as_deref().is_some_and(|path| Path::new(path).is_dir())
}

fn is_reserved_target_dir_name(name: &str) -> bool {
    name == BASE_TARGET_DIR_NAME || name.starts_with('.') || matches!(name, "debug" | "release" | "tmp" | "doc" | "package")
}

#[cfg(test)]
#[path = "clean_cargo_target_tests.rs"]
mod tests;