use super::{ChildDispatchRequest, Error};
use crate::prompt::compactor::{COMPACTOR_ROLE, compactor_goal};
use crate::prompt::inbox::{AdvanceLauncher, Launcher};
use crate::prompt::role;
use crate::prompt::{NanoIdGen, SystemClock, child_dispatch};
use crate::template::RealGit;
use std::path::Path;
const ROLE_COMPACTOR: &str = COMPACTOR_ROLE;
#[derive(Debug)]
pub enum DispatchCliError {
InvalidRole(role::validate::Invalid),
GoalRequired(String),
GoalForbidden(&'static str),
Inner(Error),
}
impl std::fmt::Display for DispatchCliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidRole(inv) => write!(f, "{inv}"),
Self::GoalRequired(r) => write!(f, "--goal is required for role {r:?}"),
Self::GoalForbidden(r) => write!(f, "--goal is not accepted for role {r:?}"),
Self::Inner(e) => write!(f, "{e}"),
}
}
}
impl From<Error> for DispatchCliError {
fn from(value: Error) -> Self {
Self::Inner(value)
}
}
pub fn run(
role: &str,
repo: &Path,
branch: &str,
goal: Option<&str>,
driver_target: &Path,
) -> Result<(), DispatchCliError> {
let launcher = AdvanceLauncher::with_exe(driver_target.to_path_buf());
run_with(role, repo, branch, goal, &launcher)
}
fn run_with(
role: &str,
repo: &Path,
parent_branch: &str,
goal: Option<&str>,
launcher: &dyn Launcher,
) -> Result<(), DispatchCliError> {
role::validate::validate(repo, parent_branch, role, &RealGit::new())
.map_err(DispatchCliError::InvalidRole)?;
let goal_text = if role == ROLE_COMPACTOR {
if goal.is_some() {
return Err(DispatchCliError::GoalForbidden(ROLE_COMPACTOR));
}
compactor_goal(parent_branch)
} else {
goal.ok_or_else(|| DispatchCliError::GoalRequired(role.to_owned()))?
.to_owned()
};
dispatch_child(repo, parent_branch, role, &goal_text, launcher)
}
fn dispatch_child(
repo: &Path,
parent_branch: &str,
role: &str,
goal: &str,
launcher: &dyn Launcher,
) -> Result<(), DispatchCliError> {
let parent_worktree = crate::workspace::agent_worktree(repo, parent_branch);
let req = ChildDispatchRequest {
repo,
parent_branch,
parent_worktree: &parent_worktree,
role,
goal,
fork_point: None,
};
let child = child_dispatch::run(&req, &RealGit::new(), &SystemClock, &NanoIdGen, launcher)?;
println!("{child}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace::fixture;
use tempfile::TempDir;
fn scaffolded_repo_with_parent(parent: &str) -> (TempDir, std::path::PathBuf) {
let (holder, repo) = fixture::workspace();
fixture::spawn_root(&repo, parent);
(holder, repo)
}
struct NoopLauncher;
impl Launcher for NoopLauncher {
fn launch(&self, _workspace: &Path, _agent_id: &str) -> std::io::Result<()> {
Ok(())
}
}
fn sub_count(repo: &Path, parent: &str) -> usize {
std::fs::read_dir(repo.join(crate::workspace::AGENTS_DIR))
.unwrap()
.flatten()
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with(&format!("{parent}-"))
})
.count()
}
#[test]
fn compactor_dispatch_forks_an_ordinary_compactor_child() {
let (_holder, repo) = scaffolded_repo_with_parent("20260101-p1");
run_with(ROLE_COMPACTOR, &repo, "20260101-p1", None, &NoopLauncher).unwrap();
assert_eq!(sub_count(&repo, "20260101-p1"), 1);
}
#[test]
fn worker_dispatch_succeeds_and_spawns_a_sub_branch() {
let (_holder, repo) = scaffolded_repo_with_parent("20260101-p1");
run_with(
"worker",
&repo,
"20260101-p1",
Some("do the thing"),
&NoopLauncher,
)
.unwrap();
assert_eq!(sub_count(&repo, "20260101-p1"), 1);
}
#[test]
fn any_config_role_dispatches_open_set() {
let (_holder, repo) = fixture::workspace();
let yaml = "roles:\n worker:\n provider: anthropic\n model: sonnet\n \
verifier:\n provider: anthropic\n model: sonnet\n";
fixture::amend_config(
&repo,
&[("providers.yaml", yaml), ("souls/verifier.md", "v\n")],
);
fixture::spawn_root(&repo, "p9");
run_with("verifier", &repo, "p9", Some("judge it"), &NoopLauncher).unwrap();
assert_eq!(sub_count(&repo, "p9"), 1);
}
#[test]
fn undefined_role_is_a_config_validation_failure() {
let (_holder, repo) = scaffolded_repo_with_parent("p1");
let err = run_with("no-such-role", &repo, "p1", Some("g"), &NoopLauncher).unwrap_err();
assert!(matches!(err, DispatchCliError::InvalidRole(_)), "{err}");
assert!(
err.to_string()
.contains("role \"no-such-role\" is not defined in"),
"{err}"
);
}
#[test]
fn worker_requires_a_goal() {
let (_holder, repo) = scaffolded_repo_with_parent("p1");
let err = run("worker", &repo, "p1", None, Path::new("true")).unwrap_err();
assert_eq!(err.to_string(), "--goal is required for role \"worker\"");
}
#[test]
fn compactor_rejects_a_goal() {
let (_holder, repo) = scaffolded_repo_with_parent("p1");
let err = run(ROLE_COMPACTOR, &repo, "p1", Some("g"), Path::new("true")).unwrap_err();
assert_eq!(
err.to_string(),
"--goal is not accepted for role \"compactor\""
);
}
#[test]
fn inner_errors_render_through_the_shared_display() {
let (_holder, repo) = scaffolded_repo_with_parent("p1");
std::fs::remove_dir_all(repo.join(crate::workspace::AGENTS_DIR).join("p1")).unwrap();
let err = run_with("worker", &repo, "p1", Some("g"), &NoopLauncher).unwrap_err();
assert!(matches!(err, DispatchCliError::Inner(_)), "{err}");
assert!(!err.to_string().is_empty());
}
}