From 2b4934f808de5506bdd510e9482957055f6912c8 Mon Sep 17 00:00:00 2001 From: Oz Date: Fri, 17 Jul 2026 13:54:42 +0000 Subject: [PATCH 1/9] Add runner config to orchestration (client, REMOTE-2149) Implements the warp-client half of the optional runner config for orchestration (server/proto: warp-server#12674). Threads an optional `runner_id` through the remote execution-mode data model, proto conversions, `RunAgentsExecutor`, and the per-child dispatch into the `agent_config_snapshot`, so a runner selected on `run_agents` / `create_orchestration_config` chooses the children's compute config. - Data model: add `runner_id` to the remote variants of `RunAgentsExecutionMode`, `StartAgentExecutionMode`, `RunAgentsLaunchedExecutionMode`, and `OrchestrationExecutionMode`, plus proto read/write and the auto-launch match check. - Rename the `CloudAgentRunnerCLICommands` feature flag to `CloudAgentRunners` and use it to gate a new Runner dropdown in the Stage 1 confirmation-card editor and the Stage 2 plan-card config block (populated via the existing `getRunners` query). - Show the launched runner, if any, in the card's terminal state. Omitting a runner is a no-op and preserves current behavior. Co-Authored-By: Oz --- app/src/ai/agent/api/convert_conversation.rs | 1 + app/src/ai/agent/api/convert_from.rs | 4 + app/src/ai/agent/api/convert_from_tests.rs | 2 + app/src/ai/agent_sdk/mod.rs | 2 +- app/src/ai/agent_sdk/telemetry.rs | 2 +- .../action_model/execute/run_agents.rs | 4 + .../action_model/execute/run_agents_tests.rs | 2 + .../action_model/execute/start_agent.rs | 2 + .../action_model/execute/start_agent_tests.rs | 1 + .../block/view_impl/orchestration_tests.rs | 1 + app/src/ai/blocklist/block_tests.rs | 6 + .../inline_action/orchestration_controls.rs | 92 ++++++++++++- .../inline_action/run_agents_card_view.rs | 121 +++++++++++++++++- .../run_agents_card_view_tests.rs | 88 +++++++++++++ .../ai/document/orchestration_config_block.rs | 85 ++++++++++++ app/src/ai/orchestration/config_state.rs | 11 ++ .../ai/orchestration/config_state_tests.rs | 17 +++ app/src/ai/orchestration/edit_state_tests.rs | 1 + app/src/ai/orchestration/mod.rs | 4 +- app/src/ai/orchestration/providers.rs | 1 + app/src/ai/orchestration/snapshots.rs | 38 +++++- app/src/ai/orchestration/snapshots_tests.rs | 34 ++++- app/src/ai/orchestration/validation_tests.rs | 1 + app/src/pane_group/pane/terminal_pane.rs | 7 + crates/ai/src/agent/action/mod.rs | 10 ++ crates/ai/src/agent/action_result/convert.rs | 3 +- crates/ai/src/agent/action_result/mod.rs | 3 + crates/ai/src/agent/orchestration_config.rs | 12 +- .../src/agent/orchestration_config_tests.rs | 50 ++++++++ crates/warp_cli/src/lib.rs | 4 +- crates/warp_features/src/lib.rs | 10 +- 31 files changed, 596 insertions(+), 23 deletions(-) diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index f25de79a133..e551073add8 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -1607,6 +1607,7 @@ pub(crate) fn convert_tool_call_result_to_input( environment_id: remote.environment_id.clone(), worker_host: remote.worker_host.clone(), computer_use_enabled: remote.computer_use_enabled, + runner_id: remote.runner_id.clone(), }, Some(api::run_agents_result::launched::ResolvedExecutionMode::Local(_)) | None => RunAgentsLaunchedExecutionMode::Local, diff --git a/app/src/ai/agent/api/convert_from.rs b/app/src/ai/agent/api/convert_from.rs index 7364a978d4b..3961c096c5e 100644 --- a/app/src/ai/agent/api/convert_from.rs +++ b/app/src/ai/agent/api/convert_from.rs @@ -129,6 +129,7 @@ fn convert_run_agents_execution_mode( environment_id: remote.environment_id, worker_host: remote.worker_host, computer_use_enabled: remote.computer_use_enabled, + runner_id: remote.runner_id, }, Some(api::run_agents::ExecutionMode::Local(_)) | None => RunAgentsExecutionMode::Local, } @@ -198,6 +199,9 @@ fn convert_start_agent_v2_execution_mode( // Auth secret is plumbed client-side via `RunAgentsRequest`; // StartAgentV2 from the server never carries it. auth_secret_name: None, + // StartAgentV2 (server→child) does not carry a runner + // override; runner selection flows through RunAgents. + runner_id: String::new(), } } Some(api::start_agent_v2::execution_mode::Mode::Local(local)) => { diff --git a/app/src/ai/agent/api/convert_from_tests.rs b/app/src/ai/agent/api/convert_from_tests.rs index 8f5a32f3065..44d31bb6e73 100644 --- a/app/src/ai/agent/api/convert_from_tests.rs +++ b/app/src/ai/agent/api/convert_from_tests.rs @@ -538,6 +538,7 @@ fn converts_remote_start_agent_with_environment_id() { harness_type: String::new(), title: String::new(), auth_secret_name: None, + runner_id: String::new(), } ); assert_eq!(lifecycle_subscription, None); @@ -580,6 +581,7 @@ fn converts_remote_start_agent_v2_with_skill_references() { harness_type: "claude-code".to_string(), title: "Remote child".to_string(), auth_secret_name: None, + runner_id: String::new(), } ); assert_eq!(lifecycle_subscription, None); diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index 77c0120af8f..3d2b9f7fde1 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -216,7 +216,7 @@ fn dispatch_command( api_key::run(ctx, global_options, api_key_cmd) } CliCommand::Runner(runner_cmd) => { - if !FeatureFlag::CloudAgentRunnerCLICommands.is_enabled() { + if !FeatureFlag::CloudAgentRunners.is_enabled() { return Err(anyhow::anyhow!("invalid value 'runner'")); } runner::run(ctx, global_options, runner_cmd) diff --git a/app/src/ai/agent_sdk/telemetry.rs b/app/src/ai/agent_sdk/telemetry.rs index 300a9c66bb4..26c097a34d2 100644 --- a/app/src/ai/agent_sdk/telemetry.rs +++ b/app/src/ai/agent_sdk/telemetry.rs @@ -564,7 +564,7 @@ impl TelemetryEventDesc for CliTelemetryEventDiscriminants { EnablementState::Flag(FeatureFlag::APIKeyManagement) } Self::RunnerList | Self::RunnerCreate | Self::RunnerUpdate | Self::RunnerDelete => { - EnablementState::Flag(FeatureFlag::CloudAgentRunnerCLICommands) + EnablementState::Flag(FeatureFlag::CloudAgentRunners) } Self::RunMessageWatch | Self::RunMessageSend diff --git a/app/src/ai/blocklist/action_model/execute/run_agents.rs b/app/src/ai/blocklist/action_model/execute/run_agents.rs index 8be5f9b4ff4..bda1992db5c 100644 --- a/app/src/ai/blocklist/action_model/execute/run_agents.rs +++ b/app/src/ai/blocklist/action_model/execute/run_agents.rs @@ -344,10 +344,12 @@ impl RunAgentsExecutor { environment_id, worker_host, computer_use_enabled, + runner_id, } => RunAgentsLaunchedExecutionMode::Remote { environment_id: environment_id.clone(), worker_host: worker_host.clone(), computer_use_enabled: *computer_use_enabled, + runner_id: runner_id.clone(), }, }; let result = RunAgentsResult::Launched { @@ -705,6 +707,7 @@ pub fn run_agents_to_start_agent_mode( environment_id, worker_host, computer_use_enabled, + runner_id, } => { // OpenCode is unsupported on Remote. if run_harness_type.eq_ignore_ascii_case("opencode") { @@ -723,6 +726,7 @@ pub fn run_agents_to_start_agent_mode( auth_secret_name: run_auth_secret_name .map(str::to_string) .filter(|s| !s.trim().is_empty()), + runner_id: runner_id.clone(), }) } } diff --git a/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs b/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs index 3a66f986af6..ff897159bad 100644 --- a/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs +++ b/app/src/ai/blocklist/action_model/execute/run_agents_tests.rs @@ -85,6 +85,7 @@ fn persist_plan_config_with_harness( execution_mode: OrchestrationExecutionMode::Remote { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), + runner_id: String::new(), }, }, status, @@ -231,6 +232,7 @@ fn remote_run_agents_action(harness_type: &str) -> AIAgentAction { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, agent_run_configs: vec![RunAgentsAgentRunConfig { name: "child".to_string(), diff --git a/app/src/ai/blocklist/action_model/execute/start_agent.rs b/app/src/ai/blocklist/action_model/execute/start_agent.rs index 6230861a4ea..36b27850835 100644 --- a/app/src/ai/blocklist/action_model/execute/start_agent.rs +++ b/app/src/ai/blocklist/action_model/execute/start_agent.rs @@ -432,6 +432,7 @@ impl StartAgentExecutor { harness_type, title, auth_secret_name, + runner_id, } => { let harness_type = Harness::parse_orchestration_harness(&harness_type) .map(|harness| harness.to_string()) @@ -479,6 +480,7 @@ impl StartAgentExecutor { harness_type, title, auth_secret_name, + runner_id, }, Some(parent_run_id), ) diff --git a/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs b/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs index 34b7c440b66..3319945d6ec 100644 --- a/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs +++ b/app/src/ai/blocklist/action_model/execute/start_agent_tests.rs @@ -1039,6 +1039,7 @@ fn execute_returns_error_when_remote_opencode_harness_is_requested() { harness_type: "opencode".to_string(), title: String::new(), auth_secret_name: None, + runner_id: String::new(), }, ); diff --git a/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs b/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs index 832dee9e305..287593037db 100644 --- a/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs +++ b/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs @@ -79,6 +79,7 @@ fn start_agent_copy_uses_remote_labels_for_remote_children() { harness_type: String::new(), title: String::new(), auth_secret_name: None, + runner_id: String::new(), }; assert_eq!(start_agent_success_suffix(&execution_mode), " remotely."); diff --git a/app/src/ai/blocklist/block_tests.rs b/app/src/ai/blocklist/block_tests.rs index ac822121097..3710c3b02bf 100644 --- a/app/src/ai/blocklist/block_tests.rs +++ b/app/src/ai/blocklist/block_tests.rs @@ -396,6 +396,7 @@ fn remote_arm_propagates_skills_into_skill_references() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, "oz", "auto", @@ -413,6 +414,7 @@ fn remote_arm_propagates_skills_into_skill_references() { computer_use_enabled, title, auth_secret_name, + runner_id: _, } = mode else { panic!("expected Remote start-agent mode"); @@ -434,6 +436,7 @@ fn remote_arm_with_empty_skills_propagates_empty_vec() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, "claude", "auto", @@ -458,6 +461,7 @@ fn remote_arm_rejects_opencode() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, "opencode", "auto", @@ -510,6 +514,7 @@ fn remote_arm_propagates_claude_auth_secret_into_mode() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, "claude", "auto", @@ -534,6 +539,7 @@ fn remote_arm_filters_whitespace_auth_secret_name_to_none() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, "codex", "auto", diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls.rs b/app/src/ai/blocklist/inline_action/orchestration_controls.rs index c4b1a5306d6..2a6f3f4f5f3 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls.rs @@ -37,9 +37,9 @@ pub use crate::ai::orchestration::{ OrchestrationConfigState, OrchestrationEditState, ORCHESTRATION_WARP_WORKER_HOST, }; use crate::ai::orchestration::{ - api_key_snapshot, environment_snapshot, harness_snapshot, host_snapshot, model_snapshot, - persist_auth_secret_selection, OptionBadge, OptionFooter, OptionRow, OptionSnapshot, - OptionSourceStatus, AUTH_SECRET_INHERIT_LABEL, + api_key_snapshot, build_runner_snapshot, environment_snapshot, harness_snapshot, host_snapshot, + model_snapshot, persist_auth_secret_selection, OptionBadge, OptionFooter, OptionRow, + OptionSnapshot, OptionSourceStatus, AUTH_SECRET_INHERIT_LABEL, }; use crate::appearance::Appearance; use crate::menu::{MenuItem, MenuItemFields}; @@ -77,6 +77,9 @@ pub trait OrchestrationControlAction: DropdownItemAction + Clone { fn harness_changed(harness_type: String) -> Self; fn environment_changed(environment_id: String) -> Self; fn create_environment_requested() -> Self; + /// Runner UID selected in the Runner dropdown; empty clears the + /// override ("Use environment default"). + fn runner_changed(runner_id: String) -> Self; /// `None` means Inherit; `Some(name)` means a named managed secret. fn auth_secret_changed(name: Option) -> Self; /// User picked the "New API key…" item; opens the workspace create modal. @@ -92,6 +95,9 @@ pub struct OrchestrationPickerHandles { pub model_picker: Option>>, pub harness_picker: Option>>, pub environment_picker: Option>>, + /// Runner picker for the Cloud variant (gated on `CloudAgentRunners`). + /// `None` until built; runners are fetched via `FactoryClient::get_runners`. + pub runner_picker: Option>>, pub host_picker: Option>, /// Picker for the managed auth secret used by non-Oz cloud children. /// `None` when the picker hasn't been built yet (e.g. harness is Oz or @@ -108,6 +114,7 @@ impl Default for OrchestrationPickerHandles { model_picker: None, harness_picker: None, environment_picker: None, + runner_picker: None, host_picker: None, auth_secret_picker: None, local_toggle: MouseStateHandle::default(), @@ -222,6 +229,7 @@ fn snapshot_execution_mode(is_local: bool) -> RunAgentsExecutionMode { environment_id: String::new(), worker_host: String::new(), computer_use_enabled: false, + runner_id: String::new(), } } } @@ -402,6 +410,7 @@ pub fn populate_environment_picker( environment_id: initial_env_id.to_string(), worker_host: String::new(), computer_use_enabled: false, + runner_id: String::new(), }, ); dropdown_handle.update(ctx, |dropdown, ctx_dropdown| { @@ -423,6 +432,62 @@ pub fn populate_environment_picker( }); } +/// Creates the Runner picker dropdown with the shared orchestration +/// chrome, then populates it from the supplied runners list. Runners are +/// not cached client-side (unlike environments), so the owning view +/// fetches them via `FactoryClient::get_runners` and passes them in; +/// `loading` renders the picker in its loading state until they arrive. +pub fn create_runner_picker( + initial_runner_id: &str, + runners: &[(String, String)], + loading: bool, + styles: &UiComponentStyles, + ctx: &mut ViewContext, +) -> ViewHandle> { + let styles = *styles; + let dropdown_handle = ctx.add_typed_action_view(move |ctx_dropdown| { + let mut dropdown = FilterableDropdown::::new(ctx_dropdown); + dropdown.set_use_overlay_layer(false, ctx_dropdown); + dropdown.set_match_menu_width_to_top_bar(true, ctx_dropdown); + dropdown.set_main_axis_size(MainAxisSize::Max, ctx_dropdown); + dropdown.set_button_variant(ButtonVariant::Secondary); + dropdown.set_style(styles); + dropdown.set_top_bar_height(ORCHESTRATION_PICKER_HEIGHT, ctx_dropdown); + dropdown.set_top_bar_max_width(f32::INFINITY); + dropdown + }); + populate_runner_picker(&dropdown_handle, runners, initial_runner_id, loading, ctx); + dropdown_handle +} + +/// Populates the runner picker from [`build_runner_snapshot`] ("Use +/// environment default" plus the supplied runners). +pub fn populate_runner_picker( + dropdown_handle: &ViewHandle>, + runners: &[(String, String)], + current_runner_id: &str, + loading: bool, + ctx: &mut ViewContext, +) { + let snapshot = build_runner_snapshot(runners.to_vec(), current_runner_id, loading); + let selected_label = selected_row_label(&snapshot); + dropdown_handle.update(ctx, |dropdown, ctx_dropdown| { + let items = snapshot + .rows + .into_iter() + .map(|row| { + MenuItem::Item(MenuItemFields::new(&row.label).with_on_select_action( + DropdownAction::select_action_and_close(A::runner_changed(row.id)), + )) + }) + .collect(); + dropdown.set_rich_items(items, ctx_dropdown); + if let Some(label) = &selected_label { + dropdown.set_selected_by_name(label, ctx_dropdown); + } + }); +} + fn render_new_environment_footer( mouse_state: MouseStateHandle, app: &AppContext, @@ -487,6 +552,7 @@ pub fn populate_host_picker( environment_id: String::new(), worker_host: initial_host.to_string(), computer_use_enabled: false, + runner_id: String::new(), }, ); let snapshot = host_snapshot(&state, ctx); @@ -1116,6 +1182,16 @@ pub fn render_picker_row_with_layout( .as_ref() .map(|p| ChildView::new(p).finish()), ); + if warp_core::features::FeatureFlag::CloudAgentRunners.is_enabled() { + add( + &mut column, + "Runner", + handles + .runner_picker + .as_ref() + .map(|p| ChildView::new(p).finish()), + ); + } } add( &mut column, @@ -1163,6 +1239,16 @@ pub fn render_picker_row_with_layout( .as_ref() .map(|p| ChildView::new(p).finish()), ); + if warp_core::features::FeatureFlag::CloudAgentRunners.is_enabled() { + add_picker( + &mut row, + "Runner", + handles + .runner_picker + .as_ref() + .map(|p| ChildView::new(p).finish()), + ); + } } add_picker( &mut row, diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 341222a74d9..4f2585b810e 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -6,7 +6,9 @@ use std::rc::Rc; use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; -use ai::agent::action_result::{RunAgentsAgentOutcomeKind, RunAgentsResult}; +use ai::agent::action_result::{ + RunAgentsAgentOutcomeKind, RunAgentsLaunchedExecutionMode, RunAgentsResult, +}; use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; use ai::skills::SkillReference; use pathfinder_geometry::vector::vec2f; @@ -60,6 +62,8 @@ use crate::ai::harness_availability::{ use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent}; use crate::appearance::Appearance; use crate::menu::{Event as MenuEvent, Menu, MenuItemFields, MenuVariant}; +#[cfg(not(target_family = "wasm"))] +use crate::server::server_api::ServerApiProvider; use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon; use crate::view_components::action_button::{ButtonSize, KeystrokeSource, NakedTheme}; @@ -175,6 +179,9 @@ impl OrchestrationControlAction for RunAgentsCardViewAction { fn create_environment_requested() -> Self { Self::CreateEnvironmentRequested } + fn runner_changed(runner_id: String) -> Self { + Self::RunnerChanged { runner_id } + } fn auth_secret_changed(auth_secret_name: Option) -> Self { Self::AuthSecretChanged { auth_secret_name } } @@ -210,6 +217,9 @@ pub enum RunAgentsCardViewAction { environment_id: String, }, CreateEnvironmentRequested, + RunnerChanged { + runner_id: String, + }, WorkerHostChanged { worker_host: String, }, @@ -256,6 +266,11 @@ pub struct RunAgentsCardView { /// One-shot guard: cancelling the auto-popped modal must not re-pop. /// Reset on harness / execution-mode change. has_auto_opened_create_modal: bool, + /// Runners fetched via `getRunners` for the Runner picker: (uid, name). + /// Runners aren't cached client-side, so we fetch them lazily. + runners: Vec<(String, String)>, + /// True while the `getRunners` fetch is in flight. + runners_loading: bool, } /// Resolves UI-only interactive defaults on edit state that has @@ -537,6 +552,8 @@ impl RunAgentsCardView { entered_event_emitted: false, decision_event_emitted: false, has_auto_opened_create_modal: false, + runners: Vec::new(), + runners_loading: false, }; view.ensure_pickers(ctx); @@ -883,6 +900,31 @@ impl RunAgentsCardView { self.handles.pickers.environment_picker = Some(handle); } + let state = &self.orchestration_edit_state.orchestration_config_state; + if self.handles.pickers.runner_picker.is_none() { + let initial_runner = match &state.execution_mode { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + let handle = oc::create_runner_picker( + &initial_runner, + &self.runners, + self.runners_loading, + &styles, + ctx, + ); + handle.update(ctx, |d, _| { + d.set_orientation(FilterableDropdownOrientation::Up) + }); + ctx.subscribe_to_view(&handle, |me, _, event, ctx| { + if let FilterableDropdownEvent::Close = event { + me.refocus_after_picker_close(ctx); + } + }); + self.handles.pickers.runner_picker = Some(handle); + self.fetch_runners(ctx); + } + let state = &self.orchestration_edit_state.orchestration_config_state; if self.handles.pickers.host_picker.is_none() { let initial_host = match &state.execution_mode { @@ -959,6 +1001,53 @@ impl RunAgentsCardView { self.sync_picker_selections(ctx); } + /// Fetches available runners via `getRunners` and repopulates the + /// Runner picker once they resolve. Runners are not cached + /// client-side (unlike environments), so this lazy fetch backs the + /// picker. No-op on wasm (no `FactoryClient` there). + #[cfg(not(target_family = "wasm"))] + fn fetch_runners(&mut self, ctx: &mut ViewContext) { + if self.runners_loading || !self.runners.is_empty() { + return; + } + self.runners_loading = true; + let client = ServerApiProvider::as_ref(ctx).get_factory_client(); + ctx.spawn( + async move { client.get_runners(None).await }, + |me, result, ctx| { + me.runners_loading = false; + match result { + Ok(runners) => { + let mut list: Vec<(String, String)> = runners + .into_iter() + .map(|r| (r.uid.inner().to_string(), r.config.name)) + .collect(); + list.sort_by(|a, b| a.1.cmp(&b.1)); + me.runners = list; + } + Err(err) => { + log::warn!("Failed to fetch runners for orchestration picker: {err}"); + } + } + let current = match &me + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + if let Some(handle) = me.handles.pickers.runner_picker.clone() { + oc::populate_runner_picker(&handle, &me.runners, ¤t, false, ctx); + } + ctx.notify(); + }, + ); + } + + #[cfg(target_family = "wasm")] + fn fetch_runners(&mut self, _ctx: &mut ViewContext) {} + /// Opens the dropdown menu above the trigger to avoid overlapping /// the input box. Only used by the confirmation card — the plan /// config card renders higher up where downward menus are fine. @@ -1243,6 +1332,13 @@ impl TypedActionView for RunAgentsCardView { RunAgentsCardViewAction::CreateEnvironmentRequested => { self.open_create_environment_modal(ctx); } + RunAgentsCardViewAction::RunnerChanged { runner_id } => { + self.orchestration_edit_state + .orchestration_config_state + .set_runner_id(runner_id.clone()); + self.refresh_accept_button_state(ctx); + ctx.notify(); + } RunAgentsCardViewAction::WorkerHostChanged { worker_host } => { self.orchestration_edit_state .orchestration_config_state @@ -1355,6 +1451,7 @@ fn diverged_orch_fields_against_config( OrchestrationExecutionMode::Remote { environment_id: cfg_env, worker_host: cfg_host, + .. }, ) = (&state.execution_mode, &config.execution_mode) { @@ -1503,17 +1600,33 @@ fn render_terminal_state( pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, StatusKind) { match result { - RunAgentsResult::Launched { agents, .. } => { + RunAgentsResult::Launched { + agents, + execution_mode, + .. + } => { let total = agents.len(); let launched = agents .iter() .filter(|a| matches!(a.kind, RunAgentsAgentOutcomeKind::Launched { .. })) .count(); + // Surface the launched runner (if any) so the terminal state + // reflects the compute the batch committed to. Empty runner_id + // means "environment default", which renders as today. + let runner_suffix = match execution_mode { + RunAgentsLaunchedExecutionMode::Remote { runner_id, .. } + if !runner_id.is_empty() => + { + format!(" on runner {runner_id}") + } + RunAgentsLaunchedExecutionMode::Remote { .. } + | RunAgentsLaunchedExecutionMode::Local => String::new(), + }; if launched == total { let label = if total == 1 { - "Spawned 1 agent".to_string() + format!("Spawned 1 agent{runner_suffix}") } else { - format!("Spawned {total} agents") + format!("Spawned {total} agents{runner_suffix}") }; (label, StatusKind::Success) } else if launched == 0 { diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs index 70fc7e29b96..b5885380861 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs @@ -74,6 +74,7 @@ fn local_to_cloud_initializes_remote_with_empty_environment() { environment_id, worker_host, computer_use_enabled, + .. } = state.orchestration_config_state.execution_mode else { panic!("expected Remote after toggle"); @@ -91,6 +92,7 @@ fn cloud_to_local_drops_environment() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, )); state @@ -120,6 +122,7 @@ fn cloud_without_env_no_longer_disables_accept() { environment_id: String::new(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, )); assert!( @@ -140,6 +143,7 @@ fn cloud_with_opencode_disables_accept() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, )); let reason = state.orchestration_config_state.accept_disabled_reason(); @@ -193,6 +197,7 @@ fn cloud_with_env_and_non_opencode_harness_allows_accept() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, )); assert!( @@ -226,6 +231,7 @@ fn set_environment_id_updates_remote() { environment_id: "old".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, )); state @@ -239,6 +245,51 @@ fn set_environment_id_updates_remote() { assert_eq!(environment_id, "new-env"); } +#[test] +fn set_runner_id_updates_remote_and_round_trips() { + let mut state = RunAgentsEditState::from_request(&make_request( + "oz", + RunAgentsExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + computer_use_enabled: false, + runner_id: String::new(), + }, + )); + state + .orchestration_config_state + .set_runner_id("runner-9".to_string()); + let RunAgentsExecutionMode::Remote { runner_id, .. } = + &state.orchestration_config_state.execution_mode + else { + panic!("expected Remote"); + }; + assert_eq!(runner_id, "runner-9"); + // The runner flows back out through to_request unchanged. + assert_eq!( + state.to_request().execution_mode, + RunAgentsExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + computer_use_enabled: false, + runner_id: "runner-9".to_string(), + } + ); +} + +#[test] +fn set_runner_id_no_op_in_local_mode() { + let mut state = + RunAgentsEditState::from_request(&make_request("oz", RunAgentsExecutionMode::Local)); + state + .orchestration_config_state + .set_runner_id("runner-1".to_string()); + assert!(matches!( + state.orchestration_config_state.execution_mode, + RunAgentsExecutionMode::Local + )); +} + #[test] fn to_request_round_trips_request_fields() { let mut req = make_request_with_skills( @@ -247,6 +298,7 @@ fn to_request_round_trips_request_fields() { environment_id: "env-2".to_string(), worker_host: "warp".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, vec![ SkillReference::BundledSkillId("writing-pr-descriptions".to_string()), @@ -396,6 +448,38 @@ mod format_terminal_state_tests { assert_eq!(label, "Spawn agents cancelled"); assert!(matches!(kind, StatusKind::Cancelled)); } + + fn launched_result_remote( + runner_id: &str, + agents: Vec, + ) -> RunAgentsResult { + RunAgentsResult::Launched { + model_id: "auto".to_string(), + harness_type: "oz".to_string(), + execution_mode: RunAgentsLaunchedExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + computer_use_enabled: false, + runner_id: runner_id.to_string(), + }, + agents, + } + } + + #[test] + fn launched_with_runner_shows_runner_in_label() { + let result = launched_result_remote("runner-1", vec![launched("child", "a-1")]); + let (label, kind) = format_terminal_state(&result); + assert_eq!(label, "Spawned 1 agent on runner runner-1"); + assert!(matches!(kind, StatusKind::Success)); + } + + #[test] + fn launched_without_runner_omits_runner_suffix() { + let result = launched_result_remote("", vec![launched("a", "a-1"), launched("b", "a-2")]); + let (label, _) = format_terminal_state(&result); + assert_eq!(label, "Spawned 2 agents"); + } } mod override_from_approved_config_tests { @@ -419,6 +503,7 @@ mod override_from_approved_config_tests { execution_mode: OrchestrationExecutionMode::Remote { environment_id: env.to_string(), worker_host: "warp".to_string(), + runner_id: String::new(), }, } } @@ -477,6 +562,7 @@ mod override_from_approved_config_tests { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, )); state @@ -499,6 +585,7 @@ mod override_from_approved_config_tests { environment_id: "old-env".to_string(), worker_host: "warp".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, )); state @@ -561,6 +648,7 @@ fn local_to_cloud_idempotent_when_already_remote() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, )); state diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index f53ba99d355..33a9c21ecb2 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -43,6 +43,8 @@ use crate::ai::harness_availability::{ }; use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent}; use crate::appearance::Appearance; +#[cfg(not(target_family = "wasm"))] +use crate::server::server_api::ServerApiProvider; use crate::ui_components::blended_colors; use crate::workspace::WorkspaceAction; use crate::BlocklistAIHistoryModel; @@ -87,6 +89,9 @@ pub enum OrchestrationConfigBlockAction { environment_id: String, }, CreateEnvironmentRequested, + RunnerChanged { + runner_id: String, + }, WorkerHostChanged { worker_host: String, }, @@ -113,6 +118,9 @@ impl OrchestrationControlAction for OrchestrationConfigBlockAction { fn create_environment_requested() -> Self { Self::CreateEnvironmentRequested } + fn runner_changed(runner_id: String) -> Self { + Self::RunnerChanged { runner_id } + } fn auth_secret_changed(auth_secret_name: Option) -> Self { Self::AuthSecretChanged { auth_secret_name } } @@ -148,6 +156,10 @@ pub struct OrchestrationConfigBlockView { /// mode) suppresses the modal on restore while still firing for /// live-session enablement. user_has_interacted: bool, + /// Runners fetched via `getRunners` for the Runner picker: (uid, name). + runners: Vec<(String, String)>, + /// True while the `getRunners` fetch is in flight. + runners_loading: bool, } impl OrchestrationConfigBlockView { @@ -309,6 +321,8 @@ impl OrchestrationConfigBlockView { suppress_refresh: false, has_auto_opened_create_modal: false, user_has_interacted: false, + runners: Vec::new(), + runners_loading: false, }; if view.is_approved { view.ensure_pickers(ctx); @@ -522,6 +536,25 @@ impl OrchestrationConfigBlockView { env_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); self.pickers.environment_picker = Some(env_handle); + let initial_runner = match &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + let runner_handle = oc::create_runner_picker( + &initial_runner, + &self.runners, + self.runners_loading, + &styles, + ctx, + ); + runner_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); + self.pickers.runner_picker = Some(runner_handle); + self.fetch_runners(ctx); + let initial_host = match &self .orchestration_edit_state .orchestration_config_state @@ -634,6 +667,51 @@ impl OrchestrationConfigBlockView { model.set_orchestration_config_for_plan(conversation_id, plan_id, config, status, ctx); }); } + + /// Fetches available runners via `getRunners` and repopulates the + /// Runner picker once they resolve. No-op on wasm (no `FactoryClient`). + #[cfg(not(target_family = "wasm"))] + fn fetch_runners(&mut self, ctx: &mut ViewContext) { + if self.runners_loading || !self.runners.is_empty() { + return; + } + self.runners_loading = true; + let client = ServerApiProvider::as_ref(ctx).get_factory_client(); + ctx.spawn( + async move { client.get_runners(None).await }, + |me, result, ctx| { + me.runners_loading = false; + match result { + Ok(runners) => { + let mut list: Vec<(String, String)> = runners + .into_iter() + .map(|r| (r.uid.inner().to_string(), r.config.name)) + .collect(); + list.sort_by(|a, b| a.1.cmp(&b.1)); + me.runners = list; + } + Err(err) => { + log::warn!("Failed to fetch runners for plan-card runner picker: {err}"); + } + } + let current = match &me + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + if let Some(handle) = me.pickers.runner_picker.clone() { + oc::populate_runner_picker(&handle, &me.runners, ¤t, false, ctx); + } + ctx.notify(); + }, + ); + } + + #[cfg(target_family = "wasm")] + fn fetch_runners(&mut self, _ctx: &mut ViewContext) {} } impl Entity for OrchestrationConfigBlockView { @@ -918,6 +996,13 @@ impl TypedActionView for OrchestrationConfigBlockView { OrchestrationConfigBlockAction::CreateEnvironmentRequested => { self.open_create_environment_modal(ctx); } + OrchestrationConfigBlockAction::RunnerChanged { runner_id } => { + self.orchestration_edit_state + .orchestration_config_state + .set_runner_id(runner_id.clone()); + self.apply_field_change(ctx); + ctx.notify(); + } OrchestrationConfigBlockAction::WorkerHostChanged { worker_host } => { self.orchestration_edit_state .orchestration_config_state diff --git a/app/src/ai/orchestration/config_state.rs b/app/src/ai/orchestration/config_state.rs index a914c3a444a..ecac447292b 100644 --- a/app/src/ai/orchestration/config_state.rs +++ b/app/src/ai/orchestration/config_state.rs @@ -106,10 +106,12 @@ impl OrchestrationConfigState { OrchestrationExecutionMode::Remote { environment_id, worker_host, + runner_id, } => RunAgentsExecutionMode::Remote { environment_id: environment_id.clone(), worker_host: worker_host.clone(), computer_use_enabled: false, + runner_id: runner_id.clone(), }, }; let mut state = Self { @@ -136,6 +138,7 @@ impl OrchestrationConfigState { environment_id: String::new(), worker_host: ORCHESTRATION_WARP_WORKER_HOST.to_string(), computer_use_enabled: false, + runner_id: String::new(), }; } } else { @@ -162,6 +165,12 @@ impl OrchestrationConfigState { } } + pub fn set_runner_id(&mut self, runner_id: String) { + if let RunAgentsExecutionMode::Remote { runner_id: id, .. } = &mut self.execution_mode { + *id = runner_id; + } + } + /// Returns `Some(reason)` if Accept / Apply must be disabled. /// Hard blocks: OpenCode + Cloud, and product-disabled local harnesses. pub fn accept_disabled_reason(&self) -> Option<&'static str> { @@ -242,10 +251,12 @@ impl OrchestrationConfigState { RunAgentsExecutionMode::Remote { environment_id, worker_host, + runner_id, .. } => OrchestrationExecutionMode::Remote { environment_id: environment_id.clone(), worker_host: worker_host.clone(), + runner_id: runner_id.clone(), }, }; OrchestrationConfig { diff --git a/app/src/ai/orchestration/config_state_tests.rs b/app/src/ai/orchestration/config_state_tests.rs index 0b5c9d0de0d..94c7a87098d 100644 --- a/app/src/ai/orchestration/config_state_tests.rs +++ b/app/src/ai/orchestration/config_state_tests.rs @@ -20,6 +20,7 @@ fn toggle_to_local_sanitizes_disabled_codex() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, ); @@ -42,6 +43,7 @@ fn toggle_to_local_preserves_claude() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, ); @@ -72,6 +74,21 @@ fn resolve_from_config_preserves_local_claude() { )); } +#[test] +fn runner_id_round_trips_through_config() { + let config = OrchestrationConfig { + model_id: "auto".to_string(), + harness_type: "oz".to_string(), + execution_mode: OrchestrationExecutionMode::Remote { + environment_id: "env-1".to_string(), + worker_host: "warp".to_string(), + runner_id: "runner-7".to_string(), + }, + }; + let state = OrchestrationConfigState::from_orchestration_config(&config); + assert_eq!(state.to_orchestration_config(), config); +} + #[test] fn resolve_from_config_sanitizes_disabled_local_codex() { let mut state = OrchestrationConfigState::from_run_agents_fields( diff --git a/app/src/ai/orchestration/edit_state_tests.rs b/app/src/ai/orchestration/edit_state_tests.rs index 23e27d99640..48c74756861 100644 --- a/app/src/ai/orchestration/edit_state_tests.rs +++ b/app/src/ai/orchestration/edit_state_tests.rs @@ -10,6 +10,7 @@ fn remote_mode() -> RunAgentsExecutionMode { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), } } diff --git a/app/src/ai/orchestration/mod.rs b/app/src/ai/orchestration/mod.rs index d61bf5dd6f2..3a52c470e3f 100644 --- a/app/src/ai/orchestration/mod.rs +++ b/app/src/ai/orchestration/mod.rs @@ -29,8 +29,8 @@ pub use providers::{ pub use snapshots::location_snapshot; pub(crate) use snapshots::AUTH_SECRET_INHERIT_LABEL; pub use snapshots::{ - api_key_snapshot, environment_snapshot, harness_snapshot, host_snapshot, model_snapshot, - OptionBadge, OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus, + api_key_snapshot, build_runner_snapshot, environment_snapshot, harness_snapshot, host_snapshot, + model_snapshot, OptionBadge, OptionFooter, OptionRow, OptionSnapshot, OptionSourceStatus, }; pub use validation::{ accept_disabled_reason_with_auth, empty_env_recommendation_message, diff --git a/app/src/ai/orchestration/providers.rs b/app/src/ai/orchestration/providers.rs index 9c93c6c3e59..0a1c54fb8ed 100644 --- a/app/src/ai/orchestration/providers.rs +++ b/app/src/ai/orchestration/providers.rs @@ -24,6 +24,7 @@ const DEFAULT_HOST_ENV_VAR: &str = "WARP_CLOUD_MODE_DEFAULT_HOST"; pub const ORCHESTRATION_WARP_WORKER_HOST: &str = WARP_WORKER_HOST; pub const ORCHESTRATION_ENV_NONE_LABEL: &str = "Empty environment"; +pub const ORCHESTRATION_RUNNER_NONE_LABEL: &str = "Use environment default"; /// Returns Warp base-model choices for orchestration. pub(crate) fn get_base_model_choices<'a>( diff --git a/app/src/ai/orchestration/snapshots.rs b/app/src/ai/orchestration/snapshots.rs index 0afe94f49d4..1d1a500463c 100644 --- a/app/src/ai/orchestration/snapshots.rs +++ b/app/src/ai/orchestration/snapshots.rs @@ -12,7 +12,7 @@ use warpui::{AppContext, SingletonEntity}; use super::config_state::{AuthSecretSelection, OrchestrationConfigState}; use super::providers::{ get_base_model_choices, resolve_default_host_slug, resolve_recent_host_slug, - ORCHESTRATION_ENV_NONE_LABEL, ORCHESTRATION_WARP_WORKER_HOST, + ORCHESTRATION_ENV_NONE_LABEL, ORCHESTRATION_RUNNER_NONE_LABEL, ORCHESTRATION_WARP_WORKER_HOST, }; use crate::ai::auth_secret_types::auth_secret_types_for_harness; use crate::ai::cloud_environments::CloudAmbientAgentEnvironment; @@ -572,6 +572,42 @@ fn build_environment_snapshot(envs: Vec<(String, String)>, current: &str) -> Opt OptionSnapshot::ready(rows, selected_id) } +// ── Runner ────────────────────────────────────────────────────────── + +/// Builds the runner options: a "Use environment default" clear row plus +/// the supplied runners (already sorted by name). Runners are not cached +/// client-side like environments, so the caller fetches them via +/// `FactoryClient::get_runners` and passes them in along with the current +/// load state; while `loading` is true the snapshot reports +/// [`OptionSourceStatus::Loading`] so the picker can show a spinner. +pub fn build_runner_snapshot( + runners: Vec<(String, String)>, + current: &str, + loading: bool, +) -> OptionSnapshot { + let mut rows = vec![OptionRow::new( + String::new(), + ORCHESTRATION_RUNNER_NONE_LABEL, + )]; + let mut selected_id = current.is_empty().then(String::new); + for (runner_id, runner_name) in runners { + if runner_id == current { + selected_id = Some(runner_id.clone()); + } + rows.push(OptionRow::new(runner_id, runner_name)); + } + OptionSnapshot { + rows, + selected_id, + status: if loading { + OptionSourceStatus::Loading + } else { + OptionSourceStatus::Ready + }, + footer: None, + } +} + #[cfg(test)] #[path = "snapshots_tests.rs"] mod tests; diff --git a/app/src/ai/orchestration/snapshots_tests.rs b/app/src/ai/orchestration/snapshots_tests.rs index c1ed0cf853b..574d0e0ddc8 100644 --- a/app/src/ai/orchestration/snapshots_tests.rs +++ b/app/src/ai/orchestration/snapshots_tests.rs @@ -3,8 +3,8 @@ use warp_cli::agent::Harness; use super::{ build_api_key_snapshot, build_environment_snapshot, build_harness_snapshot, build_host_snapshot, build_non_oz_model_snapshot, build_oz_model_snapshot, - AuthSecretNamesInput, HarnessEntryInput, ModelChoiceInput, OptionBadge, OptionFooter, - OptionSourceStatus, AUTH_SECRET_INHERIT_LABEL, DEFAULT_MODEL_LABEL, + build_runner_snapshot, AuthSecretNamesInput, HarnessEntryInput, ModelChoiceInput, OptionBadge, + OptionFooter, OptionSourceStatus, AUTH_SECRET_INHERIT_LABEL, DEFAULT_MODEL_LABEL, }; use crate::ai::local_harness_setup::LocalHarnessSetupState; use crate::ai::orchestration::config_state::AuthSecretSelection; @@ -277,3 +277,33 @@ fn environment_snapshot_puts_empty_option_first() { assert_eq!(snapshot.rows[0].label, super::ORCHESTRATION_ENV_NONE_LABEL); assert_eq!(snapshot.selected_id.as_deref(), Some("env-b")); } + +// ── Runner ────────────────────────────────────────────────────── + +#[test] +fn runner_snapshot_puts_use_default_first_and_selects() { + let snapshot = build_runner_snapshot( + vec![ + ("r-a".to_string(), "Alpha".to_string()), + ("r-b".to_string(), "Beta".to_string()), + ], + "r-b", + false, + ); + + assert_eq!(snapshot.rows[0].id, ""); + assert_eq!( + snapshot.rows[0].label, + super::ORCHESTRATION_RUNNER_NONE_LABEL + ); + assert_eq!(snapshot.selected_id.as_deref(), Some("r-b")); + assert_eq!(snapshot.status, OptionSourceStatus::Ready); +} + +#[test] +fn runner_snapshot_loading_reports_loading_status() { + let snapshot = build_runner_snapshot(vec![], "", true); + assert_eq!(snapshot.status, OptionSourceStatus::Loading); + // Empty selection maps to the "use environment default" row. + assert_eq!(snapshot.selected_id.as_deref(), Some("")); +} diff --git a/app/src/ai/orchestration/validation_tests.rs b/app/src/ai/orchestration/validation_tests.rs index 0f63e839d71..cd855c91f6a 100644 --- a/app/src/ai/orchestration/validation_tests.rs +++ b/app/src/ai/orchestration/validation_tests.rs @@ -35,6 +35,7 @@ fn cloud() -> RunAgentsExecutionMode { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), } } diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 0a37b6aebda..1bf242465f8 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -1595,6 +1595,7 @@ fn dispatch_start_agent_conversation( harness_type, title, auth_secret_name, + runner_id, } => { launch_remote_child( group, @@ -1609,6 +1610,7 @@ fn dispatch_start_agent_conversation( harness_type, title, auth_secret_name, + runner_id, }, ctx, ); @@ -1922,6 +1924,9 @@ struct RemoteLaunchFields { /// harness credentials. Resolved to `AgentConfigSnapshot.harness_auth_secrets` /// when applicable. auth_secret_name: Option, + /// Runner UID selecting the child's compute config. Empty means "no + /// override" — resolved at dispatch via the environment's default runner. + runner_id: String, } /// Sets up a hidden ambient-agent pane for a Remote child agent: creates the @@ -1953,6 +1958,7 @@ fn launch_remote_child( harness_type, title, auth_secret_name, + runner_id, } = fields; let request_id = request.id; @@ -2077,6 +2083,7 @@ fn launch_remote_child( config: Some(AgentConfigSnapshot { name: agent_name, environment_id, + runner_id: (!runner_id.is_empty()).then_some(runner_id), model_id: (!model_id.is_empty()).then_some(model_id), worker_host: (!worker_host.is_empty()).then_some(worker_host), computer_use_enabled, diff --git a/crates/ai/src/agent/action/mod.rs b/crates/ai/src/agent/action/mod.rs index 05400c891ff..dd908562150 100644 --- a/crates/ai/src/agent/action/mod.rs +++ b/crates/ai/src/agent/action/mod.rs @@ -240,6 +240,11 @@ pub enum RunAgentsExecutionMode { environment_id: String, worker_host: String, computer_use_enabled: bool, + /// Runner UID selecting the children's compute config (docker + /// image, instance shape, setup commands). Empty means "no + /// override" — fall back to the environment's default runner then + /// system defaults. + runner_id: String, }, } @@ -281,6 +286,10 @@ pub enum StartAgentExecutionMode { /// `None` means no client-side secret was selected — the remote /// environment falls back to its own ambient credentials. auth_secret_name: Option, + /// Runner UID selecting the child's compute config. Empty means + /// "no override" — resolved at dispatch via the environment's + /// default runner then system defaults. + runner_id: String, }, } @@ -311,6 +320,7 @@ impl StartAgentExecutionMode { harness_type: String::new(), title: String::new(), auth_secret_name: None, + runner_id: String::new(), } } } diff --git a/crates/ai/src/agent/action_result/convert.rs b/crates/ai/src/agent/action_result/convert.rs index a47913bce81..c8abe9ecc79 100644 --- a/crates/ai/src/agent/action_result/convert.rs +++ b/crates/ai/src/agent/action_result/convert.rs @@ -1352,12 +1352,13 @@ impl From environment_id, worker_host, computer_use_enabled, + runner_id, } => api::run_agents_result::launched::ResolvedExecutionMode::Remote( api::run_agents::Remote { environment_id, worker_host, computer_use_enabled, - runner_id: Default::default(), + runner_id, }, ), } diff --git a/crates/ai/src/agent/action_result/mod.rs b/crates/ai/src/agent/action_result/mod.rs index 6ea878d2f5c..ab23cca2694 100644 --- a/crates/ai/src/agent/action_result/mod.rs +++ b/crates/ai/src/agent/action_result/mod.rs @@ -1381,6 +1381,9 @@ pub enum RunAgentsLaunchedExecutionMode { environment_id: String, worker_host: String, computer_use_enabled: bool, + /// Resolved runner UID the batch committed to; empty when none. + #[serde(default)] + runner_id: String, }, } diff --git a/crates/ai/src/agent/orchestration_config.rs b/crates/ai/src/agent/orchestration_config.rs index dd3fed299d9..c22c43780be 100644 --- a/crates/ai/src/agent/orchestration_config.rs +++ b/crates/ai/src/agent/orchestration_config.rs @@ -20,6 +20,8 @@ pub enum OrchestrationExecutionMode { Remote { environment_id: String, worker_host: String, + /// Runner UID pre-declared on the config; empty means unset. + runner_id: String, }, } @@ -79,16 +81,20 @@ pub fn matches_active_config(request: &RunAgentsRequest, config: &OrchestrationC super::action::RunAgentsExecutionMode::Remote { environment_id, worker_host, + runner_id, .. }, OrchestrationExecutionMode::Remote { environment_id: cfg_env, worker_host: cfg_host, + runner_id: cfg_runner, }, ) => { let env_matches = environment_id.is_empty() || environment_id == cfg_env; let host_matches = worker_host.is_empty() || worker_host == cfg_host; - env_matches && host_matches + // Empty runner_id on the call inherits from the config → matches. + let runner_matches = runner_id.is_empty() || runner_id == cfg_runner; + env_matches && host_matches && runner_matches } // Variant mismatch (Local vs Remote). _ => false, @@ -107,6 +113,7 @@ impl OrchestrationConfig { OrchestrationExecutionMode::Remote { environment_id: remote.environment_id.clone(), worker_host: remote.worker_host.clone(), + runner_id: remote.runner_id.clone(), } } Some(api::orchestration_config::ExecutionMode::Local(_)) | None => { @@ -134,11 +141,12 @@ impl OrchestrationConfig { OrchestrationExecutionMode::Remote { environment_id, worker_host, + runner_id, } => Some(api::orchestration_config::ExecutionMode::Remote( api::orchestration_config::Remote { environment_id: environment_id.clone(), worker_host: worker_host.clone(), - runner_id: Default::default(), + runner_id: runner_id.clone(), }, )), }; diff --git a/crates/ai/src/agent/orchestration_config_tests.rs b/crates/ai/src/agent/orchestration_config_tests.rs index 6843908afaa..d8cf7974103 100644 --- a/crates/ai/src/agent/orchestration_config_tests.rs +++ b/crates/ai/src/agent/orchestration_config_tests.rs @@ -9,6 +9,7 @@ fn make_config(model: &str, harness: &str, remote: bool) -> OrchestrationConfig OrchestrationExecutionMode::Remote { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), + runner_id: String::new(), } } else { OrchestrationExecutionMode::Local @@ -28,6 +29,7 @@ fn make_request(model: &str, harness: &str, remote: bool) -> RunAgentsRequest { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), } } else { RunAgentsExecutionMode::Local @@ -119,6 +121,54 @@ fn remote_empty_env_inherits_and_matches() { assert!(matches_active_config(&request, &config)); } +#[test] +fn remote_matching_runner_matches() { + let mut config = make_config("auto", "oz", true); + if let OrchestrationExecutionMode::Remote { runner_id, .. } = &mut config.execution_mode { + *runner_id = "runner-1".to_string(); + } + let mut request = make_request("auto", "oz", true); + if let RunAgentsExecutionMode::Remote { runner_id, .. } = &mut request.execution_mode { + *runner_id = "runner-1".to_string(); + } + assert!(matches_active_config(&request, &config)); +} + +#[test] +fn remote_different_runner_mismatches() { + let mut config = make_config("auto", "oz", true); + if let OrchestrationExecutionMode::Remote { runner_id, .. } = &mut config.execution_mode { + *runner_id = "runner-1".to_string(); + } + let mut request = make_request("auto", "oz", true); + if let RunAgentsExecutionMode::Remote { runner_id, .. } = &mut request.execution_mode { + *runner_id = "runner-2".to_string(); + } + assert!(!matches_active_config(&request, &config)); +} + +#[test] +fn remote_empty_runner_inherits_and_matches() { + let mut config = make_config("auto", "oz", true); + if let OrchestrationExecutionMode::Remote { runner_id, .. } = &mut config.execution_mode { + *runner_id = "runner-1".to_string(); + } + // Request leaves runner_id empty → inherits from config → matches. + let request = make_request("auto", "oz", true); + assert!(matches_active_config(&request, &config)); +} + +#[test] +fn proto_round_trip_config_remote_with_runner() { + let mut config = make_config("auto", "claude", true); + if let OrchestrationExecutionMode::Remote { runner_id, .. } = &mut config.execution_mode { + *runner_id = "runner-xyz".to_string(); + } + let proto = config.to_proto(); + let round_tripped = OrchestrationConfig::from_proto(&proto); + assert_eq!(config, round_tripped); +} + #[test] fn computer_use_not_in_match_check() { let config = make_config("auto", "oz", true); diff --git a/crates/warp_cli/src/lib.rs b/crates/warp_cli/src/lib.rs index c3604a0e3ba..f1f6054cc48 100644 --- a/crates/warp_cli/src/lib.rs +++ b/crates/warp_cli/src/lib.rs @@ -274,7 +274,7 @@ impl Args { } } - if !FeatureFlag::CloudAgentRunnerCLICommands.is_enabled() { + if !FeatureFlag::CloudAgentRunners.is_enabled() { let args: Vec = env::args().collect(); if args.len() > 1 && args[1] == "runner" { eprintln!("error: unrecognized subcommand 'runner'\n"); @@ -403,7 +403,7 @@ impl Args { } // Hide the runner subcommand from help text. - if !FeatureFlag::CloudAgentRunnerCLICommands.is_enabled() { + if !FeatureFlag::CloudAgentRunners.is_enabled() { command = command.mut_subcommand("runner", |c| c.hide(true)); } diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 8f5254deb50..582bd536924 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -910,9 +910,11 @@ pub enum FeatureFlag { /// eliminating seams between adjacent box-drawing cells in the terminal. BoxDrawingGlyphs, - /// Enables the `oz runner` CRUD commands for managing cloud agent runners - /// via the CLI. - CloudAgentRunnerCLICommands, + /// Enables cloud agent runner selection: the `oz runner` CRUD commands + /// for managing runners via the CLI, and the Runner dropdown in the + /// orchestration (`run_agents`) confirmation card and plan-card config + /// block for choosing a runner when starting remote child agents. + CloudAgentRunners, } static FLAG_STATES: [AtomicBool; cardinality::()] = @@ -990,7 +992,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::McpJsonTreeView, FeatureFlag::GeminiEnterprise, FeatureFlag::BoxDrawingGlyphs, - FeatureFlag::CloudAgentRunnerCLICommands, + FeatureFlag::CloudAgentRunners, ]; /// Features enabled for feature preview build users (e.g.: Friends of Warp). From 6ab7d983e5b76c26ba473669f2b091269f467cac Mon Sep 17 00:00:00 2001 From: Oz Date: Fri, 17 Jul 2026 16:23:52 +0000 Subject: [PATCH 2/9] Fix runner picker preselection and revert completion-UI runner suffix - Re-apply the Runner dropdown selection after the streamed run_agents request finalizes (and on plan-card config refresh). The runner picker is excluded from the shared picker sync because its options load asynchronously and are cached on the view, so a late-arriving runner_id previously left the dropdown on "Use environment default". - Revert the "Spawned N agents" terminal-state change so it no longer appends the runner UID (not helpful in the completion UI). Co-Authored-By: Oz --- .../inline_action/run_agents_card_view.rs | 55 +++++++++++-------- .../run_agents_card_view_tests.rs | 33 ----------- .../ai/document/orchestration_config_block.rs | 22 ++++++++ 3 files changed, 55 insertions(+), 55 deletions(-) diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 4f2585b810e..27b2c5eae62 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -6,9 +6,7 @@ use std::rc::Rc; use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest}; -use ai::agent::action_result::{ - RunAgentsAgentOutcomeKind, RunAgentsLaunchedExecutionMode, RunAgentsResult, -}; +use ai::agent::action_result::{RunAgentsAgentOutcomeKind, RunAgentsResult}; use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; use ai::skills::SkillReference; use pathfinder_geometry::vector::vec2f; @@ -424,6 +422,11 @@ impl RunAgentsCardView { &me.handles.pickers, ctx, ); + // The runner picker isn't part of the shared picker sync + // (its options load asynchronously and are cached on the + // view), so re-apply its selection now that the streamed + // request has finalized with the requested runner. + me.resync_runner_selection(ctx); me.refresh_accept_button_state(ctx); me.maybe_auto_open_create_modal(ctx); if let Some(conversation_id) = me.block_model.conversation_id(ctx) { @@ -645,6 +648,10 @@ impl RunAgentsCardView { ); self.has_auto_opened_create_modal = false; } + // Re-apply the runner selection: a streamed update can finalize + // the requested `runner_id` after the runner options have loaded, + // and the shared picker sync does not cover the runner picker. + self.resync_runner_selection(ctx); self.refresh_accept_button_state(ctx); self.maybe_auto_open_create_modal(ctx); ctx.notify(); @@ -1048,6 +1055,26 @@ impl RunAgentsCardView { #[cfg(target_family = "wasm")] fn fetch_runners(&mut self, _ctx: &mut ViewContext) {} + /// Re-applies the runner picker's selection from the current + /// `runner_id`, using the view-cached runner list. The runner picker + /// is intentionally excluded from the shared picker sync (its options + /// are fetched asynchronously and cached on the view, not in a global + /// catalog), so callers must invoke this after the run-wide config's + /// `runner_id` may have changed (e.g. a streamed request finalizing). + fn resync_runner_selection(&mut self, ctx: &mut ViewContext) { + let current = match &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + if let Some(handle) = self.handles.pickers.runner_picker.clone() { + oc::populate_runner_picker(&handle, &self.runners, ¤t, self.runners_loading, ctx); + } + } + /// Opens the dropdown menu above the trigger to avoid overlapping /// the input box. Only used by the confirmation card — the plan /// config card renders higher up where downward menus are fine. @@ -1600,33 +1627,17 @@ fn render_terminal_state( pub(crate) fn format_terminal_state(result: &RunAgentsResult) -> (String, StatusKind) { match result { - RunAgentsResult::Launched { - agents, - execution_mode, - .. - } => { + RunAgentsResult::Launched { agents, .. } => { let total = agents.len(); let launched = agents .iter() .filter(|a| matches!(a.kind, RunAgentsAgentOutcomeKind::Launched { .. })) .count(); - // Surface the launched runner (if any) so the terminal state - // reflects the compute the batch committed to. Empty runner_id - // means "environment default", which renders as today. - let runner_suffix = match execution_mode { - RunAgentsLaunchedExecutionMode::Remote { runner_id, .. } - if !runner_id.is_empty() => - { - format!(" on runner {runner_id}") - } - RunAgentsLaunchedExecutionMode::Remote { .. } - | RunAgentsLaunchedExecutionMode::Local => String::new(), - }; if launched == total { let label = if total == 1 { - format!("Spawned 1 agent{runner_suffix}") + "Spawned 1 agent".to_string() } else { - format!("Spawned {total} agents{runner_suffix}") + format!("Spawned {total} agents") }; (label, StatusKind::Success) } else if launched == 0 { diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs index b5885380861..e0070b9236b 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs @@ -448,40 +448,7 @@ mod format_terminal_state_tests { assert_eq!(label, "Spawn agents cancelled"); assert!(matches!(kind, StatusKind::Cancelled)); } - - fn launched_result_remote( - runner_id: &str, - agents: Vec, - ) -> RunAgentsResult { - RunAgentsResult::Launched { - model_id: "auto".to_string(), - harness_type: "oz".to_string(), - execution_mode: RunAgentsLaunchedExecutionMode::Remote { - environment_id: "env-1".to_string(), - worker_host: "warp".to_string(), - computer_use_enabled: false, - runner_id: runner_id.to_string(), - }, - agents, - } - } - - #[test] - fn launched_with_runner_shows_runner_in_label() { - let result = launched_result_remote("runner-1", vec![launched("child", "a-1")]); - let (label, kind) = format_terminal_state(&result); - assert_eq!(label, "Spawned 1 agent on runner runner-1"); - assert!(matches!(kind, StatusKind::Success)); - } - - #[test] - fn launched_without_runner_omits_runner_suffix() { - let result = launched_result_remote("", vec![launched("a", "a-1"), launched("b", "a-2")]); - let (label, _) = format_terminal_state(&result); - assert_eq!(label, "Spawned 2 agents"); - } } - mod override_from_approved_config_tests { use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationExecutionMode}; diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index 33a9c21ecb2..0ad6ece43ca 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -418,6 +418,10 @@ impl OrchestrationConfigBlockView { &self.pickers, ctx, ); + // Runner picker is excluded from the shared sync (its + // options are fetched async and cached on the view), so + // re-apply its selection from the refreshed config. + self.resync_runner_selection(ctx); } ctx.notify(); } @@ -712,6 +716,24 @@ impl OrchestrationConfigBlockView { #[cfg(target_family = "wasm")] fn fetch_runners(&mut self, _ctx: &mut ViewContext) {} + + /// Re-applies the runner picker's selection from the current + /// `runner_id` using the view-cached runner list. The runner picker + /// is excluded from the shared picker sync, so this runs after the + /// config's `runner_id` may have changed (e.g. a model refresh). + fn resync_runner_selection(&mut self, ctx: &mut ViewContext) { + let current = match &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + if let Some(handle) = self.pickers.runner_picker.clone() { + oc::populate_runner_picker(&handle, &self.runners, ¤t, self.runners_loading, ctx); + } + } } impl Entity for OrchestrationConfigBlockView { From 626ab0fcfe5f37e86c4522c3904592008239cc5e Mon Sep 17 00:00:00 2001 From: Oz Date: Fri, 17 Jul 2026 16:51:27 +0000 Subject: [PATCH 3/9] Preserve runner selection across streamed run_agents updates update_request runs on every stream chunk and overwrote the edit state from the request, which usually omits a runner_id. That clobbered a runner the user selected in the card (or one already resolved on the call) back to "use environment default", and the runner picker never re-reflected the requested runner. Preserve a non-empty runner when the incoming request omits one; a request that carries a runner still wins. Co-Authored-By: Oz --- .../inline_action/run_agents_card_view.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 27b2c5eae62..0a7fa92226e 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -619,6 +619,32 @@ impl RunAgentsCardView { ctx, ); } + // Preserve a non-empty runner across streamed updates. `update_request` + // runs on every stream chunk, but run_agents requests usually omit a + // `runner_id`; without this, a runner the user picked in the card (or a + // runner already resolved on the call) would be clobbered back to + // "use environment default" on the next chunk. A request that *does* + // carry a runner still wins. + if let ( + RunAgentsExecutionMode::Remote { + runner_id: new_runner, + .. + }, + RunAgentsExecutionMode::Remote { + runner_id: current_runner, + .. + }, + ) = ( + &mut new_state.orchestration_config_state.execution_mode, + &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode, + ) { + if new_runner.is_empty() && !current_runner.is_empty() { + *new_runner = current_runner.clone(); + } + } if self.config_state() != new_state { let harness_or_model_changed = self .orchestration_edit_state From b143055ab46b7d69e4da6d227836f2501caae543 Mon Sep 17 00:00:00 2001 From: Oz Date: Fri, 17 Jul 2026 17:14:55 +0000 Subject: [PATCH 4/9] Reflect manual runner selection in the dropdown display A menu click dispatches SelectActionAndClose, which updates the edit state via RunnerChanged but does not update the dropdown's displayed selection; repopulating synchronously from the handler would panic with "Circular view update". Defer the runner-picker re-sync to the next tick so the closed dropdown reflects the runner the user picked. Verified with computer use: selecting a runner now persists (matching the Environment picker). Co-Authored-By: Oz --- .../ai/blocklist/inline_action/run_agents_card_view.rs | 9 +++++++++ app/src/ai/document/orchestration_config_block.rs | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 0a7fa92226e..acf5a3251ad 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -1390,6 +1390,15 @@ impl TypedActionView for RunAgentsCardView { .orchestration_config_state .set_runner_id(runner_id.clone()); self.refresh_accept_button_state(ctx); + // A menu click dispatches `SelectActionAndClose`, which does + // not update the dropdown's displayed selection, and we're + // mid-dispatch from the runner dropdown itself so we cannot + // repopulate it synchronously (that panics with "Circular + // view update"). Defer the re-sync so the closed dropdown + // reflects the runner the user just picked. + ctx.spawn(async {}, |me, _, ctx| { + me.resync_runner_selection(ctx); + }); ctx.notify(); } RunAgentsCardViewAction::WorkerHostChanged { worker_host } => { diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index 0ad6ece43ca..dd0becb8086 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -1023,6 +1023,14 @@ impl TypedActionView for OrchestrationConfigBlockView { .orchestration_config_state .set_runner_id(runner_id.clone()); self.apply_field_change(ctx); + // Defer re-applying the picker selection: a menu click's + // `SelectActionAndClose` doesn't update the dropdown's + // displayed value, and we're mid-dispatch from the runner + // dropdown so we can't repopulate it synchronously without a + // "Circular view update" panic. + ctx.spawn(async {}, |me, _, ctx| { + me.resync_runner_selection(ctx); + }); ctx.notify(); } OrchestrationConfigBlockAction::WorkerHostChanged { worker_host } => { From d218c7dec4eed7b92c982d6b879adeaaa7a35556 Mon Sep 17 00:00:00 2001 From: Oz Date: Fri, 17 Jul 2026 18:11:29 +0000 Subject: [PATCH 5/9] Gate runner picker creation/fetch on CloudAgentRunners + remote mode Only build the Runner picker and call getRunners when the feature flag is on and the card is in remote mode, and create it lazily on the Local->Cloud toggle, so hidden or Local-mode cards never hit getRunners. Co-Authored-By: Oz --- .../inline_action/run_agents_card_view.rs | 80 +++++++++++++------ .../ai/document/orchestration_config_block.rs | 66 ++++++++++----- 2 files changed, 104 insertions(+), 42 deletions(-) diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index acf5a3251ad..a03ad80307a 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -10,6 +10,7 @@ use ai::agent::action_result::{RunAgentsAgentOutcomeKind, RunAgentsResult}; use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationConfigStatus}; use ai::skills::SkillReference; use pathfinder_geometry::vector::vec2f; +use warp_core::features::FeatureFlag; use warp_core::send_telemetry_from_ctx; use warp_errors::report_error; use warpui::elements::{ @@ -933,30 +934,7 @@ impl RunAgentsCardView { self.handles.pickers.environment_picker = Some(handle); } - let state = &self.orchestration_edit_state.orchestration_config_state; - if self.handles.pickers.runner_picker.is_none() { - let initial_runner = match &state.execution_mode { - RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), - RunAgentsExecutionMode::Local => String::new(), - }; - let handle = oc::create_runner_picker( - &initial_runner, - &self.runners, - self.runners_loading, - &styles, - ctx, - ); - handle.update(ctx, |d, _| { - d.set_orientation(FilterableDropdownOrientation::Up) - }); - ctx.subscribe_to_view(&handle, |me, _, event, ctx| { - if let FilterableDropdownEvent::Close = event { - me.refocus_after_picker_close(ctx); - } - }); - self.handles.pickers.runner_picker = Some(handle); - self.fetch_runners(ctx); - } + self.ensure_runner_picker(ctx); let state = &self.orchestration_edit_state.orchestration_config_state; if self.handles.pickers.host_picker.is_none() { @@ -1034,6 +1012,56 @@ impl RunAgentsCardView { self.sync_picker_selections(ctx); } + /// Builds the Runner picker and kicks off the `getRunners` fetch, but + /// only when the `CloudAgentRunners` feature is enabled and the card is + /// in remote mode — otherwise the Runner control is not rendered, so + /// there is no reason to create the picker or hit `getRunners`. + /// Idempotent, and re-invoked on the Local→Cloud toggle so the picker + /// appears (and loads) the first time the card enters remote mode. + fn ensure_runner_picker(&mut self, ctx: &mut ViewContext) { + if !FeatureFlag::CloudAgentRunners.is_enabled() { + return; + } + if !self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote() + { + return; + } + if self.handles.pickers.runner_picker.is_some() { + return; + } + let appearance = Appearance::as_ref(ctx); + let (styles, _colors) = oc::picker_styles(appearance); + let initial_runner = match &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + let handle = oc::create_runner_picker( + &initial_runner, + &self.runners, + self.runners_loading, + &styles, + ctx, + ); + handle.update(ctx, |d, _| { + d.set_orientation(FilterableDropdownOrientation::Up) + }); + ctx.subscribe_to_view(&handle, |me, _, event, ctx| { + if let FilterableDropdownEvent::Close = event { + me.refocus_after_picker_close(ctx); + } + }); + self.handles.pickers.runner_picker = Some(handle); + self.fetch_runners(ctx); + } + /// Fetches available runners via `getRunners` and repopulates the /// Runner picker once they resolve. Runners are not cached /// client-side (unlike environments), so this lazy fetch backs the @@ -1344,6 +1372,10 @@ impl TypedActionView for RunAgentsCardView { fallback, ctx, ); + // Switching to Cloud reveals the Runner control (when the + // flag is on); build + fetch it lazily so Local cards never + // hit `getRunners`. + self.ensure_runner_picker(ctx); // Mode change can newly reveal the auth picker (Local // → Cloud) — give the user a fresh auto-open prompt. self.has_auto_opened_create_modal = false; diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index dd0becb8086..46b5e37c04d 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -6,6 +6,7 @@ use ai::agent::action::RunAgentsExecutionMode; use ai::agent::orchestration_config::OrchestrationConfigStatus; use pathfinder_geometry::vector::vec2f; use warp_cli::agent::Harness; +use warp_core::features::FeatureFlag; use warp_core::send_telemetry_from_ctx; use warpui::elements::{ ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, @@ -540,24 +541,7 @@ impl OrchestrationConfigBlockView { env_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); self.pickers.environment_picker = Some(env_handle); - let initial_runner = match &self - .orchestration_edit_state - .orchestration_config_state - .execution_mode - { - RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), - RunAgentsExecutionMode::Local => String::new(), - }; - let runner_handle = oc::create_runner_picker( - &initial_runner, - &self.runners, - self.runners_loading, - &styles, - ctx, - ); - runner_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); - self.pickers.runner_picker = Some(runner_handle); - self.fetch_runners(ctx); + self.ensure_runner_picker(ctx); let initial_host = match &self .orchestration_edit_state @@ -672,6 +656,48 @@ impl OrchestrationConfigBlockView { }); } + /// Builds the Runner picker and kicks off the `getRunners` fetch, but + /// only when the `CloudAgentRunners` feature is enabled and the config + /// is in remote mode — otherwise the Runner control is not rendered, so + /// there is no reason to create the picker or hit `getRunners`. + /// Idempotent, and re-invoked on the Local→Cloud toggle. + fn ensure_runner_picker(&mut self, ctx: &mut ViewContext) { + if !FeatureFlag::CloudAgentRunners.is_enabled() { + return; + } + if !self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + .is_remote() + { + return; + } + if self.pickers.runner_picker.is_some() { + return; + } + let appearance = Appearance::as_ref(ctx); + let (styles, _colors) = oc::picker_styles(appearance); + let initial_runner = match &self + .orchestration_edit_state + .orchestration_config_state + .execution_mode + { + RunAgentsExecutionMode::Remote { runner_id, .. } => runner_id.clone(), + RunAgentsExecutionMode::Local => String::new(), + }; + let runner_handle = oc::create_runner_picker( + &initial_runner, + &self.runners, + self.runners_loading, + &styles, + ctx, + ); + runner_handle.update(ctx, |d, c| d.set_use_overlay_layer(true, c)); + self.pickers.runner_picker = Some(runner_handle); + self.fetch_runners(ctx); + } + /// Fetches available runners via `getRunners` and repopulates the /// Runner picker once they resolve. No-op on wasm (no `FactoryClient`). #[cfg(not(target_family = "wasm"))] @@ -974,6 +1000,10 @@ impl TypedActionView for OrchestrationConfigBlockView { fallback, ctx, ); + // Switching to Cloud reveals the Runner control (when the + // flag is on); build + fetch it lazily so Local configs + // never hit `getRunners`. + self.ensure_runner_picker(ctx); self.apply_field_change(ctx); // Local → Cloud can newly reveal the auth picker. self.user_has_interacted = true; From 7a96b31a258e0279252034417ba1edb60682f985 Mon Sep 17 00:00:00 2001 From: Oz Date: Fri, 17 Jul 2026 18:57:07 +0000 Subject: [PATCH 6/9] Address PR review feedback for runner config - Gate runner picker creation/fetch on CloudAgentRunners + remote mode in both surfaces - Import FeatureFlag enum in orchestration_controls - Make FactoryClient available on WASM (drop cfg(not(wasm)) gating and dead-code attrs) - Use default RunnerSortBy::Name ordering instead of manual sort closure - Rename runner "none" label to "Use default" - Restore whitespace and add runner_id to merged-in remote test cases Co-Authored-By: Oz --- app/src/ai/blocklist/block_tests.rs | 1 + .../inline_action/orchestration_controls.rs | 5 +++-- .../inline_action/run_agents_card_view.rs | 20 +++++++------------ .../run_agents_card_view_tests.rs | 1 + .../ai/document/orchestration_config_block.rs | 16 +++++---------- .../ai/orchestration/config_state_tests.rs | 1 + app/src/ai/orchestration/providers.rs | 2 +- app/src/server/server_api.rs | 1 - app/src/server/server_api/factory.rs | 3 --- 9 files changed, 19 insertions(+), 31 deletions(-) diff --git a/app/src/ai/blocklist/block_tests.rs b/app/src/ai/blocklist/block_tests.rs index 198ef5b4ddf..64c163331ad 100644 --- a/app/src/ai/blocklist/block_tests.rs +++ b/app/src/ai/blocklist/block_tests.rs @@ -441,6 +441,7 @@ fn remote_arm_propagates_agent_identity_uid() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: false, + runner_id: String::new(), }, "oz", "auto", diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls.rs b/app/src/ai/blocklist/inline_action/orchestration_controls.rs index 2a6f3f4f5f3..f12ff1a2a9a 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls.rs @@ -10,6 +10,7 @@ use ai::agent::action::RunAgentsExecutionMode; use pathfinder_color::ColorU; use pathfinder_geometry::vector::{vec2f, Vector2F}; use warp_cli::agent::Harness; +use warp_core::features::FeatureFlag; use warp_core::ui::theme::Fill; use warpui::elements::{ Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, @@ -1182,7 +1183,7 @@ pub fn render_picker_row_with_layout( .as_ref() .map(|p| ChildView::new(p).finish()), ); - if warp_core::features::FeatureFlag::CloudAgentRunners.is_enabled() { + if FeatureFlag::CloudAgentRunners.is_enabled() { add( &mut column, "Runner", @@ -1239,7 +1240,7 @@ pub fn render_picker_row_with_layout( .as_ref() .map(|p| ChildView::new(p).finish()), ); - if warp_core::features::FeatureFlag::CloudAgentRunners.is_enabled() { + if FeatureFlag::CloudAgentRunners.is_enabled() { add_picker( &mut row, "Runner", diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index a03ad80307a..6eef41d17ad 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -13,6 +13,7 @@ use pathfinder_geometry::vector::vec2f; use warp_core::features::FeatureFlag; use warp_core::send_telemetry_from_ctx; use warp_errors::report_error; +use warp_graphql::queries::get_runners::RunnerSortBy; use warpui::elements::{ Border, ChildAnchor, ChildView, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Stack, Text, Wrap, @@ -61,7 +62,6 @@ use crate::ai::harness_availability::{ use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent}; use crate::appearance::Appearance; use crate::menu::{Event as MenuEvent, Menu, MenuItemFields, MenuVariant}; -#[cfg(not(target_family = "wasm"))] use crate::server::server_api::ServerApiProvider; use crate::ui_components::blended_colors; use crate::ui_components::icons::Icon; @@ -1062,11 +1062,10 @@ impl RunAgentsCardView { self.fetch_runners(ctx); } - /// Fetches available runners via `getRunners` and repopulates the - /// Runner picker once they resolve. Runners are not cached - /// client-side (unlike environments), so this lazy fetch backs the - /// picker. No-op on wasm (no `FactoryClient` there). - #[cfg(not(target_family = "wasm"))] + /// Fetches available runners via `getRunners` (name-sorted server-side) + /// and repopulates the Runner picker once they resolve. Runners are not + /// cached client-side (unlike environments), so this lazy fetch backs + /// the picker. fn fetch_runners(&mut self, ctx: &mut ViewContext) { if self.runners_loading || !self.runners.is_empty() { return; @@ -1074,17 +1073,15 @@ impl RunAgentsCardView { self.runners_loading = true; let client = ServerApiProvider::as_ref(ctx).get_factory_client(); ctx.spawn( - async move { client.get_runners(None).await }, + async move { client.get_runners(Some(RunnerSortBy::Name)).await }, |me, result, ctx| { me.runners_loading = false; match result { Ok(runners) => { - let mut list: Vec<(String, String)> = runners + me.runners = runners .into_iter() .map(|r| (r.uid.inner().to_string(), r.config.name)) .collect(); - list.sort_by(|a, b| a.1.cmp(&b.1)); - me.runners = list; } Err(err) => { log::warn!("Failed to fetch runners for orchestration picker: {err}"); @@ -1106,9 +1103,6 @@ impl RunAgentsCardView { ); } - #[cfg(target_family = "wasm")] - fn fetch_runners(&mut self, _ctx: &mut ViewContext) {} - /// Re-applies the runner picker's selection from the current /// `runner_id`, using the view-cached runner list. The runner picker /// is intentionally excluded from the shared picker sync (its options diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs index 741ef2d4616..105e430de77 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view_tests.rs @@ -450,6 +450,7 @@ mod format_terminal_state_tests { assert!(matches!(kind, StatusKind::Cancelled)); } } + mod override_from_approved_config_tests { use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationExecutionMode}; diff --git a/app/src/ai/document/orchestration_config_block.rs b/app/src/ai/document/orchestration_config_block.rs index 46b5e37c04d..e5a2cb6f086 100644 --- a/app/src/ai/document/orchestration_config_block.rs +++ b/app/src/ai/document/orchestration_config_block.rs @@ -8,6 +8,7 @@ use pathfinder_geometry::vector::vec2f; use warp_cli::agent::Harness; use warp_core::features::FeatureFlag; use warp_core::send_telemetry_from_ctx; +use warp_graphql::queries::get_runners::RunnerSortBy; use warpui::elements::{ ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, Hoverable, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, @@ -44,7 +45,6 @@ use crate::ai::harness_availability::{ }; use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent}; use crate::appearance::Appearance; -#[cfg(not(target_family = "wasm"))] use crate::server::server_api::ServerApiProvider; use crate::ui_components::blended_colors; use crate::workspace::WorkspaceAction; @@ -698,9 +698,8 @@ impl OrchestrationConfigBlockView { self.fetch_runners(ctx); } - /// Fetches available runners via `getRunners` and repopulates the - /// Runner picker once they resolve. No-op on wasm (no `FactoryClient`). - #[cfg(not(target_family = "wasm"))] + /// Fetches available runners via `getRunners` (name-sorted server-side) + /// and repopulates the Runner picker once they resolve. fn fetch_runners(&mut self, ctx: &mut ViewContext) { if self.runners_loading || !self.runners.is_empty() { return; @@ -708,17 +707,15 @@ impl OrchestrationConfigBlockView { self.runners_loading = true; let client = ServerApiProvider::as_ref(ctx).get_factory_client(); ctx.spawn( - async move { client.get_runners(None).await }, + async move { client.get_runners(Some(RunnerSortBy::Name)).await }, |me, result, ctx| { me.runners_loading = false; match result { Ok(runners) => { - let mut list: Vec<(String, String)> = runners + me.runners = runners .into_iter() .map(|r| (r.uid.inner().to_string(), r.config.name)) .collect(); - list.sort_by(|a, b| a.1.cmp(&b.1)); - me.runners = list; } Err(err) => { log::warn!("Failed to fetch runners for plan-card runner picker: {err}"); @@ -740,9 +737,6 @@ impl OrchestrationConfigBlockView { ); } - #[cfg(target_family = "wasm")] - fn fetch_runners(&mut self, _ctx: &mut ViewContext) {} - /// Re-applies the runner picker's selection from the current /// `runner_id` using the view-cached runner list. The runner picker /// is excluded from the shared picker sync, so this runs after the diff --git a/app/src/ai/orchestration/config_state_tests.rs b/app/src/ai/orchestration/config_state_tests.rs index 4ce0ce95ed6..6befbb6dd70 100644 --- a/app/src/ai/orchestration/config_state_tests.rs +++ b/app/src/ai/orchestration/config_state_tests.rs @@ -43,6 +43,7 @@ fn local_round_trip_preserves_remote_computer_use() { environment_id: "env-1".to_string(), worker_host: "warp".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, ); diff --git a/app/src/ai/orchestration/providers.rs b/app/src/ai/orchestration/providers.rs index 0a1c54fb8ed..889543b49ca 100644 --- a/app/src/ai/orchestration/providers.rs +++ b/app/src/ai/orchestration/providers.rs @@ -24,7 +24,7 @@ const DEFAULT_HOST_ENV_VAR: &str = "WARP_CLOUD_MODE_DEFAULT_HOST"; pub const ORCHESTRATION_WARP_WORKER_HOST: &str = WARP_WORKER_HOST; pub const ORCHESTRATION_ENV_NONE_LABEL: &str = "Empty environment"; -pub const ORCHESTRATION_RUNNER_NONE_LABEL: &str = "Use environment default"; +pub const ORCHESTRATION_RUNNER_NONE_LABEL: &str = "Use default"; /// Returns Warp base-model choices for orchestration. pub(crate) fn get_base_model_choices<'a>( diff --git a/app/src/server/server_api.rs b/app/src/server/server_api.rs index f3da4863500..d17ae81d198 100644 --- a/app/src/server/server_api.rs +++ b/app/src/server/server_api.rs @@ -1344,7 +1344,6 @@ impl ServerApiProvider { self.server_api.clone() } - #[cfg_attr(target_family = "wasm", expect(dead_code))] pub fn get_factory_client(&self) -> Arc { self.server_api.clone() } diff --git a/app/src/server/server_api/factory.rs b/app/src/server/server_api/factory.rs index d7a936feed2..40649e1e8ec 100644 --- a/app/src/server/server_api/factory.rs +++ b/app/src/server/server_api/factory.rs @@ -1,6 +1,3 @@ -// We don't manage runners from agent run CLI flows on WASM, so this code is unused there. -#![cfg_attr(target_family = "wasm", expect(dead_code))] - use anyhow::{anyhow, Result}; use async_trait::async_trait; use cynic::{MutationBuilder, QueryBuilder}; From 0b9c7653a3ea7fa4c9c51a66ef683fc3be609f33 Mon Sep 17 00:00:00 2001 From: Oz Date: Fri, 17 Jul 2026 19:25:46 +0000 Subject: [PATCH 7/9] Fix warp_tui test compilation: add runner_id to remote variants The warp_tui orchestration block tests construct RunAgentsExecutionMode::Remote and OrchestrationExecutionMode::Remote directly and needed the new runner_id field, which broke Formatting + Clippy and the test builds across all platforms. Co-Authored-By: Oz --- crates/warp_tui/src/orchestration_block_tests.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/warp_tui/src/orchestration_block_tests.rs b/crates/warp_tui/src/orchestration_block_tests.rs index 4ed26bf5360..85e26055861 100644 --- a/crates/warp_tui/src/orchestration_block_tests.rs +++ b/crates/warp_tui/src/orchestration_block_tests.rs @@ -61,6 +61,7 @@ fn remote(environment_id: &str, worker_host: &str) -> RunAgentsExecutionMode { environment_id: environment_id.to_string(), worker_host: worker_host.to_string(), computer_use_enabled: true, + runner_id: String::new(), } } @@ -137,6 +138,7 @@ fn edit_state_is_overridden_by_an_approved_config() { execution_mode: OrchestrationExecutionMode::Remote { environment_id: "env-2".to_string(), worker_host: "warp".to_string(), + runner_id: String::new(), }, }; let state = TuiOrchestrationBlock::config_state_from_request( @@ -194,6 +196,7 @@ fn build_request_carries_card_fields_and_edited_run_wide_state() { environment_id: "env-9".to_string(), worker_host: "self-hosted".to_string(), computer_use_enabled: true, + runner_id: String::new(), }, ); assert_eq!(built.harness_auth_secret_name.as_deref(), Some("codex-key")); From 1cf381372e216cae2e5b825360bd411c5608183a Mon Sep 17 00:00:00 2001 From: Oz Date: Fri, 17 Jul 2026 20:01:57 +0000 Subject: [PATCH 8/9] Silence wasm-only dead_code for CLI-only FactoryClient runner CRUD upsert_runner/delete_runner and UpsertedRunner back CLI commands that aren't built for wasm, so they're dead code on wasm while get_runners still powers the runner picker. Gate the dead_code lint to the wasm target only. Co-Authored-By: Oz --- app/src/server/server_api/factory.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/server/server_api/factory.rs b/app/src/server/server_api/factory.rs index 40649e1e8ec..a0aa4b2e8a8 100644 --- a/app/src/server/server_api/factory.rs +++ b/app/src/server/server_api/factory.rs @@ -18,6 +18,9 @@ use crate::server::graphql::{get_request_context, get_user_facing_error_message} /// The result of upserting a runner: the resulting [`Runner`] plus whether the /// operation updated an existing runner (vs. creating a new one). +// `upsert_runner`/`delete_runner` back CLI commands that aren't built for wasm, so +// this type is unused there while `get_runners` still powers the runner picker. +#[cfg_attr(target_family = "wasm", allow(dead_code))] pub struct UpsertedRunner { pub runner: Runner, pub is_update: bool, @@ -33,9 +36,11 @@ pub trait FactoryClient: 'static + Send + Sync { /// Create or update a runner. `input.uid` is `None` for a create and /// `Some(_)` for an update; this single method backs both CLI commands. + #[cfg_attr(target_family = "wasm", allow(dead_code))] async fn upsert_runner(&self, input: UpsertRunnerInput) -> Result; /// Delete a runner by UID, returning the deleted UID on success. + #[cfg_attr(target_family = "wasm", allow(dead_code))] async fn delete_runner(&self, uid: String) -> Result; } From 5c645ec9e3233d09966178449a1e1dbb91a93142 Mon Sep 17 00:00:00 2001 From: Oz Date: Sat, 18 Jul 2026 00:04:07 +0000 Subject: [PATCH 9/9] Add runner_id to merged-in TUI remote-child test Merging master brought in crates/warp_tui/src/orchestration_model_tests.rs, whose StartAgentExecutionMode::Remote construction needed the new runner_id field. This broke clippy + tests on all native platforms. Co-Authored-By: Oz --- crates/warp_tui/src/orchestration_model_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/warp_tui/src/orchestration_model_tests.rs b/crates/warp_tui/src/orchestration_model_tests.rs index 1d4b7e33ddb..6c9aa502039 100644 --- a/crates/warp_tui/src/orchestration_model_tests.rs +++ b/crates/warp_tui/src/orchestration_model_tests.rs @@ -186,6 +186,7 @@ fn remote_children_fail_cleanly() { harness_type: "oz".to_string(), title: "Researcher".to_string(), auth_secret_name: None, + runner_id: String::new(), agent_identity_uid: None, }, );