feat: add ecc2 orchestration templates

This commit is contained in:
Affaan Mustafa 2026-04-10 03:38:11 -07:00
parent 1e4d6a4161
commit 194bf605c2
5 changed files with 1053 additions and 119 deletions

View File

@ -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<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct OrchestrationTemplateConfig {
pub description: Option<String>,
pub project: Option<String>,
pub task_group: Option<String>,
pub agent: Option<String>,
pub profile: Option<String>,
pub worktree: Option<bool>,
pub steps: Vec<OrchestrationTemplateStepConfig>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct OrchestrationTemplateStepConfig {
pub name: Option<String>,
pub task: String,
pub agent: Option<String>,
pub profile: Option<String>,
pub worktree: Option<bool>,
pub project: Option<String>,
pub task_group: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedOrchestrationTemplate {
pub template_name: String,
pub description: Option<String>,
pub project: Option<String>,
pub task_group: Option<String>,
pub steps: Vec<ResolvedOrchestrationTemplateStep>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedOrchestrationTemplateStep {
pub name: String,
pub task: String,
pub agent: Option<String>,
pub profile: Option<String>,
pub worktree: bool,
pub project: Option<String>,
pub task_group: Option<String>,
}
#[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<String>,
pub agent_profiles: BTreeMap<String, AgentProfileConfig>,
pub orchestration_templates: BTreeMap<String, OrchestrationTemplateConfig>,
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<String, String>,
) -> Result<ResolvedOrchestrationTemplate> {
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<ResolvedAgentProfile> {
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<String, String>,
) -> Result<Option<String>> {
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<String, String>) -> Result<String> {
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: &regex::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(

View File

@ -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<String>,
/// Source session to delegate the template from
#[arg(long)]
from_session: Option<String>,
/// Template variables in key=value form
#[arg(long = "var")]
vars: Vec<String>,
},
/// 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<BTreeMap<String, String>> {
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"])

View File

@ -150,6 +150,197 @@ pub fn get_team_status(db: &StateStore, id: &str, depth: usize) -> Result<TeamSt
})
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TemplateLaunchStepOutcome {
pub step_name: String,
pub session_id: String,
pub task: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TemplateLaunchOutcome {
pub template_name: String,
pub step_count: usize,
pub anchor_session_id: Option<String>,
pub created: Vec<TemplateLaunchStepOutcome>,
}
pub async fn launch_orchestration_template(
db: &StateStore,
cfg: &Config,
template_name: &str,
source_session_id: Option<&str>,
task: Option<&str>,
variables: BTreeMap<String, String>,
) -> Result<TemplateLaunchOutcome> {
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<String> = 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<String, String>,
) -> BTreeMap<String, String> {
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<String>,
@ -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<String> {
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<SessionAgentProfile>,
grouping: SessionGrouping,
) -> Result<String> {
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<Option<SessionAgentProfile>> {
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)?;

View File

@ -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!(

View File

@ -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<String>,
variables: BTreeMap<String, String>,
},
}
#[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<String>,
variables: BTreeMap<String, String>,
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<SpawnRequest, String> {
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<SpawnRequest, String> {
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<Option<SpawnRequest>, String> {
let captures = Regex::new(
r"(?is)^\s*template\s+(?P<name>[A-Za-z0-9_-]+)(?:\s+for\s+(?P<task>.*?))?(?:\s+with\s+(?P<vars>.+))?\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<BTreeMap<String, String>, 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<String> {
}
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::<std::collections::BTreeSet<_>>();
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,