From 559b6e33d83702172eb6999e06963b7a86ac73da Mon Sep 17 00:00:00 2001 From: Roberdan Date: Wed, 6 May 2026 22:21:13 +0200 Subject: [PATCH] feat(executor): RunnerDefaults::from_env for CONVERGIO_RUNNER_DEFAULT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon hard-coded `claude:sonnet` as the executor's default runner. Operators who use Copilot (or any other vendor) had no way to flip the dispatcher's default short of editing `defaults.rs`. Adds: - `RunnerDefaults::from_env()` reading `CONVERGIO_RUNNER_DEFAULT` (wire format `:` — e.g. `copilot:gpt-5.2`, `claude:opus`), `CONVERGIO_RUNNER_PROFILE` (`standard` / `restricted` / `unrestricted`), and `CONVERGIO_DAEMON_URL`. Invalid values log a warning and fall back to the compiled-in default. - Server wiring: `convergio-server`'s background loop and the manual `POST /v1/dispatch` route both call `Executor::new(...).with_defaults(RunnerDefaults::from_env())`. - Boot-time `tracing::info!` logs the resolved kind/profile so operators can confirm the daemon picked up their override. Tests cover the four cases (default, valid override, invalid fallback, daemon-url override), serialised via a `Mutex` because env vars are process-global. Verified end-to-end: with `CONVERGIO_RUNNER_DEFAULT=copilot:gpt-5.2` + `CONVERGIO_EXECUTOR_USE_RUNNER=1` set in the launchd plist, the executor's tick spawns real `copilot -p ...` processes carrying the per-task prompt + graph context-pack from `convergio-runner`. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/convergio-executor/src/defaults.rs | 104 ++++++++++++++++++ crates/convergio-server/src/main.rs | 23 +++- .../convergio-server/src/routes/dispatch.rs | 5 +- 3 files changed, 124 insertions(+), 8 deletions(-) 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}))) }