mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-22 01:53:34 +08:00
feat: add ecc2 orchestration templates
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use regex::Regex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -78,6 +79,50 @@ pub struct ResolvedAgentProfile {
|
|||||||
pub append_system_prompt: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -93,6 +138,7 @@ pub struct Config {
|
|||||||
pub default_agent: String,
|
pub default_agent: String,
|
||||||
pub default_agent_profile: Option<String>,
|
pub default_agent_profile: Option<String>,
|
||||||
pub agent_profiles: BTreeMap<String, AgentProfileConfig>,
|
pub agent_profiles: BTreeMap<String, AgentProfileConfig>,
|
||||||
|
pub orchestration_templates: BTreeMap<String, OrchestrationTemplateConfig>,
|
||||||
pub auto_dispatch_unread_handoffs: bool,
|
pub auto_dispatch_unread_handoffs: bool,
|
||||||
pub auto_dispatch_limit_per_session: usize,
|
pub auto_dispatch_limit_per_session: usize,
|
||||||
pub auto_create_worktrees: bool,
|
pub auto_create_worktrees: bool,
|
||||||
@@ -156,6 +202,7 @@ impl Default for Config {
|
|||||||
default_agent: "claude".to_string(),
|
default_agent: "claude".to_string(),
|
||||||
default_agent_profile: None,
|
default_agent_profile: None,
|
||||||
agent_profiles: BTreeMap::new(),
|
agent_profiles: BTreeMap::new(),
|
||||||
|
orchestration_templates: BTreeMap::new(),
|
||||||
auto_dispatch_unread_handoffs: false,
|
auto_dispatch_unread_handoffs: false,
|
||||||
auto_dispatch_limit_per_session: 5,
|
auto_dispatch_limit_per_session: 5,
|
||||||
auto_create_worktrees: true,
|
auto_create_worktrees: true,
|
||||||
@@ -219,6 +266,80 @@ impl Config {
|
|||||||
self.resolve_agent_profile_inner(name, &mut chain)
|
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(
|
fn resolve_agent_profile_inner(
|
||||||
&self,
|
&self,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -226,10 +347,7 @@ impl Config {
|
|||||||
) -> Result<ResolvedAgentProfile> {
|
) -> Result<ResolvedAgentProfile> {
|
||||||
if chain.iter().any(|existing| existing == name) {
|
if chain.iter().any(|existing| existing == name) {
|
||||||
chain.push(name.to_string());
|
chain.push(name.to_string());
|
||||||
anyhow::bail!(
|
anyhow::bail!("agent profile inheritance cycle: {}", chain.join(" -> "));
|
||||||
"agent profile inheritance cycle: {}",
|
|
||||||
chain.join(" -> ")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile = self
|
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 {
|
impl BudgetAlertThresholds {
|
||||||
pub fn sanitized(self) -> Self {
|
pub fn sanitized(self) -> Self {
|
||||||
let values = [self.advisory, self.warning, self.critical];
|
let values = [self.advisory, self.warning, self.critical];
|
||||||
@@ -574,6 +741,7 @@ mod tests {
|
|||||||
PaneLayout,
|
PaneLayout,
|
||||||
};
|
};
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -979,6 +1147,90 @@ inherits = "a"
|
|||||||
.contains("agent profile inheritance cycle"));
|
.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]
|
#[test]
|
||||||
fn completion_summary_notifications_deserialize_from_toml() {
|
fn completion_summary_notifications_deserialize_from_toml() {
|
||||||
let config: Config = toml::from_str(
|
let config: Config = toml::from_str(
|
||||||
|
|||||||
135
ecc2/src/main.rs
135
ecc2/src/main.rs
@@ -9,6 +9,7 @@ mod worktree;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
@@ -78,6 +79,20 @@ enum Commands {
|
|||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
worktree: WorktreePolicyArgs,
|
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
|
/// Route work to an existing delegate when possible, otherwise spawn a new one
|
||||||
Assign {
|
Assign {
|
||||||
/// Lead session ID or alias
|
/// Lead session ID or alias
|
||||||
@@ -458,7 +473,8 @@ async fn main() -> Result<()> {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
let session_id = session::manager::create_session_from_source_with_profile_and_grouping(
|
let session_id =
|
||||||
|
session::manager::create_session_from_source_with_profile_and_grouping(
|
||||||
&db,
|
&db,
|
||||||
&cfg,
|
&cfg,
|
||||||
&task,
|
&task,
|
||||||
@@ -479,6 +495,43 @@ async fn main() -> Result<()> {
|
|||||||
short_session(&source.id)
|
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 {
|
Some(Commands::Assign {
|
||||||
from_session,
|
from_session,
|
||||||
task,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn cli_parses_team_command() {
|
fn cli_parses_team_command() {
|
||||||
let cli = Cli::try_parse_from(["ecc", "team", "planner", "--depth", "3"])
|
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)]
|
#[derive(Debug, Clone, Default, Serialize)]
|
||||||
pub struct HeartbeatEnforcementOutcome {
|
pub struct HeartbeatEnforcementOutcome {
|
||||||
pub stale_sessions: Vec<String>,
|
pub stale_sessions: Vec<String>,
|
||||||
@@ -1743,7 +1934,13 @@ pub async fn run_session(
|
|||||||
|
|
||||||
let agent_program = agent_program(agent_type)?;
|
let agent_program = agent_program(agent_type)?;
|
||||||
let profile = db.get_session_profile(session_id)?;
|
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(
|
capture_command_output(
|
||||||
cfg.db_path.clone(),
|
cfg.db_path.clone(),
|
||||||
session_id.to_string(),
|
session_id.to_string(),
|
||||||
@@ -1901,8 +2098,32 @@ async fn queue_session_in_dir_with_runner_program(
|
|||||||
inherited_profile_session_id: Option<&str>,
|
inherited_profile_session_id: Option<&str>,
|
||||||
grouping: SessionGrouping,
|
grouping: SessionGrouping,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let profile =
|
let profile = resolve_launch_profile(db, cfg, profile_name, inherited_profile_session_id)?;
|
||||||
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
|
let effective_agent_type = profile
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|profile| profile.agent.as_deref())
|
.and_then(|profile| profile.agent.as_deref())
|
||||||
@@ -2060,7 +2281,9 @@ fn resolve_launch_profile(
|
|||||||
inherited_profile_session_id: Option<&str>,
|
inherited_profile_session_id: Option<&str>,
|
||||||
) -> Result<Option<SessionAgentProfile>> {
|
) -> Result<Option<SessionAgentProfile>> {
|
||||||
let inherited_profile_name = match inherited_profile_session_id {
|
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,
|
None => None,
|
||||||
};
|
};
|
||||||
let profile_name = explicit_profile_name
|
let profile_name = explicit_profile_name
|
||||||
@@ -2275,7 +2498,10 @@ fn build_agent_command(
|
|||||||
command.arg("--append-system-prompt").arg(prompt);
|
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
|
command
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2844,6 +3070,7 @@ mod tests {
|
|||||||
default_agent: "claude".to_string(),
|
default_agent: "claude".to_string(),
|
||||||
default_agent_profile: None,
|
default_agent_profile: None,
|
||||||
agent_profiles: Default::default(),
|
agent_profiles: Default::default(),
|
||||||
|
orchestration_templates: Default::default(),
|
||||||
auto_dispatch_unread_handoffs: false,
|
auto_dispatch_unread_handoffs: false,
|
||||||
auto_dispatch_limit_per_session: 5,
|
auto_dispatch_limit_per_session: 5,
|
||||||
auto_create_worktrees: true,
|
auto_create_worktrees: true,
|
||||||
@@ -3364,7 +3591,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test(flavor = "current_thread")]
|
#[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 tempdir = TestDir::new("manager-default-agent-profile")?;
|
||||||
let repo_root = tempdir.path().join("repo");
|
let repo_root = tempdir.path().join("repo");
|
||||||
init_git_repo(&repo_root)?;
|
init_git_repo(&repo_root)?;
|
||||||
|
|||||||
@@ -591,8 +591,8 @@ impl StateStore {
|
|||||||
.context("serialize allowed agent profile tools")?;
|
.context("serialize allowed agent profile tools")?;
|
||||||
let disallowed_tools_json = serde_json::to_string(&profile.disallowed_tools)
|
let disallowed_tools_json = serde_json::to_string(&profile.disallowed_tools)
|
||||||
.context("serialize disallowed agent profile tools")?;
|
.context("serialize disallowed agent profile tools")?;
|
||||||
let add_dirs_json = serde_json::to_string(&profile.add_dirs)
|
let add_dirs_json =
|
||||||
.context("serialize agent profile add_dirs")?;
|
serde_json::to_string(&profile.add_dirs).context("serialize agent profile add_dirs")?;
|
||||||
|
|
||||||
self.conn.execute(
|
self.conn.execute(
|
||||||
"INSERT INTO session_profiles (
|
"INSERT INTO session_profiles (
|
||||||
@@ -2683,7 +2683,10 @@ mod tests {
|
|||||||
assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]);
|
assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]);
|
||||||
assert_eq!(profile.disallowed_tools, vec!["Bash"]);
|
assert_eq!(profile.disallowed_tools, vec!["Bash"]);
|
||||||
assert_eq!(profile.permission_mode.as_deref(), Some("plan"));
|
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.max_budget_usd, Some(1.5));
|
||||||
assert_eq!(profile.token_budget, Some(1200));
|
assert_eq!(profile.token_budget, Some(1200));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use ratatui::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||||
use std::time::UNIX_EPOCH;
|
use std::time::UNIX_EPOCH;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
@@ -273,16 +273,31 @@ struct TimelineEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct SpawnRequest {
|
enum SpawnRequest {
|
||||||
|
AdHoc {
|
||||||
requested_count: usize,
|
requested_count: usize,
|
||||||
task: String,
|
task: String,
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
name: String,
|
||||||
|
task: Option<String>,
|
||||||
|
variables: BTreeMap<String, String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct SpawnPlan {
|
enum SpawnPlan {
|
||||||
|
AdHoc {
|
||||||
requested_count: usize,
|
requested_count: usize,
|
||||||
spawn_count: usize,
|
spawn_count: usize,
|
||||||
task: String,
|
task: String,
|
||||||
|
},
|
||||||
|
Template {
|
||||||
|
name: String,
|
||||||
|
task: Option<String>,
|
||||||
|
variables: BTreeMap<String, String>,
|
||||||
|
step_count: usize,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
@@ -1357,7 +1372,7 @@ impl Dashboard {
|
|||||||
"Keyboard Shortcuts:".to_string(),
|
"Keyboard Shortcuts:".to_string(),
|
||||||
"".to_string(),
|
"".to_string(),
|
||||||
" n New session".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(),
|
" 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 for selected lead".to_string(),
|
||||||
" B Rebalance backed-up delegate handoff backlog across lead teams".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.spawn_input = Some(self.spawn_prompt_seed());
|
||||||
self.set_operator_note(
|
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,7 +3434,13 @@ impl Dashboard {
|
|||||||
let agent = self.cfg.default_agent.clone();
|
let agent = self.cfg.default_agent.clone();
|
||||||
let mut created_ids = Vec::new();
|
let mut created_ids = Vec::new();
|
||||||
|
|
||||||
for task in expand_spawn_tasks(&plan.task, plan.spawn_count) {
|
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(
|
let session_id = match manager::create_session_with_grouping(
|
||||||
&self.db,
|
&self.db,
|
||||||
&self.cfg,
|
&self.cfg,
|
||||||
@@ -3441,10 +3462,11 @@ impl Dashboard {
|
|||||||
format!(
|
format!(
|
||||||
"spawn partially completed: {} of {} queued before failure: {error}",
|
"spawn partially completed: {} of {} queued before failure: {error}",
|
||||||
created_ids.len(),
|
created_ids.len(),
|
||||||
plan.spawn_count
|
spawn_count
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
if let Some(layout_note) = self.auto_split_layout_after_spawn(created_ids.len())
|
if let Some(layout_note) =
|
||||||
|
self.auto_split_layout_after_spawn(created_ids.len())
|
||||||
{
|
{
|
||||||
summary.push_str(" | ");
|
summary.push_str(" | ");
|
||||||
summary.push_str(&layout_note);
|
summary.push_str(&layout_note);
|
||||||
@@ -3478,6 +3500,31 @@ impl Dashboard {
|
|||||||
|
|
||||||
created_ids.push(session_id);
|
created_ids.push(session_id);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
SpawnPlan::Template {
|
||||||
|
name,
|
||||||
|
task,
|
||||||
|
variables,
|
||||||
|
..
|
||||||
|
} => match manager::launch_orchestration_template(
|
||||||
|
&self.db,
|
||||||
|
&self.cfg,
|
||||||
|
name,
|
||||||
|
source_session_id.as_deref(),
|
||||||
|
task.as_deref(),
|
||||||
|
variables.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(outcome) => {
|
||||||
|
created_ids.extend(outcome.created.into_iter().map(|step| step.session_id));
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
self.set_operator_note(format!("template launch failed: {error}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
let preferred_selection =
|
let preferred_selection =
|
||||||
post_spawn_selection_id(source_session_id.as_deref(), &created_ids);
|
post_spawn_selection_id(source_session_id.as_deref(), &created_ids);
|
||||||
@@ -5392,11 +5439,7 @@ impl Dashboard {
|
|||||||
fn selected_session_metrics_text(&self) -> String {
|
fn selected_session_metrics_text(&self) -> String {
|
||||||
if let Some(session) = self.sessions.get(self.selected_session) {
|
if let Some(session) = self.sessions.get(self.selected_session) {
|
||||||
let metrics = &session.metrics;
|
let metrics = &session.metrics;
|
||||||
let selected_profile = self
|
let selected_profile = self.db.get_session_profile(&session.id).ok().flatten();
|
||||||
.db
|
|
||||||
.get_session_profile(&session.id)
|
|
||||||
.ok()
|
|
||||||
.flatten();
|
|
||||||
let group_peers = self
|
let group_peers = self
|
||||||
.sessions
|
.sessions
|
||||||
.iter()
|
.iter()
|
||||||
@@ -5433,10 +5476,8 @@ impl Dashboard {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(max_budget_usd) = profile.max_budget_usd {
|
if let Some(max_budget_usd) = profile.max_budget_usd {
|
||||||
profile_details.push(format!(
|
profile_details
|
||||||
"Profile cost {}",
|
.push(format!("Profile cost {}", format_currency(max_budget_usd)));
|
||||||
format_currency(max_budget_usd)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if !profile.allowed_tools.is_empty() {
|
if !profile.allowed_tools.is_empty() {
|
||||||
profile_details.push(format!(
|
profile_details.push(format!(
|
||||||
@@ -5958,6 +5999,11 @@ impl Dashboard {
|
|||||||
.max_parallel_sessions
|
.max_parallel_sessions
|
||||||
.saturating_sub(self.active_session_count());
|
.saturating_sub(self.active_session_count());
|
||||||
|
|
||||||
|
match request {
|
||||||
|
SpawnRequest::AdHoc {
|
||||||
|
requested_count,
|
||||||
|
task,
|
||||||
|
} => {
|
||||||
if available_slots == 0 {
|
if available_slots == 0 {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"cannot queue sessions: active session limit reached ({})",
|
"cannot queue sessions: active session limit reached ({})",
|
||||||
@@ -5965,12 +6011,47 @@ impl Dashboard {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(SpawnPlan {
|
Ok(SpawnPlan::AdHoc {
|
||||||
requested_count: request.requested_count,
|
requested_count,
|
||||||
spawn_count: request.requested_count.min(available_slots),
|
spawn_count: requested_count.min(available_slots),
|
||||||
task: request.task,
|
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 {
|
fn pane_areas(&self, area: Rect) -> PaneAreas {
|
||||||
let detail_panes = self.visible_detail_panes();
|
let detail_panes = self.visible_detail_panes();
|
||||||
@@ -6289,6 +6370,10 @@ fn parse_spawn_request(input: &str) -> Result<SpawnRequest, String> {
|
|||||||
return Err("spawn request cannot be empty".to_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")
|
let count = Regex::new(r"\b([1-9]\d*)\b")
|
||||||
.expect("spawn count regex")
|
.expect("spawn count regex")
|
||||||
.captures(trimmed)
|
.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());
|
return Err("spawn request must include a task description".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(SpawnRequest {
|
Ok(SpawnRequest::AdHoc {
|
||||||
requested_count: count,
|
requested_count: count,
|
||||||
task,
|
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 {
|
fn extract_spawn_task(input: &str) -> String {
|
||||||
let trimmed = input.trim();
|
let trimmed = input.trim();
|
||||||
let lower = trimmed.to_ascii_lowercase();
|
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 {
|
fn build_spawn_note(plan: &SpawnPlan, created_count: usize, queued_count: usize) -> String {
|
||||||
let task = truncate_for_dashboard(&plan.task, 72);
|
let mut note = match plan {
|
||||||
let mut note = if plan.spawn_count < plan.requested_count {
|
SpawnPlan::AdHoc {
|
||||||
|
requested_count,
|
||||||
|
spawn_count,
|
||||||
|
task,
|
||||||
|
} => {
|
||||||
|
let task = truncate_for_dashboard(task, 72);
|
||||||
|
if spawn_count < requested_count {
|
||||||
format!(
|
format!(
|
||||||
"spawned {created_count} session(s) for {task} (requested {}, capped at {})",
|
"spawned {created_count} session(s) for {task} (requested {requested_count}, capped at {spawn_count})"
|
||||||
plan.requested_count, plan.spawn_count
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("spawned {created_count} session(s) for {task}")
|
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 {
|
if queued_count > 0 {
|
||||||
@@ -11053,7 +11211,7 @@ diff --git a/src/lib.rs b/src/lib.rs
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request,
|
request,
|
||||||
SpawnRequest {
|
SpawnRequest::AdHoc {
|
||||||
requested_count: 10,
|
requested_count: 10,
|
||||||
task: "stabilize the queue".to_string(),
|
task: "stabilize the queue".to_string(),
|
||||||
}
|
}
|
||||||
@@ -11066,13 +11224,33 @@ diff --git a/src/lib.rs b/src/lib.rs
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request,
|
request,
|
||||||
SpawnRequest {
|
SpawnRequest::AdHoc {
|
||||||
requested_count: 1,
|
requested_count: 1,
|
||||||
task: "stabilize the queue".to_string(),
|
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]
|
#[test]
|
||||||
fn build_spawn_plan_caps_requested_count_to_available_slots() {
|
fn build_spawn_plan_caps_requested_count_to_available_slots() {
|
||||||
let dashboard = test_dashboard(
|
let dashboard = test_dashboard(
|
||||||
@@ -11090,7 +11268,7 @@ diff --git a/src/lib.rs b/src/lib.rs
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
plan,
|
plan,
|
||||||
SpawnPlan {
|
SpawnPlan::AdHoc {
|
||||||
requested_count: 9,
|
requested_count: 9,
|
||||||
spawn_count: 5,
|
spawn_count: 5,
|
||||||
task: "ship release notes".to_string(),
|
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]
|
#[test]
|
||||||
fn expand_spawn_tasks_suffixes_multi_session_requests() {
|
fn expand_spawn_tasks_suffixes_multi_session_requests() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -13074,6 +13391,7 @@ diff --git a/src/lib.rs b/src/lib.rs
|
|||||||
default_agent: "claude".to_string(),
|
default_agent: "claude".to_string(),
|
||||||
default_agent_profile: None,
|
default_agent_profile: None,
|
||||||
agent_profiles: Default::default(),
|
agent_profiles: Default::default(),
|
||||||
|
orchestration_templates: Default::default(),
|
||||||
auto_dispatch_unread_handoffs: false,
|
auto_dispatch_unread_handoffs: false,
|
||||||
auto_dispatch_limit_per_session: 5,
|
auto_dispatch_limit_per_session: 5,
|
||||||
auto_create_worktrees: true,
|
auto_create_worktrees: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user