fix: make mission workspaces backend-aware

This commit is contained in:
Thomas Marchand
2026-01-18 11:10:03 +00:00
parent 3ea5c79b95
commit e872eee19c
4 changed files with 215 additions and 90 deletions

View File

@@ -132,9 +132,9 @@ pub async fn get_backend_config(
let api_key_configured = state
.secrets
.as_ref()
.map(|s| {
// Check async context
false // TODO: implement proper secret check
.map(|_s| {
// TODO: implement proper secret check
false
})
.unwrap_or(false);
serde_json::json!({

View File

@@ -1059,7 +1059,7 @@ pub async fn create_mission(
) -> Result<Json<Mission>, (StatusCode, String)> {
let (tx, rx) = oneshot::channel();
let (title, workspace_id, agent, model_override, backend) = body
let (title, workspace_id, agent, model_override, mut backend) = body
.map(|b| {
(
b.title.clone(),
@@ -1071,6 +1071,12 @@ pub async fn create_mission(
})
.unwrap_or((None, None, None, None, None));
if let Some(value) = backend.as_ref() {
if value.trim().is_empty() {
backend = None;
}
}
// Validate agent exists before creating mission (fail fast with clear error)
if let Some(ref agent_name) = agent {
super::library::validate_agent_exists(&state, agent_name)
@@ -1078,6 +1084,16 @@ pub async fn create_mission(
.map_err(|e| (StatusCode::BAD_REQUEST, e))?;
}
if let Some(ref backend_id) = backend {
let registry = state.backend_registry.read().await;
if registry.get(backend_id).is_none() {
return Err((
StatusCode::BAD_REQUEST,
format!("Unknown backend: {}", backend_id),
));
}
}
let control = control_for_user(&state, &user).await;
control
.cmd_tx
@@ -2318,6 +2334,7 @@ async fn control_actor_loop(
tid,
mission.workspace_id,
mission.agent.clone(),
Some(mission.backend.clone()),
);
// Load existing history
for entry in &mission.history {
@@ -2449,19 +2466,20 @@ async fn control_actor_loop(
let progress_ref = Arc::clone(&progress);
// Capture which mission this task is working on
let mission_id = current_mission.read().await.clone();
let (workspace_id, model_override, mission_agent) = if let Some(mid) = mission_id {
let (workspace_id, model_override, mission_agent, backend_id) = if let Some(mid) = mission_id {
match mission_store.get_mission(mid).await {
Ok(Some(mission)) => (
Some(mission.workspace_id),
mission.model_override.clone(),
mission.agent.clone(),
Some(mission.backend.clone()),
),
Ok(None) => {
tracing::warn!(
"Mission {} not found while resolving workspace",
mid
);
(None, None, None)
(None, None, None, None)
}
Err(e) => {
tracing::warn!(
@@ -2469,11 +2487,11 @@ async fn control_actor_loop(
mid,
e
);
(None, None, None)
(None, None, None, None)
}
}
} else {
(None, None, None)
(None, None, None, None)
};
// Per-message agent overrides mission agent
let agent_override = per_msg_agent.or(mission_agent);
@@ -2499,6 +2517,7 @@ async fn control_actor_loop(
progress_ref,
mission_id,
workspace_id,
backend_id,
model_override,
agent_override,
)
@@ -2680,6 +2699,7 @@ async fn control_actor_loop(
mission_id,
mission.workspace_id,
mission.agent.clone(),
Some(mission.backend.clone()),
);
// Load existing history into runner to preserve conversation context
@@ -2847,6 +2867,7 @@ async fn control_actor_loop(
let tree_ref = Arc::clone(&current_tree);
let progress_ref = Arc::clone(&progress);
let workspace_id = Some(mission.workspace_id);
let backend_id = Some(mission.backend.clone());
let model_override = mission.model_override.clone();
// Resume uses mission agent (no per-message override for resumes)
let agent_override = mission.agent.clone();
@@ -2871,6 +2892,7 @@ async fn control_actor_loop(
progress_ref,
Some(mission_id),
workspace_id,
backend_id,
model_override,
agent_override,
)
@@ -3303,19 +3325,20 @@ async fn control_actor_loop(
running_cancel = Some(cancel.clone());
// Capture which mission this task is working on
let mission_id = current_mission.read().await.clone();
let (workspace_id, model_override, mission_agent) = if let Some(mid) = mission_id {
let (workspace_id, model_override, mission_agent, backend_id) = if let Some(mid) = mission_id {
match mission_store.get_mission(mid).await {
Ok(Some(mission)) => (
Some(mission.workspace_id),
mission.model_override.clone(),
mission.agent.clone(),
Some(mission.backend.clone()),
),
Ok(None) => {
tracing::warn!(
"Mission {} not found while resolving workspace",
mid
);
(None, None, None)
(None, None, None, None)
}
Err(e) => {
tracing::warn!(
@@ -3323,11 +3346,11 @@ async fn control_actor_loop(
mid,
e
);
(None, None, None)
(None, None, None, None)
}
}
} else {
(None, None, None)
(None, None, None, None)
};
// Per-message agent overrides mission agent
let agent_override = per_msg_agent.or(mission_agent);
@@ -3352,6 +3375,7 @@ async fn control_actor_loop(
progress_ref,
mission_id,
workspace_id,
backend_id,
model_override,
agent_override,
)
@@ -3572,6 +3596,7 @@ async fn run_single_control_turn(
progress_snapshot: Arc<RwLock<ExecutionProgress>>,
mission_id: Option<Uuid>,
workspace_id: Option<Uuid>,
backend_id: Option<String>,
model_override: Option<String>,
agent_override: Option<String>,
) -> crate::agents::AgentResult {
@@ -3587,13 +3612,20 @@ async fn run_single_control_turn(
// Get library for skill syncing
let lib_guard = library.read().await;
let lib_ref = lib_guard.as_ref().map(|l| l.as_ref());
let dir =
match workspace::prepare_mission_workspace_with_skills(&ws, &mcp, lib_ref, mid).await {
Ok(dir) => dir,
Err(e) => {
tracing::warn!("Failed to prepare mission workspace: {}", e);
ws.path.clone()
}
let dir = match workspace::prepare_mission_workspace_with_skills_backend(
&ws,
&mcp,
lib_ref,
mid,
backend_id.as_deref().unwrap_or("opencode"),
)
.await
{
Ok(dir) => dir,
Err(e) => {
tracing::warn!("Failed to prepare mission workspace: {}", e);
ws.path.clone()
}
};
(dir, Some(ws))
} else {

View File

@@ -75,6 +75,9 @@ pub struct MissionRunner {
/// Workspace ID where this mission should run
pub workspace_id: Uuid,
/// Backend ID used for this mission
pub backend_id: String,
/// Current state
pub state: MissionRunState,
@@ -111,10 +114,16 @@ pub struct MissionRunner {
impl MissionRunner {
/// Create a new mission runner.
pub fn new(mission_id: Uuid, workspace_id: Uuid, agent_override: Option<String>) -> Self {
pub fn new(
mission_id: Uuid,
workspace_id: Uuid,
agent_override: Option<String>,
backend_id: Option<String>,
) -> Self {
Self {
mission_id,
workspace_id,
backend_id: backend_id.unwrap_or_else(|| "opencode".to_string()),
state: MissionRunState::Queued,
agent_override,
queue: VecDeque::new(),
@@ -239,6 +248,7 @@ impl MissionRunner {
let mission_id = self.mission_id;
let workspace_id = self.workspace_id;
let agent_override = self.agent_override.clone();
let backend_id = self.backend_id.clone();
let user_message = msg.content.clone();
let msg_id = msg.id;
tracing::info!(
@@ -282,6 +292,7 @@ impl MissionRunner {
progress_ref,
mission_id,
Some(workspace_id),
backend_id,
agent_override,
)
.await;
@@ -384,6 +395,7 @@ async fn run_mission_turn(
progress_snapshot: Arc<RwLock<ExecutionProgress>>,
mission_id: Uuid,
workspace_id: Option<Uuid>,
backend_id: String,
agent_override: Option<String>,
) -> AgentResult {
let mut config = config;
@@ -465,8 +477,14 @@ async fn run_mission_turn(
let mission_work_dir = match {
let lib_guard = library.read().await;
let lib_ref = lib_guard.as_ref().map(|l| l.as_ref());
workspace::prepare_mission_workspace_with_skills(&workspace, &mcp, lib_ref, mission_id)
.await
workspace::prepare_mission_workspace_with_skills_backend(
&workspace,
&mcp,
lib_ref,
mission_id,
&backend_id,
)
.await
} {
Ok(dir) => {
tracing::info!(

View File

@@ -692,6 +692,62 @@ fn opencode_entry_from_mcp(
}
}
fn claude_entry_from_mcp(
config: &McpServerConfig,
workspace_dir: &Path,
workspace_root: &Path,
workspace_type: WorkspaceType,
workspace_env: &HashMap<String, String>,
shared_network: Option<bool>,
) -> serde_json::Value {
match &config.transport {
McpTransport::Http { endpoint, headers } => {
let mut entry = serde_json::Map::new();
entry.insert("url".to_string(), json!(endpoint));
if !headers.is_empty() {
entry.insert("headers".to_string(), json!(headers));
}
serde_json::Value::Object(entry)
}
McpTransport::Stdio { .. } => {
let opencode_entry = opencode_entry_from_mcp(
config,
workspace_dir,
workspace_root,
workspace_type,
workspace_env,
shared_network,
);
let command_vec = opencode_entry
.get("command")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let command = command_vec
.first()
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let args: Vec<String> = command_vec
.iter()
.skip(1)
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
let mut entry = serde_json::Map::new();
entry.insert("command".to_string(), json!(command));
entry.insert("args".to_string(), json!(args));
if let Some(env) = opencode_entry.get("environment").and_then(|v| v.as_object()) {
entry.insert("env".to_string(), serde_json::Value::Object(env.clone()));
}
serde_json::Value::Object(entry)
}
}
}
async fn write_opencode_config(
workspace_dir: &Path,
mcp_configs: Vec<McpServerConfig>,
@@ -810,6 +866,7 @@ async fn write_claudecode_config(
workspace_type: WorkspaceType,
workspace_env: &HashMap<String, String>,
skill_contents: Option<&[SkillContent]>,
shared_network: Option<bool>,
) -> anyhow::Result<()> {
// Create .claude directory
let claude_dir = workspace_dir.join(".claude");
@@ -817,64 +874,24 @@ async fn write_claudecode_config(
// Build MCP servers config in Claude Code format
let mut mcp_servers = serde_json::Map::new();
let mut used = std::collections::HashSet::new();
let filtered_configs = mcp_configs.into_iter().filter(|c| c.enabled);
for config in filtered_configs {
let server_config = match &config.transport {
McpTransport::Stdio(stdio) => {
// For container workspaces, wrap commands with nspawn
let (command, args) = match workspace_type {
WorkspaceType::Chroot => {
let container_name = workspace_root
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| "default".to_string());
let mut nspawn_args = vec![
"-q".to_string(),
"-D".to_string(),
workspace_root.to_string_lossy().to_string(),
"-M".to_string(),
container_name,
"--".to_string(),
];
nspawn_args.extend(stdio.command.iter().cloned());
nspawn_args.extend(stdio.args.iter().cloned());
("systemd-nspawn".to_string(), nspawn_args)
}
WorkspaceType::Host => {
let command = stdio.command.first().cloned().unwrap_or_default();
let args: Vec<String> = stdio
.command
.iter()
.skip(1)
.chain(stdio.args.iter())
.cloned()
.collect();
(command, args)
}
};
// Merge environment variables
let mut env = workspace_env.clone();
env.extend(stdio.env.clone());
json!({
"command": command,
"args": args,
"env": env,
})
}
McpTransport::Http(http) => {
json!({
"url": http.endpoint,
"headers": http.headers,
})
}
};
let key = sanitize_key(&config.name);
mcp_servers.insert(key, server_config);
let base = sanitize_key(&config.name);
let key = unique_key(&base, &mut used);
mcp_servers.insert(
key,
claude_entry_from_mcp(
&config,
workspace_dir,
workspace_root,
workspace_type,
workspace_env,
shared_network,
),
);
}
// Write settings.local.json
@@ -887,10 +904,15 @@ async fn write_claudecode_config(
// Generate CLAUDE.md from skills
if let Some(skills) = skill_contents {
if !skills.is_empty() {
let claude_md_path = workspace_dir.join("CLAUDE.md");
if skills.is_empty() {
let _ = tokio::fs::remove_file(&claude_md_path).await;
} else {
let mut claude_md = String::new();
claude_md.push_str("# Project Context\n\n");
claude_md.push_str("This file was generated by Open Agent. It contains context from configured skills.\n\n");
claude_md.push_str(
"This file was generated by Open Agent. It contains context from configured skills.\n\n",
);
for skill in skills {
claude_md.push_str(&format!("## {}\n\n", skill.name));
@@ -908,7 +930,6 @@ async fn write_claudecode_config(
claude_md.push_str("\n\n");
}
let claude_md_path = workspace_dir.join("CLAUDE.md");
tokio::fs::write(&claude_md_path, claude_md).await?;
}
}
@@ -943,6 +964,17 @@ pub async fn write_backend_config(
.await
}
"claudecode" => {
// Keep OpenCode config in sync for compatibility with existing execution pipeline.
write_opencode_config(
workspace_dir,
mcp_configs.clone(),
workspace_root,
workspace_type,
workspace_env,
skill_allowlist,
shared_network,
)
.await?;
write_claudecode_config(
workspace_dir,
mcp_configs,
@@ -950,6 +982,7 @@ pub async fn write_backend_config(
workspace_type,
workspace_env,
skill_contents,
shared_network,
)
.await
}
@@ -1173,6 +1206,25 @@ pub async fn sync_skills_to_dir(
return Ok(());
}
let skills_to_write = collect_skill_contents(skill_names, context_name, library).await;
write_skills_to_workspace(target_dir, &skills_to_write).await?;
tracing::info!(
context = %context_name,
skills = ?skill_names,
target = %target_dir.display(),
"Synced skills to directory"
);
Ok(())
}
async fn collect_skill_contents(
skill_names: &[String],
context_name: &str,
library: &LibraryStore,
) -> Vec<SkillContent> {
let mut skills_to_write: Vec<SkillContent> = Vec::new();
for skill_name in skill_names {
@@ -1200,16 +1252,7 @@ pub async fn sync_skills_to_dir(
}
}
write_skills_to_workspace(target_dir, &skills_to_write).await?;
tracing::info!(
context = %context_name,
skills = ?skill_names,
target = %target_dir.display(),
"Synced skills to directory"
);
Ok(())
skills_to_write
}
/// Tool content to be written to the workspace.
@@ -1500,6 +1543,24 @@ pub async fn prepare_mission_workspace_with_skills(
mcp: &McpRegistry,
library: Option<&LibraryStore>,
mission_id: Uuid,
) -> anyhow::Result<PathBuf> {
prepare_mission_workspace_with_skills_backend(
workspace,
mcp,
library,
mission_id,
"opencode",
)
.await
}
/// Prepare a workspace directory for a mission with skill and tool syncing for a specific backend.
pub async fn prepare_mission_workspace_with_skills_backend(
workspace: &Workspace,
mcp: &McpRegistry,
library: Option<&LibraryStore>,
mission_id: Uuid,
backend_id: &str,
) -> anyhow::Result<PathBuf> {
let dir = mission_workspace_dir_for_root(&workspace.path, mission_id);
prepare_workspace_dir(&dir).await?;
@@ -1509,13 +1570,27 @@ pub async fn prepare_mission_workspace_with_skills(
} else {
Some(workspace.skills.as_slice())
};
write_opencode_config(
let mut skill_contents: Option<Vec<SkillContent>> = None;
if backend_id == "claudecode" {
if let Some(lib) = library {
let context = format!("mission-{}", mission_id);
let skill_names =
resolve_workspace_skill_names(workspace, lib).await.unwrap_or_default();
let skills = collect_skill_contents(&skill_names, &context, lib).await;
skill_contents = Some(skills);
}
}
write_backend_config(
&dir,
backend_id,
mcp_configs,
&workspace.path,
workspace.workspace_type,
&workspace.env_vars,
skill_allowlist,
skill_contents.as_deref(),
workspace.shared_network,
)
.await?;