feat: add ecc2 orchestration templates

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

View File

@@ -150,6 +150,197 @@ pub fn get_team_status(db: &StateStore, id: &str, depth: usize) -> Result<TeamSt
})
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TemplateLaunchStepOutcome {
pub step_name: String,
pub session_id: String,
pub task: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct TemplateLaunchOutcome {
pub template_name: String,
pub step_count: usize,
pub anchor_session_id: Option<String>,
pub created: Vec<TemplateLaunchStepOutcome>,
}
pub async fn launch_orchestration_template(
db: &StateStore,
cfg: &Config,
template_name: &str,
source_session_id: Option<&str>,
task: Option<&str>,
variables: BTreeMap<String, String>,
) -> Result<TemplateLaunchOutcome> {
let repo_root =
std::env::current_dir().context("Failed to resolve current working directory")?;
let runner_program =
std::env::current_exe().context("Failed to resolve ECC executable path")?;
let source_session = source_session_id
.map(|id| resolve_session(db, id))
.transpose()?;
let vars = build_template_variables(&repo_root, source_session.as_ref(), task, variables);
let template = cfg.resolve_orchestration_template(template_name, &vars)?;
let live_sessions = db
.list_sessions()?
.into_iter()
.filter(|session| {
matches!(
session.state,
SessionState::Pending
| SessionState::Running
| SessionState::Idle
| SessionState::Stale
)
})
.count();
let available_slots = cfg.max_parallel_sessions.saturating_sub(live_sessions);
if template.steps.len() > available_slots {
anyhow::bail!(
"template {template_name} requires {} session slots but only {available_slots} available",
template.steps.len()
);
}
let default_profile = cfg
.default_agent_profile
.as_deref()
.map(|name| cfg.resolve_agent_profile(name))
.transpose()?;
let base_grouping = SessionGrouping {
project: Some(
source_session
.as_ref()
.map(|session| session.project.clone())
.unwrap_or_else(|| default_project_label(&repo_root)),
),
task_group: Some(
source_session
.as_ref()
.map(|session| session.task_group.clone())
.or_else(|| task.map(default_task_group_label))
.unwrap_or_else(|| template_name.replace(['_', '-'], " ")),
),
};
let mut created = Vec::with_capacity(template.steps.len());
let mut anchor_session_id = source_session.as_ref().map(|session| session.id.clone());
let mut created_anchor_id: Option<String> = None;
for step in template.steps {
let profile = match step.profile.as_deref() {
Some(name) => Some(cfg.resolve_agent_profile(name)?),
None if step.agent.is_some() => None,
None => default_profile.clone(),
};
let agent = step
.agent
.as_deref()
.unwrap_or(&cfg.default_agent)
.to_string();
let grouping = SessionGrouping {
project: step
.project
.clone()
.or_else(|| base_grouping.project.clone()),
task_group: step
.task_group
.clone()
.or_else(|| base_grouping.task_group.clone()),
};
let session_id = queue_session_with_resolved_profile_and_runner_program(
db,
cfg,
&step.task,
&agent,
step.worktree,
&repo_root,
&runner_program,
profile,
grouping,
)
.await?;
if let Some(parent_id) = anchor_session_id.as_deref() {
let parent = resolve_session(db, parent_id)?;
send_task_handoff(
db,
&parent,
&session_id,
&step.task,
&format!("template {} | {}", template_name, step.name),
)?;
} else {
created_anchor_id = Some(session_id.clone());
anchor_session_id = Some(session_id.clone());
}
if created_anchor_id.is_none() {
created_anchor_id = Some(session_id.clone());
}
created.push(TemplateLaunchStepOutcome {
step_name: step.name,
session_id,
task: step.task,
});
}
Ok(TemplateLaunchOutcome {
template_name: template_name.to_string(),
step_count: created.len(),
anchor_session_id: source_session
.as_ref()
.map(|session| session.id.clone())
.or(created_anchor_id),
created,
})
}
pub(crate) fn build_template_variables(
repo_root: &Path,
source_session: Option<&Session>,
task: Option<&str>,
mut variables: BTreeMap<String, String>,
) -> BTreeMap<String, String> {
if let Some(source) = source_session {
variables
.entry("source_task".to_string())
.or_insert_with(|| source.task.clone());
variables
.entry("source_project".to_string())
.or_insert_with(|| source.project.clone());
variables
.entry("source_task_group".to_string())
.or_insert_with(|| source.task_group.clone());
variables
.entry("source_agent".to_string())
.or_insert_with(|| source.agent_type.clone());
}
let effective_task = task
.map(ToOwned::to_owned)
.or_else(|| source_session.map(|session| session.task.clone()));
if let Some(task) = effective_task {
variables.entry("task".to_string()).or_insert(task.clone());
variables
.entry("task_group".to_string())
.or_insert_with(|| default_task_group_label(&task));
}
variables.entry("project".to_string()).or_insert_with(|| {
source_session
.map(|session| session.project.clone())
.unwrap_or_else(|| default_project_label(repo_root))
});
variables
.entry("cwd".to_string())
.or_insert_with(|| repo_root.display().to_string());
variables
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct HeartbeatEnforcementOutcome {
pub stale_sessions: Vec<String>,
@@ -1743,7 +1934,13 @@ pub async fn run_session(
let agent_program = agent_program(agent_type)?;
let profile = db.get_session_profile(session_id)?;
let command = build_agent_command(&agent_program, task, session_id, working_dir, profile.as_ref());
let command = build_agent_command(
&agent_program,
task,
session_id,
working_dir,
profile.as_ref(),
);
capture_command_output(
cfg.db_path.clone(),
session_id.to_string(),
@@ -1901,8 +2098,32 @@ async fn queue_session_in_dir_with_runner_program(
inherited_profile_session_id: Option<&str>,
grouping: SessionGrouping,
) -> Result<String> {
let profile =
resolve_launch_profile(db, cfg, profile_name, inherited_profile_session_id)?;
let profile = resolve_launch_profile(db, cfg, profile_name, inherited_profile_session_id)?;
queue_session_with_resolved_profile_and_runner_program(
db,
cfg,
task,
agent_type,
use_worktree,
repo_root,
runner_program,
profile,
grouping,
)
.await
}
async fn queue_session_with_resolved_profile_and_runner_program(
db: &StateStore,
cfg: &Config,
task: &str,
agent_type: &str,
use_worktree: bool,
repo_root: &Path,
runner_program: &Path,
profile: Option<SessionAgentProfile>,
grouping: SessionGrouping,
) -> Result<String> {
let effective_agent_type = profile
.as_ref()
.and_then(|profile| profile.agent.as_deref())
@@ -2060,7 +2281,9 @@ fn resolve_launch_profile(
inherited_profile_session_id: Option<&str>,
) -> Result<Option<SessionAgentProfile>> {
let inherited_profile_name = match inherited_profile_session_id {
Some(session_id) => db.get_session_profile(session_id)?.map(|profile| profile.profile_name),
Some(session_id) => db
.get_session_profile(session_id)?
.map(|profile| profile.profile_name),
None => None,
};
let profile_name = explicit_profile_name
@@ -2275,7 +2498,10 @@ fn build_agent_command(
command.arg("--append-system-prompt").arg(prompt);
}
}
command.arg(task).current_dir(working_dir).stdin(Stdio::null());
command
.arg(task)
.current_dir(working_dir)
.stdin(Stdio::null());
command
}
@@ -2844,6 +3070,7 @@ mod tests {
default_agent: "claude".to_string(),
default_agent_profile: None,
agent_profiles: Default::default(),
orchestration_templates: Default::default(),
auto_dispatch_unread_handoffs: false,
auto_dispatch_limit_per_session: 5,
auto_create_worktrees: true,
@@ -3364,7 +3591,8 @@ mod tests {
}
#[tokio::test(flavor = "current_thread")]
async fn create_session_uses_default_agent_profile_and_persists_launch_settings() -> Result<()> {
async fn create_session_uses_default_agent_profile_and_persists_launch_settings() -> Result<()>
{
let tempdir = TestDir::new("manager-default-agent-profile")?;
let repo_root = tempdir.path().join("repo");
init_git_repo(&repo_root)?;

View File

@@ -591,8 +591,8 @@ impl StateStore {
.context("serialize allowed agent profile tools")?;
let disallowed_tools_json = serde_json::to_string(&profile.disallowed_tools)
.context("serialize disallowed agent profile tools")?;
let add_dirs_json = serde_json::to_string(&profile.add_dirs)
.context("serialize agent profile add_dirs")?;
let add_dirs_json =
serde_json::to_string(&profile.add_dirs).context("serialize agent profile add_dirs")?;
self.conn.execute(
"INSERT INTO session_profiles (
@@ -2683,7 +2683,10 @@ mod tests {
assert_eq!(profile.allowed_tools, vec!["Read", "Edit"]);
assert_eq!(profile.disallowed_tools, vec!["Bash"]);
assert_eq!(profile.permission_mode.as_deref(), Some("plan"));
assert_eq!(profile.add_dirs, vec![PathBuf::from("docs"), PathBuf::from("specs")]);
assert_eq!(
profile.add_dirs,
vec![PathBuf::from("docs"), PathBuf::from("specs")]
);
assert_eq!(profile.max_budget_usd, Some(1.5));
assert_eq!(profile.token_budget, Some(1200));
assert_eq!(