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 let api_key_configured = state
.secrets .secrets
.as_ref() .as_ref()
.map(|s| { .map(|_s| {
// Check async context // TODO: implement proper secret check
false // TODO: implement proper secret check false
}) })
.unwrap_or(false); .unwrap_or(false);
serde_json::json!({ serde_json::json!({

View File

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

View File

@@ -75,6 +75,9 @@ pub struct MissionRunner {
/// Workspace ID where this mission should run /// Workspace ID where this mission should run
pub workspace_id: Uuid, pub workspace_id: Uuid,
/// Backend ID used for this mission
pub backend_id: String,
/// Current state /// Current state
pub state: MissionRunState, pub state: MissionRunState,
@@ -111,10 +114,16 @@ pub struct MissionRunner {
impl MissionRunner { impl MissionRunner {
/// Create a new mission runner. /// 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 { Self {
mission_id, mission_id,
workspace_id, workspace_id,
backend_id: backend_id.unwrap_or_else(|| "opencode".to_string()),
state: MissionRunState::Queued, state: MissionRunState::Queued,
agent_override, agent_override,
queue: VecDeque::new(), queue: VecDeque::new(),
@@ -239,6 +248,7 @@ impl MissionRunner {
let mission_id = self.mission_id; let mission_id = self.mission_id;
let workspace_id = self.workspace_id; let workspace_id = self.workspace_id;
let agent_override = self.agent_override.clone(); let agent_override = self.agent_override.clone();
let backend_id = self.backend_id.clone();
let user_message = msg.content.clone(); let user_message = msg.content.clone();
let msg_id = msg.id; let msg_id = msg.id;
tracing::info!( tracing::info!(
@@ -282,6 +292,7 @@ impl MissionRunner {
progress_ref, progress_ref,
mission_id, mission_id,
Some(workspace_id), Some(workspace_id),
backend_id,
agent_override, agent_override,
) )
.await; .await;
@@ -384,6 +395,7 @@ async fn run_mission_turn(
progress_snapshot: Arc<RwLock<ExecutionProgress>>, progress_snapshot: Arc<RwLock<ExecutionProgress>>,
mission_id: Uuid, mission_id: Uuid,
workspace_id: Option<Uuid>, workspace_id: Option<Uuid>,
backend_id: String,
agent_override: Option<String>, agent_override: Option<String>,
) -> AgentResult { ) -> AgentResult {
let mut config = config; let mut config = config;
@@ -465,8 +477,14 @@ async fn run_mission_turn(
let mission_work_dir = match { let mission_work_dir = match {
let lib_guard = library.read().await; let lib_guard = library.read().await;
let lib_ref = lib_guard.as_ref().map(|l| l.as_ref()); let lib_ref = lib_guard.as_ref().map(|l| l.as_ref());
workspace::prepare_mission_workspace_with_skills(&workspace, &mcp, lib_ref, mission_id) workspace::prepare_mission_workspace_with_skills_backend(
.await &workspace,
&mcp,
lib_ref,
mission_id,
&backend_id,
)
.await
} { } {
Ok(dir) => { Ok(dir) => {
tracing::info!( 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( async fn write_opencode_config(
workspace_dir: &Path, workspace_dir: &Path,
mcp_configs: Vec<McpServerConfig>, mcp_configs: Vec<McpServerConfig>,
@@ -810,6 +866,7 @@ async fn write_claudecode_config(
workspace_type: WorkspaceType, workspace_type: WorkspaceType,
workspace_env: &HashMap<String, String>, workspace_env: &HashMap<String, String>,
skill_contents: Option<&[SkillContent]>, skill_contents: Option<&[SkillContent]>,
shared_network: Option<bool>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
// Create .claude directory // Create .claude directory
let claude_dir = workspace_dir.join(".claude"); let claude_dir = workspace_dir.join(".claude");
@@ -817,64 +874,24 @@ async fn write_claudecode_config(
// Build MCP servers config in Claude Code format // Build MCP servers config in Claude Code format
let mut mcp_servers = serde_json::Map::new(); 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); let filtered_configs = mcp_configs.into_iter().filter(|c| c.enabled);
for config in filtered_configs { for config in filtered_configs {
let server_config = match &config.transport { let base = sanitize_key(&config.name);
McpTransport::Stdio(stdio) => { let key = unique_key(&base, &mut used);
// For container workspaces, wrap commands with nspawn mcp_servers.insert(
let (command, args) = match workspace_type { key,
WorkspaceType::Chroot => { claude_entry_from_mcp(
let container_name = workspace_root &config,
.file_name() workspace_dir,
.map(|s| s.to_string_lossy().to_string()) workspace_root,
.unwrap_or_else(|| "default".to_string()); workspace_type,
let mut nspawn_args = vec![ workspace_env,
"-q".to_string(), shared_network,
"-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);
} }
// Write settings.local.json // Write settings.local.json
@@ -887,10 +904,15 @@ async fn write_claudecode_config(
// Generate CLAUDE.md from skills // Generate CLAUDE.md from skills
if let Some(skills) = skill_contents { 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(); let mut claude_md = String::new();
claude_md.push_str("# Project Context\n\n"); 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 { for skill in skills {
claude_md.push_str(&format!("## {}\n\n", skill.name)); claude_md.push_str(&format!("## {}\n\n", skill.name));
@@ -908,7 +930,6 @@ async fn write_claudecode_config(
claude_md.push_str("\n\n"); claude_md.push_str("\n\n");
} }
let claude_md_path = workspace_dir.join("CLAUDE.md");
tokio::fs::write(&claude_md_path, claude_md).await?; tokio::fs::write(&claude_md_path, claude_md).await?;
} }
} }
@@ -943,6 +964,17 @@ pub async fn write_backend_config(
.await .await
} }
"claudecode" => { "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( write_claudecode_config(
workspace_dir, workspace_dir,
mcp_configs, mcp_configs,
@@ -950,6 +982,7 @@ pub async fn write_backend_config(
workspace_type, workspace_type,
workspace_env, workspace_env,
skill_contents, skill_contents,
shared_network,
) )
.await .await
} }
@@ -1173,6 +1206,25 @@ pub async fn sync_skills_to_dir(
return Ok(()); 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(); let mut skills_to_write: Vec<SkillContent> = Vec::new();
for skill_name in skill_names { 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?; skills_to_write
tracing::info!(
context = %context_name,
skills = ?skill_names,
target = %target_dir.display(),
"Synced skills to directory"
);
Ok(())
} }
/// Tool content to be written to the workspace. /// Tool content to be written to the workspace.
@@ -1500,6 +1543,24 @@ pub async fn prepare_mission_workspace_with_skills(
mcp: &McpRegistry, mcp: &McpRegistry,
library: Option<&LibraryStore>, library: Option<&LibraryStore>,
mission_id: Uuid, 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> { ) -> anyhow::Result<PathBuf> {
let dir = mission_workspace_dir_for_root(&workspace.path, mission_id); let dir = mission_workspace_dir_for_root(&workspace.path, mission_id);
prepare_workspace_dir(&dir).await?; prepare_workspace_dir(&dir).await?;
@@ -1509,13 +1570,27 @@ pub async fn prepare_mission_workspace_with_skills(
} else { } else {
Some(workspace.skills.as_slice()) 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, &dir,
backend_id,
mcp_configs, mcp_configs,
&workspace.path, &workspace.path,
workspace.workspace_type, workspace.workspace_type,
&workspace.env_vars, &workspace.env_vars,
skill_allowlist, skill_allowlist,
skill_contents.as_deref(),
workspace.shared_network, workspace.shared_network,
) )
.await?; .await?;