Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/src/ai/blocklist/orchestration_topology.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions app/src/ai/blocklist/orchestration_topology_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions app/src/workspace/auto_handoff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,6 +27,7 @@ pub(crate) enum AutoCloudHandoffSkipReason {
MissingServerConversationToken,
SharedSessionViewer,
CloudHandoffUnavailable,
OrchestratorWithLocalChildren,
AlreadyAttempted,
NoFocusedConversation,
TerminalNotFound { terminal_view_id: EntityId },
Expand All @@ -42,13 +44,18 @@ 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 {
pub(crate) fn from_conversation(
conversation: &AIConversation,
can_handoff_to_cloud: bool,
already_attempted: bool,
has_local_orchestrated_children: bool,
) -> Self {
Self {
is_empty: conversation.is_empty(),
Expand All @@ -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,
}
}

Expand All @@ -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);
}
Expand Down Expand Up @@ -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()
{
Expand Down
14 changes: 14 additions & 0 deletions app/src/workspace/auto_handoff_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand All @@ -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 {
Expand Down
Loading