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

@@ -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,