diff --git a/app/src/ai/blocklist/orchestration_topology.rs b/app/src/ai/blocklist/orchestration_topology.rs index 9aa7a1e58f0..ff556cc32d3 100644 --- a/app/src/ai/blocklist/orchestration_topology.rs +++ b/app/src/ai/blocklist/orchestration_topology.rs @@ -70,6 +70,32 @@ pub fn collect_descendant_conversation_ids_in_spawn_order( } } +/// Returns `true` if `orchestrator_id` has at least one **active local** child +/// agent in its orchestration subtree. +/// +/// "Local" means the child runs in this client (`!is_remote_child()`); a +/// remote/cloud child is owned by its worker and is unaffected by handing the +/// parent off to the cloud. "Active" means the child is not in a terminal state +/// (`!ConversationStatus::is_done()`), so a session whose children have all +/// finished is not treated as still orchestrating. +/// +/// This is used to keep automatic cloud handoff from forking only the +/// orchestrator to the cloud and orphaning its still-running local children. +/// Descendants whose `AIConversation` is not loaded are ignored — active local +/// children are always loaded in this client. +pub fn has_local_orchestrated_children( + history: &BlocklistAIHistoryModel, + orchestrator_id: AIConversationId, +) -> bool { + descendant_conversation_ids_in_spawn_order(history, orchestrator_id) + .iter() + .any(|id| { + history.conversation(id).is_some_and(|conversation| { + !conversation.is_remote_child() && !conversation.status().is_done() + }) + }) +} + /// Returns descendants in the canonical orchestration pill order: /// 1) pinned children /// 2) unpinned children diff --git a/app/src/ai/blocklist/orchestration_topology_tests.rs b/app/src/ai/blocklist/orchestration_topology_tests.rs index b71ea122861..92d742fdeeb 100644 --- a/app/src/ai/blocklist/orchestration_topology_tests.rs +++ b/app/src/ai/blocklist/orchestration_topology_tests.rs @@ -252,6 +252,109 @@ fn orchestration_aware_status_uses_direct_status_for_non_parent() { }); }); } +#[test] +fn has_local_orchestrated_children_detects_active_local_children() { + 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 orchestrator_id = history_model.update(&mut app, |history_model, ctx| { + history_model.start_new_conversation(terminal_view_id, false, false, false, ctx) + }); + + // No children yet. + history_model.read(&app, |history_model, _| { + assert!(!has_local_orchestrated_children( + history_model, + orchestrator_id + )); + }); + + let child = history_model.update(&mut app, |history_model, ctx| { + history_model.start_new_child_conversation( + terminal_view_id, + "local-child".to_string(), + orchestrator_id, + None, + ctx, + ) + }); + history_model.update(&mut app, |history_model, ctx| { + history_model.update_conversation_status( + terminal_view_id, + child, + ConversationStatus::InProgress, + ctx, + ); + }); + + // An active local child counts. + history_model.read(&app, |history_model, _| { + assert!(has_local_orchestrated_children( + history_model, + orchestrator_id + )); + }); + + // A finished local child no longer counts. + history_model.update(&mut app, |history_model, ctx| { + history_model.update_conversation_status( + terminal_view_id, + child, + ConversationStatus::Success, + ctx, + ); + }); + history_model.read(&app, |history_model, _| { + assert!(!has_local_orchestrated_children( + history_model, + orchestrator_id + )); + }); + }); +} + +#[test] +fn has_local_orchestrated_children_ignores_remote_children() { + 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 orchestrator_id = history_model.update(&mut app, |history_model, ctx| { + history_model.start_new_conversation(terminal_view_id, false, false, false, ctx) + }); + let remote_child = history_model.update(&mut app, |history_model, ctx| { + history_model.start_new_child_conversation( + terminal_view_id, + "remote-child".to_string(), + orchestrator_id, + None, + ctx, + ) + }); + history_model.update(&mut app, |history_model, ctx| { + history_model.update_conversation_status( + terminal_view_id, + remote_child, + ConversationStatus::InProgress, + ctx, + ); + history_model.mark_conversation_as_remote_child(remote_child, ctx); + }); + + // A remote child runs on its own worker and is not orphaned by a + // parent-only cloud handoff, so it must not count. + history_model.read(&app, |history_model, _| { + assert!(!has_local_orchestrated_children( + history_model, + orchestrator_id + )); + }); + }); +} + #[test] fn descendant_conversation_ids_in_spawn_order_returns_empty_without_children() { App::test((), |mut app| async move { diff --git a/app/src/workspace/auto_handoff.rs b/app/src/workspace/auto_handoff.rs index b3218b9a37f..f3ef4ff9ffa 100644 --- a/app/src/workspace/auto_handoff.rs +++ b/app/src/workspace/auto_handoff.rs @@ -13,6 +13,7 @@ use super::{ use crate::ai::active_agent_views_model::{ActiveAgentViewsModel, ConversationOrTaskId}; use crate::ai::agent::conversation::{AIConversation, AIConversationId}; use crate::ai::ambient_agents::telemetry::CloudAgentTelemetryEvent; +use crate::ai::blocklist::orchestration_topology::has_local_orchestrated_children; use crate::settings::AISettings; use crate::system::{SystemStats, SystemStatsEvent}; use crate::terminal::view::TerminalView; @@ -26,6 +27,7 @@ pub(crate) enum AutoCloudHandoffSkipReason { MissingServerConversationToken, SharedSessionViewer, CloudHandoffUnavailable, + OrchestratorWithLocalChildren, AlreadyAttempted, NoFocusedConversation, TerminalNotFound { terminal_view_id: EntityId }, @@ -42,6 +44,10 @@ pub(crate) struct AutoCloudHandoffEligibility { pub(crate) is_viewing_shared_session: bool, pub(crate) can_handoff_to_cloud: bool, pub(crate) already_attempted: bool, + /// True when the focused conversation is an orchestrator with at least one + /// active local child agent. Handing such a session off to the cloud would + /// fork only the parent and orphan its local children, so we skip it. + pub(crate) has_local_orchestrated_children: bool, } impl AutoCloudHandoffEligibility { @@ -49,6 +55,7 @@ impl AutoCloudHandoffEligibility { conversation: &AIConversation, can_handoff_to_cloud: bool, already_attempted: bool, + has_local_orchestrated_children: bool, ) -> Self { Self { is_empty: conversation.is_empty(), @@ -57,6 +64,7 @@ impl AutoCloudHandoffEligibility { is_viewing_shared_session: conversation.is_viewing_shared_session(), can_handoff_to_cloud, already_attempted, + has_local_orchestrated_children, } } @@ -73,6 +81,9 @@ impl AutoCloudHandoffEligibility { if !self.is_in_progress { return Some(AutoCloudHandoffSkipReason::NotInProgress); } + if self.has_local_orchestrated_children { + return Some(AutoCloudHandoffSkipReason::OrchestratorWithLocalChildren); + } if !self.has_server_conversation_token { return Some(AutoCloudHandoffSkipReason::MissingServerConversationToken); } @@ -316,6 +327,7 @@ impl AutoCloudHandoffController { conversation, can_handoff_to_cloud, self.attempted_conversation_ids.contains(&conversation_id), + has_local_orchestrated_children(history, conversation_id), ) .skip_reason() { diff --git a/app/src/workspace/auto_handoff_tests.rs b/app/src/workspace/auto_handoff_tests.rs index 418ffd39353..ba0b4fb2c80 100644 --- a/app/src/workspace/auto_handoff_tests.rs +++ b/app/src/workspace/auto_handoff_tests.rs @@ -8,6 +8,7 @@ fn eligibility() -> AutoCloudHandoffEligibility { is_viewing_shared_session: false, can_handoff_to_cloud: true, already_attempted: false, + has_local_orchestrated_children: false, } } @@ -16,6 +17,19 @@ fn eligible_running_synced_conversation_is_not_skipped() { assert_eq!(eligibility().skip_reason(), None); } +#[test] +fn auto_handoff_skips_orchestrator_with_local_children() { + let eligibility = AutoCloudHandoffEligibility { + has_local_orchestrated_children: true, + ..eligibility() + }; + + assert_eq!( + eligibility.skip_reason(), + Some(AutoCloudHandoffSkipReason::OrchestratorWithLocalChildren) + ); +} + #[test] fn auto_handoff_skips_empty_conversations() { let eligibility = AutoCloudHandoffEligibility {