Lots of changes... not done yet.
This commit is contained in:
@@ -85,7 +85,7 @@ impl CallMCPToolExecutor {
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
log::info!("[tool-debug] CallMCPToolExecutor::execute called");
|
||||
crate::ai::tool_diagnostics::tool_debug!("CallMCPToolExecutor::execute called");
|
||||
let server_output_id = get_server_output_id(input.conversation_id, ctx);
|
||||
let AIAgentAction {
|
||||
action:
|
||||
@@ -97,21 +97,21 @@ impl CallMCPToolExecutor {
|
||||
..
|
||||
} = input.action
|
||||
else {
|
||||
log::error!("[tool-debug] CallMCPToolExecutor::execute: action type mismatch!");
|
||||
log::error!("CallMCPToolExecutor::execute: action type mismatch");
|
||||
return ActionExecution::InvalidAction;
|
||||
};
|
||||
|
||||
let name_owned = name.to_owned();
|
||||
let name_clone = name_owned.clone();
|
||||
log::info!(
|
||||
"[tool-debug] CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}",
|
||||
crate::ai::tool_diagnostics::tool_debug!(
|
||||
"CallMCPToolExecutor: tool_name={}, server_id={:?}, input={}",
|
||||
name,
|
||||
server_id,
|
||||
serde_json::to_string(input).unwrap_or_else(|_| "<serialize error>".to_string())
|
||||
);
|
||||
|
||||
let serde_json::Value::Object(mut arguments) = input.clone() else {
|
||||
log::error!("[tool-debug] CallMCPToolExecutor: input is not an object!");
|
||||
log::error!("CallMCPToolExecutor: input is not an object");
|
||||
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
|
||||
CallMCPToolResult::Error("MCP server tool input not an object".to_owned()),
|
||||
));
|
||||
@@ -143,15 +143,15 @@ impl CallMCPToolExecutor {
|
||||
|
||||
let Some(reconnecting_peer) = templatable_peer else {
|
||||
log::error!(
|
||||
"[tool-debug] CallMCPToolExecutor: MCP server for tool '{}' NOT FOUND",
|
||||
"CallMCPToolExecutor: MCP server for tool '{}' not found",
|
||||
name_owned
|
||||
);
|
||||
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
|
||||
CallMCPToolResult::Error("MCP server for tool not found".to_owned()),
|
||||
));
|
||||
};
|
||||
log::info!(
|
||||
"[tool-debug] CallMCPToolExecutor: found MCP server peer for tool '{}'",
|
||||
crate::ai::tool_diagnostics::tool_debug!(
|
||||
"CallMCPToolExecutor: found MCP server peer for tool '{}'",
|
||||
name_owned
|
||||
);
|
||||
|
||||
@@ -314,8 +314,8 @@ fn handle_call_tool_result(
|
||||
tool_name: String,
|
||||
ctx: &galaxyui::AppContext,
|
||||
) -> AIAgentActionResultType {
|
||||
log::info!(
|
||||
"[tool-debug] handle_call_tool_result: tool_name={}, is_ok={}",
|
||||
crate::ai::tool_diagnostics::tool_debug!(
|
||||
"handle_call_tool_result: tool_name={}, is_ok={}",
|
||||
tool_name,
|
||||
res.is_ok()
|
||||
);
|
||||
|
||||
@@ -108,8 +108,8 @@ impl FileGlobExecutor {
|
||||
else {
|
||||
return ActionExecution::InvalidAction;
|
||||
};
|
||||
log::info!(
|
||||
"[tool-debug] FileGlobExecutor::execute: patterns={:?}, path={:?}",
|
||||
crate::ai::tool_diagnostics::tool_debug!(
|
||||
"FileGlobExecutor::execute: patterns={:?}, path={:?}",
|
||||
patterns,
|
||||
path
|
||||
);
|
||||
|
||||
@@ -237,8 +237,8 @@ impl GrepExecutor {
|
||||
else {
|
||||
return ActionExecution::InvalidAction;
|
||||
};
|
||||
log::info!(
|
||||
"[tool-debug] GrepExecutor::execute: queries={:?}, path={:?}",
|
||||
crate::ai::tool_diagnostics::tool_debug!(
|
||||
"GrepExecutor::execute: queries={:?}, path={:?}",
|
||||
queries,
|
||||
path
|
||||
);
|
||||
|
||||
@@ -91,8 +91,8 @@ impl ReadFilesExecutor {
|
||||
else {
|
||||
return ActionExecution::InvalidAction;
|
||||
};
|
||||
log::info!(
|
||||
"[tool-debug] ReadFilesExecutor::execute: {} files requested",
|
||||
crate::ai::tool_diagnostics::tool_debug!(
|
||||
"ReadFilesExecutor::execute: {} files requested",
|
||||
locations.len()
|
||||
);
|
||||
|
||||
|
||||
@@ -173,14 +173,14 @@ impl RequestFileEditsExecutor {
|
||||
else {
|
||||
return ActionExecution::InvalidAction;
|
||||
};
|
||||
log::info!(
|
||||
"[tool-debug] RequestFileEditsExecutor::execute: action_id={:?}",
|
||||
crate::ai::tool_diagnostics::tool_debug!(
|
||||
"RequestFileEditsExecutor::execute: action_id={:?}",
|
||||
id
|
||||
);
|
||||
|
||||
let Some(diff_view) = self.diff_views.get(id) else {
|
||||
log::warn!(
|
||||
"[tool-debug] RequestFileEditsExecutor: no diff view found for action_id={:?}",
|
||||
crate::ai::tool_diagnostics::tool_debug!(
|
||||
"RequestFileEditsExecutor: no diff view found for action_id={:?}",
|
||||
id
|
||||
);
|
||||
return ActionExecution::NotReady;
|
||||
|
||||
@@ -21,7 +21,7 @@ use warpui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
|
||||
use super::start_agent::{
|
||||
StartAgentDispatch, StartAgentExecutor, StartAgentExecutorEvent, StartAgentOutcome,
|
||||
StartAgentWaitPolicy,
|
||||
StartAgentRequestId, StartAgentWaitPolicy,
|
||||
};
|
||||
use super::{
|
||||
child_agent_delegation_denial_reason, ActionExecution, AnyActionExecution, ExecuteActionInput,
|
||||
@@ -146,9 +146,12 @@ impl RunAgentsExecutor {
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
self.recovery_action_ids.remove(action_id);
|
||||
self.start_agent_executor.update(ctx, |executor, _| {
|
||||
executor.cancel_dispatches_for_action(action_id);
|
||||
let detached_dispatches = self.start_agent_executor.update(ctx, |executor, _| {
|
||||
executor.cancel_dispatches_for_action(action_id)
|
||||
});
|
||||
log::info!(
|
||||
"RunAgents cancellation detached {detached_dispatches} pending child dispatch(es) for action {action_id}"
|
||||
);
|
||||
if self.pending.remove(action_id).is_some() {
|
||||
ctx.emit(RunAgentsExecutorEvent::SpawningFinished {
|
||||
action_id: action_id.clone(),
|
||||
@@ -163,6 +166,22 @@ impl RunAgentsExecutor {
|
||||
) {
|
||||
for agent in agents {
|
||||
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
|
||||
let RunAgentsAgentOutcomeKind::Completed { agent_id, .. } = &agent.kind else {
|
||||
continue;
|
||||
};
|
||||
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
|
||||
continue;
|
||||
};
|
||||
self.launched_agents
|
||||
.entry(conversation_id)
|
||||
.or_default()
|
||||
.insert(
|
||||
normalized_name,
|
||||
ExistingLaunchedAgent {
|
||||
name: agent.name.clone(),
|
||||
agent_id: agent_id.clone(),
|
||||
},
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
|
||||
@@ -372,6 +391,10 @@ impl RunAgentsExecutor {
|
||||
);
|
||||
|
||||
let mut slots: Vec<ChildSlot> = Vec::with_capacity(agent_run_configs.len());
|
||||
let wait_policy = match &run_execution_mode {
|
||||
RunAgentsExecutionMode::Local => StartAgentWaitPolicy::Completion,
|
||||
RunAgentsExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
|
||||
};
|
||||
for cfg in &agent_run_configs {
|
||||
let normalized_name = normalize_agent_name(&cfg.name)
|
||||
.expect("validated RunAgents requests have non-empty agent names");
|
||||
@@ -382,7 +405,7 @@ impl RunAgentsExecutor {
|
||||
cfg.name.clone(),
|
||||
parent_conversation_id,
|
||||
child_conversation_id,
|
||||
parent_run_id.clone(),
|
||||
wait_policy,
|
||||
exec_ctx,
|
||||
)
|
||||
});
|
||||
@@ -485,9 +508,9 @@ impl RunAgentsExecutor {
|
||||
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let outcomes = join_all(slots.into_iter().map(resolve_child_slot)).await;
|
||||
let resolved_slots = join_all(slots.into_iter().map(resolve_child_slot)).await;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
for (slot_index, kind) in outcomes.iter().enumerate() {
|
||||
for (slot_index, resolved) in resolved_slots.iter().enumerate() {
|
||||
log::info!(
|
||||
"RunAgents child launch outcome action_id={} parent_conversation_id={} \
|
||||
agent_name={} slot_index={} outcome={}",
|
||||
@@ -498,21 +521,32 @@ impl RunAgentsExecutor {
|
||||
.map(String::as_str)
|
||||
.unwrap_or("<unknown>"),
|
||||
slot_index,
|
||||
run_agents_agent_outcome_kind_label(kind)
|
||||
run_agents_agent_outcome_kind_label(&resolved.outcome)
|
||||
);
|
||||
}
|
||||
outcomes
|
||||
resolved_slots
|
||||
},
|
||||
move |me, outcomes, ctx| {
|
||||
move |me, resolved_slots, ctx| {
|
||||
if !me.is_pending(&action_id_for_aggr) {
|
||||
return;
|
||||
}
|
||||
let timed_out_request_ids = resolved_slots
|
||||
.iter()
|
||||
.filter_map(|resolved| resolved.timed_out_request_id)
|
||||
.collect::<Vec<_>>();
|
||||
if !timed_out_request_ids.is_empty() {
|
||||
me.start_agent_executor.update(ctx, |executor, _| {
|
||||
for request_id in timed_out_request_ids {
|
||||
executor.detach_dispatch(request_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
let agents: Vec<RunAgentsAgentOutcome> = agent_run_configs_for_result
|
||||
.iter()
|
||||
.zip(outcomes)
|
||||
.map(|(cfg, kind)| RunAgentsAgentOutcome {
|
||||
.zip(resolved_slots)
|
||||
.map(|(cfg, resolved)| RunAgentsAgentOutcome {
|
||||
name: cfg.name.clone(),
|
||||
kind,
|
||||
kind: resolved.outcome,
|
||||
})
|
||||
.collect();
|
||||
me.record_launched_agents(parent_conversation_id_for_result, &agents);
|
||||
@@ -526,7 +560,7 @@ impl RunAgentsExecutor {
|
||||
"action_id": action_id_for_aggr.to_string(),
|
||||
"parent_conversation_id": parent_conversation_id_for_result.to_string(),
|
||||
"agent_count": agents.len(),
|
||||
"launched_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Launched { .. })).count(),
|
||||
"launched_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Launched { .. } | RunAgentsAgentOutcomeKind::Completed { .. })).count(),
|
||||
"failed_count": agents.iter().filter(|agent| matches!(agent.kind, RunAgentsAgentOutcomeKind::Failed { .. })).count(),
|
||||
"agents": agents
|
||||
.iter()
|
||||
@@ -536,6 +570,12 @@ impl RunAgentsExecutor {
|
||||
"status": "launched",
|
||||
"agent_id": agent_id.as_str(),
|
||||
}),
|
||||
RunAgentsAgentOutcomeKind::Completed { agent_id, output } => serde_json::json!({
|
||||
"name": agent.name.as_str(),
|
||||
"status": "completed",
|
||||
"agent_id": agent_id.as_str(),
|
||||
"output": output,
|
||||
}),
|
||||
RunAgentsAgentOutcomeKind::Failed { error } => serde_json::json!({
|
||||
"name": agent.name.as_str(),
|
||||
"status": "failed",
|
||||
@@ -723,6 +763,7 @@ fn start_agent_execution_mode_label(mode: &StartAgentExecutionMode) -> &'static
|
||||
fn run_agents_agent_outcome_kind_label(kind: &RunAgentsAgentOutcomeKind) -> &'static str {
|
||||
match kind {
|
||||
RunAgentsAgentOutcomeKind::Launched { .. } => "launched",
|
||||
RunAgentsAgentOutcomeKind::Completed { .. } => "completed",
|
||||
RunAgentsAgentOutcomeKind::Failed { .. } => "failed",
|
||||
}
|
||||
}
|
||||
@@ -732,18 +773,30 @@ enum ChildSlot {
|
||||
Pending(StartAgentDispatch),
|
||||
}
|
||||
|
||||
async fn resolve_child_slot(slot: ChildSlot) -> RunAgentsAgentOutcomeKind {
|
||||
#[derive(Debug)]
|
||||
struct ResolvedChildSlot {
|
||||
outcome: RunAgentsAgentOutcomeKind,
|
||||
timed_out_request_id: Option<StartAgentRequestId>,
|
||||
}
|
||||
|
||||
async fn resolve_child_slot(slot: ChildSlot) -> ResolvedChildSlot {
|
||||
resolve_child_slot_with_timeout(slot, SPAWN_TIMEOUT).await
|
||||
}
|
||||
|
||||
async fn resolve_child_slot_with_timeout(
|
||||
slot: ChildSlot,
|
||||
spawn_timeout: Duration,
|
||||
) -> RunAgentsAgentOutcomeKind {
|
||||
) -> ResolvedChildSlot {
|
||||
let dispatch = match slot {
|
||||
ChildSlot::Failed(error) => return RunAgentsAgentOutcomeKind::Failed { error },
|
||||
ChildSlot::Failed(error) => {
|
||||
return ResolvedChildSlot {
|
||||
outcome: RunAgentsAgentOutcomeKind::Failed { error },
|
||||
timed_out_request_id: None,
|
||||
};
|
||||
}
|
||||
ChildSlot::Pending(dispatch) => dispatch,
|
||||
};
|
||||
let request_id = dispatch.request_id;
|
||||
|
||||
let outcome = match dispatch.wait_policy {
|
||||
StartAgentWaitPolicy::Completion => dispatch.receiver.recv().await.ok(),
|
||||
@@ -754,31 +807,41 @@ async fn resolve_child_slot_with_timeout(
|
||||
{
|
||||
futures::future::Either::Left((outcome, _)) => outcome.ok(),
|
||||
futures::future::Either::Right((_, _)) => {
|
||||
dispatch.mark_detached();
|
||||
log::warn!(
|
||||
"Agent spawn timed out after {} seconds",
|
||||
spawn_timeout.as_secs()
|
||||
);
|
||||
return RunAgentsAgentOutcomeKind::Failed {
|
||||
error: format!(
|
||||
"Agent failed to start within {} seconds. \
|
||||
The harness binary may not be installed.",
|
||||
spawn_timeout.as_secs()
|
||||
),
|
||||
return ResolvedChildSlot {
|
||||
outcome: RunAgentsAgentOutcomeKind::Failed {
|
||||
error: format!(
|
||||
"Agent failed to start within {} seconds. \
|
||||
The harness binary may not be installed.",
|
||||
spawn_timeout.as_secs()
|
||||
),
|
||||
},
|
||||
timed_out_request_id: Some(request_id),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match outcome {
|
||||
Some(StartAgentOutcome::Started { agent_id })
|
||||
| Some(StartAgentOutcome::Completed { agent_id, .. }) => {
|
||||
let outcome = match outcome {
|
||||
Some(StartAgentOutcome::Started { agent_id }) => {
|
||||
RunAgentsAgentOutcomeKind::Launched { agent_id }
|
||||
}
|
||||
Some(StartAgentOutcome::Completed { agent_id, output }) => {
|
||||
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
|
||||
}
|
||||
Some(StartAgentOutcome::Error(error)) => RunAgentsAgentOutcomeKind::Failed { error },
|
||||
None => RunAgentsAgentOutcomeKind::Failed {
|
||||
error: "Child agent was cancelled before completion".to_string(),
|
||||
},
|
||||
};
|
||||
ResolvedChildSlot {
|
||||
outcome,
|
||||
timed_out_request_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -967,6 +1030,19 @@ fn existing_launched_agents_for_conversation(
|
||||
};
|
||||
for agent in agents {
|
||||
let RunAgentsAgentOutcomeKind::Launched { agent_id } = &agent.kind else {
|
||||
let RunAgentsAgentOutcomeKind::Completed { agent_id, .. } = &agent.kind
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
|
||||
continue;
|
||||
};
|
||||
existing_agents.entry(normalized_name).or_insert_with(|| {
|
||||
ExistingLaunchedAgent {
|
||||
name: agent.name.clone(),
|
||||
agent_id: agent_id.clone(),
|
||||
}
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let Some(normalized_name) = normalize_agent_name(&agent.name) else {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
|
||||
use ai::agent::action::{RunAgentsAgentRunConfig, RunAgentsExecutionMode, RunAgentsRequest};
|
||||
use ai::agent::orchestration_config::{
|
||||
@@ -525,12 +527,16 @@ fn completion_slots_are_polled_concurrently_and_preserve_request_order() {
|
||||
let (second_sender, second_receiver) = async_channel::bounded(1);
|
||||
let slots = vec![
|
||||
ChildSlot::Pending(StartAgentDispatch {
|
||||
request_id: StartAgentRequestId::from_raw_for_test(1),
|
||||
receiver: first_receiver,
|
||||
wait_policy: StartAgentWaitPolicy::Completion,
|
||||
detached: Arc::new(AtomicBool::new(false)),
|
||||
}),
|
||||
ChildSlot::Pending(StartAgentDispatch {
|
||||
request_id: StartAgentRequestId::from_raw_for_test(2),
|
||||
receiver: second_receiver,
|
||||
wait_policy: StartAgentWaitPolicy::Completion,
|
||||
detached: Arc::new(AtomicBool::new(false)),
|
||||
}),
|
||||
ChildSlot::Failed("prelaunch failure".to_string()),
|
||||
];
|
||||
@@ -557,15 +563,16 @@ fn completion_slots_are_polled_concurrently_and_preserve_request_order() {
|
||||
let outcomes = outcomes.await;
|
||||
|
||||
assert!(matches!(
|
||||
&outcomes[0],
|
||||
&outcomes[0].outcome,
|
||||
RunAgentsAgentOutcomeKind::Failed { error } if error == "first failed"
|
||||
));
|
||||
assert!(matches!(
|
||||
&outcomes[1],
|
||||
RunAgentsAgentOutcomeKind::Launched { agent_id } if agent_id == "second-agent"
|
||||
&outcomes[1].outcome,
|
||||
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
|
||||
if agent_id == "second-agent" && output == "done"
|
||||
));
|
||||
assert!(matches!(
|
||||
&outcomes[2],
|
||||
&outcomes[2].outcome,
|
||||
RunAgentsAgentOutcomeKind::Failed { error } if error == "prelaunch failure"
|
||||
));
|
||||
});
|
||||
@@ -577,8 +584,10 @@ fn completion_wait_ignores_spawn_timeout() {
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
let completion = Box::pin(resolve_child_slot_with_timeout(
|
||||
ChildSlot::Pending(StartAgentDispatch {
|
||||
request_id: StartAgentRequestId::from_raw_for_test(1),
|
||||
receiver,
|
||||
wait_policy: StartAgentWaitPolicy::Completion,
|
||||
detached: Arc::new(AtomicBool::new(false)),
|
||||
}),
|
||||
Duration::from_millis(1),
|
||||
));
|
||||
@@ -598,8 +607,9 @@ fn completion_wait_ignores_spawn_timeout() {
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
completion.await,
|
||||
RunAgentsAgentOutcomeKind::Launched { agent_id } if agent_id == "child-agent"
|
||||
completion.await.outcome,
|
||||
RunAgentsAgentOutcomeKind::Completed { agent_id, output }
|
||||
if agent_id == "child-agent" && output == "done"
|
||||
));
|
||||
});
|
||||
}
|
||||
@@ -610,21 +620,73 @@ fn startup_wait_retains_spawn_timeout() {
|
||||
let (_sender, receiver) = async_channel::bounded(1);
|
||||
let outcome = resolve_child_slot_with_timeout(
|
||||
ChildSlot::Pending(StartAgentDispatch {
|
||||
request_id: StartAgentRequestId::from_raw_for_test(1),
|
||||
receiver,
|
||||
wait_policy: StartAgentWaitPolicy::Startup,
|
||||
detached: Arc::new(AtomicBool::new(false)),
|
||||
}),
|
||||
Duration::from_millis(1),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(outcome.timed_out_request_id.is_some());
|
||||
assert_eq!(
|
||||
outcome.timed_out_request_id,
|
||||
Some(StartAgentRequestId::from_raw_for_test(1))
|
||||
);
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
outcome.outcome,
|
||||
RunAgentsAgentOutcomeKind::Failed { error }
|
||||
if error.contains("Agent failed to start within")
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_timeout_detaches_exact_pending_request() {
|
||||
App::test((), |mut app| async move {
|
||||
let state = initialize_run_agents_test(&mut app, ExecutionMode::App);
|
||||
let start_agent_executor = state.start_agent_executor;
|
||||
let parent_conversation_id = state.conversation_id;
|
||||
let dispatch = start_agent_executor.update(&mut app, |executor, ctx| {
|
||||
executor.dispatch(
|
||||
AIAgentActionId::from("run-agents-timeout".to_string()),
|
||||
"child".to_string(),
|
||||
"work".to_string(),
|
||||
StartAgentExecutionMode::Remote {
|
||||
environment_id: "environment".to_string(),
|
||||
skill_references: Vec::new(),
|
||||
model_id: "model".to_string(),
|
||||
computer_use_enabled: false,
|
||||
worker_host: String::new(),
|
||||
harness_type: "oz".to_string(),
|
||||
title: String::new(),
|
||||
auth_secret_name: None,
|
||||
},
|
||||
None,
|
||||
parent_conversation_id,
|
||||
Some("parent-run".to_string()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let request_id = dispatch.request_id;
|
||||
|
||||
let resolved =
|
||||
resolve_child_slot_with_timeout(ChildSlot::Pending(dispatch), Duration::from_millis(1))
|
||||
.await;
|
||||
let timed_out_request_id = resolved
|
||||
.timed_out_request_id
|
||||
.expect("startup timeout should expose request identity");
|
||||
start_agent_executor.update(&mut app, |executor, _| {
|
||||
assert!(executor.detach_dispatch(timed_out_request_id));
|
||||
});
|
||||
|
||||
start_agent_executor.read(&app, |executor, _| {
|
||||
assert!(!executor.has_pending_dispatch_for_test(request_id));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn initialize_run_agents_test(app: &mut App, mode: ExecutionMode) -> RunAgentsTestState {
|
||||
initialize_settings_for_tests_with_mode(app, mode, false);
|
||||
let global_resource_handles = GlobalResourceHandles::mock(app);
|
||||
|
||||
@@ -13,7 +13,6 @@ use galaxy_core::execution_mode::AppExecutionMode;
|
||||
use galaxy_util::path::ShellFamily;
|
||||
use galaxyui::r#async::{Spawnable, Timer};
|
||||
use galaxyui::{Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
|
||||
use itertools::Itertools;
|
||||
use parking_lot::FairMutex;
|
||||
|
||||
use super::{ActionExecution, AnyActionExecution, ExecuteActionInput, PreprocessActionInput};
|
||||
@@ -37,11 +36,11 @@ use crate::{send_telemetry_from_ctx, TelemetryEvent};
|
||||
|
||||
pub struct ShellCommandExecutor {
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
block_finished_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
|
||||
block_finished_senders: HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
|
||||
/// Senders used by `Check now` and the automatic monitor watchdog to force a long-running
|
||||
/// shell command's pending poll future to resolve immediately with a fresh snapshot,
|
||||
/// bypassing the agent-set timeout.
|
||||
force_refresh_senders: HashMap<BlockSelector, oneshot::Sender<()>>,
|
||||
force_refresh_senders: HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
|
||||
terminal_model: Arc<FairMutex<TerminalModel>>,
|
||||
terminal_view_id: EntityId,
|
||||
/// Sender to notify when user hands control back to agent after TransferShellCommandControlToUser.
|
||||
@@ -80,24 +79,39 @@ impl ShellCommandExecutor {
|
||||
event: &ModelEvent,
|
||||
_ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
// We wait for precmd for the block _after_ the requested command's block so that
|
||||
// downstream checks for current working directory are fresh. The precmd hook is when
|
||||
// the shell relays current working directory to warp.
|
||||
if let ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. }) = event {
|
||||
// Precmd provides fresh CWD metadata, while BlockCompleted is definitive completion
|
||||
// evidence for shells that never deliver a subsequent precmd.
|
||||
if matches!(
|
||||
event,
|
||||
ModelEvent::BlockMetadataReceived(BlockMetadataReceivedEvent { .. })
|
||||
| ModelEvent::BlockCompleted(_)
|
||||
) {
|
||||
let model = self.terminal_model.lock();
|
||||
let block_finished_senders = self.block_finished_senders.drain().collect_vec();
|
||||
for (block_selector, block_finished_tx) in block_finished_senders.into_iter() {
|
||||
if let Some(block) = block_selector.get_block(&model) {
|
||||
if block.is_command_finished() {
|
||||
let block_finished_senders = self.block_finished_senders.drain().collect::<Vec<_>>();
|
||||
for (block_selector, block_finished_txs) in block_finished_senders {
|
||||
let completed_block = block_selector.get_block(&model).filter(|block| {
|
||||
block.is_command_finished()
|
||||
&& match event {
|
||||
ModelEvent::BlockCompleted(completed) => {
|
||||
block.id() == &completed.block_id
|
||||
}
|
||||
ModelEvent::BlockMetadataReceived(_) => true,
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
if completed_block.is_some() {
|
||||
for block_finished_tx in block_finished_txs {
|
||||
if let Err(e) = block_finished_tx.send(()) {
|
||||
log::warn!(
|
||||
"Failed to notify block completion for running requested command: {e:?}"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
self.block_finished_senders
|
||||
.insert(block_selector, block_finished_tx);
|
||||
}
|
||||
} else {
|
||||
// The requested-command association may not exist yet. Keep all waiters until
|
||||
// this selector resolves and its block actually completes, or it is cancelled.
|
||||
self.block_finished_senders
|
||||
.insert(block_selector, block_finished_txs);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,8 +209,8 @@ impl ShellCommandExecutor {
|
||||
input: ExecuteActionInput,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> impl Into<AnyActionExecution> {
|
||||
log::info!(
|
||||
"[tool-debug] ShellCommandExecutor::execute: action_type={:?}",
|
||||
crate::ai::tool_diagnostics::tool_debug!(
|
||||
"ShellCommandExecutor::execute: action_type={:?}",
|
||||
std::mem::discriminant(&input.action.action)
|
||||
);
|
||||
let model = self.terminal_model.lock();
|
||||
@@ -204,12 +218,6 @@ impl ShellCommandExecutor {
|
||||
// Determine the action we want to take based on the input.
|
||||
let action_id = input.action.id.clone();
|
||||
|
||||
let command = model
|
||||
.block_list()
|
||||
.active_block()
|
||||
.command_with_secrets_unobfuscated(false)
|
||||
.clone();
|
||||
|
||||
let handle = ctx.handle();
|
||||
match &input.action.action {
|
||||
AIAgentActionType::RequestCommandOutput {
|
||||
@@ -222,18 +230,13 @@ impl ShellCommandExecutor {
|
||||
.active_block()
|
||||
.is_active_and_long_running()
|
||||
{
|
||||
// Another command is still running (e.g. stuck in a pager). Return an error
|
||||
// result so the model receives feedback and can adapt. Using Completed with a
|
||||
// non-zero exit code ensures a follow-up request is triggered.
|
||||
return ActionExecution::Sync(AIAgentActionResultType::RequestCommandOutput(
|
||||
RequestCommandOutputResult::Completed {
|
||||
command: command.clone(),
|
||||
block_id: model.block_list().active_block().id().clone(),
|
||||
output: "Error: Cannot execute command because another command is still running in the terminal.".to_string(),
|
||||
exit_code: ExitCode::from(1),
|
||||
start_ts: None,
|
||||
completed_ts: None,
|
||||
},
|
||||
let running_command = model
|
||||
.block_list()
|
||||
.active_block()
|
||||
.command_with_secrets_unobfuscated(false);
|
||||
return ActionExecution::Sync(terminal_busy_execution_error(
|
||||
command,
|
||||
&running_command,
|
||||
));
|
||||
}
|
||||
// If another conversation has taken over the agent view since this command
|
||||
@@ -275,8 +278,7 @@ impl ShellCommandExecutor {
|
||||
// Remove the senders from the maps.
|
||||
if let Some(handle) = handle.upgrade(ctx) {
|
||||
handle.update(ctx, |me, _| {
|
||||
me.block_finished_senders.remove(&block_selector);
|
||||
me.force_refresh_senders.remove(&block_selector);
|
||||
me.prune_closed_senders(&block_selector);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -339,8 +341,7 @@ impl ShellCommandExecutor {
|
||||
// Remove the senders from the maps.
|
||||
if let Some(handle) = handle.upgrade(ctx) {
|
||||
handle.update(ctx, |me, _| {
|
||||
me.block_finished_senders.remove(&block_selector);
|
||||
me.force_refresh_senders.remove(&block_selector);
|
||||
me.prune_closed_senders(&block_selector);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -371,6 +372,7 @@ impl ShellCommandExecutor {
|
||||
},
|
||||
));
|
||||
}
|
||||
let command = block.command_with_secrets_unobfuscated(false);
|
||||
drop(model);
|
||||
|
||||
let block_selector = BlockSelector::Id(block_id.clone());
|
||||
@@ -380,8 +382,7 @@ impl ShellCommandExecutor {
|
||||
// Remove the senders from the maps.
|
||||
if let Some(handle) = handle.upgrade(ctx) {
|
||||
handle.update(ctx, |me, _| {
|
||||
me.block_finished_senders.remove(&block_selector);
|
||||
me.force_refresh_senders.remove(&block_selector);
|
||||
me.prune_closed_senders(&block_selector);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -419,7 +420,9 @@ impl ShellCommandExecutor {
|
||||
// Set up a future to also wait for block completion.
|
||||
let (block_finished_tx, block_finished_rx) = oneshot::channel();
|
||||
self.block_finished_senders
|
||||
.insert(block_selector.clone(), block_finished_tx);
|
||||
.entry(block_selector.clone())
|
||||
.or_default()
|
||||
.push(block_finished_tx);
|
||||
|
||||
// Build the future that captures terminal model and block data.
|
||||
let transfer_future = {
|
||||
@@ -491,7 +494,7 @@ impl ShellCommandExecutor {
|
||||
// Clean up.
|
||||
if let Some(handle) = handle.upgrade(ctx) {
|
||||
handle.update(ctx, |me, _| {
|
||||
me.block_finished_senders.remove(&block_selector);
|
||||
me.prune_closed_senders(&block_selector);
|
||||
me.control_handback_sender = None;
|
||||
});
|
||||
}
|
||||
@@ -520,13 +523,17 @@ impl ShellCommandExecutor {
|
||||
// Create a channel to notify us when we receive block metadata.
|
||||
let (block_metadata_received_tx, block_metadata_received_rx) = oneshot::channel();
|
||||
self.block_finished_senders
|
||||
.insert(block_selector.clone(), block_metadata_received_tx);
|
||||
.entry(block_selector.clone())
|
||||
.or_default()
|
||||
.push(block_metadata_received_tx);
|
||||
|
||||
// Create a channel so `Check now` or the automatic monitor watchdog can short-circuit
|
||||
// the timeout and deliver the agent a fresh snapshot immediately.
|
||||
let (force_refresh_tx, force_refresh_rx) = oneshot::channel();
|
||||
self.force_refresh_senders
|
||||
.insert(block_selector.clone(), force_refresh_tx);
|
||||
.entry(block_selector.clone())
|
||||
.or_default()
|
||||
.push(force_refresh_tx);
|
||||
|
||||
// Create a future that resolves when we should send a result to the agent.
|
||||
let terminal_model = self.terminal_model.clone();
|
||||
@@ -600,7 +607,12 @@ impl ShellCommandExecutor {
|
||||
completed_ts: block.completed_ts().cloned(),
|
||||
}
|
||||
} else {
|
||||
let grid_contents = if model.is_alt_screen_active() {
|
||||
let selected_block_owns_alt_screen = selected_block_owns_alt_screen(
|
||||
model.is_alt_screen_active(),
|
||||
model.active_block_id(),
|
||||
block.id(),
|
||||
);
|
||||
let grid_contents = if selected_block_owns_alt_screen {
|
||||
formatted_terminal_contents_for_input(
|
||||
model.alt_screen().grid_handler(),
|
||||
None,
|
||||
@@ -618,7 +630,7 @@ impl ShellCommandExecutor {
|
||||
block_id: block.id().clone(),
|
||||
grid_contents,
|
||||
cursor: CURSOR_MARKER,
|
||||
is_alt_screen_active: model.is_alt_screen_active(),
|
||||
is_alt_screen_active: selected_block_owns_alt_screen,
|
||||
is_preempted,
|
||||
}
|
||||
}
|
||||
@@ -630,23 +642,50 @@ impl ShellCommandExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn cancel_execution(&mut self, id: &AIAgentActionId, _ctx: &mut ModelContext<Self>) {
|
||||
pub(super) fn cancel_execution(
|
||||
&mut self,
|
||||
id: &AIAgentActionId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> bool {
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
let active_block = terminal_model.block_list().active_block();
|
||||
if !active_block.is_active_and_long_running() {
|
||||
return;
|
||||
}
|
||||
|
||||
let selector = if active_block
|
||||
.requested_command_action_id()
|
||||
.is_some_and(|requested_command_id| requested_command_id == id)
|
||||
{
|
||||
BlockSelector::RequestedCommandId(id.clone())
|
||||
let requested_selector = BlockSelector::RequestedCommandId(id.clone());
|
||||
let requested_block_is_running = requested_selector
|
||||
.get_block(&terminal_model)
|
||||
.is_some_and(|block| block.is_active_and_long_running() && !block.finished());
|
||||
let selector = if requested_block_is_running {
|
||||
requested_selector
|
||||
} else {
|
||||
BlockSelector::Id(active_block.id().clone())
|
||||
BlockSelector::Id(terminal_model.active_block_id().clone())
|
||||
};
|
||||
self.block_finished_senders.remove(&selector);
|
||||
self.force_refresh_senders.remove(&selector);
|
||||
// Cancelling the wait future alone would report cancellation while the process keeps
|
||||
// running. Terminate the exact requested command before resolving the action as cancelled.
|
||||
if requested_block_is_running {
|
||||
ctx.emit(ShellCommandExecutorEvent::CancelExecution {
|
||||
action_id: id.clone(),
|
||||
});
|
||||
}
|
||||
if !requested_block_is_running {
|
||||
self.block_finished_senders.remove(&selector);
|
||||
self.force_refresh_senders.remove(&selector);
|
||||
}
|
||||
requested_block_is_running
|
||||
}
|
||||
|
||||
fn prune_closed_senders(&mut self, selector: &BlockSelector) {
|
||||
Self::prune_closed_sender_group(&mut self.block_finished_senders, selector);
|
||||
Self::prune_closed_sender_group(&mut self.force_refresh_senders, selector);
|
||||
}
|
||||
|
||||
fn prune_closed_sender_group(
|
||||
senders: &mut HashMap<BlockSelector, Vec<oneshot::Sender<()>>>,
|
||||
selector: &BlockSelector,
|
||||
) {
|
||||
if let Some(selector_senders) = senders.get_mut(selector) {
|
||||
selector_senders.retain(|sender| !sender.is_canceled());
|
||||
if selector_senders.is_empty() {
|
||||
senders.remove(selector);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Force any in-flight poll for the given long-running command block to resolve
|
||||
@@ -657,9 +696,8 @@ impl ShellCommandExecutor {
|
||||
/// control to the user). Returns whether a matching poll was successfully refreshed.
|
||||
pub fn force_refresh_block(&mut self, block_id: &BlockId) -> bool {
|
||||
let terminal_model = self.terminal_model.lock();
|
||||
// Find a sender whose selector resolves to this block. In practice there is at
|
||||
// most one: a given block can have at most one in-flight `action_result_future`
|
||||
// at a time.
|
||||
// Find every pending poll whose selector resolves to this block. Multiple provider polls
|
||||
// may legitimately wait on the same command and must be refreshed together.
|
||||
let matching_selector = self
|
||||
.force_refresh_senders
|
||||
.keys()
|
||||
@@ -674,8 +712,12 @@ impl ShellCommandExecutor {
|
||||
drop(terminal_model);
|
||||
|
||||
if let Some(selector) = matching_selector {
|
||||
if let Some(sender) = self.force_refresh_senders.remove(&selector) {
|
||||
return sender.send(()).is_ok();
|
||||
if let Some(senders) = self.force_refresh_senders.remove(&selector) {
|
||||
let mut refreshed = false;
|
||||
for sender in senders {
|
||||
refreshed |= sender.send(()).is_ok();
|
||||
}
|
||||
return refreshed;
|
||||
}
|
||||
}
|
||||
false
|
||||
@@ -714,6 +756,21 @@ fn command_for_execution(
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_busy_execution_error(command: &str, running_command: &str) -> AIAgentActionResultType {
|
||||
AIAgentActionResultType::RequestCommandOutput(RequestCommandOutputResult::ExecutionError {
|
||||
command: command.to_string(),
|
||||
message: format!("terminal is busy running command '{running_command}'"),
|
||||
})
|
||||
}
|
||||
|
||||
fn selected_block_owns_alt_screen(
|
||||
is_alt_screen_active: bool,
|
||||
active_block_id: &BlockId,
|
||||
selected_block_id: &BlockId,
|
||||
) -> bool {
|
||||
is_alt_screen_active && active_block_id == selected_block_id
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
|
||||
enum BlockSelector {
|
||||
Id(BlockId),
|
||||
@@ -919,7 +976,9 @@ pub enum ShellCommandExecutorEvent {
|
||||
input: Bytes,
|
||||
mode: AIAgentPtyWriteMode,
|
||||
},
|
||||
CancelExecution,
|
||||
CancelExecution {
|
||||
action_id: AIAgentActionId,
|
||||
},
|
||||
/// Emitted when the agent requests to transfer control of a long-running command to the user.
|
||||
TransferControlToUser {
|
||||
action_id: AIAgentActionId,
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
use std::sync::Arc;
|
||||
use std::task::Poll;
|
||||
|
||||
use async_channel::unbounded;
|
||||
use futures::channel::oneshot;
|
||||
use futures::{pin_mut, poll};
|
||||
use parking_lot::FairMutex;
|
||||
use warpui::{App, EntityId};
|
||||
|
||||
use super::{command_for_execution, ActionResult, BlockSelector, ShellCommandExecutor};
|
||||
use crate::ai::agent::ShellCommandDelay;
|
||||
use crate::terminal::event::{BlockMetadataReceivedEvent, BlockWorkingDirectoryUpdatedEvent};
|
||||
use super::{
|
||||
command_for_execution, selected_block_owns_alt_screen, terminal_busy_execution_error,
|
||||
ActionResult, BlockSelector, ShellCommandExecutor,
|
||||
};
|
||||
use crate::ai::agent::{
|
||||
AIAgentActionId, AIAgentActionResultType, RequestCommandOutputResult, ShellCommandDelay,
|
||||
};
|
||||
use crate::terminal::event::{
|
||||
BlockCompletedEvent, BlockMetadataReceivedEvent, BlockType, BlockWorkingDirectoryUpdatedEvent,
|
||||
};
|
||||
use crate::terminal::model::block::{BlockId, BlockMetadata};
|
||||
use crate::terminal::model::session::active_session::ActiveSession;
|
||||
use crate::terminal::model::session::Sessions;
|
||||
use crate::terminal::model::terminal_model::{BlockIndex, TerminalModel};
|
||||
use crate::terminal::model_events::{ModelEvent, ModelEventDispatcher};
|
||||
use crate::terminal::shell::ShellType;
|
||||
use crate::AIConversationId;
|
||||
|
||||
#[test]
|
||||
fn wait_commands_disable_implicit_posix_pagers_without_masking_exit_status() {
|
||||
@@ -31,6 +41,41 @@ fn wait_commands_disable_implicit_posix_pagers_without_masking_exit_status() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_busy_is_an_execution_error_for_the_unstarted_command() {
|
||||
let result = terminal_busy_execution_error("cargo test", "sleep 120");
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
AIAgentActionResultType::RequestCommandOutput(
|
||||
RequestCommandOutputResult::ExecutionError { command, message }
|
||||
) if command == "cargo test"
|
||||
&& message == "terminal is busy running command 'sleep 120'"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_poll_uses_alt_screen_only_for_its_owning_block() {
|
||||
let active_block_id = BlockId::new();
|
||||
let selected_block_id = BlockId::new();
|
||||
|
||||
assert!(!selected_block_owns_alt_screen(
|
||||
true,
|
||||
&active_block_id,
|
||||
&selected_block_id
|
||||
));
|
||||
assert!(selected_block_owns_alt_screen(
|
||||
true,
|
||||
&active_block_id,
|
||||
&active_block_id
|
||||
));
|
||||
assert!(!selected_block_owns_alt_screen(
|
||||
false,
|
||||
&active_block_id,
|
||||
&active_block_id
|
||||
));
|
||||
}
|
||||
|
||||
/// Locks in the contract that `ShellCommandExecutor`'s requested-command finish
|
||||
/// detector reacts only to `BlockMetadataReceived` (precmd) and not to
|
||||
/// `BlockWorkingDirectoryUpdated` (OSC 7). The detector relies on
|
||||
@@ -63,7 +108,7 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
|
||||
let selector = BlockSelector::Id(block_id);
|
||||
let (tx, _rx) = oneshot::channel::<()>();
|
||||
executor.update(&mut app, |executor, _ctx| {
|
||||
executor.block_finished_senders.insert(selector, tx);
|
||||
executor.block_finished_senders.insert(selector, vec![tx]);
|
||||
});
|
||||
assert_eq!(
|
||||
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
|
||||
@@ -88,8 +133,7 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
|
||||
that map is reserved for precmd (BlockMetadataReceived)"
|
||||
);
|
||||
|
||||
// Precmd event — the senders map should be drained (and since the
|
||||
// block isn't in the terminal model, the sender is dropped).
|
||||
// An unrelated precmd cannot resolve this selector, so its waiter must survive.
|
||||
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
|
||||
ctx.emit(ModelEvent::BlockMetadataReceived(
|
||||
BlockMetadataReceivedEvent {
|
||||
@@ -102,8 +146,8 @@ fn block_working_directory_updated_does_not_drain_finish_senders() {
|
||||
});
|
||||
assert_eq!(
|
||||
app.read(|ctx| executor.as_ref(ctx).block_finished_senders.len()),
|
||||
0,
|
||||
"BlockMetadataReceived should drain the finish senders"
|
||||
1,
|
||||
"BlockMetadataReceived must retain unresolved finish senders"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -138,7 +182,7 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
|
||||
executor.update(&mut app, |executor, _| {
|
||||
executor
|
||||
.force_refresh_senders
|
||||
.insert(BlockSelector::Id(block_id.clone()), tx);
|
||||
.insert(BlockSelector::Id(block_id.clone()), vec![tx]);
|
||||
assert!(executor.force_refresh_block(&block_id));
|
||||
assert!(!executor.force_refresh_block(&block_id));
|
||||
});
|
||||
@@ -149,7 +193,7 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
|
||||
executor.update(&mut app, |executor, _| {
|
||||
executor
|
||||
.force_refresh_senders
|
||||
.insert(BlockSelector::Id(block_id.clone()), tx);
|
||||
.insert(BlockSelector::Id(block_id.clone()), vec![tx]);
|
||||
});
|
||||
terminal_model.lock().finish_block();
|
||||
assert!(executor.update(&mut app, |executor, _| {
|
||||
@@ -158,6 +202,150 @@ fn force_refresh_block_reports_and_resolves_matching_poll() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requested_command_waiter_survives_early_metadata_and_resolves_after_association() {
|
||||
App::test((), |mut app| async move {
|
||||
let terminal_view_id = EntityId::new();
|
||||
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||
let (_model_events_tx, model_events_rx) = unbounded();
|
||||
let model_event_dispatcher =
|
||||
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
|
||||
let active_session = app.add_model(|ctx| {
|
||||
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
|
||||
});
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
let executor = app.add_model(|ctx| {
|
||||
ShellCommandExecutor::new(
|
||||
active_session,
|
||||
terminal_model.clone(),
|
||||
&model_event_dispatcher,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
let action_id = AIAgentActionId::from("requested-command".to_string());
|
||||
let result_future = executor.update(&mut app, |executor, _| {
|
||||
executor.action_result_future(
|
||||
BlockSelector::RequestedCommandId(action_id.clone()),
|
||||
Some(ShellCommandDelay::OnCompletion),
|
||||
)
|
||||
});
|
||||
pin_mut!(result_future);
|
||||
|
||||
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
|
||||
ctx.emit(ModelEvent::BlockMetadataReceived(
|
||||
BlockMetadataReceivedEvent {
|
||||
block_metadata: BlockMetadata::new(None, Some("/tmp/early".to_string())),
|
||||
block_index: BlockIndex::zero(),
|
||||
is_after_in_band_command: false,
|
||||
is_done_bootstrapping: true,
|
||||
},
|
||||
));
|
||||
});
|
||||
assert!(matches!(poll!(&mut result_future), Poll::Pending));
|
||||
|
||||
terminal_model
|
||||
.lock()
|
||||
.simulate_long_running_block("printf done", "done");
|
||||
let block_id = terminal_model.lock().active_block_id().clone();
|
||||
terminal_model
|
||||
.lock()
|
||||
.block_list_mut()
|
||||
.active_block_mut()
|
||||
.set_agent_interaction_mode_for_requested_command(
|
||||
action_id,
|
||||
None,
|
||||
AIConversationId::new(),
|
||||
);
|
||||
terminal_model.lock().finish_block();
|
||||
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
|
||||
ctx.emit(ModelEvent::BlockCompleted(block_completed_event(
|
||||
block_id.clone(),
|
||||
)));
|
||||
});
|
||||
|
||||
assert!(matches!(
|
||||
result_future.await,
|
||||
ActionResult::CommandFinished {
|
||||
block_id: result_block_id,
|
||||
..
|
||||
} if result_block_id == block_id
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_completion_polls_for_same_block_both_resolve_on_block_completed() {
|
||||
App::test((), |mut app| async move {
|
||||
let terminal_view_id = EntityId::new();
|
||||
let sessions = app.add_model(|_| Sessions::new_for_test());
|
||||
let (_model_events_tx, model_events_rx) = unbounded();
|
||||
let model_event_dispatcher =
|
||||
app.add_model(|ctx| ModelEventDispatcher::new(model_events_rx, sessions.clone(), ctx));
|
||||
let active_session = app.add_model(|ctx| {
|
||||
ActiveSession::new(sessions.clone(), model_event_dispatcher.clone(), ctx)
|
||||
});
|
||||
let terminal_model = Arc::new(FairMutex::new(TerminalModel::mock(None, None)));
|
||||
terminal_model
|
||||
.lock()
|
||||
.simulate_long_running_block("sleep 1", "finished");
|
||||
let block_id = terminal_model.lock().active_block_id().clone();
|
||||
let executor = app.add_model(|ctx| {
|
||||
ShellCommandExecutor::new(
|
||||
active_session,
|
||||
terminal_model.clone(),
|
||||
&model_event_dispatcher,
|
||||
terminal_view_id,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
let first = executor.update(&mut app, |executor, _| {
|
||||
executor.action_result_future(
|
||||
BlockSelector::Id(block_id.clone()),
|
||||
Some(ShellCommandDelay::OnCompletion),
|
||||
)
|
||||
});
|
||||
let second = executor.update(&mut app, |executor, _| {
|
||||
executor.action_result_future(
|
||||
BlockSelector::Id(block_id.clone()),
|
||||
Some(ShellCommandDelay::OnCompletion),
|
||||
)
|
||||
});
|
||||
pin_mut!(first);
|
||||
pin_mut!(second);
|
||||
assert!(matches!(poll!(&mut first), Poll::Pending));
|
||||
assert!(matches!(poll!(&mut second), Poll::Pending));
|
||||
|
||||
terminal_model.lock().finish_block();
|
||||
model_event_dispatcher.update(&mut app, |_dispatcher, ctx| {
|
||||
ctx.emit(ModelEvent::BlockCompleted(block_completed_event(
|
||||
block_id.clone(),
|
||||
)));
|
||||
});
|
||||
|
||||
let first_result = first.await;
|
||||
let second_result = second.await;
|
||||
assert!(matches!(first_result, ActionResult::CommandFinished { .. }));
|
||||
assert!(matches!(
|
||||
second_result,
|
||||
ActionResult::CommandFinished { .. }
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
fn block_completed_event(block_id: BlockId) -> BlockCompletedEvent {
|
||||
BlockCompletedEvent {
|
||||
block_latency_data: None,
|
||||
block_type: BlockType::Restored,
|
||||
num_secrets_obfuscated: 0,
|
||||
block_index: BlockIndex::zero(),
|
||||
block_id,
|
||||
session_id: None,
|
||||
restored_block_was_local: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_refresh_wakes_on_completion_poll_with_preempted_snapshot() {
|
||||
App::test((), |mut app| async move {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use futures::future::BoxFuture;
|
||||
use futures::FutureExt;
|
||||
@@ -42,9 +44,24 @@ pub enum StartAgentWaitPolicy {
|
||||
Completion,
|
||||
}
|
||||
|
||||
fn wait_policy_for_execution_mode(mode: &StartAgentExecutionMode) -> StartAgentWaitPolicy {
|
||||
match mode {
|
||||
StartAgentExecutionMode::Local { .. } => StartAgentWaitPolicy::Completion,
|
||||
StartAgentExecutionMode::Remote { .. } => StartAgentWaitPolicy::Startup,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StartAgentDispatch {
|
||||
pub request_id: StartAgentRequestId,
|
||||
pub receiver: async_channel::Receiver<StartAgentOutcome>,
|
||||
pub wait_policy: StartAgentWaitPolicy,
|
||||
pub(super) detached: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl StartAgentDispatch {
|
||||
pub(super) fn mark_detached(&self) {
|
||||
self.detached.store(true, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid_local_child_harness_error(harness_type: &str) -> String {
|
||||
@@ -139,6 +156,7 @@ struct PendingStartAgent {
|
||||
/// Set once the child conversation is synchronously created.
|
||||
child_conversation_id: Option<AIConversationId>,
|
||||
sender: async_channel::Sender<StartAgentOutcome>,
|
||||
detached: Arc<AtomicBool>,
|
||||
/// Direct Bedrock/OpenAI parents do not have a server run id or an
|
||||
/// orchestration event stream. Keep the tool call open until their local
|
||||
/// child finishes, then return the child's output inline.
|
||||
@@ -176,9 +194,17 @@ impl StartAgentExecutor {
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
let child_link_event = {
|
||||
let Some(pending) = self.pending.get_mut(&request_id) else {
|
||||
let Some(pending) = self.pending.get(&request_id) else {
|
||||
return;
|
||||
};
|
||||
if pending.detached.load(Ordering::Acquire) {
|
||||
self.pending.remove(&request_id);
|
||||
return;
|
||||
}
|
||||
let pending = self
|
||||
.pending
|
||||
.get_mut(&request_id)
|
||||
.expect("pending request was checked above");
|
||||
pending.child_conversation_id = Some(child_conversation_id);
|
||||
if let Some(agent_name) = pending.run_agents_child_name.clone() {
|
||||
Some(StartAgentExecutorEvent::RunAgentsChildConversationCreated {
|
||||
@@ -595,16 +621,13 @@ impl StartAgentExecutor {
|
||||
}
|
||||
};
|
||||
|
||||
// In local mode (no parent_run_id), block until the child finishes
|
||||
// so the parent model receives the child's output as the tool result.
|
||||
let wait_policy = if parent_run_id.is_none() {
|
||||
StartAgentWaitPolicy::Completion
|
||||
} else {
|
||||
StartAgentWaitPolicy::Startup
|
||||
};
|
||||
// Local children return their completed work; remote children acknowledge startup and
|
||||
// continue through the hosted orchestration lifecycle.
|
||||
let wait_policy = wait_policy_for_execution_mode(&execution_mode);
|
||||
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
let request_id = self.next_request_id();
|
||||
let detached = Arc::new(AtomicBool::new(false));
|
||||
self.pending.insert(
|
||||
request_id,
|
||||
PendingStartAgent {
|
||||
@@ -613,6 +636,7 @@ impl StartAgentExecutor {
|
||||
parent_conversation_id,
|
||||
child_conversation_id: None,
|
||||
sender,
|
||||
detached,
|
||||
wait_policy,
|
||||
},
|
||||
);
|
||||
@@ -667,24 +691,23 @@ impl StartAgentExecutor {
|
||||
parent_run_id: Option<String>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> StartAgentDispatch {
|
||||
let wait_policy = if parent_run_id.is_none() {
|
||||
StartAgentWaitPolicy::Completion
|
||||
} else {
|
||||
StartAgentWaitPolicy::Startup
|
||||
};
|
||||
let wait_policy = wait_policy_for_execution_mode(&execution_mode);
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
let request_id = self.next_request_id();
|
||||
let detached = Arc::new(AtomicBool::new(false));
|
||||
if let Some(error) = child_agent_delegation_denial_reason(parent_conversation_id, ctx) {
|
||||
let _ = sender.try_send(StartAgentOutcome::Error(error));
|
||||
return StartAgentDispatch {
|
||||
request_id,
|
||||
receiver,
|
||||
wait_policy,
|
||||
detached,
|
||||
};
|
||||
}
|
||||
|
||||
let (prompt, execution_mode) =
|
||||
normalize_legacy_local_child_harness_command(prompt, execution_mode);
|
||||
let prompt = compose_leaf_agent_prompt(&prompt);
|
||||
let request_id = self.next_request_id();
|
||||
self.pending.insert(
|
||||
request_id,
|
||||
PendingStartAgent {
|
||||
@@ -693,6 +716,7 @@ impl StartAgentExecutor {
|
||||
parent_conversation_id,
|
||||
child_conversation_id: None,
|
||||
sender,
|
||||
detached: detached.clone(),
|
||||
wait_policy,
|
||||
},
|
||||
);
|
||||
@@ -708,8 +732,10 @@ impl StartAgentExecutor {
|
||||
},
|
||||
)));
|
||||
StartAgentDispatch {
|
||||
request_id,
|
||||
receiver,
|
||||
wait_policy,
|
||||
detached,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,16 +745,12 @@ impl StartAgentExecutor {
|
||||
name: String,
|
||||
parent_conversation_id: AIConversationId,
|
||||
child_conversation_id: AIConversationId,
|
||||
parent_run_id: Option<String>,
|
||||
wait_policy: StartAgentWaitPolicy,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> StartAgentDispatch {
|
||||
let wait_policy = if parent_run_id.is_none() {
|
||||
StartAgentWaitPolicy::Completion
|
||||
} else {
|
||||
StartAgentWaitPolicy::Startup
|
||||
};
|
||||
let (sender, receiver) = async_channel::bounded(1);
|
||||
let request_id = self.next_request_id();
|
||||
let detached = Arc::new(AtomicBool::new(false));
|
||||
self.pending.insert(
|
||||
request_id,
|
||||
PendingStartAgent {
|
||||
@@ -737,19 +759,54 @@ impl StartAgentExecutor {
|
||||
parent_conversation_id,
|
||||
child_conversation_id: Some(child_conversation_id),
|
||||
sender,
|
||||
detached: detached.clone(),
|
||||
wait_policy,
|
||||
},
|
||||
);
|
||||
self.record_child_conversation(request_id, child_conversation_id, ctx);
|
||||
StartAgentDispatch {
|
||||
request_id,
|
||||
receiver,
|
||||
wait_policy,
|
||||
detached,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel_dispatches_for_action(&mut self, action_id: &AIAgentActionId) {
|
||||
self.pending
|
||||
.retain(|_, pending| &pending.action_id != action_id);
|
||||
/// Detaches one exact dispatch. If its launch callback is already queued,
|
||||
/// the shared marker prevents that callback from linking a late child.
|
||||
pub fn detach_dispatch(&mut self, request_id: StartAgentRequestId) -> bool {
|
||||
let Some(pending) = self.pending.remove(&request_id) else {
|
||||
return false;
|
||||
};
|
||||
pending.detached.store(true, Ordering::Release);
|
||||
true
|
||||
}
|
||||
|
||||
/// Test-only lookup for request ownership without exposing executor internals.
|
||||
#[cfg(test)]
|
||||
pub fn has_pending_dispatch_for_test(&self, request_id: StartAgentRequestId) -> bool {
|
||||
self.pending.contains_key(&request_id)
|
||||
}
|
||||
|
||||
pub fn cancel_dispatches_for_action(&mut self, action_id: &AIAgentActionId) -> usize {
|
||||
let request_ids = self
|
||||
.pending
|
||||
.iter()
|
||||
.filter_map(|(request_id, pending)| {
|
||||
(&pending.action_id == action_id).then_some(*request_id)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let detached_count = request_ids.len();
|
||||
for request_id in request_ids {
|
||||
self.detach_dispatch(request_id);
|
||||
}
|
||||
detached_count
|
||||
}
|
||||
|
||||
/// Cancels only the caller's pending tool wait. A child that was already created keeps
|
||||
/// running independently and remains available in conversation history.
|
||||
pub(super) fn cancel_execution(&mut self, action_id: &AIAgentActionId) {
|
||||
self.cancel_dispatches_for_action(action_id);
|
||||
}
|
||||
|
||||
pub(super) fn preprocess_action(
|
||||
|
||||
@@ -257,6 +257,71 @@ fn dispatch_denies_child_conversation_defense_in_depth() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_execution_waits_for_completion() {
|
||||
assert_eq!(
|
||||
wait_policy_for_execution_mode(&StartAgentExecutionMode::local_with_defaults()),
|
||||
StartAgentWaitPolicy::Completion
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detach_dispatch_rejects_late_child_callback() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let dispatch = executor.update(&mut app, |executor, ctx| {
|
||||
executor.dispatch(
|
||||
AIAgentActionId::from("run-agents".to_string()),
|
||||
"child".to_string(),
|
||||
"work".to_string(),
|
||||
StartAgentExecutionMode::Remote {
|
||||
environment_id: "environment".to_string(),
|
||||
skill_references: Vec::new(),
|
||||
model_id: "model".to_string(),
|
||||
computer_use_enabled: false,
|
||||
worker_host: String::new(),
|
||||
harness_type: "oz".to_string(),
|
||||
title: String::new(),
|
||||
auth_secret_name: None,
|
||||
},
|
||||
None,
|
||||
parent_conversation_id,
|
||||
Some(PARENT_RUN_ID.to_string()),
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
assert!(executor.update(&mut app, |executor, _| {
|
||||
executor.detach_dispatch(dispatch.request_id)
|
||||
}));
|
||||
|
||||
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||
history.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
"child".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
history_model.update(&mut app, |history, ctx| {
|
||||
history.record_new_conversation_request_complete(
|
||||
dispatch.request_id,
|
||||
child_conversation_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
executor.read(&app, |executor, _| assert!(executor.pending.is_empty()));
|
||||
assert!(dispatch.receiver.try_recv().is_err());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_local_codex_command_prompt_normalizes_to_local_harness() {
|
||||
let (prompt, execution_mode) = normalize_legacy_local_child_harness_command(
|
||||
@@ -878,6 +943,69 @@ fn direct_provider_error_preserves_child_for_inspection() {
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_standalone_start_agent_drops_dispatch_but_keeps_child_running() {
|
||||
App::test((), |mut app| async move {
|
||||
initialize_history_persistence_for_tests(&mut app);
|
||||
let terminal_view_id = EntityId::new();
|
||||
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
|
||||
let executor = app.add_model(StartAgentExecutor::new);
|
||||
let parent_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||
history.start_new_conversation(terminal_view_id, false, false, false, ctx)
|
||||
});
|
||||
let action = build_start_agent_action(
|
||||
StartAgentVersion::V1,
|
||||
StartAgentExecutionMode::local_with_defaults(),
|
||||
);
|
||||
let execution = executor.update(&mut app, |executor, ctx| {
|
||||
executor
|
||||
.execute(
|
||||
ExecuteActionInput {
|
||||
action: &action,
|
||||
conversation_id: parent_conversation_id,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
.into()
|
||||
});
|
||||
let child_conversation_id = history_model.update(&mut app, |history, ctx| {
|
||||
history.start_new_child_conversation(
|
||||
terminal_view_id,
|
||||
"child".to_string(),
|
||||
parent_conversation_id,
|
||||
None,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
history_model.update(&mut app, |history, ctx| {
|
||||
history.record_new_conversation_request_complete(
|
||||
FIRST_REQUEST_ID,
|
||||
child_conversation_id,
|
||||
ctx,
|
||||
);
|
||||
});
|
||||
|
||||
executor.update(&mut app, |executor, _| {
|
||||
executor.cancel_execution(&action.id);
|
||||
});
|
||||
|
||||
executor.read(&app, |executor, _| assert!(executor.pending.is_empty()));
|
||||
history_model.read(&app, |history, _| {
|
||||
assert_eq!(
|
||||
history
|
||||
.conversation(&child_conversation_id)
|
||||
.expect("child should remain in history")
|
||||
.status(),
|
||||
&ConversationStatus::InProgress
|
||||
);
|
||||
});
|
||||
let AnyActionExecution::Async { execute_future, .. } = execution else {
|
||||
panic!("expected async StartAgent execution");
|
||||
};
|
||||
let _ = execute_future.await;
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_direct_provider_child_resolves_pending_wait() {
|
||||
App::test((), |mut app| async move {
|
||||
@@ -975,7 +1103,7 @@ fn reattach_reuses_persisted_child_without_launching_another_agent() {
|
||||
"child".to_string(),
|
||||
parent_conversation_id,
|
||||
child_conversation_id,
|
||||
None,
|
||||
StartAgentWaitPolicy::Completion,
|
||||
ctx,
|
||||
)
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user