mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-11 02:20:29 +08:00
feat: add ecc2 orchestration templates
This commit is contained in:
parent
1e4d6a4161
commit
194bf605c2
@ -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: ®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(
|
||||
|
||||
161
ecc2/src/main.rs
161
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<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"])
|
||||
|
||||
@ -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)?;
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user