use anyhow::Result;
use chrono::Local;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::{agent, paths, session};
use crate::project::{self, ProjectConfig};
use crate::store::{Store, TaskCompletionUpdate};
use crate::types::*;
use super::run_dispatch_resolve::{apply_project_defaults, resolve_agent_setup};
use super::run_validate::{IdConflict, resolve_id_conflict, validate_dispatch};
use super::{RunArgs, context_file_from_spec, resolve_max_duration_mins, resolve_prompt_input, run_prompt};
pub(super) struct PreparedDispatch {
pub detected_project: Option<ProjectConfig>,
pub agent_kind: AgentKind,
pub agent_display_name: String,
pub requested_skills: Vec<String>,
pub effective_model: Option<String>,
pub budget_active: bool,
pub agent: Box<dyn agent::Agent>,
pub task_id: TaskId,
pub task: Task,
pub log_path: PathBuf,
pub workgroup: Option<Workgroup>,
pub repo_path: Option<String>,
pub wt_path: Option<String>,
pub effective_dir: Option<String>,
}
fn stale_worktree_dir_error(dir: &str, branch: Option<&str>) -> String {
branch.map(|branch| format!("batch file / task dir missing in worktree: {dir} - workgroup state is stale, run aid worktree remove {branch} and retry"))
.unwrap_or_else(|| format!("working directory does not exist: {dir}"))
}
pub(super) fn prepare_dispatch(store: &Arc<Store>, args: &mut RunArgs) -> Result<PreparedDispatch> {
args.prompt = resolve_prompt_input(&args.prompt, args.prompt_file.as_deref())?;
args.prompt_file = None;
args.max_duration_mins = resolve_max_duration_mins(args.timeout, args.max_duration_mins);
let had_explicit_result_file = args.result_file.is_some();
let detected_project = project::detect_project();
apply_project_defaults(args, detected_project.as_ref());
let agent_setup = resolve_agent_setup(store, args)?;
let mut task_id = match args.existing_task_id.clone() {
Some(id) => {
crate::sanitize::validate_task_id(id.as_str())?;
id
}
None => TaskId::generate(),
};
let log_path = paths::log_path(task_id.as_str());
let workgroup = run_prompt::load_workgroup(store, args.group.as_deref())?;
let explicit_repo_path = crate::repo_root::resolve_explicit_repo_path(args.repo_root.as_deref(), args.repo.as_deref())?;
let caller = session::current_caller();
let worktree_setup = (|| -> Result<_> {
let (wt_path, wt_branch, effective_dir, resolved_repo, fresh_worktree) =
run_prompt::resolve_worktree_paths(args, explicit_repo_path.as_deref())?;
let repo_path = resolved_repo.clone().or(explicit_repo_path.clone());
let mut emit_gitbutler_setup_hint = false;
if let Some(ref wt) = wt_path {
if let Err(holder) = crate::worktree::try_acquire_worktree_lock(Path::new(wt), task_id.as_str()) {
anyhow::bail!("Worktree {wt} is locked by task {holder} — concurrent access prevented. Use separate worktree names for parallel tasks.");
}
}
if let Some(ref wt) = wt_path
&& std::env::var("AID_GITBUTLER").map(|value| value != "0").unwrap_or(true)
&& let Some(ref project) = detected_project
&& let Some(repo) = repo_path.as_deref()
{
let worktree = Path::new(wt);
let plan = crate::gitbutler::task_worktree_integration_plan(
Path::new(repo),
worktree,
project.gitbutler_mode(),
agent_setup.agent_kind.as_str(),
);
emit_gitbutler_setup_hint = plan.emit_setup_hint;
if plan.install_claude_hooks {
if let Err(err) = crate::gitbutler::install_claude_hooks(worktree) {
aid_warn!("[aid] gitbutler: failed to install claude hooks: {err}");
}
} else if let Some(command) = plan.on_done_command {
args.on_done = Some(match args.on_done.take() {
Some(existing) if !existing.trim().is_empty() => format!("{existing} && {command}"),
_ => command,
});
}
}
if let (Some(wt), Some(repo)) = (wt_path.as_deref(), repo_path.as_deref()) {
let context_files: Vec<String> = args.context.iter().map(|spec| context_file_from_spec(spec)).collect();
let synced = crate::worktree::sync_context_files_into_worktree(
Path::new(repo),
Path::new(wt),
&context_files,
);
if !synced.is_empty() {
aid_info!(
"[aid] Synced {} context file(s) into worktree: {}",
synced.len(),
synced.join(", ")
);
}
}
if let (Some(_), Some(dir)) = (wt_path.as_deref(), effective_dir.as_deref()) && !Path::new(dir).is_dir() {
anyhow::bail!(
"{}",
stale_worktree_dir_error(dir, wt_branch.as_deref().or(args.worktree.as_deref()))
);
}
Ok((wt_path, wt_branch, effective_dir, repo_path, fresh_worktree, emit_gitbutler_setup_hint))
})();
let (wt_path, wt_branch, effective_dir, repo_path, fresh_worktree, emit_gitbutler_setup_hint) = match worktree_setup {
Ok(paths) => paths,
Err(err) => {
let failed_task = Task {
id: task_id.clone(),
agent: agent_setup.agent_kind,
custom_agent_name: agent_setup.custom_agent_name.clone(),
prompt: args.prompt.clone(),
resolved_prompt: None,
category: None,
status: TaskStatus::Failed,
parent_task_id: args.parent_task_id.clone(),
workgroup_id: args.group.clone(),
caller_kind: caller.as_ref().map(|item| item.kind.clone()),
caller_session_id: caller.as_ref().map(|item| item.session_id.clone()),
agent_session_id: None,
repo_path: explicit_repo_path.clone(),
worktree_path: None,
worktree_branch: args.worktree.clone(),
start_sha: None,
log_path: Some(log_path.to_string_lossy().to_string()),
output_path: args.output.clone(),
tokens: None,
prompt_tokens: None,
duration_ms: Some(0),
model: agent_setup.effective_model.clone(),
cost_usd: None,
exit_code: None,
created_at: Local::now(),
completed_at: Some(Local::now()),
verify: args.verify.clone(),
verify_status: VerifyStatus::Skipped,
pending_reason: None,
read_only: args.read_only,
budget: args.budget,
audit_verdict: None,
audit_report_path: None,
delivery_assessment: None,
};
let _ = store.insert_task(&failed_task);
run_prompt::insert_phase_error_event(
store,
&task_id,
"worktree setup",
&err.to_string(),
None,
);
return Err(err);
}
};
let mut task = Task {
id: task_id.clone(),
agent: agent_setup.agent_kind,
custom_agent_name: agent_setup.custom_agent_name.clone(),
prompt: args.prompt.clone(),
resolved_prompt: None,
category: None,
status: TaskStatus::Pending,
parent_task_id: args.parent_task_id.clone(),
workgroup_id: args.group.clone(),
caller_kind: caller.as_ref().map(|item| item.kind.clone()),
caller_session_id: caller.as_ref().map(|item| item.session_id.clone()),
agent_session_id: None,
repo_path: repo_path.clone(),
worktree_path: wt_path.clone(),
worktree_branch: wt_branch,
start_sha: None,
log_path: Some(log_path.to_string_lossy().to_string()),
output_path: args.output.clone(),
tokens: None,
prompt_tokens: None,
duration_ms: None,
model: agent_setup.effective_model.clone(),
cost_usd: None,
exit_code: None,
created_at: Local::now(),
completed_at: None,
verify: args.verify.clone(),
verify_status: VerifyStatus::Skipped,
pending_reason: None,
read_only: args.read_only,
budget: args.budget,
audit_verdict: None,
audit_report_path: None,
delivery_assessment: None,
};
let normalized_prompt = task.prompt.trim().to_lowercase();
let profile = agent::classifier::classify(
&task.prompt,
agent::classifier::count_file_mentions(&normalized_prompt),
task.prompt.chars().count(),
);
let auto_result_file = crate::cmd::report_mode::apply_defaults(args, profile.category)
&& !had_explicit_result_file
&& args.output.is_none()
&& args.result_file.as_deref() == Some(crate::cmd::report_mode::DEFAULT_AUDIT_RESULT_FILE);
task.category = Some(profile.category.label().to_string());
for warning in validate_dispatch(args, &agent_setup.agent_kind) {
aid_warn!("[aid] Warning: {warning}");
}
if args.existing_task_id.is_some() {
match resolve_id_conflict(store, task_id.as_str())? {
IdConflict::None => store.insert_task(&task)?,
IdConflict::ReplaceWaiting => store.replace_waiting_task(&task)?,
IdConflict::Running => {
anyhow::bail!(
"Task '{}' is still running. Stop it first: aid stop {}",
task_id, task_id
);
}
IdConflict::AutoSuffix(new_id) => {
aid_info!("[aid] ID '{}' already exists, using '{}'", task_id, new_id);
task.id = TaskId(new_id.clone());
task_id = TaskId(new_id);
store.insert_task(&task)?;
}
}
} else {
store.insert_task(&task)?;
}
if emit_gitbutler_setup_hint {
let _ = store.insert_event(&TaskEvent {
task_id: task_id.clone(),
timestamp: Local::now(),
event_kind: EventKind::Milestone,
detail: "Hint: run `but setup` from the main repo to enable GitButler integration for future tasks."
.to_string(),
metadata: None,
});
}
if !args.dry_run
&& let (Some(wt), Some(repo)) = (wt_path.as_deref(), repo_path.as_deref())
&& let Err(err) = crate::worktree_deps::prepare_worktree_dependencies(
store,
&task_id,
Path::new(repo),
Path::new(wt),
args.setup.as_deref(),
args.link_deps,
crate::idle_timeout::idle_timeout_secs_from_env(args.env.as_ref()),
fresh_worktree,
)
{
crate::worktree::clear_worktree_lock(Path::new(wt));
store.complete_task_atomic(
TaskCompletionUpdate {
id: task_id.as_str(),
status: TaskStatus::Failed,
tokens: None,
duration_ms: 0,
model: agent_setup.effective_model.as_deref(),
cost_usd: None,
exit_code: None,
},
&TaskEvent {
task_id: task_id.clone(),
timestamp: Local::now(),
event_kind: EventKind::Error,
detail: format!("Failed during worktree setup: {err}"),
metadata: None,
},
)?;
return Err(err);
}
if auto_result_file {
let result_file = crate::cmd::report_mode::task_result_file(task_id.as_str());
args.result_file = Some(result_file.clone());
aid_info!("[aid] Audit report mode: auto-set --result-file {result_file}");
}
Ok(PreparedDispatch {
detected_project,
agent_kind: agent_setup.agent_kind,
agent_display_name: agent_setup.agent_display_name,
requested_skills: agent_setup.requested_skills,
effective_model: agent_setup.effective_model,
budget_active: agent_setup.budget_active,
agent: agent_setup.agent,
task_id,
task,
log_path,
workgroup,
repo_path,
wt_path,
effective_dir,
})
}
#[cfg(test)]
#[path = "run_dispatch_prepare_tests.rs"]
mod tests;