diff --git a/crates/convergio-executor/src/defaults.rs b/crates/convergio-executor/src/defaults.rs index 36560501..cf906018 100644 --- a/crates/convergio-executor/src/defaults.rs +++ b/crates/convergio-executor/src/defaults.rs @@ -6,6 +6,7 @@ use convergio_runner::{PermissionProfile, RunnerKind}; use serde::{Deserialize, Serialize}; +use std::str::FromStr; /// Template the executor uses for tasks that opt out of runner-based /// dispatch. ADR-0034 introduced per-task `runner_kind` / `profile` @@ -53,3 +54,106 @@ impl Default for RunnerDefaults { } } } + +impl RunnerDefaults { + /// Read defaults from the environment, falling back to + /// [`RunnerDefaults::default`] for any value not provided. + /// + /// Recognised variables: + /// - `CONVERGIO_RUNNER_DEFAULT` — wire format `:`. + /// Examples: `claude:sonnet`, `claude:opus`, `copilot:gpt-5.2`, + /// `copilot:claude-opus`. Invalid strings log a warning and the + /// compiled-in default is used. + /// - `CONVERGIO_RUNNER_PROFILE` — `standard` (default), + /// `restricted`, `unrestricted`. + /// - `CONVERGIO_DAEMON_URL` — base URL the spawned agent calls + /// back to; defaults to `http://127.0.0.1:8420`. + pub fn from_env() -> Self { + let mut out = Self::default(); + if let Ok(raw) = std::env::var("CONVERGIO_RUNNER_DEFAULT") { + match RunnerKind::from_str(raw.trim()) { + Ok(k) => out.kind = k, + Err(e) => tracing::warn!( + raw = %raw, + error = %e, + "CONVERGIO_RUNNER_DEFAULT not parseable; using compiled-in default" + ), + } + } + if let Ok(raw) = std::env::var("CONVERGIO_RUNNER_PROFILE") { + match PermissionProfile::from_str(raw.trim()) { + Ok(p) => out.profile = p, + Err(e) => tracing::warn!( + raw = %raw, + error = %e, + "CONVERGIO_RUNNER_PROFILE not parseable; using compiled-in default" + ), + } + } + if let Ok(url) = std::env::var("CONVERGIO_DAEMON_URL") { + if !url.trim().is_empty() { + out.daemon_url = url; + } + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + // Env vars are process-global; serialise the cases that touch + // them so they don't race when `cargo test` parallelises. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn clear_env() { + for k in [ + "CONVERGIO_RUNNER_DEFAULT", + "CONVERGIO_RUNNER_PROFILE", + "CONVERGIO_DAEMON_URL", + ] { + std::env::remove_var(k); + } + } + + #[test] + fn from_env_falls_back_to_default_when_unset() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + let d = RunnerDefaults::from_env(); + assert_eq!(d.kind.to_string(), "claude:sonnet"); + assert!(matches!(d.profile, PermissionProfile::Standard)); + } + + #[test] + fn from_env_picks_copilot_when_requested() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + std::env::set_var("CONVERGIO_RUNNER_DEFAULT", "copilot:gpt-5.2"); + let d = RunnerDefaults::from_env(); + clear_env(); + assert_eq!(d.kind.to_string(), "copilot:gpt-5.2"); + } + + #[test] + fn from_env_invalid_runner_keeps_default() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + std::env::set_var("CONVERGIO_RUNNER_DEFAULT", "garbage-no-colon"); + let d = RunnerDefaults::from_env(); + clear_env(); + assert_eq!(d.kind.to_string(), "claude:sonnet"); + } + + #[test] + fn from_env_overrides_daemon_url() { + let _g = ENV_LOCK.lock().unwrap(); + clear_env(); + std::env::set_var("CONVERGIO_DAEMON_URL", "http://10.0.0.1:9000"); + let d = RunnerDefaults::from_env(); + clear_env(); + assert_eq!(d.daemon_url, "http://10.0.0.1:9000"); + } +} diff --git a/crates/convergio-server/src/main.rs b/crates/convergio-server/src/main.rs index e1bba854..c11a6cce 100644 --- a/crates/convergio-server/src/main.rs +++ b/crates/convergio-server/src/main.rs @@ -11,7 +11,9 @@ use convergio_db::Pool; use convergio_durability::reaper::{self, ReaperConfig}; use convergio_durability::telemetry_collector::{self, CollectorConfig}; use convergio_durability::{init as init_durability, Durability}; -use convergio_executor::{spawn_loop as executor_spawn_loop, Executor, SpawnTemplate}; +use convergio_executor::{ + spawn_loop as executor_spawn_loop, Executor, RunnerDefaults, SpawnTemplate, +}; use convergio_lifecycle::watcher::{self, WatcherConfig}; use convergio_lifecycle::Supervisor; use convergio_server::{router, AppState}; @@ -117,11 +119,20 @@ async fn start( }; let _watcher = watcher::spawn((*supervisor).clone(), watcher_config); - let executor = Arc::new(Executor::new( - (*durability).clone(), - (*supervisor).clone(), - SpawnTemplate::default(), - )); + let runner_defaults = RunnerDefaults::from_env(); + tracing::info!( + runner_kind = %runner_defaults.kind, + runner_profile = ?runner_defaults.profile, + "executor runner defaults" + ); + let executor = Arc::new( + Executor::new( + (*durability).clone(), + (*supervisor).clone(), + SpawnTemplate::default(), + ) + .with_defaults(runner_defaults), + ); let executor_tick = Duration::seconds(parse_env_i64("CONVERGIO_EXECUTOR_TICK_SECS", 30)); let _executor_loop = executor_spawn_loop(executor, executor_tick); diff --git a/crates/convergio-server/src/routes/dispatch.rs b/crates/convergio-server/src/routes/dispatch.rs index f595f725..3ed2c93a 100644 --- a/crates/convergio-server/src/routes/dispatch.rs +++ b/crates/convergio-server/src/routes/dispatch.rs @@ -8,7 +8,7 @@ use crate::error::ApiError; use axum::extract::State; use axum::routing::post; use axum::{Json, Router}; -use convergio_executor::{Executor, SpawnTemplate}; +use convergio_executor::{Executor, RunnerDefaults, SpawnTemplate}; use serde_json::{json, Value}; /// Mount the dispatch route. @@ -21,7 +21,8 @@ async fn dispatch(State(state): State) -> Result, ApiError (*state.durability).clone(), (*state.supervisor).clone(), SpawnTemplate::default(), - ); + ) + .with_defaults(RunnerDefaults::from_env()); let n = exec.tick().await.map_err(map_exec)?; Ok(Json(json!({"dispatched": n}))) }