From 5c14241b919e6f35f689a40a6dc5f2168c6f155d Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Tue, 28 Jul 2026 17:11:34 -0500 Subject: [PATCH] Auto-monitor long-running commands with agent --- app/src/ai/blocklist/agent_view/controller.rs | 4 + .../blocklist/controller/response_stream.rs | 35 ++++++++- app/src/terminal/model/block.rs | 2 +- app/src/terminal/view.rs | 73 +++++++++++++++---- 4 files changed, 98 insertions(+), 16 deletions(-) diff --git a/app/src/ai/blocklist/agent_view/controller.rs b/app/src/ai/blocklist/agent_view/controller.rs index 5a0e4ff1..b9d219a3 100644 --- a/app/src/ai/blocklist/agent_view/controller.rs +++ b/app/src/ai/blocklist/agent_view/controller.rs @@ -413,6 +413,10 @@ impl AgentViewController { self.agent_view_state.is_active() } + pub fn active_conversation_id(&self) -> Option { + self.agent_view_state.active_conversation_id() + } + pub fn is_inline(&self) -> bool { self.agent_view_state.is_inline() } diff --git a/app/src/ai/blocklist/controller/response_stream.rs b/app/src/ai/blocklist/controller/response_stream.rs index 3d816efc..7b3c2270 100644 --- a/app/src/ai/blocklist/controller/response_stream.rs +++ b/app/src/ai/blocklist/controller/response_stream.rs @@ -93,6 +93,8 @@ pub struct ResponseStream { id: ResponseStreamId, params: api::RequestParams, retry_count: usize, + /// One-time fallback from the profile's thinking model to its coding model. + coding_model_fallback_attempted: bool, start_time: DateTime, time_to_latest_event: TimeDelta, cancellation_tx: Option>, @@ -254,6 +256,7 @@ impl ResponseStream { time_to_latest_event: TimeDelta::seconds(0), cancellation_tx: Some(cancellation_tx), retry_count: 0, + coding_model_fallback_attempted: false, original_error: None, has_received_client_actions: false, ai_identifiers, @@ -320,7 +323,7 @@ impl ResponseStream { let request_id = Uuid::new_v4(); self.current_request_id = Some(request_id); - let params = self.params.clone(); + let mut params = self.params.clone(); let provider_config = Self::resolve_provider_config(params.model.as_str(), ctx); let server_api = ServerApiProvider::as_ref(ctx).get_ai_client().clone(); let _ = ctx.spawn( @@ -334,6 +337,28 @@ impl ResponseStream { ); } + fn should_fallback_to_coding_model( + &self, + error: &Arc, + ) -> bool { + if self.coding_model_fallback_attempted || self.has_received_client_actions { + return false; + } + let coding_model = self.params.coding_model.as_str(); + !coding_model.is_empty() + && coding_model != self.params.model.as_str() + && matches!( + error.as_ref(), + crate::server::server_api::AIApiError::QuotaLimit { .. } + ) + } + + fn retry_with_coding_model(&mut self, ctx: &mut ModelContext) { + self.coding_model_fallback_attempted = true; + self.params.model = self.params.coding_model.clone(); + self.retry(ctx); + } + /// Cancels the stream. The conversation_id is preserved in the emitted event for async handling. pub(super) fn cancel( &mut self, @@ -478,6 +503,14 @@ impl ResponseStream { self.original_error = Some(format!("{e:?}")); } + if self.should_fallback_to_coding_model(&e) { + log::warn!( + "Thinking model rate-limited; retrying with the profile coding model" + ); + self.retry_with_coding_model(ctx); + return; + } + let is_online = NetworkStatus::as_ref(ctx).is_online(); match recovery_action( self.has_received_client_actions, diff --git a/app/src/terminal/model/block.rs b/app/src/terminal/model/block.rs index 9e322a4a..4e7459db 100644 --- a/app/src/terminal/model/block.rs +++ b/app/src/terminal/model/block.rs @@ -67,7 +67,7 @@ use crate::terminal::shell::ShellType; use crate::terminal::view::WithinBlockBanner; use crate::terminal::{BlockPadding, ShellHost, SizeInfo}; -pub const LONG_RUNNING_COMMAND_DURATION_MS: u64 = 50; +pub const LONG_RUNNING_COMMAND_DURATION_MS: u64 = 3_000; pub const LONG_RUNNING_BOTTOM_PADDING_LINES: f32 = 0.2; /// We don't consider commands that were killed via Ctrl-C (error code 130) or that were killed diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index b3823cc7..f1165b45 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -7482,7 +7482,7 @@ impl TerminalView { drop(model); ctx.emit(Event::ExecuteCommand(ExecuteCommandEvent { - command, + command: command.clone(), session_id, source, should_add_command_to_history: true, @@ -7498,21 +7498,66 @@ impl TerminalView { }); } - // If the command turns out to be long-running, lock the input in agent mode. + // After three seconds, automatically open the inline command-monitoring agent. + // Use the same established tag-in path as the manual "Use agent" affordance so + // running-command context, the CLI subagent task, and main-conversation history + // remain connected through the existing machinery. ctx.spawn( - // Command execution is triggered by a subscriber to the event above, so - // give some buffer to actually determine if its long running. - Timer::after(Duration::from_millis(LONG_RUNNING_COMMAND_DURATION_MS * 2)), + Timer::after(Duration::from_millis(LONG_RUNNING_COMMAND_DURATION_MS)), move |me, _, ctx| { - if me - .model - .lock() - .block_list() - .block_with_id(&block_id) - .is_some_and(|block| block.is_active_and_long_running()) - { - me.input.update(ctx, |input, ctx| { - input.set_input_mode_agent(false, ctx); + let is_still_running = { + let model = me.model.lock(); + model + .block_list() + .block_with_id(&block_id) + .is_some_and(|block| block.is_active_and_long_running()) + }; + if !is_still_running { + return; + } + + me.agent_view_controller.update(ctx, |controller, ctx| { + if !controller.is_active() { + if let Err(error) = controller.try_enter_inline_agent_view( + None, + AgentViewEntryOrigin::LongRunningCommand, + ctx, + ) { + log::error!( + "Failed to automatically open long-running command monitor: {error}" + ); + } + } + }); + me.tag_in_agent_for_user_long_running_command(ctx); + + let active_profile = AIExecutionProfilesModel::as_ref(ctx) + .active_profile(Some(me.view_id), ctx); + let profile_name = active_profile.data().name.clone(); + let coding_model = active_profile + .data() + .coding_model + .as_ref() + .map(|model| model.as_str()) + .unwrap_or("profile default"); + log::info!( + "Opening long-running command monitor with selected profile {profile_name:?} (coding model {coding_model})" + ); + + let prompt = format!( + "Monitor this running command and report evidence-based status. Use the currently selected execution profile ({profile_name}) and its configured model choices. Identify concrete success signals, explicit failures, repeated retries, blocked input, lock waits, and suspicious lack of progress. Do not declare success merely because output stops, and do not interrupt or modify the process. For database work, flag a small update that appears stuck and distinguish a likely lock wait or deadlock from legitimate work when possible.\n\nCommand:\n```sh\n{command}\n```" + ); + let conversation_id = me + .agent_view_controller + .as_ref(ctx) + .active_conversation_id(); + if let Some(conversation_id) = conversation_id { + me.ai_controller.update(ctx, |controller, ctx| { + controller.send_agent_query_in_conversation( + prompt, + conversation_id, + ctx, + ); }); } },