Make direct-provider agent runs durable

This commit is contained in:
2026-08-14 22:02:15 -05:00
parent f4a04d0240
commit b079f036fa
50 changed files with 9473 additions and 3189 deletions
+239 -38
View File
@@ -35,7 +35,8 @@ pub use execute::{
};
use futures::future::{join_all, BoxFuture};
use galaxy_agent_core::{
PermissionDecision, PermissionKind, PermissionRequest, ToolEvent, ToolResult, ToolResultStatus,
ExternalWorkId, PendingToolBatch, PermissionDecision, PermissionKind, PermissionRequest,
ToolEvent, ToolResult, ToolResultStatus,
};
use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use itertools::Itertools;
@@ -71,6 +72,7 @@ use crate::ai::document::ai_document_model::AIDocumentModel;
use crate::ai::get_relevant_files::controller::GetRelevantFilesController;
#[cfg(not(target_family = "wasm"))]
use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord};
use crate::ai::runtime::ProviderToolExecutionRef;
use crate::terminal::model::session::active_session::ActiveSession;
use crate::terminal::model_events::ModelEventDispatcher;
use crate::terminal::TerminalModel;
@@ -172,6 +174,22 @@ struct RunningActions {
action_ids: Vec<AIAgentActionId>,
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub(super) enum ProviderActionQueueError {
#[error("provider action set mismatch: expected {expected:?}, received {received:?}")]
ActionSetMismatch {
expected: Vec<String>,
received: Vec<String>,
},
#[error("provider action '{call_id}' is already correlated to active work")]
ExistingCorrelation { call_id: String },
}
type ProviderActionCorrelation = (
(AIConversationId, AIAgentActionId),
ProviderToolExecutionRef,
);
impl RunningActions {
fn new(phase: RunningActionPhase, action_id: AIAgentActionId) -> Self {
Self {
@@ -268,6 +286,13 @@ fn permission_kind_for_action(action: &AIAgentActionType) -> PermissionKind {
}
}
fn sort_action_results_by_order(
results: &mut [Arc<AIAgentActionResult>],
action_order: &HashMap<AIAgentActionId, usize>,
) {
results.sort_by_key(|result| action_order.get(&result.id).copied().unwrap_or(usize::MAX));
}
fn domain_tool_result(action_result: &AIAgentActionResult, permission_denied: bool) -> ToolResult {
let status = if permission_denied {
ToolResultStatus::Denied
@@ -626,6 +651,10 @@ pub struct BlocklistAIActionModel {
/// than reconstructing them from the legacy request protobuf.
finished_tool_results: HashMap<AIConversationId, Vec<ToolResult>>,
/// Provider-owned action results retained until their exact tool batch is fully committed.
provider_finished_action_results:
HashMap<(AIConversationId, ExternalWorkId), Vec<Arc<AIAgentActionResult>>>,
/// Original order for the current batch of actions.
///
/// We maintain this so that even though we might process actions in parallel,
@@ -635,6 +664,10 @@ pub struct BlocklistAIActionModel {
/// Permission-card rejections that still need a correlated completion event.
denied_permissions: HashSet<(AIConversationId, AIAgentActionId)>,
/// Durable provider work identity for actions owned by an active provider run.
provider_tool_executions:
HashMap<(AIConversationId, AIAgentActionId), ProviderToolExecutionRef>,
/// Past actions and their corresponding statuses from previous AI exchanges.
past_action_results: HashMap<AIAgentActionId, Arc<AIAgentActionResult>>,
@@ -669,10 +702,18 @@ impl BlocklistAIActionModel {
)
});
ctx.subscribe_to_model(&executor, move |me, _, event, ctx| match event {
BlocklistAIActionExecutorEvent::ExecutingAction { action_id } => {
ctx.emit(BlocklistAIActionEvent::ExecutingAction(action_id.clone()));
BlocklistAIActionExecutorEvent::ExecutingAction {
action_id,
conversation_id,
} => {
let execution_ref = me.provider_tool_execution_ref(*conversation_id, action_id);
ctx.emit(BlocklistAIActionEvent::ExecutingAction {
action_id: action_id.clone(),
execution_ref: execution_ref.clone(),
});
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: action_id.clone(),
execution_ref,
event: ToolEvent::Started {
call_id: action_id.to_string(),
},
@@ -710,11 +751,13 @@ impl BlocklistAIActionModel {
pending_actions: Default::default(),
finished_action_results: Default::default(),
finished_tool_results: Default::default(),
provider_finished_action_results: Default::default(),
executor,
past_action_results: HashMap::new(),
running_actions: Default::default(),
action_order: Default::default(),
denied_permissions: Default::default(),
provider_tool_executions: Default::default(),
terminal_view_id,
pending_preprocessed_actions: Default::default(),
is_view_only: false,
@@ -752,7 +795,10 @@ impl BlocklistAIActionModel {
action_id.clone(),
RunningActionPhase::Serial,
);
ctx.emit(BlocklistAIActionEvent::ExecutingAction(action_id.clone()));
ctx.emit(BlocklistAIActionEvent::ExecutingAction {
action_id: action_id.clone(),
execution_ref: self.provider_tool_execution_ref(conversation_id, action_id),
});
}
/// Returns true if the action model is operating in view-only mode (used for shared-session viewers).
@@ -942,9 +988,14 @@ impl BlocklistAIActionModel {
fn sort_finished_results(&mut self, conversation_id: AIConversationId) {
if let Some(action_order) = self.action_order.get(&conversation_id) {
if let Some(finished_results) = self.finished_action_results.get_mut(&conversation_id) {
finished_results.sort_by_key(|result| {
action_order.get(&result.id).copied().unwrap_or(usize::MAX)
});
sort_action_results_by_order(finished_results, action_order);
}
for ((finished_conversation_id, _), finished_results) in
&mut self.provider_finished_action_results
{
if *finished_conversation_id == conversation_id {
sort_action_results_by_order(finished_results, action_order);
}
}
if let Some(tool_results) = self.finished_tool_results.get_mut(&conversation_id) {
let tool_order = action_order
@@ -1113,6 +1164,7 @@ impl BlocklistAIActionModel {
// Search through all conversations' finished action results
self.finished_action_results
.values()
.chain(self.provider_finished_action_results.values())
.flat_map(|results| results.iter())
.find(|result| &result.id == id)
.or_else(|| self.past_action_results.get(id))
@@ -1288,11 +1340,14 @@ impl BlocklistAIActionModel {
"reason": format!("{reason:?}"),
}),
);
ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(
action.id.clone(),
));
let execution_ref = self.provider_tool_execution_ref(conversation_id, &action.id);
ctx.emit(BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id: action.id.clone(),
execution_ref: execution_ref.clone(),
});
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: action.id.clone(),
execution_ref,
event: ToolEvent::PermissionRequested {
request: PermissionRequest {
id: permission_request_id(&action.id),
@@ -1389,6 +1444,7 @@ impl BlocklistAIActionModel {
);
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: action_id.clone(),
execution_ref: self.provider_tool_execution_ref(conversation_id, &action_id),
event: ToolEvent::PermissionResolved {
request_id: permission_request_id(&action_id),
call_id: action_id.to_string(),
@@ -1461,6 +1517,40 @@ impl BlocklistAIActionModel {
})
}
fn provider_tool_execution_ref(
&self,
conversation_id: AIConversationId,
action_id: &AIAgentActionId,
) -> Option<ProviderToolExecutionRef> {
self.provider_tool_executions
.get(&(conversation_id, action_id.clone()))
.cloned()
}
#[allow(dead_code)]
pub(super) fn queue_provider_actions(
&mut self,
actions: Vec<AIAgentAction>,
conversation_id: AIConversationId,
batch: &PendingToolBatch,
ctx: &mut ModelContext<Self>,
) -> Result<(), ProviderActionQueueError> {
let refs = provider_action_correlations(&actions, conversation_id, batch)?;
for ((_, action_id), _) in &refs {
if self
.provider_tool_executions
.contains_key(&(conversation_id, action_id.clone()))
{
return Err(ProviderActionQueueError::ExistingCorrelation {
call_id: action_id.to_string(),
});
}
}
self.provider_tool_executions.extend(refs);
self.queue_actions(actions, conversation_id, ctx);
Ok(())
}
/// Queues the `actions` in the given iterator for the given conversation,
/// to be dispatched in the order in which they appear in the iterator.
pub(super) fn queue_actions(
@@ -1553,11 +1643,18 @@ impl BlocklistAIActionModel {
// as otherwise tools get stuck in a pending state on the viewer's side of things. This check
// must be scoped to the current conversation as some providers generate tool call IDs that
// only unique within a conversation.
if self
let has_finished_result = self
.finished_action_results
.get(&conversation_id)
.is_some_and(|results| results.iter().any(|r| r.id == action_id))
{
.is_some_and(|results| results.iter().any(|result| result.id == action_id))
|| self
.provider_finished_action_results
.iter()
.filter(|((finished_conversation_id, _), _)| {
*finished_conversation_id == conversation_id
})
.any(|(_, results)| results.iter().any(|result| result.id == action_id));
if has_finished_result {
continue;
}
@@ -1577,7 +1674,10 @@ impl BlocklistAIActionModel {
.entry(conversation_id)
.or_default()
.push_back(action);
ctx.emit(BlocklistAIActionEvent::QueuedAction(action_id));
ctx.emit(BlocklistAIActionEvent::QueuedAction {
execution_ref: self.provider_tool_execution_ref(conversation_id, &action_id),
action_id,
});
}
self.try_to_execute_available_actions(conversation_id, ctx);
}
@@ -1681,6 +1781,14 @@ impl BlocklistAIActionModel {
executor.cancel_all_running_async_actions_for_conversation(conversation_id, reason, ctx)
});
if let Some(preprocessing) = self.pending_preprocessed_actions.remove(&conversation_id) {
self.provider_tool_executions
.retain(|(correlated_conversation_id, action_id), _| {
*correlated_conversation_id != conversation_id
|| !preprocessing.contains(action_id)
});
}
let Some(actions_to_cancel) = self.pending_actions.get_mut(&conversation_id) else {
return;
};
@@ -1752,10 +1860,14 @@ impl BlocklistAIActionModel {
);
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: pending_action.id.clone(),
execution_ref: self
.provider_tool_execution_ref(conversation_id, &pending_action.id),
event: ToolEvent::PermissionResolved {
request_id: permission_request_id(&pending_action.id),
call_id: pending_action.id.to_string(),
decision: PermissionDecision::Denied { reason: None },
decision: PermissionDecision::Denied {
reason: Some("Permission denied by the user.".to_string()),
},
},
});
}
@@ -1817,11 +1929,40 @@ impl BlocklistAIActionModel {
.unwrap_or_default()
}
pub(super) fn provider_finished_action_results(
&self,
conversation_id: AIConversationId,
work_id: &ExternalWorkId,
) -> Vec<Arc<AIAgentActionResult>> {
self.provider_finished_action_results
.get(&(conversation_id, work_id.clone()))
.cloned()
.unwrap_or_default()
}
pub(super) fn archive_provider_finished_action_results(
&mut self,
conversation_id: AIConversationId,
work_id: &ExternalWorkId,
) {
let results = self
.provider_finished_action_results
.remove(&(conversation_id, work_id.clone()))
.unwrap_or_default();
for result in results {
self.past_action_results.insert(result.id.clone(), result);
}
}
/// Clears finished action results for a conversation. Used when reverting.
pub(super) fn clear_finished_action_results(&mut self, conversation_id: AIConversationId) {
self.action_order.remove(&conversation_id);
self.finished_action_results.remove(&conversation_id);
self.finished_tool_results.remove(&conversation_id);
self.provider_finished_action_results
.retain(|(finished_conversation_id, _), _| {
*finished_conversation_id != conversation_id
});
}
#[cfg(test)]
@@ -1921,14 +2062,19 @@ impl BlocklistAIActionModel {
}
}
let execution_ref = self
.provider_tool_executions
.remove(&(conversation_id, action_result.id.clone()));
let permission_denied = self
.denied_permissions
.remove(&(conversation_id, action_result.id.clone()));
let tool_result = domain_tool_result(&action_result, permission_denied);
self.finished_tool_results
.entry(conversation_id)
.or_default()
.push(tool_result.clone());
if execution_ref.is_none() {
self.finished_tool_results
.entry(conversation_id)
.or_default()
.push(tool_result.clone());
}
#[cfg(not(target_family = "wasm"))]
log_tool_event(
ctx,
@@ -1955,17 +2101,29 @@ impl BlocklistAIActionModel {
"error": action_result_error_summary(&action_result.result),
}),
);
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: action_result.id.clone(),
event: ToolEvent::Completed {
result: tool_result,
},
});
// Permission denial completes provider-owned calls when the permission decision is
// applied, so emitting a second correlated completion would violate exactly-once delivery.
if execution_ref.is_none() || !permission_denied {
ctx.emit(BlocklistAIActionEvent::ToolLifecycle {
action_id: action_result.id.clone(),
execution_ref: execution_ref.clone(),
event: ToolEvent::Completed {
result: tool_result,
},
});
}
self.finished_action_results
.entry(conversation_id)
.or_default()
.push(action_result);
if let Some(execution_ref) = &execution_ref {
self.provider_finished_action_results
.entry((conversation_id, execution_ref.work_id()))
.or_default()
.push(action_result);
} else {
self.finished_action_results
.entry(conversation_id)
.or_default()
.push(action_result);
}
if self
.running_actions
@@ -1986,6 +2144,7 @@ impl BlocklistAIActionModel {
action_id,
conversation_id,
cancellation_reason,
execution_ref: execution_ref.clone(),
});
if self
@@ -1999,8 +2158,10 @@ impl BlocklistAIActionModel {
// completion (no cancellation reason) is resolved by the controller's
// follow-up handling. Stamping here for any of those would clobber the real
// status and message.
if cancellation_reason
.is_some_and(|r| matches!(r.conversation_outcome(), CancellationOutcome::Cancelled))
if execution_ref.is_none()
&& cancellation_reason.is_some_and(|r| {
matches!(r.conversation_outcome(), CancellationOutcome::Cancelled)
})
{
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| {
// Treat action result as authoritative for determining status.
@@ -2110,23 +2271,63 @@ impl BlocklistAIActionModel {
}
}
fn provider_action_correlations(
actions: &[AIAgentAction],
conversation_id: AIConversationId,
batch: &PendingToolBatch,
) -> Result<Vec<ProviderActionCorrelation>, ProviderActionQueueError> {
let expected = batch.unresolved_call_ids();
let received = actions
.iter()
.map(|action| action.id.to_string())
.collect::<Vec<_>>();
if expected != received {
return Err(ProviderActionQueueError::ActionSetMismatch { expected, received });
}
Ok(actions
.iter()
.map(|action| {
(
(conversation_id, action.id.clone()),
ProviderToolExecutionRef::new(
conversation_id,
&batch.work_id,
action.id.to_string(),
),
)
})
.collect())
}
#[derive(Debug, Clone)]
pub enum BlocklistAIActionEvent {
/// Emitted when the action with the given ID is enqueued for execution.
QueuedAction(AIAgentActionId),
QueuedAction {
action_id: AIAgentActionId,
execution_ref: Option<ProviderToolExecutionRef>,
},
/// Emitted when the action with the given ID requires user confirmation to execute.
ActionBlockedOnUserConfirmation(AIAgentActionId),
ActionBlockedOnUserConfirmation {
action_id: AIAgentActionId,
execution_ref: Option<ProviderToolExecutionRef>,
},
/// Emitted when the action with the given ID begins execution.
ExecutingAction(AIAgentActionId),
ExecutingAction {
action_id: AIAgentActionId,
execution_ref: Option<ProviderToolExecutionRef>,
},
/// Emitted when the action with the given ID has finished.
FinishedAction {
action_id: AIAgentActionId,
conversation_id: AIConversationId,
cancellation_reason: Option<CancellationReason>,
execution_ref: Option<ProviderToolExecutionRef>,
},
/// Provider-neutral permission and execution lifecycle event for runtime consumers.
ToolLifecycle {
action_id: AIAgentActionId,
execution_ref: Option<ProviderToolExecutionRef>,
event: ToolEvent,
},
InitProject(AIAgentActionId),
@@ -2142,10 +2343,10 @@ pub enum BlocklistAIActionEvent {
impl BlocklistAIActionEvent {
pub fn action_id(&self) -> &AIAgentActionId {
match self {
BlocklistAIActionEvent::QueuedAction(action_id) => action_id,
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id) => action_id,
BlocklistAIActionEvent::ExecutingAction(action_id) => action_id,
BlocklistAIActionEvent::FinishedAction { action_id, .. } => action_id,
BlocklistAIActionEvent::QueuedAction { action_id, .. }
| BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { action_id, .. }
| BlocklistAIActionEvent::ExecutingAction { action_id, .. }
| BlocklistAIActionEvent::FinishedAction { action_id, .. } => action_id,
BlocklistAIActionEvent::ToolLifecycle { action_id, .. } => action_id,
BlocklistAIActionEvent::InitProject(action_id) => action_id,
BlocklistAIActionEvent::ToggleCodeReview(action_id) => action_id,
+5 -1
View File
@@ -688,6 +688,7 @@ impl BlocklistAIActionExecutor {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
conversation_id,
});
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
result: Arc::new(AIAgentActionResult {
@@ -904,6 +905,7 @@ impl BlocklistAIActionExecutor {
);
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
conversation_id,
});
log::info!("[tool-debug] try_to_execute_action: spawning ASYNC execution for action_id={:?}", action_id);
ctx.spawn(execute_future, move |me, result, ctx| {
@@ -932,6 +934,7 @@ impl BlocklistAIActionExecutor {
AnyActionExecution::Sync(action_result) => {
ctx.emit(BlocklistAIActionExecutorEvent::ExecutingAction {
action_id: action_id.clone(),
conversation_id,
});
ctx.emit(BlocklistAIActionExecutorEvent::FinishedAction {
result: Arc::new(AIAgentActionResult {
@@ -1140,9 +1143,10 @@ impl Entity for BlocklistAIActionExecutor {
}
pub enum BlocklistAIActionExecutorEvent {
/// Emitted when an action is execution starts.
/// Emitted when an action begins execution.
ExecutingAction {
action_id: AIAgentActionId,
conversation_id: AIConversationId,
},
/// Emitted when an action has finished.
+61 -3
View File
@@ -4,7 +4,8 @@ use std::sync::Arc;
use super::*;
use crate::ai::agent::task::TaskId;
use crate::ai::agent::{
AIAgentActionResultType, AnyFileContent, FileContext, GrepResult, ReadFilesResult,
AIAgentAction, AIAgentActionResultType, AIAgentActionType, AnyFileContent, FileContext,
GrepResult, ReadFilesResult,
};
fn make_action_result(id: &str) -> Arc<AIAgentActionResult> {
@@ -23,6 +24,36 @@ fn action_result(id: &str, result: AIAgentActionResultType) -> AIAgentActionResu
}
}
fn action(id: &str) -> AIAgentAction {
AIAgentAction {
id: AIAgentActionId::from(id.to_string()),
action: AIAgentActionType::InitProject,
task_id: TaskId::new("task".to_string()),
requires_result: true,
tool_name: Some("init_project".to_string()),
}
}
fn pending_tool_batch(call_ids: &[&str]) -> PendingToolBatch {
PendingToolBatch {
work_id: galaxy_agent_core::ExternalWorkId {
run_id: galaxy_agent_core::ProviderRunId::new("run"),
epoch: galaxy_agent_core::RunEpoch::new(7),
},
calls: call_ids
.iter()
.map(|call_id| galaxy_agent_core::PendingToolCall {
call: galaxy_agent_core::ToolCall {
id: (*call_id).to_string(),
name: "init_project".to_string(),
arguments: serde_json::json!({}),
},
state: galaxy_agent_core::PendingToolCallState::Proposed,
})
.collect(),
}
}
fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> usize {
let mut current_phase = None;
let mut count = 0;
@@ -45,6 +76,34 @@ fn count_startable_actions_for_pass(phases: &[(RunningActionPhase, bool)]) -> us
count
}
#[test]
fn provider_action_correlations_require_the_exact_unresolved_batch_order() {
let conversation_id = AIConversationId::new();
let batch = pending_tool_batch(&["first", "second"]);
let actions = vec![action("first"), action("second")];
let correlations = provider_action_correlations(&actions, conversation_id, &batch).unwrap();
assert_eq!(correlations.len(), 2);
assert_eq!(correlations[0].0, (conversation_id, actions[0].id.clone()));
assert_eq!(correlations[0].1.run_id, batch.work_id.run_id);
assert_eq!(correlations[0].1.epoch, batch.work_id.epoch);
assert_eq!(correlations[0].1.call_id, "first");
let error = provider_action_correlations(
&[action("second"), action("first")],
conversation_id,
&batch,
)
.unwrap_err();
assert_eq!(
error,
ProviderActionQueueError::ActionSetMismatch {
expected: vec!["first".to_string(), "second".to_string()],
received: vec!["second".to_string(), "first".to_string()],
}
);
}
#[test]
fn parallel_phase_only_admits_matching_autoexecutable_actions() {
let phase =
@@ -94,8 +153,7 @@ fn finished_results_stay_in_original_action_order() {
make_action_result("second"),
];
finished_results
.sort_by_key(|result| action_order.get(&result.id).copied().unwrap_or(usize::MAX));
sort_action_results_by_order(&mut finished_results, &action_order);
assert_eq!(
finished_results[0].id,
+3 -3
View File
@@ -4723,7 +4723,7 @@ impl AIBlock {
}
match event {
BlocklistAIActionEvent::ExecutingAction(..) => {
BlocklistAIActionEvent::ExecutingAction { .. } => {
match &me.autonomy_setting_speedbump {
AutonomySettingSpeedbump::ShouldShowForAutoexecutingReadonlyCommands {
action_id: speedbump_action_id,
@@ -4793,7 +4793,7 @@ impl AIBlock {
_ => {}
}
}
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(..) => {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
ctx.emit(AIBlockEvent::ActionBlockedOnUserConfirmation);
}
BlocklistAIActionEvent::FinishedAction { action_id, .. } => {
@@ -4950,7 +4950,7 @@ impl AIBlock {
}
ctx.notify();
}
BlocklistAIActionEvent::QueuedAction(action_id) => {
BlocklistAIActionEvent::QueuedAction { action_id, .. } => {
// Update search codebase view status when action is queued
if let Some(view) = me.search_codebase_view.get(action_id) {
view.update(ctx, |view, ctx| {
+40 -15
View File
@@ -15,6 +15,7 @@ use crate::ai::agent::{
};
use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewEntryOrigin};
use crate::ai::blocklist::context_model::block_context_from_terminal_model;
use crate::ai::blocklist::controller::PendingProviderCommandCompletion;
use crate::ai::blocklist::{
BlocklistAIActionEvent, BlocklistAIActionModel, BlocklistAIController,
BlocklistAIControllerEvent, BlocklistAIHistoryEvent,
@@ -56,6 +57,7 @@ struct PendingCommandCompletion {
initial_requested_command_action_id: Option<AIAgentActionId>,
prompt: String,
completed_command: RunningCommand,
exit_code: i32,
final_turn_started: bool,
}
@@ -169,7 +171,7 @@ impl CLISubagentController {
});
ctx.subscribe_to_model(action_model, |me, _, event, ctx| match event {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(_) => {
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { .. } => {
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(true);
@@ -181,7 +183,7 @@ impl CLISubagentController {
agent_has_control: active_block.is_agent_in_control(),
});
}
BlocklistAIActionEvent::ExecutingAction(..) => {
BlocklistAIActionEvent::ExecutingAction { .. } => {
let mut terminal_model = me.terminal_model.lock();
let active_block = terminal_model.block_list_mut().active_block_mut();
active_block.update_is_agent_blocked(false);
@@ -303,6 +305,7 @@ impl CLISubagentController {
requested_command_id: requested_command_action_id.clone(),
is_alt_screen_active: false,
},
exit_code,
final_turn_started: false,
})
}
@@ -319,16 +322,40 @@ impl CLISubagentController {
};
drop(terminal_model);
let Some(has_last_snapshot) = me
let provider_consumed_completion = completion.as_ref().is_some_and(|completion| {
me.controller.update(ctx, |controller, ctx| {
controller.accept_provider_command_completion(
completion.conversation_id,
PendingProviderCommandCompletion::new(
completion.completed_command.block_id.clone(),
completion.initial_requested_command_action_id.clone(),
completion.completed_command.command.clone(),
completion.completed_command.grid_contents.clone(),
completion.exit_code,
),
ctx,
)
})
});
let has_last_snapshot = me
.active_subagents_by_block
.get(&block_id)
.map(|state| state.last_snapshot_at.is_some())
else {
return;
};
.is_some_and(|state| state.last_snapshot_at.is_some());
if has_last_snapshot {
ctx.emit(CLISubagentEvent::UpdatedLastSnapshot);
}
if provider_consumed_completion {
me.finish_subagent(
&block_id,
conversation_id,
requested_command_action_id,
ctx,
);
return;
}
if !me.active_subagents_by_block.contains_key(&block_id) {
return;
}
// A Stop takeover intentionally cancels the subagent. The command may still
// finish later, but that completion must not start a new assessment turn. Also
@@ -483,7 +510,11 @@ impl CLISubagentController {
if self
.controller
.as_ref(ctx)
.has_active_stream_for_conversation(conversation_id, ctx)
.has_active_provider_run(conversation_id)
|| self
.controller
.as_ref(ctx)
.has_active_stream_for_conversation(conversation_id, ctx)
|| self
.action_model
.as_ref(ctx)
@@ -737,13 +768,7 @@ impl CLISubagentController {
.collect()
};
self.controller.update(ctx, |controller, ctx| {
controller.resume_conversation(
conversation_id,
/*can_attempt_resume_on_error*/ true,
/*is_auto_resume_after_error*/ false,
resume_context,
ctx,
);
controller.resume_conversation(conversation_id, resume_context, ctx);
});
}
}
+1 -1
View File
@@ -329,7 +329,7 @@ impl BlocklistAIStatusBar {
);
ctx.subscribe_to_model(&action_model, |_, _, event, ctx| match event {
BlocklistAIActionEvent::ExecutingAction(..)
BlocklistAIActionEvent::ExecutingAction { .. }
| BlocklistAIActionEvent::FinishedAction { .. } => ctx.notify(),
_ => (),
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,88 +1,4 @@
use super::{is_interactive_remote_command, recovery_action, RecoveryAction};
// Argument order: has_received_client_actions, is_recoverable, has_retry_budget,
// can_attempt_resume_on_error, is_online.
#[test]
fn pre_action_failures_retry() {
assert_eq!(
recovery_action(false, true, true, true, true),
RecoveryAction::RetryNow
);
// Resume eligibility is irrelevant pre-actions.
assert_eq!(
recovery_action(false, true, true, false, true),
RecoveryAction::RetryNow
);
}
#[test]
fn pre_action_failures_wait_for_connectivity_when_offline() {
assert_eq!(
recovery_action(false, true, true, true, false),
RecoveryAction::RetryWhenOnline
);
}
#[test]
fn pre_action_budget_exhaustion_is_terminal() {
// The request has already been retried MAX_RETRIES times; stop.
assert_eq!(
recovery_action(false, true, false, true, true),
RecoveryAction::Fail
);
assert_eq!(
recovery_action(false, true, false, true, false),
RecoveryAction::Fail
);
}
#[test]
fn non_recoverable_pre_action_failure_is_terminal() {
assert_eq!(
recovery_action(false, false, true, true, true),
RecoveryAction::Fail
);
}
#[test]
fn post_action_recoverable_failures_resume() {
assert_eq!(
recovery_action(true, true, true, true, true),
RecoveryAction::Resume
);
// Offline doesn't change the decision; the resume spawn waits for connectivity.
assert_eq!(
recovery_action(true, true, true, true, false),
RecoveryAction::Resume
);
// The in-request retry budget is irrelevant once actions have executed.
assert_eq!(
recovery_action(true, true, false, true, true),
RecoveryAction::Resume
);
}
#[test]
fn post_action_failures_without_resume_eligibility_are_terminal() {
// Resume requests themselves run with can_attempt_resume_on_error=false,
// bounding recovery to a single resume.
assert_eq!(
recovery_action(true, true, true, false, true),
RecoveryAction::Fail
);
}
#[test]
fn non_recoverable_post_action_failure_is_terminal() {
// A non-recoverable error (e.g. a client error) ends the conversation even
// after actions have executed.
assert_eq!(
recovery_action(true, false, true, true, true),
RecoveryAction::Fail
);
}
use super::is_interactive_remote_command;
#[test]
fn raw_interactive_ssh_is_treated_as_remote_for_acp() {
for command in [
@@ -194,7 +194,6 @@ impl SlashCommandRequest {
entrypoint,
is_auto_resume_after_error: false,
}),
/*can_attempt_resume_on_error*/ true,
is_queued_prompt,
ctx,
) {
File diff suppressed because it is too large Load Diff
+43
View File
@@ -563,6 +563,44 @@ impl BlocklistAIHistoryModel {
conversation.write_updated_conversation_state(ctx);
}
pub(crate) fn persist_active_provider_run_json(
&mut self,
conversation_id: AIConversationId,
snapshot: Option<String>,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
let conversation = self
.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
conversation.set_active_provider_run_json(snapshot);
conversation.write_updated_conversation_state(ctx);
Ok(())
}
pub(crate) fn rebind_provider_projection(
&mut self,
conversation_id: AIConversationId,
task_id: &TaskId,
exchange_id: AIAgentExchangeId,
response_stream_id: ResponseStreamId,
terminal_surface_id: EntityId,
ctx: &mut ModelContext<Self>,
) -> Result<(), UpdateHistoryError> {
let conversation = self
.conversations_by_id
.get_mut(&conversation_id)
.ok_or(UpdateHistoryError::ConversationNotFound(conversation_id))?;
conversation.rebind_provider_projection(
task_id,
exchange_id,
response_stream_id,
terminal_surface_id,
ctx,
)?;
Ok(())
}
fn update_cached_metadata_for_conversation(&mut self, conversation_id: AIConversationId) {
let Some(conversation) = self.conversations_by_id.get(&conversation_id) else {
return;
@@ -1652,6 +1690,7 @@ impl BlocklistAIHistoryModel {
let conversation_data = AgentConversationData {
agent_backend: source_conversation.agent_backend().for_fork(),
active_provider_run_json: None,
server_conversation_token: None,
conversation_usage_metadata: Some(source_conversation.usage_metadata()),
reverted_action_ids,
@@ -1816,6 +1855,7 @@ impl BlocklistAIHistoryModel {
// be recomputed based on the retained exchanges in a follow-up.
let conversation_data = AgentConversationData {
agent_backend: conversation.agent_backend().for_fork(),
active_provider_run_json: None,
server_conversation_token: None,
conversation_usage_metadata: None,
reverted_action_ids,
@@ -2824,6 +2864,9 @@ fn merged_remote_child_placeholder_conversation_data(
// Placeholder authoritative.
agent_backend: placeholder.agent_backend().clone(),
// Active process-local provider runs cannot be merged from a cloud transcript.
active_provider_run_json: None,
// Cloud authoritative.
server_conversation_token: cloud_conversation
.server_conversation_token()
@@ -418,21 +418,21 @@ impl RequestedCommandView {
if !is_finished {
ctx.subscribe_to_model(action_model, |me, _, event, ctx| {
match event {
BlocklistAIActionEvent::QueuedAction(action_id)
BlocklistAIActionEvent::QueuedAction { action_id, .. }
if *action_id == me.action_id =>
{
ctx.notify();
}
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id)
if *action_id == me.action_id =>
{
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation {
action_id, ..
} if *action_id == me.action_id => {
if me.action_type.is_requested_command() {
me.ensure_editor(ctx);
}
me.set_is_header_expanded(true, ctx);
ctx.notify();
}
BlocklistAIActionEvent::ExecutingAction(action_id)
BlocklistAIActionEvent::ExecutingAction { action_id, .. }
if *action_id == me.action_id =>
{
// For shared-session viewers, sync the command text from the action when it starts executing.
@@ -376,7 +376,7 @@ impl RunAgentsCardView {
{
ctx.notify();
}
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation(action_id)
BlocklistAIActionEvent::ActionBlockedOnUserConfirmation { action_id, .. }
if action_id == &action_id_for_action_events =>
{
// Normal case: streaming is complete and the action is
@@ -11,7 +11,6 @@ use warpui::r#async::SpawnedFutureHandle;
use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
use super::super::controller::{BlocklistAIController, BlocklistAIControllerEvent};
use crate::ai::agent::api::generate_multi_agent_output;
use crate::ai::agent::conversation::AIConversationId;
use crate::ai::agent::{
AIIdentifiers, FileContext, PassiveCodeDiffEntry, PassiveSuggestionTrigger,