From 194bf605c216547319af6aae1e069e2f79acf3fe Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 10 Apr 2026 03:38:11 -0700 Subject: [PATCH] feat: add ecc2 orchestration templates --- ecc2/src/config/mod.rs | 260 ++++++++++++++++++- ecc2/src/main.rs | 161 +++++++++++- ecc2/src/session/manager.rs | 240 ++++++++++++++++- ecc2/src/session/store.rs | 9 +- ecc2/src/tui/dashboard.rs | 502 +++++++++++++++++++++++++++++------- 5 files changed, 1053 insertions(+), 119 deletions(-) diff --git a/ecc2/src/config/mod.rs b/ecc2/src/config/mod.rs index 938903a1..4e00bc9b 100644 --- a/ecc2/src/config/mod.rs +++ b/ecc2/src/config/mod.rs @@ -1,5 +1,6 @@ use anyhow::{Context, Result}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use regex::Regex; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::PathBuf; @@ -78,6 +79,50 @@ pub struct ResolvedAgentProfile { pub append_system_prompt: Option, } +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct OrchestrationTemplateConfig { + pub description: Option, + pub project: Option, + pub task_group: Option, + pub agent: Option, + pub profile: Option, + pub worktree: Option, + pub steps: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct OrchestrationTemplateStepConfig { + pub name: Option, + pub task: String, + pub agent: Option, + pub profile: Option, + pub worktree: Option, + pub project: Option, + pub task_group: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedOrchestrationTemplate { + pub template_name: String, + pub description: Option, + pub project: Option, + pub task_group: Option, + pub steps: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedOrchestrationTemplateStep { + pub name: String, + pub task: String, + pub agent: Option, + pub profile: Option, + pub worktree: bool, + pub project: Option, + pub task_group: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] pub struct Config { @@ -93,6 +138,7 @@ pub struct Config { pub default_agent: String, pub default_agent_profile: Option, pub agent_profiles: BTreeMap, + pub orchestration_templates: BTreeMap, pub auto_dispatch_unread_handoffs: bool, pub auto_dispatch_limit_per_session: usize, pub auto_create_worktrees: bool, @@ -156,6 +202,7 @@ impl Default for Config { default_agent: "claude".to_string(), default_agent_profile: None, agent_profiles: BTreeMap::new(), + orchestration_templates: BTreeMap::new(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, @@ -219,6 +266,80 @@ impl Config { self.resolve_agent_profile_inner(name, &mut chain) } + pub fn resolve_orchestration_template( + &self, + name: &str, + vars: &BTreeMap, + ) -> Result { + let template = self + .orchestration_templates + .get(name) + .ok_or_else(|| anyhow::anyhow!("Unknown orchestration template: {name}"))?; + + if template.steps.is_empty() { + anyhow::bail!("orchestration template {name} has no steps"); + } + + let description = interpolate_optional_string(template.description.as_deref(), vars)?; + let project = interpolate_optional_string(template.project.as_deref(), vars)?; + let task_group = interpolate_optional_string(template.task_group.as_deref(), vars)?; + let default_agent = interpolate_optional_string(template.agent.as_deref(), vars)?; + let default_profile = interpolate_optional_string(template.profile.as_deref(), vars)?; + if let Some(profile_name) = default_profile.as_deref() { + self.resolve_agent_profile(profile_name)?; + } + + let mut steps = Vec::with_capacity(template.steps.len()); + for (index, step) in template.steps.iter().enumerate() { + let task = interpolate_required_string(&step.task, vars).with_context(|| { + format!( + "resolve task for orchestration template {name} step {}", + index + 1 + ) + })?; + let step_name = interpolate_optional_string(step.name.as_deref(), vars)? + .unwrap_or_else(|| format!("step {}", index + 1)); + let agent = interpolate_optional_string( + step.agent.as_deref().or(default_agent.as_deref()), + vars, + )?; + let profile = interpolate_optional_string( + step.profile.as_deref().or(default_profile.as_deref()), + vars, + )?; + if let Some(profile_name) = profile.as_deref() { + self.resolve_agent_profile(profile_name)?; + } + + steps.push(ResolvedOrchestrationTemplateStep { + name: step_name, + task, + agent, + profile, + worktree: step + .worktree + .or(template.worktree) + .unwrap_or(self.auto_create_worktrees), + project: interpolate_optional_string( + step.project.as_deref().or(project.as_deref()), + vars, + )?, + task_group: interpolate_optional_string( + step.task_group.as_deref().or(task_group.as_deref()), + vars, + )?, + }); + } + + Ok(ResolvedOrchestrationTemplate { + template_name: name.to_string(), + description, + project, + task_group, + steps, + }) + } + fn resolve_agent_profile_inner( &self, name: &str, @@ -226,10 +347,7 @@ impl Config { ) -> Result { if chain.iter().any(|existing| existing == name) { chain.push(name.to_string()); - anyhow::bail!( - "agent profile inheritance cycle: {}", - chain.join(" -> ") - ); + anyhow::bail!("agent profile inheritance cycle: {}", chain.join(" -> ")); } let profile = self @@ -550,6 +668,55 @@ where } } +fn interpolate_optional_string( + value: Option<&str>, + vars: &BTreeMap, +) -> Result> { + value + .map(|value| interpolate_required_string(value, vars)) + .transpose() + .map(|value| { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) + }) +} + +fn interpolate_required_string(value: &str, vars: &BTreeMap) -> Result { + let placeholder = Regex::new(r"\{\{\s*([A-Za-z0-9_-]+)\s*\}\}") + .expect("orchestration template placeholder regex"); + let mut missing = Vec::new(); + let rendered = placeholder.replace_all(value, |captures: ®ex::Captures<'_>| { + let key = captures + .get(1) + .map(|capture| capture.as_str()) + .unwrap_or_default(); + match vars.get(key) { + Some(value) => value.to_string(), + None => { + missing.push(key.to_string()); + String::new() + } + } + }); + + if !missing.is_empty() { + missing.sort(); + missing.dedup(); + anyhow::bail!( + "missing orchestration template variable(s): {}", + missing.join(", ") + ); + } + + Ok(rendered.into_owned()) +} + impl BudgetAlertThresholds { pub fn sanitized(self) -> Self { let values = [self.advisory, self.warning, self.critical]; @@ -574,6 +741,7 @@ mod tests { PaneLayout, }; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use std::collections::BTreeMap; use std::path::PathBuf; use uuid::Uuid; @@ -979,6 +1147,90 @@ inherits = "a" .contains("agent profile inheritance cycle")); } + #[test] + fn orchestration_templates_resolve_steps_and_interpolate_variables() { + let config: Config = toml::from_str( + r#" +default_agent = "claude" +default_agent_profile = "reviewer" + +[agent_profiles.reviewer] +model = "sonnet" + +[orchestration_templates.feature_development] +description = "Ship {{task}}" +project = "{{project}}" +task_group = "{{task_group}}" +profile = "reviewer" +worktree = true + +[[orchestration_templates.feature_development.steps]] +name = "planner" +task = "Plan {{task}}" +agent = "claude" + +[[orchestration_templates.feature_development.steps]] +name = "reviewer" +task = "Review {{task}} in {{component}}" +profile = "reviewer" +worktree = false +"#, + ) + .unwrap(); + + let vars = BTreeMap::from([ + ("task".to_string(), "stabilize auth callback".to_string()), + ("project".to_string(), "ecc-core".to_string()), + ("task_group".to_string(), "auth callback".to_string()), + ("component".to_string(), "billing".to_string()), + ]); + let template = config + .resolve_orchestration_template("feature_development", &vars) + .unwrap(); + + assert_eq!(template.template_name, "feature_development"); + assert_eq!( + template.description.as_deref(), + Some("Ship stabilize auth callback") + ); + assert_eq!(template.project.as_deref(), Some("ecc-core")); + assert_eq!(template.task_group.as_deref(), Some("auth callback")); + assert_eq!(template.steps.len(), 2); + assert_eq!(template.steps[0].name, "planner"); + assert_eq!(template.steps[0].task, "Plan stabilize auth callback"); + assert_eq!(template.steps[0].agent.as_deref(), Some("claude")); + assert_eq!(template.steps[0].profile.as_deref(), Some("reviewer")); + assert!(template.steps[0].worktree); + assert_eq!( + template.steps[1].task, + "Review stabilize auth callback in billing" + ); + assert!(!template.steps[1].worktree); + } + + #[test] + fn orchestration_templates_fail_when_required_variables_are_missing() { + let config: Config = toml::from_str( + r#" +[orchestration_templates.feature_development] +[[orchestration_templates.feature_development.steps]] +task = "Plan {{task}} for {{component}}" +"#, + ) + .unwrap(); + + let error = config + .resolve_orchestration_template( + "feature_development", + &BTreeMap::from([("task".to_string(), "fix retry".to_string())]), + ) + .expect_err("missing template variables must fail"); + let error_text = format!("{error:#}"); + assert!(error_text + .contains("resolve task for orchestration template feature_development step 1")); + assert!(error_text.contains("missing orchestration template variable(s): component")); + } + #[test] fn completion_summary_notifications_deserialize_from_toml() { let config: Config = toml::from_str( diff --git a/ecc2/src/main.rs b/ecc2/src/main.rs index ee1fcb50..d7b88de7 100644 --- a/ecc2/src/main.rs +++ b/ecc2/src/main.rs @@ -9,6 +9,7 @@ mod worktree; use anyhow::Result; use clap::Parser; use serde::Serialize; +use std::collections::BTreeMap; use std::path::PathBuf; use tracing_subscriber::EnvFilter; @@ -78,6 +79,20 @@ enum Commands { #[command(flatten)] worktree: WorktreePolicyArgs, }, + /// Launch a named orchestration template + Template { + /// Template name defined in ecc2.toml + name: String, + /// Optional task injected into the template context + #[arg(short, long)] + task: Option, + /// Source session to delegate the template from + #[arg(long)] + from_session: Option, + /// Template variables in key=value form + #[arg(long = "var")] + vars: Vec, + }, /// Route work to an existing delegate when possible, otherwise spawn a new one Assign { /// Lead session ID or alias @@ -458,20 +473,21 @@ async fn main() -> Result<()> { ) }); - let session_id = session::manager::create_session_from_source_with_profile_and_grouping( - &db, - &cfg, - &task, - &agent, - use_worktree, - profile.as_deref(), - &source.id, - session::SessionGrouping { - project: Some(source.project.clone()), - task_group: Some(source.task_group.clone()), - }, - ) - .await?; + let session_id = + session::manager::create_session_from_source_with_profile_and_grouping( + &db, + &cfg, + &task, + &agent, + use_worktree, + profile.as_deref(), + &source.id, + session::SessionGrouping { + project: Some(source.project.clone()), + task_group: Some(source.task_group.clone()), + }, + ) + .await?; send_handoff_message(&db, &source.id, &session_id)?; println!( "Delegated session started: {} <- {}", @@ -479,6 +495,43 @@ async fn main() -> Result<()> { short_session(&source.id) ); } + Some(Commands::Template { + name, + task, + from_session, + vars, + }) => { + let source_session_id = from_session + .as_deref() + .map(|session_id| resolve_session_id(&db, session_id)) + .transpose()?; + let outcome = session::manager::launch_orchestration_template( + &db, + &cfg, + &name, + source_session_id.as_deref(), + task.as_deref(), + parse_template_vars(&vars)?, + ) + .await?; + println!( + "Template launched: {} ({} step{})", + outcome.template_name, + outcome.created.len(), + if outcome.created.len() == 1 { "" } else { "s" } + ); + if let Some(anchor_session_id) = outcome.anchor_session_id.as_deref() { + println!("Anchor session: {}", short_session(anchor_session_id)); + } + for step in outcome.created { + println!( + "- {} -> {} | {}", + step.step_name, + short_session(&step.session_id), + step.task + ); + } + } Some(Commands::Assign { from_session, task, @@ -2174,6 +2227,22 @@ fn send_handoff_message(db: &session::store::StateStore, from_id: &str, to_id: & ) } +fn parse_template_vars(values: &[String]) -> Result> { + let mut vars = BTreeMap::new(); + for value in values { + let (key, raw_value) = value + .split_once('=') + .ok_or_else(|| anyhow::anyhow!("template vars must use key=value form: {value}"))?; + let key = key.trim(); + let raw_value = raw_value.trim(); + if key.is_empty() || raw_value.is_empty() { + anyhow::bail!("template vars must use non-empty key=value form: {value}"); + } + vars.insert(key.to_string(), raw_value.to_string()); + } + Ok(vars) +} + #[cfg(test)] mod tests { use super::*; @@ -2424,6 +2493,70 @@ mod tests { } } + #[test] + fn cli_parses_template_command() { + let cli = Cli::try_parse_from([ + "ecc", + "template", + "feature_development", + "--task", + "stabilize auth callback", + "--from-session", + "lead", + "--var", + "component=billing", + "--var", + "area=oauth", + ]) + .expect("template should parse"); + + match cli.command { + Some(Commands::Template { + name, + task, + from_session, + vars, + }) => { + assert_eq!(name, "feature_development"); + assert_eq!(task.as_deref(), Some("stabilize auth callback")); + assert_eq!(from_session.as_deref(), Some("lead")); + assert_eq!( + vars, + vec!["component=billing".to_string(), "area=oauth".to_string(),] + ); + } + _ => panic!("expected template subcommand"), + } + } + + #[test] + fn parse_template_vars_builds_map() { + let vars = + parse_template_vars(&["component=billing".to_string(), "area=oauth".to_string()]) + .expect("template vars"); + + assert_eq!( + vars, + BTreeMap::from([ + ("area".to_string(), "oauth".to_string()), + ("component".to_string(), "billing".to_string()), + ]) + ); + } + + #[test] + fn parse_template_vars_rejects_invalid_entries() { + let error = parse_template_vars(&["missing-delimiter".to_string()]) + .expect_err("invalid template var should fail"); + + assert!( + error + .to_string() + .contains("template vars must use key=value form"), + "unexpected error: {error}" + ); + } + #[test] fn cli_parses_team_command() { let cli = Cli::try_parse_from(["ecc", "team", "planner", "--depth", "3"]) diff --git a/ecc2/src/session/manager.rs b/ecc2/src/session/manager.rs index 28669e9d..3f5f2c2c 100644 --- a/ecc2/src/session/manager.rs +++ b/ecc2/src/session/manager.rs @@ -150,6 +150,197 @@ pub fn get_team_status(db: &StateStore, id: &str, depth: usize) -> Result, + pub created: Vec, +} + +pub async fn launch_orchestration_template( + db: &StateStore, + cfg: &Config, + template_name: &str, + source_session_id: Option<&str>, + task: Option<&str>, + variables: BTreeMap, +) -> Result { + let repo_root = + std::env::current_dir().context("Failed to resolve current working directory")?; + let runner_program = + std::env::current_exe().context("Failed to resolve ECC executable path")?; + let source_session = source_session_id + .map(|id| resolve_session(db, id)) + .transpose()?; + let vars = build_template_variables(&repo_root, source_session.as_ref(), task, variables); + let template = cfg.resolve_orchestration_template(template_name, &vars)?; + let live_sessions = db + .list_sessions()? + .into_iter() + .filter(|session| { + matches!( + session.state, + SessionState::Pending + | SessionState::Running + | SessionState::Idle + | SessionState::Stale + ) + }) + .count(); + let available_slots = cfg.max_parallel_sessions.saturating_sub(live_sessions); + if template.steps.len() > available_slots { + anyhow::bail!( + "template {template_name} requires {} session slots but only {available_slots} available", + template.steps.len() + ); + } + + let default_profile = cfg + .default_agent_profile + .as_deref() + .map(|name| cfg.resolve_agent_profile(name)) + .transpose()?; + let base_grouping = SessionGrouping { + project: Some( + source_session + .as_ref() + .map(|session| session.project.clone()) + .unwrap_or_else(|| default_project_label(&repo_root)), + ), + task_group: Some( + source_session + .as_ref() + .map(|session| session.task_group.clone()) + .or_else(|| task.map(default_task_group_label)) + .unwrap_or_else(|| template_name.replace(['_', '-'], " ")), + ), + }; + + let mut created = Vec::with_capacity(template.steps.len()); + let mut anchor_session_id = source_session.as_ref().map(|session| session.id.clone()); + let mut created_anchor_id: Option = None; + + for step in template.steps { + let profile = match step.profile.as_deref() { + Some(name) => Some(cfg.resolve_agent_profile(name)?), + None if step.agent.is_some() => None, + None => default_profile.clone(), + }; + let agent = step + .agent + .as_deref() + .unwrap_or(&cfg.default_agent) + .to_string(); + let grouping = SessionGrouping { + project: step + .project + .clone() + .or_else(|| base_grouping.project.clone()), + task_group: step + .task_group + .clone() + .or_else(|| base_grouping.task_group.clone()), + }; + let session_id = queue_session_with_resolved_profile_and_runner_program( + db, + cfg, + &step.task, + &agent, + step.worktree, + &repo_root, + &runner_program, + profile, + grouping, + ) + .await?; + + if let Some(parent_id) = anchor_session_id.as_deref() { + let parent = resolve_session(db, parent_id)?; + send_task_handoff( + db, + &parent, + &session_id, + &step.task, + &format!("template {} | {}", template_name, step.name), + )?; + } else { + created_anchor_id = Some(session_id.clone()); + anchor_session_id = Some(session_id.clone()); + } + + if created_anchor_id.is_none() { + created_anchor_id = Some(session_id.clone()); + } + + created.push(TemplateLaunchStepOutcome { + step_name: step.name, + session_id, + task: step.task, + }); + } + + Ok(TemplateLaunchOutcome { + template_name: template_name.to_string(), + step_count: created.len(), + anchor_session_id: source_session + .as_ref() + .map(|session| session.id.clone()) + .or(created_anchor_id), + created, + }) +} + +pub(crate) fn build_template_variables( + repo_root: &Path, + source_session: Option<&Session>, + task: Option<&str>, + mut variables: BTreeMap, +) -> BTreeMap { + if let Some(source) = source_session { + variables + .entry("source_task".to_string()) + .or_insert_with(|| source.task.clone()); + variables + .entry("source_project".to_string()) + .or_insert_with(|| source.project.clone()); + variables + .entry("source_task_group".to_string()) + .or_insert_with(|| source.task_group.clone()); + variables + .entry("source_agent".to_string()) + .or_insert_with(|| source.agent_type.clone()); + } + + let effective_task = task + .map(ToOwned::to_owned) + .or_else(|| source_session.map(|session| session.task.clone())); + if let Some(task) = effective_task { + variables.entry("task".to_string()).or_insert(task.clone()); + variables + .entry("task_group".to_string()) + .or_insert_with(|| default_task_group_label(&task)); + } + + variables.entry("project".to_string()).or_insert_with(|| { + source_session + .map(|session| session.project.clone()) + .unwrap_or_else(|| default_project_label(repo_root)) + }); + variables + .entry("cwd".to_string()) + .or_insert_with(|| repo_root.display().to_string()); + + variables +} + #[derive(Debug, Clone, Default, Serialize)] pub struct HeartbeatEnforcementOutcome { pub stale_sessions: Vec, @@ -1743,7 +1934,13 @@ pub async fn run_session( let agent_program = agent_program(agent_type)?; let profile = db.get_session_profile(session_id)?; - let command = build_agent_command(&agent_program, task, session_id, working_dir, profile.as_ref()); + let command = build_agent_command( + &agent_program, + task, + session_id, + working_dir, + profile.as_ref(), + ); capture_command_output( cfg.db_path.clone(), session_id.to_string(), @@ -1901,8 +2098,32 @@ async fn queue_session_in_dir_with_runner_program( inherited_profile_session_id: Option<&str>, grouping: SessionGrouping, ) -> Result { - let profile = - resolve_launch_profile(db, cfg, profile_name, inherited_profile_session_id)?; + let profile = resolve_launch_profile(db, cfg, profile_name, inherited_profile_session_id)?; + queue_session_with_resolved_profile_and_runner_program( + db, + cfg, + task, + agent_type, + use_worktree, + repo_root, + runner_program, + profile, + grouping, + ) + .await +} + +async fn queue_session_with_resolved_profile_and_runner_program( + db: &StateStore, + cfg: &Config, + task: &str, + agent_type: &str, + use_worktree: bool, + repo_root: &Path, + runner_program: &Path, + profile: Option, + grouping: SessionGrouping, +) -> Result { let effective_agent_type = profile .as_ref() .and_then(|profile| profile.agent.as_deref()) @@ -2060,7 +2281,9 @@ fn resolve_launch_profile( inherited_profile_session_id: Option<&str>, ) -> Result> { let inherited_profile_name = match inherited_profile_session_id { - Some(session_id) => db.get_session_profile(session_id)?.map(|profile| profile.profile_name), + Some(session_id) => db + .get_session_profile(session_id)? + .map(|profile| profile.profile_name), None => None, }; let profile_name = explicit_profile_name @@ -2275,7 +2498,10 @@ fn build_agent_command( command.arg("--append-system-prompt").arg(prompt); } } - command.arg(task).current_dir(working_dir).stdin(Stdio::null()); + command + .arg(task) + .current_dir(working_dir) + .stdin(Stdio::null()); command } @@ -2844,6 +3070,7 @@ mod tests { default_agent: "claude".to_string(), default_agent_profile: None, agent_profiles: Default::default(), + orchestration_templates: Default::default(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true, @@ -3364,7 +3591,8 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn create_session_uses_default_agent_profile_and_persists_launch_settings() -> Result<()> { + async fn create_session_uses_default_agent_profile_and_persists_launch_settings() -> Result<()> + { let tempdir = TestDir::new("manager-default-agent-profile")?; let repo_root = tempdir.path().join("repo"); init_git_repo(&repo_root)?; diff --git a/ecc2/src/session/store.rs b/ecc2/src/session/store.rs index d0b82686..8d028e76 100644 --- a/ecc2/src/session/store.rs +++ b/ecc2/src/session/store.rs @@ -591,8 +591,8 @@ impl StateStore { .context("serialize allowed agent profile tools")?; let disallowed_tools_json = serde_json::to_string(&profile.disallowed_tools) .context("serialize disallowed agent profile tools")?; - let add_dirs_json = serde_json::to_string(&profile.add_dirs) - .context("serialize agent profile add_dirs")?; + let add_dirs_json = + serde_json::to_string(&profile.add_dirs).context("serialize agent profile add_dirs")?; self.conn.execute( "INSERT INTO session_profiles ( @@ -2683,7 +2683,10 @@ mod tests { assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]); assert_eq!(profile.disallowed_tools, vec!["Bash"]); assert_eq!(profile.permission_mode.as_deref(), Some("plan")); - assert_eq!(profile.add_dirs, vec![PathBuf::from("docs"), PathBuf::from("specs")]); + assert_eq!( + profile.add_dirs, + vec![PathBuf::from("docs"), PathBuf::from("specs")] + ); assert_eq!(profile.max_budget_usd, Some(1.5)); assert_eq!(profile.token_budget, Some(1200)); assert_eq!( diff --git a/ecc2/src/tui/dashboard.rs b/ecc2/src/tui/dashboard.rs index a471d637..e137653c 100644 --- a/ecc2/src/tui/dashboard.rs +++ b/ecc2/src/tui/dashboard.rs @@ -8,7 +8,7 @@ use ratatui::{ }, }; use regex::Regex; -use std::collections::{HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::time::UNIX_EPOCH; use tokio::sync::broadcast; @@ -273,16 +273,31 @@ struct TimelineEvent { } #[derive(Debug, Clone, PartialEq, Eq)] -struct SpawnRequest { - requested_count: usize, - task: String, +enum SpawnRequest { + AdHoc { + requested_count: usize, + task: String, + }, + Template { + name: String, + task: Option, + variables: BTreeMap, + }, } #[derive(Debug, Clone, PartialEq, Eq)] -struct SpawnPlan { - requested_count: usize, - spawn_count: usize, - task: String, +enum SpawnPlan { + AdHoc { + requested_count: usize, + spawn_count: usize, + task: String, + }, + Template { + name: String, + task: Option, + variables: BTreeMap, + step_count: usize, + }, } #[derive(Debug, Clone, Copy)] @@ -1357,7 +1372,7 @@ impl Dashboard { "Keyboard Shortcuts:".to_string(), "".to_string(), " n New session".to_string(), - " N Natural-language multi-agent spawn prompt".to_string(), + " N Natural-language multi-agent or template spawn prompt".to_string(), " a Assign follow-up work from selected session".to_string(), " b Rebalance backed-up delegate handoff backlog for selected lead".to_string(), " B Rebalance backed-up delegate handoff backlog across lead teams".to_string(), @@ -3062,7 +3077,7 @@ impl Dashboard { self.spawn_input = Some(self.spawn_prompt_seed()); self.set_operator_note( - "spawn mode | try: give me 3 agents working on fix flaky tests".to_string(), + "spawn mode | try: give me 3 agents working on fix flaky tests | or: template feature_development for fix flaky tests".to_string(), ); } @@ -3419,64 +3434,96 @@ impl Dashboard { let agent = self.cfg.default_agent.clone(); let mut created_ids = Vec::new(); - for task in expand_spawn_tasks(&plan.task, plan.spawn_count) { - let session_id = match manager::create_session_with_grouping( + match &plan { + SpawnPlan::AdHoc { + requested_count: _, + spawn_count, + task, + } => { + for task in expand_spawn_tasks(task, *spawn_count) { + let session_id = match manager::create_session_with_grouping( + &self.db, + &self.cfg, + &task, + &agent, + self.cfg.auto_create_worktrees, + source_grouping.clone(), + ) + .await + { + Ok(session_id) => session_id, + Err(error) => { + let preferred_selection = + post_spawn_selection_id(source_session_id.as_deref(), &created_ids); + self.refresh_after_spawn(preferred_selection.as_deref()); + let mut summary = if created_ids.is_empty() { + format!("spawn failed: {error}") + } else { + format!( + "spawn partially completed: {} of {} queued before failure: {error}", + created_ids.len(), + spawn_count + ) + }; + if let Some(layout_note) = + self.auto_split_layout_after_spawn(created_ids.len()) + { + summary.push_str(" | "); + summary.push_str(&layout_note); + } + self.set_operator_note(summary); + return; + } + }; + + if let (Some(source_id), Some(task), Some(context)) = ( + source_session_id.as_ref(), + source_task.as_ref(), + handoff_context.as_ref(), + ) { + if let Err(error) = comms::send( + &self.db, + source_id, + &session_id, + &comms::MessageType::TaskHandoff { + task: task.clone(), + context: context.clone(), + }, + ) { + tracing::warn!( + "Failed to send handoff from session {} to {}: {error}", + source_id, + session_id + ); + } + } + + created_ids.push(session_id); + } + } + SpawnPlan::Template { + name, + task, + variables, + .. + } => match manager::launch_orchestration_template( &self.db, &self.cfg, - &task, - &agent, - self.cfg.auto_create_worktrees, - source_grouping.clone(), + name, + source_session_id.as_deref(), + task.as_deref(), + variables.clone(), ) .await { - Ok(session_id) => session_id, + Ok(outcome) => { + created_ids.extend(outcome.created.into_iter().map(|step| step.session_id)); + } Err(error) => { - let preferred_selection = - post_spawn_selection_id(source_session_id.as_deref(), &created_ids); - self.refresh_after_spawn(preferred_selection.as_deref()); - let mut summary = if created_ids.is_empty() { - format!("spawn failed: {error}") - } else { - format!( - "spawn partially completed: {} of {} queued before failure: {error}", - created_ids.len(), - plan.spawn_count - ) - }; - if let Some(layout_note) = self.auto_split_layout_after_spawn(created_ids.len()) - { - summary.push_str(" | "); - summary.push_str(&layout_note); - } - self.set_operator_note(summary); + self.set_operator_note(format!("template launch failed: {error}")); return; } - }; - - if let (Some(source_id), Some(task), Some(context)) = ( - source_session_id.as_ref(), - source_task.as_ref(), - handoff_context.as_ref(), - ) { - if let Err(error) = comms::send( - &self.db, - source_id, - &session_id, - &comms::MessageType::TaskHandoff { - task: task.clone(), - context: context.clone(), - }, - ) { - tracing::warn!( - "Failed to send handoff from session {} to {}: {error}", - source_id, - session_id - ); - } - } - - created_ids.push(session_id); + }, } let preferred_selection = @@ -5392,11 +5439,7 @@ impl Dashboard { fn selected_session_metrics_text(&self) -> String { if let Some(session) = self.sessions.get(self.selected_session) { let metrics = &session.metrics; - let selected_profile = self - .db - .get_session_profile(&session.id) - .ok() - .flatten(); + let selected_profile = self.db.get_session_profile(&session.id).ok().flatten(); let group_peers = self .sessions .iter() @@ -5433,10 +5476,8 @@ impl Dashboard { )); } if let Some(max_budget_usd) = profile.max_budget_usd { - profile_details.push(format!( - "Profile cost {}", - format_currency(max_budget_usd) - )); + profile_details + .push(format!("Profile cost {}", format_currency(max_budget_usd))); } if !profile.allowed_tools.is_empty() { profile_details.push(format!( @@ -5958,18 +5999,58 @@ impl Dashboard { .max_parallel_sessions .saturating_sub(self.active_session_count()); - if available_slots == 0 { - return Err(format!( - "cannot queue sessions: active session limit reached ({})", - self.cfg.max_parallel_sessions - )); - } + match request { + SpawnRequest::AdHoc { + requested_count, + task, + } => { + if available_slots == 0 { + return Err(format!( + "cannot queue sessions: active session limit reached ({})", + self.cfg.max_parallel_sessions + )); + } - Ok(SpawnPlan { - requested_count: request.requested_count, - spawn_count: request.requested_count.min(available_slots), - task: request.task, - }) + Ok(SpawnPlan::AdHoc { + requested_count, + spawn_count: requested_count.min(available_slots), + task, + }) + } + SpawnRequest::Template { + name, + task, + variables, + } => { + let repo_root = std::env::current_dir().map_err(|error| { + format!("failed to resolve cwd for template preview: {error}") + })?; + let source_session = self.sessions.get(self.selected_session); + let preview_vars = manager::build_template_variables( + &repo_root, + source_session, + task.as_deref(), + variables.clone(), + ); + let template = self + .cfg + .resolve_orchestration_template(&name, &preview_vars) + .map_err(|error| error.to_string())?; + if available_slots < template.steps.len() { + return Err(format!( + "template {name} requires {} session slots but only {available_slots} available", + template.steps.len() + )); + } + + Ok(SpawnPlan::Template { + name, + task, + variables, + step_count: template.steps.len(), + }) + } + } } fn pane_areas(&self, area: Rect) -> PaneAreas { @@ -6289,6 +6370,10 @@ fn parse_spawn_request(input: &str) -> Result { return Err("spawn request cannot be empty".to_string()); } + if let Some(template_request) = parse_template_spawn_request(trimmed)? { + return Ok(template_request); + } + let count = Regex::new(r"\b([1-9]\d*)\b") .expect("spawn count regex") .captures(trimmed) @@ -6301,12 +6386,66 @@ fn parse_spawn_request(input: &str) -> Result { return Err("spawn request must include a task description".to_string()); } - Ok(SpawnRequest { + Ok(SpawnRequest::AdHoc { requested_count: count, task, }) } +fn parse_template_spawn_request(input: &str) -> Result, String> { + let captures = Regex::new( + r"(?is)^\s*template\s+(?P[A-Za-z0-9_-]+)(?:\s+for\s+(?P.*?))?(?:\s+with\s+(?P.+))?\s*$", + ) + .expect("template spawn regex") + .captures(input); + + let Some(captures) = captures else { + return Ok(None); + }; + + let name = captures + .name("name") + .map(|value| value.as_str().trim().to_string()) + .ok_or_else(|| "template request must include a template name".to_string())?; + let task = captures + .name("task") + .map(|value| value.as_str().trim().to_string()) + .filter(|value| !value.is_empty()); + let variables = captures + .name("vars") + .map(|value| parse_template_request_variables(value.as_str())) + .transpose()? + .unwrap_or_default(); + + Ok(Some(SpawnRequest::Template { + name, + task, + variables, + })) +} + +fn parse_template_request_variables(input: &str) -> Result, String> { + let mut variables = BTreeMap::new(); + for entry in input + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + { + let (key, value) = entry + .split_once('=') + .ok_or_else(|| format!("template vars must use key=value form: {entry}"))?; + let key = key.trim(); + let value = value.trim(); + if key.is_empty() || value.is_empty() { + return Err(format!( + "template vars must use non-empty key=value form: {entry}" + )); + } + variables.insert(key.to_string(), value.to_string()); + } + Ok(variables) +} + fn extract_spawn_task(input: &str) -> String { let trimmed = input.trim(); let lower = trimmed.to_ascii_lowercase(); @@ -6344,14 +6483,33 @@ fn expand_spawn_tasks(task: &str, count: usize) -> Vec { } fn build_spawn_note(plan: &SpawnPlan, created_count: usize, queued_count: usize) -> String { - let task = truncate_for_dashboard(&plan.task, 72); - let mut note = if plan.spawn_count < plan.requested_count { - format!( - "spawned {created_count} session(s) for {task} (requested {}, capped at {})", - plan.requested_count, plan.spawn_count - ) - } else { - format!("spawned {created_count} session(s) for {task}") + let mut note = match plan { + SpawnPlan::AdHoc { + requested_count, + spawn_count, + task, + } => { + let task = truncate_for_dashboard(task, 72); + if spawn_count < requested_count { + format!( + "spawned {created_count} session(s) for {task} (requested {requested_count}, capped at {spawn_count})" + ) + } else { + format!("spawned {created_count} session(s) for {task}") + } + } + SpawnPlan::Template { + name, + task, + step_count, + .. + } => { + let scope = task + .as_ref() + .map(|task| format!(" for {}", truncate_for_dashboard(task, 72))) + .unwrap_or_default(); + format!("launched template {name} ({created_count}/{step_count} step(s)){scope}") + } }; if queued_count > 0 { @@ -11053,7 +11211,7 @@ diff --git a/src/lib.rs b/src/lib.rs assert_eq!( request, - SpawnRequest { + SpawnRequest::AdHoc { requested_count: 10, task: "stabilize the queue".to_string(), } @@ -11066,13 +11224,33 @@ diff --git a/src/lib.rs b/src/lib.rs assert_eq!( request, - SpawnRequest { + SpawnRequest::AdHoc { requested_count: 1, task: "stabilize the queue".to_string(), } ); } + #[test] + fn parse_spawn_request_extracts_template_request() { + let request = parse_spawn_request( + "template feature_development for stabilize auth callback with component=billing, area=oauth", + ) + .expect("template request should parse"); + + assert_eq!( + request, + SpawnRequest::Template { + name: "feature_development".to_string(), + task: Some("stabilize auth callback".to_string()), + variables: BTreeMap::from([ + ("area".to_string(), "oauth".to_string()), + ("component".to_string(), "billing".to_string()), + ]), + } + ); + } + #[test] fn build_spawn_plan_caps_requested_count_to_available_slots() { let dashboard = test_dashboard( @@ -11090,7 +11268,7 @@ diff --git a/src/lib.rs b/src/lib.rs assert_eq!( plan, - SpawnPlan { + SpawnPlan::AdHoc { requested_count: 9, spawn_count: 5, task: "ship release notes".to_string(), @@ -11098,6 +11276,145 @@ diff --git a/src/lib.rs b/src/lib.rs ); } + #[test] + fn build_spawn_plan_resolves_template_steps() { + let mut dashboard = test_dashboard(Vec::new(), 0); + dashboard.cfg.orchestration_templates = BTreeMap::from([( + "feature_development".to_string(), + crate::config::OrchestrationTemplateConfig { + description: None, + project: None, + task_group: None, + agent: Some("claude".to_string()), + profile: None, + worktree: Some(true), + steps: vec![ + crate::config::OrchestrationTemplateStepConfig { + name: Some("planner".to_string()), + task: "Plan {{task}}".to_string(), + project: None, + task_group: None, + agent: None, + profile: None, + worktree: None, + }, + crate::config::OrchestrationTemplateStepConfig { + name: Some("builder".to_string()), + task: "Build {{task}} in {{component}}".to_string(), + project: None, + task_group: None, + agent: None, + profile: None, + worktree: None, + }, + ], + }, + )]); + + let plan = dashboard + .build_spawn_plan( + "template feature_development for stabilize auth callback with component=billing", + ) + .expect("template spawn plan"); + + assert_eq!( + plan, + SpawnPlan::Template { + name: "feature_development".to_string(), + task: Some("stabilize auth callback".to_string()), + variables: BTreeMap::from([("component".to_string(), "billing".to_string(),)]), + step_count: 2, + } + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn submit_spawn_prompt_launches_orchestration_template() -> Result<()> { + let tempdir = std::env::temp_dir().join(format!("dashboard-template-{}", Uuid::new_v4())); + let repo_root = tempdir.join("repo"); + init_git_repo(&repo_root)?; + + let original_dir = std::env::current_dir()?; + std::env::set_current_dir(&repo_root)?; + + let mut cfg = build_config(&tempdir); + cfg.orchestration_templates = BTreeMap::from([( + "feature_development".to_string(), + crate::config::OrchestrationTemplateConfig { + description: None, + project: Some("ecc2-smoke".to_string()), + task_group: Some("{{task}}".to_string()), + agent: Some("claude".to_string()), + profile: None, + worktree: Some(false), + steps: vec![ + crate::config::OrchestrationTemplateStepConfig { + name: Some("planner".to_string()), + task: "Plan {{task}}".to_string(), + project: None, + task_group: None, + agent: None, + profile: None, + worktree: None, + }, + crate::config::OrchestrationTemplateStepConfig { + name: Some("builder".to_string()), + task: "Build {{task}} in {{component}}".to_string(), + project: None, + task_group: None, + agent: None, + profile: None, + worktree: None, + }, + ], + }, + )]); + + let db = StateStore::open(&cfg.db_path)?; + let mut dashboard = Dashboard::new(db, cfg); + dashboard.spawn_input = Some( + "template feature_development for stabilize auth callback with component=billing" + .to_string(), + ); + + dashboard.submit_spawn_prompt().await; + + let operator_note = dashboard + .operator_note + .clone() + .expect("template launch should set an operator note"); + assert!( + operator_note + .contains("launched template feature_development (2/2 step(s)) for stabilize auth callback"), + "unexpected operator note: {operator_note}" + ); + assert_eq!(dashboard.sessions.len(), 2); + assert!(dashboard + .sessions + .iter() + .all(|session| session.project == "ecc2-smoke")); + assert!(dashboard + .sessions + .iter() + .all(|session| session.task_group == "stabilize auth callback")); + let tasks = dashboard + .sessions + .iter() + .map(|session| session.task.as_str()) + .collect::>(); + assert_eq!( + tasks, + std::collections::BTreeSet::from([ + "Build stabilize auth callback in billing", + "Plan stabilize auth callback", + ]) + ); + + std::env::set_current_dir(original_dir)?; + let _ = std::fs::remove_dir_all(&tempdir); + Ok(()) + } + #[test] fn expand_spawn_tasks_suffixes_multi_session_requests() { assert_eq!( @@ -13074,6 +13391,7 @@ diff --git a/src/lib.rs b/src/lib.rs default_agent: "claude".to_string(), default_agent_profile: None, agent_profiles: Default::default(), + orchestration_templates: Default::default(), auto_dispatch_unread_handoffs: false, auto_dispatch_limit_per_session: 5, auto_create_worktrees: true,