diff --git a/app/src/ai/agent/api/convert_conversation.rs b/app/src/ai/agent/api/convert_conversation.rs index fdebf73fa24..763975f23dc 100644 --- a/app/src/ai/agent/api/convert_conversation.rs +++ b/app/src/ai/agent/api/convert_conversation.rs @@ -85,6 +85,7 @@ pub fn convert_conversation_data_to_ai_conversation( orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -104,6 +105,7 @@ pub fn convert_conversation_data_to_ai_conversation( orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: metadata .ambient_agent_task_id diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index 57c23674a1c..7f85444a36e 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -346,6 +346,10 @@ pub struct AIConversation { /// these conversations — the remote worker's own client handles status /// reporting. is_remote_child: bool, + /// True when this is an owned cloud parent hosted by a remote driver and + /// observed locally. Unlike `is_viewing_shared_session`, this marker is + /// durable so the local observer cursor and hierarchy can be restored. + is_durable_observer_parent: bool, /// The last event sequence number observed from the v2 orchestration /// event log. Used on restore to resume event delivery without @@ -411,6 +415,7 @@ impl AIConversation { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, last_event_sequence: None, orchestration_configs: HashMap::new(), pinned: false, @@ -545,6 +550,7 @@ impl AIConversation { orchestration_harness_type, parent_conversation_id, is_remote_child, + is_durable_observer_parent, run_id, autoexecute_override, last_event_sequence, @@ -596,6 +602,7 @@ impl AIConversation { data.orchestration_harness_type, parent_conversation_id, data.is_remote_child, + data.is_durable_observer_parent, data.run_id, autoexecute_override, data.last_event_sequence, @@ -613,6 +620,7 @@ impl AIConversation { None, None, false, + false, None, AIConversationAutoexecuteMode::default(), None, @@ -622,7 +630,7 @@ impl AIConversation { Ok(Self { id, - is_viewing_shared_session: false, + is_viewing_shared_session: is_durable_observer_parent, is_cli_agent_transcript: false, task_store, status, @@ -653,6 +661,7 @@ impl AIConversation { orchestration_harness_type, parent_conversation_id, is_remote_child, + is_durable_observer_parent, last_event_sequence, orchestration_configs: HashMap::new(), pinned, @@ -683,6 +692,14 @@ impl AIConversation { self.is_viewing_shared_session = is_viewing_shared_session; } + pub fn is_durable_observer_parent(&self) -> bool { + self.is_durable_observer_parent + } + + pub fn set_is_durable_observer_parent(&mut self, durable: bool) { + self.is_durable_observer_parent = durable; + } + pub fn is_cli_agent_transcript(&self) -> bool { self.is_cli_agent_transcript } @@ -3477,8 +3494,10 @@ impl AIConversation { &mut self, ctx: &mut ModelContext, ) { - // We should not persist non-local conversations (e.g. shared sessions). - if self.is_viewing_shared_session { + // Passive shared-session views remain ephemeral. Owned cloud parents + // are the narrow exception: their local observer cursor and child + // hierarchy must survive restart. + if self.is_viewing_shared_session && !self.is_durable_observer_parent { return; } @@ -3546,6 +3565,7 @@ impl AIConversation { orchestration_harness_type: self.orchestration_harness_type.clone(), parent_conversation_id: self.parent_conversation_id.map(|id| id.to_string()), is_remote_child: self.is_remote_child, + is_durable_observer_parent: self.is_durable_observer_parent, // Legacy field; retained for backward-compatible // deserialization but no longer written. The optimistic-root // case is now handled by `Task::source_for_persistence` diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index 9012bfde8da..47702d5e077 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -1719,6 +1719,59 @@ impl AgentConversationsModel { } } + /// Updates a cached task to reflect that execution has started and its + /// session is now known (from a `run_session_linked` wire event). If the + /// task is not yet cached, starts a fetch to retrieve it. + /// + /// Mutating the cache entry directly avoids a full round-trip while still + /// giving `decide_child_pane_materialization` the `InProgress` + + /// `is_sandbox_running=true` + `session_id` it needs to return `AttachLive` + /// on the next pill click. `TasksUpdated` is emitted so any pending + /// re-drives fire immediately. + pub fn update_task_as_running_with_session( + &mut self, + task_id: &AmbientAgentTaskId, + session_id_str: String, + ctx: &mut ModelContext, + ) { + use crate::ai::ambient_agents::AmbientAgentTaskState; + if let Some(task) = self.tasks.get_mut(task_id) { + task.session_id = Some(session_id_str); + task.is_sandbox_running = true; + // Only promote to InProgress if still in a queued/pending state; + // never downgrade a terminal state that may have arrived concurrently. + match task.state { + AmbientAgentTaskState::Queued + | AmbientAgentTaskState::Pending + | AmbientAgentTaskState::Claimed => { + task.state = AmbientAgentTaskState::InProgress; + } + _ => {} + } + ctx.emit(AgentConversationsModelEvent::TasksUpdated); + } else { + // Task not cached yet; start a fetch. + self.async_fetch_task(task_id, ctx); + } + } + + /// Evicts a task from the cache and immediately starts a fresh + /// `GET /agent/runs/{id}` fetch. Used by the family drain when a terminal + /// lifecycle event arrives for a child whose cached state is stale (e.g. + /// still shows `Queued` from the initial discovery fetch). The refreshed + /// data — including the server conversation token and terminal state — + /// enables `decide_child_pane_materialization` to return `LoadTranscript` + /// so subsequent pill clicks load the cloud transcript. + pub fn evict_and_refetch_task( + &mut self, + task_id: &AmbientAgentTaskId, + ctx: &mut ModelContext, + ) { + self.tasks.remove(task_id); + self.task_fetch_state.remove(task_id); + self.async_fetch_task(task_id, ctx); + } + /// Get raw task data by task ID, fetching from server if not in memory. /// If the task is already in memory, returns it immediately. /// If not, spawns an async task to fetch it from the server, stores it in memory, diff --git a/app/src/ai/agent_conversations_model_tests.rs b/app/src/ai/agent_conversations_model_tests.rs index 7dfedb76a8e..da5df53f148 100644 --- a/app/src/ai/agent_conversations_model_tests.rs +++ b/app/src/ai/agent_conversations_model_tests.rs @@ -70,6 +70,7 @@ fn create_test_task( display_name: Some(format!("User {creator_uid}")), }), executor: None, + scope: None, conversation_id: None, request_usage: None, agent_config_snapshot: None, @@ -234,6 +235,7 @@ fn test_title_update_refreshes_shadowing_task_title() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -341,6 +343,7 @@ fn test_display_status_uses_matching_conversation_for_in_progress_task() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, @@ -398,6 +401,7 @@ fn test_display_status_uses_active_execution_over_previous_conversation_status() orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, @@ -462,6 +466,7 @@ fn test_display_status_updates_when_blocked_conversation_resumes() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, @@ -542,6 +547,7 @@ fn test_display_status_terminal_task_state_overrides_matching_conversation() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, @@ -597,6 +603,7 @@ fn test_status_filter_uses_display_status_for_task_backed_conversations() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, @@ -1052,6 +1059,7 @@ fn test_get_entries_excludes_conversation_shadowed_by_child_task() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -1202,6 +1210,7 @@ fn test_get_entries_merges_task_and_local_conversation_by_run_id() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, @@ -1257,6 +1266,7 @@ fn test_get_entries_merges_task_and_local_conversation_by_server_token() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -1467,6 +1477,7 @@ fn test_resolve_open_action_returns_none_for_active_unattachable_session() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, @@ -1753,6 +1764,7 @@ fn test_server_token_assignment_updates_copy_link_resolution() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -1915,6 +1927,7 @@ fn test_resolve_copy_link_uses_attached_synced_conversation_for_task_without_tok orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, @@ -2244,6 +2257,7 @@ fn test_get_entries_prefers_task_when_task_id_matches_conversation_run_id() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(task_id.clone()), autoexecute_override: None, @@ -2305,6 +2319,7 @@ fn test_get_entries_prefers_task_when_server_token_matches() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, diff --git a/app/src/ai/ambient_agents/mod.rs b/app/src/ai/ambient_agents/mod.rs index e4acdd970b8..daf3664f2b0 100644 --- a/app/src/ai/ambient_agents/mod.rs +++ b/app/src/ai/ambient_agents/mod.rs @@ -18,7 +18,8 @@ pub mod telemetry; pub use task::{ AgentConfigSnapshot, AgentSource, AmbientAgentLiveSessionState, AmbientAgentTask, - AmbientAgentTaskState, TaskStatusMessage, cancel_task_silently, cancel_task_with_toast, + AmbientAgentTaskState, TaskOwnership, TaskStatusMessage, cancel_task_silently, + cancel_task_with_toast, }; pub const OUT_OF_CREDITS_TASK_FAILURE_MESSAGE: &str = "Out of credits. Upgrade your Warp plan to continue running cloud agents."; diff --git a/app/src/ai/ambient_agents/spawn_tests.rs b/app/src/ai/ambient_agents/spawn_tests.rs index 2dc83fa5575..a8d975456af 100644 --- a/app/src/ai/ambient_agents/spawn_tests.rs +++ b/app/src/ai/ambient_agents/spawn_tests.rs @@ -34,6 +34,7 @@ fn task_with( session_link, creator: None, executor: None, + scope: None, conversation_id: None, request_usage: None, agent_config_snapshot: None, diff --git a/app/src/ai/ambient_agents/task.rs b/app/src/ai/ambient_agents/task.rs index 84b5920f644..8df54596ab9 100644 --- a/app/src/ai/ambient_agents/task.rs +++ b/app/src/ai/ambient_agents/task.rs @@ -12,14 +12,18 @@ use url::Url; use warp_core::ui::theme::WarpTheme; use warp_errors::report_error; use warpui::color::ColorU; -use warpui::{SingletonEntity, View, ViewContext}; +use warpui::{AppContext, SingletonEntity, View, ViewContext}; use super::AmbientAgentTaskId; use crate::ai::artifacts::{Artifact, deserialize_artifacts}; +use crate::auth::AuthStateProvider; +use crate::auth::user::PrincipalType; +use crate::server::ids::ServerId; use crate::server::server_api::ServerApiProvider; use crate::ui_components::icons::Icon; use crate::view_components::DismissibleToast; use crate::workspace::ToastStack; +use crate::workspaces::user_workspaces::UserWorkspaces; fn parse_session_id_from_link(session_link: &str) -> Option { Url::parse(session_link).ok().and_then(|url| { @@ -165,6 +169,9 @@ pub struct AmbientAgentTask { pub creator: Option, #[serde(default)] pub executor: Option, + /// Authoritative server ownership scope. Older servers omit this field. + #[serde(default)] + pub scope: Option, pub conversation_id: Option, pub request_usage: Option, pub is_sandbox_running: bool, @@ -242,6 +249,43 @@ pub fn normalize_orchestrator_agent_name(raw: &str) -> Option { (!trimmed.is_empty()).then(|| trimmed.to_string()) } +/// Server task ownership scope (`TaskItem.scope`). +#[derive(Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(tag = "type")] +pub enum TaskScope { + User { uid: String }, + Team { uid: String }, + Unknown, +} + +impl<'de> Deserialize<'de> for TaskScope { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct WireScope { + #[serde(rename = "type")] + scope_type: Option, + uid: Option, + } + + let scope = WireScope::deserialize(deserializer)?; + Ok(match (scope.scope_type.as_deref(), scope.uid) { + (Some("User"), Some(uid)) if !uid.is_empty() => Self::User { uid }, + (Some("Team"), Some(uid)) if !uid.is_empty() => Self::Team { uid }, + _ => Self::Unknown, + }) + } +} + +/// Whether the authenticated principal owns a task. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TaskOwnership { + Owned, + NotOwned, + Unknown, +} impl AmbientAgentTask { pub fn run_id(&self) -> AmbientAgentTaskId { self.task_id @@ -357,6 +401,77 @@ impl AmbientAgentTask { self.executor.as_ref().and_then(|e| e.display_name.clone()) } + /// Resolves ownership from authoritative task scope. Creator equality is + /// only a compatibility fallback for task payloads from older servers. + pub fn ownership_for_current_principal(&self, app: &AppContext) -> TaskOwnership { + let current_user_uid = AuthStateProvider::as_ref(app) + .get() + .user_id() + .map(|uid| uid.as_string()); + let current_principal_type = + AuthStateProvider::as_ref(app) + .get() + .principal_type() + .map(|principal_type| match principal_type { + PrincipalType::User => "user", + PrincipalType::ServiceAccount => "service_account", + }); + self.resolve_ownership( + current_user_uid.as_deref(), + current_principal_type, + |team_uid| { + ServerId::try_from(team_uid).ok().is_some_and(|team_uid| { + UserWorkspaces::as_ref(app) + .team_from_uid_across_all_workspaces(team_uid) + .is_some() + }) + }, + ) + } + + pub(crate) fn resolve_ownership( + &self, + current_user_uid: Option<&str>, + current_principal_type: Option<&str>, + is_current_team: impl FnOnce(&str) -> bool, + ) -> TaskOwnership { + let Some(current_user_uid) = current_user_uid else { + return TaskOwnership::Unknown; + }; + let Some(current_principal_type) = current_principal_type else { + return TaskOwnership::Unknown; + }; + match self.scope.as_ref() { + Some(TaskScope::User { uid }) => { + if uid == current_user_uid && current_principal_type == "user" { + TaskOwnership::Owned + } else { + TaskOwnership::NotOwned + } + } + Some(TaskScope::Team { uid }) => { + if is_current_team(uid) { + TaskOwnership::Owned + } else { + TaskOwnership::NotOwned + } + } + Some(TaskScope::Unknown) => TaskOwnership::Unknown, + None => { + if self.creator.as_ref().is_some_and(|creator| { + creator.uid == current_user_uid + && creator + .creator_type + .eq_ignore_ascii_case(current_principal_type) + }) { + TaskOwnership::Owned + } else { + TaskOwnership::Unknown + } + } + } + } + /// Returns true if the underlying session for the ambient agent is no longer running. pub fn is_no_longer_running(&self) -> bool { !self.active_run_execution().is_sandbox_running && !self.state.is_working() diff --git a/app/src/ai/ambient_agents/task_tests.rs b/app/src/ai/ambient_agents/task_tests.rs index af2cf60a1cb..2569c3eb2ba 100644 --- a/app/src/ai/ambient_agents/task_tests.rs +++ b/app/src/ai/ambient_agents/task_tests.rs @@ -2,8 +2,8 @@ use chrono::{Duration, Utc}; use serde_json::{Value, json}; use super::{ - AgentConfigSnapshot, AgentSource, AmbientAgentTask, AmbientAgentTaskState, TaskStatusErrorCode, - TaskStatusMessage, + AgentConfigSnapshot, AgentSource, AmbientAgentTask, AmbientAgentTaskState, TaskOwnership, + TaskPrincipalInfo, TaskScope, TaskStatusErrorCode, TaskStatusMessage, }; fn make_task(snapshot_name: Option<&str>, title: &str) -> AmbientAgentTask { @@ -28,6 +28,7 @@ fn make_task(snapshot_name: Option<&str>, title: &str) -> AmbientAgentTask { session_link: None, creator: None, executor: None, + scope: None, conversation_id: None, request_usage: None, is_sandbox_running: false, @@ -154,3 +155,112 @@ fn ambient_agent_task_deserializes_github_webhook_source() { assert_eq!(task.source, Some(AgentSource::GitHubWebhook)); assert!(task.blocks_cloud_followups()); } + +#[test] +fn ambient_agent_task_deserializes_user_and_team_scope() { + let mut user = task_json_with_run_time("run_time", json!("PT1S")); + user["scope"] = json!({"type": "User", "uid": "user-1"}); + let user: AmbientAgentTask = serde_json::from_value(user).unwrap(); + assert_eq!( + user.scope, + Some(TaskScope::User { + uid: "user-1".to_string(), + }) + ); + + let mut team = task_json_with_run_time("run_time", json!("PT1S")); + team["scope"] = json!({"type": "Team", "uid": "team-1"}); + let team: AmbientAgentTask = serde_json::from_value(team).unwrap(); + assert_eq!( + team.scope, + Some(TaskScope::Team { + uid: "team-1".to_string(), + }) + ); +} + +#[test] +fn ambient_agent_task_scope_is_compatible_when_absent_unknown_or_malformed() { + let absent: AmbientAgentTask = + serde_json::from_value(task_json_with_run_time("run_time", json!("PT1S"))).unwrap(); + assert_eq!(absent.scope, None); + + for scope in [ + json!({"type": "Organization", "uid": "org-1"}), + json!({"type": "User"}), + json!({"uid": "user-1"}), + json!({"type": "Team", "uid": ""}), + ] { + let mut task = task_json_with_run_time("run_time", json!("PT1S")); + task["scope"] = scope; + let task: AmbientAgentTask = serde_json::from_value(task).unwrap(); + assert_eq!(task.scope, Some(TaskScope::Unknown)); + } +} + +#[test] +fn task_scope_is_authoritative_for_user_and_team_ownership() { + let mut task = make_task(None, "Task"); + task.scope = Some(TaskScope::User { + uid: "current-user".to_string(), + }); + assert_eq!( + task.resolve_ownership(Some("current-user"), Some("user"), |_| false), + TaskOwnership::Owned + ); + assert_eq!( + task.resolve_ownership(Some("other-user"), Some("user"), |_| false), + TaskOwnership::NotOwned + ); + + task.scope = Some(TaskScope::Team { + uid: "team-1".to_string(), + }); + assert_eq!( + task.resolve_ownership( + Some("service-account"), + Some("service_account"), + |team| team == "team-1" + ), + TaskOwnership::Owned + ); + assert_eq!( + task.resolve_ownership(Some("current-user"), Some("user"), |_| false), + TaskOwnership::NotOwned + ); +} + +#[test] +fn task_ownership_falls_back_to_exact_creator_match_only_when_scope_absent() { + let mut task = make_task(None, "Task"); + task.creator = Some(TaskPrincipalInfo { + creator_type: "user".to_string(), + uid: "current-user".to_string(), + display_name: None, + }); + assert_eq!( + task.resolve_ownership(Some("current-user"), Some("user"), |_| false), + TaskOwnership::Owned + ); + assert_eq!( + task.resolve_ownership(Some("other-user"), Some("user"), |_| false), + TaskOwnership::Unknown, + "creator mismatch is not authoritative non-ownership" + ); + assert_eq!( + task.resolve_ownership(Some("current-user"), Some("service_account"), |_| false), + TaskOwnership::Unknown, + "creator fallback requires principal type as well as UID" + ); + + task.scope = Some(TaskScope::Unknown); + assert_eq!( + task.resolve_ownership(Some("current-user"), Some("user"), |_| false), + TaskOwnership::Unknown, + "present but unknown scope must not use creator fallback" + ); + assert_eq!( + task.resolve_ownership(None, Some("user"), |_| true), + TaskOwnership::Unknown + ); +} diff --git a/app/src/ai/blocklist/agent_view/controller.rs b/app/src/ai/blocklist/agent_view/controller.rs index 520d9d82086..220ad905c7e 100644 --- a/app/src/ai/blocklist/agent_view/controller.rs +++ b/app/src/ai/blocklist/agent_view/controller.rs @@ -14,6 +14,7 @@ use crate::ai::agent::conversation::AIConversationId; use crate::ai::blocklist::orchestration_topology::{ OrchestrationNavigationDirection, adjacent_orchestration_child_conversation_id, }; +use crate::features::FeatureFlag; use crate::terminal::TerminalModel; use crate::terminal::input::message_bar::{Message, MessageItem}; use crate::terminal::input::slash_commands::SlashCommandTrigger; @@ -804,22 +805,29 @@ impl AgentViewController { } let history_model = BlocklistAIHistoryModel::handle(ctx); - let (conversation_id, exchange_count) = if let Some(conversation) = - conversation_id.and_then(|id| history_model.as_ref(ctx).conversation(&id)) - { - (conversation.id(), conversation.exchange_count()) - } else { - let id = history_model.update(ctx, |history_model, ctx| { - history_model.start_new_conversation( - self.terminal_view_id, - false, - matches!(&origin, AgentViewEntryOrigin::CloudAgent), - matches!(&origin, AgentViewEntryOrigin::ThirdPartyCloudAgent), - ctx, + let (conversation_id, exchange_count, is_existing_child_placeholder) = + if let Some(conversation) = + conversation_id.and_then(|id| history_model.as_ref(ctx).conversation(&id)) + { + ( + conversation.id(), + conversation.exchange_count(), + conversation.is_remote_child() + || (conversation.is_viewing_shared_session() + && conversation.parent_conversation_id().is_some()), ) - }); - (id, 0) - }; + } else { + let id = history_model.update(ctx, |history_model, ctx| { + history_model.start_new_conversation( + self.terminal_view_id, + false, + matches!(&origin, AgentViewEntryOrigin::CloudAgent), + matches!(&origin, AgentViewEntryOrigin::ThirdPartyCloudAgent), + ctx, + ) + }); + (id, 0, false) + }; history_model.update(ctx, |history_model, ctx| { history_model.set_active_conversation_id(conversation_id, self.terminal_view_id, ctx) }); @@ -841,9 +849,17 @@ impl AgentViewController { .block_list_mut() .enter_conversation_context(conversation_id, display_mode.is_inline(), is_cloud); + // An empty child placeholder is still an existing run, not a brand-new + // cloud conversation. This applies to owner-side remote children and + // viewer-side shared-session children. Preserve that distinction so + // TerminalView does not insert cloud composition UI while the child is + // restoring or waiting for its first streamed exchange. + let is_new = exchange_count == 0 + && !(FeatureFlag::OrchestrationUnifiedStack.is_enabled() + && is_existing_child_placeholder); ctx.emit(AgentViewControllerEvent::EnteredAgentView { conversation_id, - is_new: exchange_count == 0, + is_new, origin, display_mode, }); diff --git a/app/src/ai/blocklist/agent_view/orchestration_pill_bar_tests.rs b/app/src/ai/blocklist/agent_view/orchestration_pill_bar_tests.rs index cf28067d6a2..9f7114689d6 100644 --- a/app/src/ai/blocklist/agent_view/orchestration_pill_bar_tests.rs +++ b/app/src/ai/blocklist/agent_view/orchestration_pill_bar_tests.rs @@ -166,6 +166,7 @@ fn pill_bar_data_layer_finds_restored_children_before_pane_creation() { orchestration_harness_type: None, parent_conversation_id: Some(parent_id.to_string()), is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(child_run_id.clone()), autoexecute_override: None, @@ -217,6 +218,7 @@ fn pill_bar_data_layer_finds_restored_children_before_pane_creation() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(parent_run_id.clone()), autoexecute_override: None, diff --git a/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs b/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs index e6d34f5381f..739e8faa0df 100644 --- a/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs +++ b/app/src/ai/blocklist/block/view_impl/orchestration_tests.rs @@ -142,6 +142,7 @@ fn participant_for_restored_child_run_id_resolves_to_agent_name() { orchestration_harness_type: None, parent_conversation_id: Some(parent_id.to_string()), is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(child_run_id.clone()), autoexecute_override: None, @@ -196,6 +197,7 @@ fn participant_for_restored_child_run_id_resolves_to_agent_name() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(parent_run_id.clone()), autoexecute_override: None, diff --git a/app/src/ai/blocklist/history_model.rs b/app/src/ai/blocklist/history_model.rs index 14b742f950f..acaec99b394 100644 --- a/app/src/ai/blocklist/history_model.rs +++ b/app/src/ai/blocklist/history_model.rs @@ -568,6 +568,91 @@ impl BlocklistAIHistoryModel { conversation_id } + /// Returns the existing run-id mapping for a remote child, creating one + /// from the supplied task metadata if none exists yet. Idempotent: racing + /// `ChildStarted`, lifecycle, and viewer metadata callbacks all converge + /// on the same entry. + #[allow(clippy::too_many_arguments)] + pub fn ensure_remote_child_conversation( + &mut self, + terminal_surface_id: EntityId, + parent_conversation_id: AIConversationId, + run_id: String, + task_id: crate::ai::ambient_agents::AmbientAgentTaskId, + name: String, + fallback_title: String, + orchestration_harness: Option, + ctx: &mut ModelContext, + ) -> AIConversationId { + if let Some(conversation_id) = self.conversation_id_for_agent_id(&run_id) { + return conversation_id; + } + + let conversation_id = self.start_new_child_conversation( + terminal_surface_id, + name, + parent_conversation_id, + orchestration_harness, + ctx, + ); + self.mark_conversation_as_remote_child(conversation_id, ctx); + if !fallback_title.is_empty() + && let Some(conversation) = self.conversation_mut(&conversation_id) + { + conversation.set_fallback_display_title(fallback_title); + } + self.assign_run_id_for_conversation( + conversation_id, + run_id, + Some(task_id), + terminal_surface_id, + ctx, + ); + conversation_id + } + + /// Marks an owned remote-driver parent as a durable local Observer. + /// Passive shared links never call this path. + pub fn mark_conversation_as_durable_observer_parent( + &mut self, + conversation_id: AIConversationId, + task_id: crate::ai::ambient_agents::AmbientAgentTaskId, + ctx: &mut ModelContext, + ) { + let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) else { + return; + }; + if conversation.parent_conversation_id().is_some() { + return; + } + conversation.set_is_durable_observer_parent(true); + conversation.set_task_id(task_id); + if let Some(key) = agent_id_key(conversation) { + self.agent_id_to_conversation_id + .insert(key, conversation_id); + } + self.persist_conversation_state(conversation_id, ctx); + } + + /// Attaches an eagerly hydrated durable Observer parent to the restored + /// ambient pane before shared-session replay begins. + pub fn restore_durable_observer_parent_for_task( + &mut self, + task_id: crate::ai::ambient_agents::AmbientAgentTaskId, + terminal_surface_id: EntityId, + ctx: &mut ModelContext, + ) -> Option { + let conversation_id = self.conversation_id_for_agent_id(&task_id.to_string())?; + let mut conversation = self.conversation(&conversation_id)?.clone(); + if !conversation.is_durable_observer_parent() { + return None; + } + conversation.set_is_viewing_shared_session(true); + self.restore_conversations(terminal_surface_id, vec![conversation], ctx); + self.set_active_conversation_id(conversation_id, terminal_surface_id, ctx); + Some(conversation_id) + } + /// Sets the parent conversation ID on a child conversation and updates /// the `children_by_parent` index. All parent-child relationships should /// be established through this method so the index stays in sync. @@ -1629,6 +1714,7 @@ impl BlocklistAIHistoryModel { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: Some(source_conversation.autoexecute_override().into()), @@ -1807,6 +1893,7 @@ impl BlocklistAIHistoryModel { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: Some(conversation.autoexecute_override().into()), @@ -2856,6 +2943,7 @@ fn merged_remote_child_placeholder_conversation_data( .parent_conversation_id() .map(|id| id.to_string()), is_remote_child: placeholder.is_remote_child(), + is_durable_observer_parent: false, pinned: placeholder.is_pinned(), // Reset on merge. diff --git a/app/src/ai/blocklist/history_model/conversation_loader.rs b/app/src/ai/blocklist/history_model/conversation_loader.rs index da65dc516ab..1fab9944b7d 100644 --- a/app/src/ai/blocklist/history_model/conversation_loader.rs +++ b/app/src/ai/blocklist/history_model/conversation_loader.rs @@ -544,6 +544,32 @@ impl BlocklistAIHistoryModel { } } + // Durable Observer parents are hidden shared-session vehicles, + // not navigation rows. Hydrate them eagerly so ambient-pane + // restore can attach the exact local conversation (and its + // cursor) before response replay and OVM registration. + if conversation_data + .as_ref() + .is_some_and(|data| data.is_durable_observer_parent) + { + let observer_parent = if agent_conversation.tasks.is_empty() { + self.load_conversation_from_db(&conversation_id) + } else { + convert_persisted_conversation_to_ai_conversation_with_metadata( + agent_conversation.clone(), + ) + }; + if let Some(observer_parent) = observer_parent { + self.conversations_by_id + .insert(conversation_id, observer_parent); + } else { + log::warn!( + "Failed to eagerly hydrate durable Observer parent {conversation_id}" + ); + } + return None; + } + Some(HistoricalConversationRow { agent_conversation, conversation_id, diff --git a/app/src/ai/blocklist/history_model_tests.rs b/app/src/ai/blocklist/history_model_tests.rs index 03fe374d44c..ede31e6b59d 100644 --- a/app/src/ai/blocklist/history_model_tests.rs +++ b/app/src/ai/blocklist/history_model_tests.rs @@ -70,6 +70,205 @@ fn create_persisted_query( } } +#[test] +fn test_durable_observer_parent_marker_is_written_before_shutdown() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let mut global_resource_handles = GlobalResourceHandles::mock(&mut app); + global_resource_handles.model_event_sender = Some(sender); + app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles)); + + let history_model = + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); + let terminal_view_id = EntityId::new(); + let task_id: AmbientAgentTaskId = "11111111-1111-1111-1111-111111111111".parse().unwrap(); + let conversation_id = history_model.update(&mut app, |history, ctx| { + let conversation_id = + history.start_new_conversation(terminal_view_id, false, true, false, ctx); + history.mark_conversation_as_durable_observer_parent(conversation_id, task_id, ctx); + conversation_id + }); + + let ModelEvent::UpdateMultiAgentConversation { + conversation_id: persisted_id, + conversation_data, + .. + } = receiver.recv_timeout(Duration::from_secs(1)).unwrap() + else { + panic!("expected durable Observer persistence event"); + }; + assert_eq!(persisted_id, conversation_id.to_string()); + assert!(conversation_data.is_durable_observer_parent); + assert_eq!(conversation_data.run_id, Some(task_id.to_string())); + }); +} + +#[test] +fn ensure_remote_child_conversation_creates_one_named_run_mapping() { + 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 parent_run_id = "11111111-1111-1111-1111-111111111111"; + let child_task_id: AmbientAgentTaskId = + "22222222-2222-2222-2222-222222222222".parse().unwrap(); + + let (parent_id, first, second) = history_model.update(&mut app, |history, ctx| { + let parent_id = + history.start_new_conversation(terminal_view_id, false, true, false, ctx); + history.assign_run_id_for_conversation( + parent_id, + parent_run_id.to_string(), + parent_run_id.parse().ok(), + terminal_view_id, + ctx, + ); + let first = history.ensure_remote_child_conversation( + terminal_view_id, + parent_id, + child_task_id.to_string(), + child_task_id, + "Researcher".to_string(), + "Investigate observer restore".to_string(), + Some(Harness::Codex), + ctx, + ); + let second = history.ensure_remote_child_conversation( + terminal_view_id, + parent_id, + child_task_id.to_string(), + child_task_id, + "Duplicate".to_string(), + String::new(), + Some(Harness::Oz), + ctx, + ); + (parent_id, first, second) + }); + + assert_eq!(first, second); + history_model.read(&app, |history, _| { + assert_eq!( + history.conversation_id_for_agent_id(&child_task_id.to_string()), + Some(first), + "message sender attribution must resolve through the run-id index", + ); + assert_eq!(history.child_conversation_ids_of(&parent_id), &[first]); + let child = history.conversation(&first).unwrap(); + assert_eq!(child.agent_name(), Some("Researcher")); + assert_eq!(child.parent_conversation_id(), Some(parent_id)); + assert!(child.is_remote_child()); + assert!(!child.is_viewing_shared_session()); + assert_eq!(child.orchestration_harness(), Some(Harness::Codex)); + }); + }); +} + +#[test] +fn historical_durable_observer_parent_restores_cursor_and_child_hierarchy() { + App::test((), |mut app| async move { + let parent_id = AIConversationId::new(); + let child_id = AIConversationId::new(); + let parent_run_id = "33333333-3333-3333-3333-333333333333"; + let child_run_id = "44444444-4444-4444-4444-444444444444"; + let now = Utc::now().naive_utc(); + let rows = vec![ + persisted_agent_conversation( + parent_id, + AgentConversationData { + server_conversation_token: Some("parent-token".to_string()), + conversation_usage_metadata: None, + reverted_action_ids: None, + forked_from_server_conversation_token: None, + artifacts_json: None, + parent_agent_id: None, + agent_name: None, + orchestration_harness_type: None, + parent_conversation_id: None, + is_remote_child: false, + is_durable_observer_parent: true, + root_task_is_optimistic: None, + run_id: Some(parent_run_id.to_string()), + autoexecute_override: None, + last_event_sequence: Some(41), + pinned: false, + }, + now, + Some("Observe remote parent"), + ), + persisted_agent_conversation( + child_id, + AgentConversationData { + server_conversation_token: Some("child-token".to_string()), + conversation_usage_metadata: None, + reverted_action_ids: None, + forked_from_server_conversation_token: None, + artifacts_json: None, + parent_agent_id: Some(parent_run_id.to_string()), + agent_name: Some("Remote child".to_string()), + orchestration_harness_type: Some(Harness::Claude.config_name().to_string()), + parent_conversation_id: Some(parent_id.to_string()), + is_remote_child: true, + is_durable_observer_parent: false, + root_task_is_optimistic: None, + run_id: Some(child_run_id.to_string()), + autoexecute_override: None, + last_event_sequence: None, + pinned: false, + }, + now - chrono::Duration::seconds(1), + Some("Remote child"), + ), + ]; + let history_model = + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &rows)); + + history_model.read(&app, |history, _| { + let parent = history + .conversation(&parent_id) + .expect("parent eagerly hydrated"); + assert!(parent.is_durable_observer_parent()); + assert!(parent.is_viewing_shared_session()); + assert_eq!(parent.last_event_sequence(), Some(41)); + assert_eq!( + history.conversation_id_for_agent_id(parent_run_id), + Some(parent_id) + ); + assert_eq!(history.child_conversation_ids_of(&parent_id), &[child_id]); + assert_eq!( + history.conversation_id_for_agent_id(child_run_id), + Some(child_id) + ); + }); + + let restored_surface = EntityId::new(); + history_model.update(&mut app, |history, ctx| { + assert_eq!( + history.restore_durable_observer_parent_for_task( + parent_run_id.parse().unwrap(), + restored_surface, + ctx, + ), + Some(parent_id) + ); + }); + history_model.read(&app, |history, _| { + assert_eq!( + history.active_conversation_id(restored_surface), + Some(parent_id) + ); + assert_eq!( + history + .conversation(&parent_id) + .and_then(AIConversation::last_event_sequence), + Some(41) + ); + }); + }); +} + fn create_user_query_message( id: &str, task_id: &str, @@ -640,6 +839,7 @@ fn test_initialize_historical_conversations_resolves_parent_agent_id_children_vi orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: true, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -662,6 +862,7 @@ fn test_initialize_historical_conversations_resolves_parent_agent_id_children_vi orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(parent_run_id.clone()), autoexecute_override: None, @@ -712,6 +913,7 @@ fn test_initialize_historical_conversations_uses_root_task_description_title() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -878,6 +1080,7 @@ fn test_initialize_historical_conversations_eagerly_hydrates_orchestration_child orchestration_harness_type: None, parent_conversation_id: Some(parent_id.to_string()), is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(child_run_id.clone()), autoexecute_override: None, @@ -901,6 +1104,7 @@ fn test_initialize_historical_conversations_eagerly_hydrates_orchestration_child orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(parent_run_id.clone()), autoexecute_override: None, @@ -3275,6 +3479,7 @@ fn test_find_by_token_after_insert_forked_conversation_from_tasks() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -3473,6 +3678,7 @@ fn test_fork_then_bind_handoff_token_resolves_to_forked_conversation() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -3561,6 +3767,7 @@ fn test_fork_then_bind_handoff_token_persists_to_restored_conversation() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -3674,6 +3881,7 @@ fn test_fork_then_bind_handoff_token_updates_cached_metadata_and_emits_refresh_e orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -3803,6 +4011,7 @@ fn test_fork_conversation_preserves_task_ids_when_requested() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -3954,6 +4163,7 @@ fn test_fork_conversation_title_override_replaces_prefix() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -4047,6 +4257,7 @@ fn hydrate_remote_child_placeholder_with_cloud_transcript_preserves_placeholder_ orchestration_harness_type: None, parent_conversation_id: Some(parent_id.to_string()), is_remote_child: true, + is_durable_observer_parent: false, root_task_is_optimistic: Some(true), run_id: Some(placeholder_task_id_str.clone()), autoexecute_override: None, @@ -4093,6 +4304,7 @@ fn hydrate_remote_child_placeholder_with_cloud_transcript_preserves_placeholder_ orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -4849,6 +5061,7 @@ fn straddle_rewind_followup_requests_are_clean_and_durable() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, diff --git a/app/src/ai/blocklist/mod.rs b/app/src/ai/blocklist/mod.rs index bf601637465..64160057eb6 100644 --- a/app/src/ai/blocklist/mod.rs +++ b/app/src/ai/blocklist/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod diff_types; pub(crate) mod handoff; pub(crate) mod local_agent_task_sync_model; +pub(crate) mod orchestration_child_tracker; pub(crate) mod orchestration_event_streamer; pub(crate) mod orchestration_events; pub(crate) mod orchestration_topology; diff --git a/app/src/ai/blocklist/orchestration_child_tracker.rs b/app/src/ai/blocklist/orchestration_child_tracker.rs new file mode 100644 index 00000000000..149d94276db --- /dev/null +++ b/app/src/ai/blocklist/orchestration_child_tracker.rs @@ -0,0 +1,547 @@ +//! Guides child runs from first discovery through to pane materialization. +//! +//! When the parent's SSE stream fires a `child_agent_started` event, the +//! tracker creates a local placeholder, fetches the child's task metadata, +//! waits for the sandbox session to be linked, and then requests the pane +//! group to open a live or transcript pane. Every signal a child can produce +//! — `child_agent_started`, lifecycle events, `run_session_linked`, +//! REST seed rows, and in-band registrations from `StartAgentExecutor` — +//! enters through the single [`OrchestrationChildTracker::observe_child`] +//! entry point. +//! +//! [`OrchestrationEventConsumer`] captures the one behavioral axis between +//! orchestrator and shared-session observer: who pushes the server cursor +//! and who receives the parent's own inbox events. It says nothing about +//! authenticated ownership, permissions, or pane capability. +//! +//! Pill-bar broadcasts (`ChildSpawned` / `ChildStatusChanged`) are emitted +//! via the `ctx` so downstream views can react without polling. +//! +//! # TODO: unify `is_remote_child` and `is_viewing_shared_session` +//! Both flags mark conversations that are local placeholders for a remote run +//! accessed via the shared-session protocol. The only semantic difference is +//! which code path created the placeholder. A future cleanup should merge them +//! into a single `is_remote_placeholder` flag and persist all placeholder +//! conversations uniformly, making `is_durable_observer_parent` (M3) +//! unnecessary. + +use std::collections::{HashMap, HashSet}; + +use session_sharing_protocol::common::SessionId; +use warp_multi_agent_api as api; +use warpui::ModelContext; +#[cfg(not(test))] +use warpui::SingletonEntity; + +#[cfg(not(test))] +use super::history_model::BlocklistAIHistoryModel; +use super::orchestration_event_streamer::{ + OrchestrationEventStreamer, OrchestrationEventStreamerEvent, + conversation_status_from_lifecycle_event_type, +}; +// Compiled out of unit-test builds so the tracker state machine can be +// exercised without installing the full model singleton graph; the +// `#[cfg(test)]` dispatch-counter path stands in instead. +#[cfg(not(test))] +use crate::ai::agent_conversations_model::AgentConversationsModel; +use crate::ai::ambient_agents::{AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState}; + +/// Every way a child run can become known funnels into +/// [`OrchestrationChildTracker::observe_child`]. +pub enum ChildSignal { + /// `child_agent_started` on the parent run (child run id in `ref_id`). + Started, + /// `run_session_linked` on the child run: carries the sandbox session + /// UUID directly, letting the tracker fill in `session_id` without a + /// metadata fetch. + SessionLinked { session_uuid: String }, + /// Any recognised lifecycle event on the child run. + Lifecycle(api::LifecycleEventType), + /// A REST seed row (cold-start seed / restore fetch). Boxed because the + /// task row dwarfs the other variants. + #[allow(dead_code)] + Seeded(Box), + /// A child created by this process (`run_agents` / `start_agent`): the + /// executor registers the child it just made, marking it already-represented. + #[allow(dead_code)] + Registered, +} + +/// Per-child orchestration state, keyed by [`AmbientAgentTaskId`]. +pub struct TrackedChild { + /// `None` until execution is claimed and a session is linked. + pub session_id: Option, + /// Last observed task state, when known (seeded/refetched rows). + pub last_state: Option, + /// True once pane materialization has been requested for this child. + pub pane_materialized: bool, + /// `true` for every tracker-materialized placeholder — owner-side + /// discoveries and viewer-created children alike use the single unified + /// `is_remote_child` marker, never `is_viewing_shared_session` (which is + /// reserved for the parent viewer placeholder). `false` only for in-band + /// children, which already own a real local conversation and are tracked + /// for status only. + #[allow(dead_code)] + pub is_remote_child: bool, +} + +/// Owns discovery, placeholder bookkeeping, claim-time metadata refetch, and +/// pane-materialization requests for one parent family. +pub struct OrchestrationChildTracker { + parent_task_id: AmbientAgentTaskId, + /// Materialized children keyed by task id. + children: HashMap, + /// Secondary index from stringified `run_id` to task id, kept in sync + /// with `children`. + children_by_run_id: HashMap, + /// In-band children created by this process (`ChildSignal::Registered`). + /// They already own a real conversation and have their session assigned + /// by the executor, so the tracker observes them for status only and + /// never issues a discovery/claim metadata fetch on their behalf. + in_band_children: HashSet, + /// In-flight metadata fetches keyed by `run_id`. A second discovery signal + /// for a run already being fetched is a no-op. + metadata_fetches: HashSet, + /// Session ids delivered by `run_session_linked` before the child's + /// placeholder exists; applied when the child is created. + pending_session_ids: HashMap, + /// Test-only: counts stubbed metadata-fetch dispatches so fetch dedup can + /// be asserted without the full `AgentConversationsModel` plumbing. + #[cfg(test)] + metadata_fetch_dispatch_count: usize, +} + +impl OrchestrationChildTracker { + /// Builds an empty tracker for the given parent family. + pub fn new(parent_task_id: AmbientAgentTaskId) -> Self { + Self { + parent_task_id, + children: HashMap::new(), + children_by_run_id: HashMap::new(), + in_band_children: HashSet::new(), + metadata_fetches: HashSet::new(), + pending_session_ids: HashMap::new(), + #[cfg(test)] + metadata_fetch_dispatch_count: 0, + } + } + + /// The single entry point for all child state changes: + /// + /// 0. Drop tombstoned runs. + /// 1. Create-or-update the placeholder (`is_remote_child = true`). + /// 2. Write status through on `Lifecycle` signals (sole status writer). + /// 3. Refetch metadata while `session_id` is missing or the pane is not + /// materialized. + /// 4. Request pane materialization once `session_id` is known, or a + /// transcript view once terminal. + pub fn observe_child( + &mut self, + child_run_id: &str, + signal: ChildSignal, + killed_run_ids: &HashSet, + ctx: &mut ModelContext, + ) { + // Step 0: tombstone gate. This runs before any placeholder creation + // or pane request — including across the metadata-fetch await and the + // cancel-during-spawn race — so a locally killed run cannot be + // resurrected mid-fetch. + if killed_run_ids.contains(child_run_id) { + self.forget_run(child_run_id); + return; + } + + let Ok(task_id) = child_run_id.parse::() else { + log::warn!( + "[orch-tracker] signal for malformed run_id={child_run_id:?} \ + (parent_task_id={}); dropping", + self.parent_task_id, + ); + return; + }; + + match signal { + ChildSignal::Registered => { + self.register_in_band_child(task_id, child_run_id, ctx); + } + ChildSignal::SessionLinked { session_uuid } => { + self.apply_session_linked(task_id, &session_uuid); + } + ChildSignal::Lifecycle(kind) => { + self.apply_lifecycle(task_id, child_run_id, kind, ctx); + } + ChildSignal::Seeded(task) => { + self.apply_seeded(*task, ctx); + } + ChildSignal::Started => { + self.apply_started(task_id, child_run_id, ctx); + } + } + } + + /// Discovery via `child_agent_started`. Idempotent: an already-known child + /// (in-band `Registered`, existing placeholder) or a run with an in-flight + /// fetch only re-drives step 3/4; the first sighting of a genuinely new + /// out-of-band run inserts a pending `TrackedChild` immediately — before the + /// async metadata fetch completes — so that subsequent `Lifecycle` and + /// `SessionLinked` signals see `tracker_known=true` and are processed. + fn apply_started( + &mut self, + task_id: AmbientAgentTaskId, + run_id: &str, + ctx: &mut ModelContext, + ) { + if self.children.contains_key(&task_id) { + // Already represented; keep hydrating if not yet complete. + self.refetch_metadata_if_incomplete(task_id, run_id, ctx); + self.maybe_request_pane_materialization(task_id, ctx); + return; + } + // Insert a placeholder TrackedChild immediately so lifecycle and + // session-linked signals that arrive before the async metadata fetch + // completes see tracker_known=true. Any session_id that arrived + // before this signal is also applied now. + let session_id = self.pending_session_ids.remove(&task_id); + self.insert_child( + task_id, + run_id, + TrackedChild { + session_id, + last_state: None, + pane_materialized: false, + is_remote_child: true, + }, + ctx, + ); + // Also kick the metadata fetch to get real task state, session_id, + // and conversation token for transcript / live-attach decisions. + self.spawn_metadata_fetch(task_id, run_id, ctx); + } + + /// Sole status writer for placeholder children (step 2). Emits the pill-bar + /// broadcast in both modes; also writes the new status through to the + /// history model so the pill badge updates immediately. Unknown children + /// fall back to the discovery path so lifecycle acts as a self-healing + /// backstop for a missed `child_agent_started`. + fn apply_lifecycle( + &mut self, + task_id: AmbientAgentTaskId, + run_id: &str, + kind: api::LifecycleEventType, + ctx: &mut ModelContext, + ) { + let tracker_known = self.children.contains_key(&task_id); + if tracker_known { + let status = conversation_status_from_lifecycle_event_type(kind); + // Write status through immediately so the pill bar badge reflects + // the lifecycle transition without waiting for a redraw cycle. + #[cfg(not(test))] + { + let child_info = { + let history = BlocklistAIHistoryModel::as_ref(ctx); + history + .conversation_id_for_agent_id(run_id) + .and_then(|child_conv_id| { + history + .terminal_surface_id_for_conversation(&child_conv_id) + .map(|surface_id| (child_conv_id, surface_id)) + }) + }; + if let Some((child_conv_id, surface_id)) = child_info { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.update_conversation_status( + surface_id, + child_conv_id, + status.clone(), + ctx, + ); + }); + } + } + ctx.emit(OrchestrationEventStreamerEvent::ChildStatusChanged { + parent_task_id: self.parent_task_id, + run_id: run_id.to_string(), + status, + }); + self.refetch_metadata_if_incomplete(task_id, run_id, ctx); + self.maybe_request_pane_materialization(task_id, ctx); + return; + } + // Lifecycle for an unknown run is a complete discovery backstop: + // insert once (emitting ChildSpawned), start/dedupe metadata hydration, + // and publish the status immediately. This handles a missed or + // reordered child_agent_started event without a tracker-only ghost. + self.apply_started(task_id, run_id, ctx); + let status = conversation_status_from_lifecycle_event_type(kind); + ctx.emit(OrchestrationEventStreamerEvent::ChildStatusChanged { + parent_task_id: self.parent_task_id, + run_id: run_id.to_string(), + status, + }); + } + + /// Registers an in-band child (created by this process) with its existing + /// local conversation. Marks the run already-represented so later + /// `Started`/`Lifecycle` signals are idempotent status updates rather than + /// placeholder creation. + fn register_in_band_child( + &mut self, + task_id: AmbientAgentTaskId, + run_id: &str, + ctx: &mut ModelContext, + ) { + // An in-band child has a real conversation tracked via the history + // model's run-id index; any speculative fetch is moot. + self.metadata_fetches.remove(run_id); + self.in_band_children.insert(task_id); + if self.children.contains_key(&task_id) { + return; + } + let session_id = self.pending_session_ids.remove(&task_id); + self.insert_child( + task_id, + run_id, + TrackedChild { + session_id, + last_state: None, + pane_materialized: false, + // In-band children own a real local conversation; they are + // never persisted as `is_remote_child` placeholders. + is_remote_child: false, + }, + ctx, + ); + // If the session link already arrived, hydrate the pane immediately. + self.maybe_request_pane_materialization(task_id, ctx); + } + + /// Applies a REST seed / restore row. Creates the placeholder if new and + /// records the latest known state and session id. + fn apply_seeded( + &mut self, + task: AmbientAgentTask, + ctx: &mut ModelContext, + ) { + // The ancestor endpoint includes the parent itself in the response; + // skip it. + if task.task_id == self.parent_task_id { + return; + } + let task_id = task.task_id; + let run_id = task_id.to_string(); + let seed_session_id = task + .session_id + .as_deref() + .and_then(|s| s.parse::().ok()); + let state = task.state.clone(); + + self.metadata_fetches.remove(&run_id); + + if let Some(existing) = self.children.get_mut(&task_id) { + existing.last_state = Some(state); + if existing.session_id.is_none() { + existing.session_id = seed_session_id; + } + self.maybe_request_pane_materialization(task_id, ctx); + return; + } + + // Materialize the unified child placeholder. The tracker records + // `is_remote_child = true` in both owner and viewer mode. Fall back + // to any pending session link. + let session_id = seed_session_id.or_else(|| self.pending_session_ids.remove(&task_id)); + self.insert_child( + task_id, + &run_id, + TrackedChild { + session_id, + last_state: Some(state), + pane_materialized: false, + is_remote_child: true, + }, + ctx, + ); + self.maybe_request_pane_materialization(task_id, ctx); + } + + /// Handles `run_session_linked`: fills in `session_id` directly (no + /// metadata fetch) and requests pane materialization immediately. If the + /// placeholder does not exist yet, the session id is stashed and applied + /// when the child is created. + fn apply_session_linked(&mut self, task_id: AmbientAgentTaskId, session_uuid: &str) { + let Ok(session_id) = session_uuid.parse::() else { + log::warn!( + "[orch-tracker] run_session_linked with malformed session_uuid={session_uuid:?} \ + for task_id={task_id} (parent_task_id={}); dropping", + self.parent_task_id, + ); + return; + }; + match self.children.get_mut(&task_id) { + Some(child) => { + if child.session_id.is_none() { + child.session_id = Some(session_id); + } + // Request the live pane now that the session is known, + // bypassing the metadata-fetch round-trip. + self.request_pane_materialization(task_id); + } + None => { + self.pending_session_ids.insert(task_id, session_id); + } + } + } + + /// Re-drives step 3: refetch while `session_id` is missing or the pane has + /// not been materialized. No-op once the child is fully hydrated. + fn refetch_metadata_if_incomplete( + &mut self, + task_id: AmbientAgentTaskId, + run_id: &str, + ctx: &mut ModelContext, + ) { + // In-band children are hydrated by the executor, not the tracker. + if self.in_band_children.contains(&task_id) { + return; + } + let incomplete = self + .children + .get(&task_id) + .is_some_and(|child| child.session_id.is_none() || !child.pane_materialized); + if incomplete { + self.spawn_metadata_fetch(task_id, run_id, ctx); + } + } + + /// Step 4: request pane materialization once a `session_id` is known. + fn maybe_request_pane_materialization( + &mut self, + task_id: AmbientAgentTaskId, + _ctx: &mut ModelContext, + ) { + let should_request = self + .children + .get(&task_id) + .is_some_and(|child| child.session_id.is_some() && !child.pane_materialized); + if should_request { + self.request_pane_materialization(task_id); + } + } + + /// Marks the child's pane as materialized. + fn request_pane_materialization(&mut self, task_id: AmbientAgentTaskId) { + if let Some(child) = self.children.get_mut(&task_id) { + child.pane_materialized = true; + } + } + + /// Records a new tracked child, keeping both indices in sync, and emits + /// the `ChildSpawned` pill-bar broadcast exactly once. + fn insert_child( + &mut self, + task_id: AmbientAgentTaskId, + run_id: &str, + child: TrackedChild, + ctx: &mut ModelContext, + ) { + self.children.insert(task_id, child); + self.children_by_run_id.insert(run_id.to_string(), task_id); + ctx.emit(OrchestrationEventStreamerEvent::ChildSpawned { + parent_task_id: self.parent_task_id, + run_id: run_id.to_string(), + }); + } + + /// Starts (or dedupes) a metadata fetch for a run. Routes through + /// `AgentConversationsModel::get_or_async_fetch_task_data` — the shared + /// fetch authority with in-flight dedup, failure cooldowns, and a cache. + /// A synchronous cache hit resolves the placeholder inline via + /// [`Self::apply_seeded`]; a cache miss spawns the shared fetch and + /// resolves on a later re-drive (a subsequent `child_agent_started` or + /// lifecycle signal finds the cache warm). The tracker's own + /// `metadata_fetches` guard suppresses redundant dispatches while a + /// fetch is outstanding. + /// + /// The `run_id` guard is inserted first so the cache-hit `apply_seeded` + /// (which clears it) and the in-flight case are both handled correctly. + fn spawn_metadata_fetch( + &mut self, + task_id: AmbientAgentTaskId, + run_id: &str, + ctx: &mut ModelContext, + ) { + if !self.metadata_fetches.insert(run_id.to_string()) { + // Guard is set from a prior dispatch. If the async fetch has since + // completed, the cache is now warm and will return the task + // synchronously; otherwise the in-flight dedup inside + // AgentConversationsModel suppresses a redundant network request. + #[cfg(not(test))] + { + let cached = AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { + model.get_or_async_fetch_task_data(&task_id, ctx) + }); + if let Some(task) = cached { + self.metadata_fetches.remove(run_id); + self.apply_seeded(task, ctx); + } + } + return; + } + log::debug!( + "[orch-tracker] metadata fetch queued for run_id={run_id} \ + (parent_task_id={})", + self.parent_task_id, + ); + #[cfg(test)] + { + // Unit tests exercise the state machine without the model's + // singleton graph; count the dispatch instead of issuing it. + let _ = (task_id, &ctx); + self.metadata_fetch_dispatch_count += 1; + } + #[cfg(not(test))] + { + let cached = AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { + model.get_or_async_fetch_task_data(&task_id, ctx) + }); + // Cache hit: create/refresh the unified placeholder immediately. + // A miss leaves the guard set; the shared fetch populates the cache + // and a later re-drive completes discovery. + if let Some(task) = cached { + self.apply_seeded(task, ctx); + } + } + } + + /// Drops all tracked state for a run (tombstone / kill path). + fn forget_run(&mut self, run_id: &str) { + self.metadata_fetches.remove(run_id); + if let Some(task_id) = self.children_by_run_id.remove(run_id) { + self.children.remove(&task_id); + self.in_band_children.remove(&task_id); + self.pending_session_ids.remove(&task_id); + } + } + + /// Test-only: number of metadata-fetch dispatches issued so far. Lets + /// drain-integration tests in `orchestration_event_streamer_tests.rs` + /// (a sibling module without access to private fields) assert fetch + /// dedup. + #[cfg(test)] + pub(crate) fn metadata_fetch_dispatch_count(&self) -> usize { + self.metadata_fetch_dispatch_count + } + + /// Test-only: whether a metadata fetch is currently in flight for + /// `run_id`. Used by sibling-module drain tests to assert discovery and + /// lifecycle signals were routed into the tracker. + #[cfg(test)] + pub(crate) fn has_in_flight_fetch(&self, run_id: &str) -> bool { + self.metadata_fetches.contains(run_id) + } + +} + +#[cfg(test)] +#[path = "orchestration_child_tracker_tests.rs"] +mod tests; diff --git a/app/src/ai/blocklist/orchestration_child_tracker_tests.rs b/app/src/ai/blocklist/orchestration_child_tracker_tests.rs new file mode 100644 index 00000000000..11167e61a78 --- /dev/null +++ b/app/src/ai/blocklist/orchestration_child_tracker_tests.rs @@ -0,0 +1,314 @@ +//! Tests for [`OrchestrationChildTracker`]'s internal state machine. +//! +//! These exercise `observe_child` against a real +//! `ModelContext` (so the pill-bar broadcasts +//! have somewhere to go) but assert only on the tracker's own state — the +//! persisted placeholder write, metadata-fetch dispatch, and pane +//! materialization are exercised through the tracker's in-memory bookkeeping +//! (including the unified `is_remote_child` intent), so no history / network +//! plumbing is required. + +use std::collections::HashSet; +use std::sync::Arc; + +use warp_multi_agent_api as api; +use warpui::App; + +use super::*; +use crate::ai::ambient_agents::{AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState}; +use crate::ai::blocklist::history_model::BlocklistAIHistoryModel; +use crate::server::server_api::ServerApiProvider; +use crate::server::server_api::ai::{AIClient, MockAIClient}; + +const PARENT_RUN_ID: &str = "11111111-1111-1111-1111-111111111111"; +const CHILD_A_RUN_ID: &str = "22222222-2222-2222-2222-222222222222"; +const SESSION_A: &str = "44444444-4444-4444-4444-444444444444"; + +fn task_id(s: &str) -> AmbientAgentTaskId { + s.parse().expect("hardcoded task id parses") +} + +#[test] +fn lifecycle_before_started_creates_one_pending_child() { + App::test((), |mut app| async move { + let streamer = install_streamer(&mut app); + streamer.update(&mut app, |_streamer, ctx| { + let mut tracker = observer_tracker(); + let killed = HashSet::new(); + + tracker.observe_child( + CHILD_A_RUN_ID, + ChildSignal::Lifecycle(api::LifecycleEventType::InProgress), + &killed, + ctx, + ); + tracker.observe_child(CHILD_A_RUN_ID, ChildSignal::Started, &killed, ctx); + + let child = tracker + .children + .get(&task_id(CHILD_A_RUN_ID)) + .expect("lifecycle is a discovery backstop"); + assert!(child.is_remote_child); + assert_eq!(tracker.children.len(), 1); + assert_eq!( + tracker.metadata_fetch_dispatch_count, 1, + "reordered lifecycle and Started signals share one fetch", + ); + }); + }); +} + +/// Builds a minimal child task row for `ChildSignal::Seeded`, parented under +/// `PARENT_RUN_ID` so `apply_seeded` treats it as a real child rather than +/// the parent's own row. +fn child_task(task_id: AmbientAgentTaskId) -> AmbientAgentTask { + use chrono::Utc; + AmbientAgentTask { + task_id, + parent_run_id: Some(PARENT_RUN_ID.to_string()), + title: "child".to_string(), + state: AmbientAgentTaskState::InProgress, + prompt: "prompt".to_string(), + created_at: Utc::now(), + started_at: Some(Utc::now()), + updated_at: Utc::now(), + run_time: None, + status_message: None, + source: None, + session_id: None, + session_link: None, + creator: None, + executor: None, + scope: None, + conversation_id: None, + request_usage: None, + agent_config_snapshot: None, + artifacts: vec![], + is_sandbox_running: false, + last_event_sequence: None, + children: vec![], + } +} + +/// Installs the singletons `OrchestrationEventStreamer` depends on and +/// returns the streamer handle. Mirrors the setup in +/// `orchestration_event_streamer_tests.rs`. +fn install_streamer(app: &mut App) -> warpui::ModelHandle { + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); + let ai_client: Arc = Arc::new(MockAIClient::new()); + let server_api = ServerApiProvider::new_for_test().get(); + app.add_singleton_model(|ctx| { + OrchestrationEventStreamer::new_with_clients_for_test(ai_client, server_api, ctx) + }) +} + +fn observer_tracker() -> OrchestrationChildTracker { + OrchestrationChildTracker::new(task_id(PARENT_RUN_ID)) +} + +#[test] +fn started_creates_pending_entry_and_is_idempotent() { + App::test((), |mut app| async move { + let streamer = install_streamer(&mut app); + streamer.update(&mut app, |_streamer, ctx| { + let mut tracker = observer_tracker(); + let killed = HashSet::new(); + + tracker.observe_child(CHILD_A_RUN_ID, ChildSignal::Started, &killed, ctx); + + // Membership is inserted before the metadata fetch returns so a + // lifecycle-first race can update the same child. + assert!( + tracker.metadata_fetches.contains(CHILD_A_RUN_ID), + "first Started must record an in-flight fetch" + ); + assert!(tracker.children.contains_key(&task_id(CHILD_A_RUN_ID))); + assert_eq!(tracker.metadata_fetch_dispatch_count, 1); + + // Second Started for the same run id is a no-op. + tracker.observe_child(CHILD_A_RUN_ID, ChildSignal::Started, &killed, ctx); + assert_eq!( + tracker.metadata_fetch_dispatch_count, 1, + "a repeat Started must not dispatch another fetch" + ); + }); + }); +} + +#[test] +fn lifecycle_for_tombstoned_run_is_noop() { + App::test((), |mut app| async move { + let streamer = install_streamer(&mut app); + streamer.update(&mut app, |_streamer, ctx| { + let mut tracker = observer_tracker(); + let mut killed = HashSet::new(); + killed.insert(CHILD_A_RUN_ID.to_string()); + + tracker.observe_child( + CHILD_A_RUN_ID, + ChildSignal::Lifecycle(api::LifecycleEventType::InProgress), + &killed, + ctx, + ); + + assert!( + tracker.children.is_empty(), + "tombstoned run must not create a placeholder" + ); + assert!( + tracker.metadata_fetches.is_empty(), + "tombstoned run must not dispatch a metadata fetch" + ); + assert_eq!(tracker.metadata_fetch_dispatch_count, 0); + }); + }); +} + +#[test] +fn registered_prevents_placeholder_creation() { + App::test((), |mut app| async move { + let streamer = install_streamer(&mut app); + streamer.update(&mut app, |_streamer, ctx| { + let mut tracker = observer_tracker(); + let killed = HashSet::new(); + + tracker.observe_child( + CHILD_A_RUN_ID, + ChildSignal::Registered, + &killed, + ctx, + ); + + let entry = tracker + .children + .get(&task_id(CHILD_A_RUN_ID)) + .expect("registered child is tracked immediately"); + assert!( + !entry.is_remote_child, + "an in-band child is not an is_remote_child placeholder" + ); + assert!( + tracker.in_band_children.contains(&task_id(CHILD_A_RUN_ID)), + "registered child is marked in-band" + ); + assert!( + tracker.metadata_fetches.is_empty(), + "an in-band child needs no discovery fetch" + ); + assert_eq!(tracker.metadata_fetch_dispatch_count, 0); + + // A later Started for the same run id must be an idempotent no-op: + // no placeholder creation, no metadata fetch. + tracker.observe_child(CHILD_A_RUN_ID, ChildSignal::Started, &killed, ctx); + assert_eq!( + tracker.metadata_fetch_dispatch_count, 0, + "Started for an already-registered run must not fetch" + ); + assert!( + tracker.children.contains_key(&task_id(CHILD_A_RUN_ID)), + "the registered entry survives a subsequent Started" + ); + }); + }); +} + +#[test] +fn session_linked_fills_session_id_and_requests_pane_without_fetch() { + App::test((), |mut app| async move { + let streamer = install_streamer(&mut app); + streamer.update(&mut app, |_streamer, ctx| { + let mut tracker = observer_tracker(); + let killed = HashSet::new(); + + // Establish a tracked child first (no session id yet). + tracker.observe_child( + CHILD_A_RUN_ID, + ChildSignal::Registered { + conversation_id: AIConversationId::new(), + }, + &killed, + ctx, + ); + + tracker.observe_child( + CHILD_A_RUN_ID, + ChildSignal::SessionLinked { + session_uuid: SESSION_A.to_string(), + }, + &killed, + ctx, + ); + + let entry = tracker + .children + .get(&task_id(CHILD_A_RUN_ID)) + .expect("child is tracked"); + assert_eq!( + entry.session_id, + Some(SESSION_A.parse().unwrap()), + "SessionLinked fills in the session id directly" + ); + assert!( + entry.pane_materialized, + "SessionLinked requests pane materialization immediately" + ); + assert_eq!( + tracker.metadata_fetch_dispatch_count, 0, + "SessionLinked must not trigger a metadata fetch" + ); + }); + }); +} + +#[test] +fn two_started_signals_issue_one_metadata_fetch() { + App::test((), |mut app| async move { + let streamer = install_streamer(&mut app); + streamer.update(&mut app, |_streamer, ctx| { + let mut tracker = observer_tracker(); + let killed = HashSet::new(); + + tracker.observe_child(CHILD_A_RUN_ID, ChildSignal::Started, &killed, ctx); + tracker.observe_child(CHILD_A_RUN_ID, ChildSignal::Started, &killed, ctx); + + assert_eq!( + tracker.metadata_fetch_dispatch_count, 1, + "two Started signals for the same run id must dedupe to one fetch" + ); + assert!(tracker.metadata_fetches.contains(CHILD_A_RUN_ID)); + }); + }); +} + +#[test] +fn seeded_child_placeholder_is_remote_child_in_viewer_mode() { + // Validation criterion 4 (TECH QUALITY-928 §7.4): a child placeholder + // materialized by the tracker uses the single unified `is_remote_child` + // marker even in viewer mode. The tracker never sets + // `is_viewing_shared_session` on a child — that flavor stays reserved for + // the parent viewer placeholder — so a viewer-created child persists as an + // `is_remote_child` row and survives restart. + App::test((), |mut app| async move { + let streamer = install_streamer(&mut app); + streamer.update(&mut app, |_streamer, ctx| { + let mut tracker = observer_tracker(); + let killed = HashSet::new(); + + tracker.observe_child( + CHILD_A_RUN_ID, + ChildSignal::Seeded(Box::new(child_task(task_id(CHILD_A_RUN_ID)))), + &killed, + ctx, + ); + + let entry = tracker + .children + .get(&task_id(CHILD_A_RUN_ID)) + .expect("seeded child placeholder is tracked immediately"); + assert!( + entry.is_remote_child, + "viewer-created child placeholders use the unified is_remote_child marker" + ); + }); + }); +} diff --git a/app/src/ai/blocklist/orchestration_event_streamer.rs b/app/src/ai/blocklist/orchestration_event_streamer.rs index 2b6b29f74cb..6ec94bd6794 100644 --- a/app/src/ai/blocklist/orchestration_event_streamer.rs +++ b/app/src/ai/blocklist/orchestration_event_streamer.rs @@ -15,12 +15,14 @@ use warpui::{ }; use super::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; +use super::orchestration_child_tracker::{ChildSignal, OrchestrationChildTracker}; use super::orchestration_events::{ LifecycleEventDetailPayload, LifecycleEventDetailStage, OrchestrationEventService, PendingEvent, PendingEventDetail, build_lifecycle_event, }; use crate::ai::agent::conversation::{AIAgentHarness, AIConversationId, ConversationStatus}; use crate::ai::agent::{AIAgentExchangeId, AIAgentOutputMessageType, ReceivedMessageInput}; +use crate::ai::agent_conversations_model::AgentConversationsModel; use crate::ai::agent_events::{ AgentEventConsumer, AgentEventConsumerControlFlow, AgentEventDriverConfig, AgentEventFilter, AgentMessageEventMetadata, MessageHydrator, ServerApiAgentEventSource, run_agent_event_driver, @@ -42,10 +44,18 @@ const SSE_DRAIN_INTERVAL_MS: u64 = 500; /// Cap killed-run tombstones while keeping normal sessions well below the limit. const MAX_KILLED_RUN_IDS: usize = 1024; /// Max child runs fetched per cold-start `?ancestor_run_id=` REST seed in -/// viewer mode. Matches the legacy `OrchestrationViewerModel` poller's value -/// (the server caps at 100 anyway). +/// viewer mode. The server caps at 100 regardless. const VIEWER_MODE_SEED_FETCH_LIMIT: i32 = 100; +/// Wire `event_type` for a parent's own inbox message events. +const EVENT_NEW_MESSAGE: &str = "new_message"; +/// Wire `event_type` emitted on a PARENT run when a child task is created +/// (`AddTask` with `parent_run_id`); the child run id is carried in `ref_id`. +const EVENT_CHILD_AGENT_STARTED: &str = "child_agent_started"; +/// Wire `event_type` emitted on a CHILD run when its sandbox session links; +/// the session UUID is carried in `ref_id`. +const EVENT_RUN_SESSION_LINKED: &str = "run_session_linked"; + /// Per-event item delivered from the SSE background task to the entity. struct SseStreamItem { event: AgentRunEvent, @@ -72,35 +82,19 @@ struct SseForwardingConsumer { } /// Per-event item delivered from the ancestor SSE background task to the -/// entity. Mirrors [`SseStreamItem`] but does not currently carry a -/// hydrated message: the only ancestor consumer today is viewer mode, -/// which surfaces only lifecycle transitions and so skips message -/// hydration. If/when the ancestor path picks up a non-viewer caller -/// (e.g. a local orchestrator subscribing to its own `ancestor_run_id` -/// stream in lieu of N per-run-ids streams for its children — see -/// [`AncestorForwardingConsumer`]), this struct would gain a hydrated- -/// message field analogous to [`SseStreamItem`]. +/// entity. Carries no hydrated message because the ancestor consumer only +/// surfaces lifecycle transitions. struct AncestorSseStreamItem { event: AgentRunEvent, } -/// Forwarding consumer used by the ancestor SSE driver. Mirrors -/// [`SseForwardingConsumer`] but does no message hydration: the only -/// current caller is the shared-session viewer's pill bar, which only -/// surfaces lifecycle events. -/// -/// Future direction: a local orchestrator could subscribe to its own -/// `ancestor_run_id` stream (one SSE per parent family) instead of -/// having each local child open its own per-run-ids stream. At that -/// point this consumer would gain an opt-in hydrate flag analogous to -/// [`SseForwardingConsumer::hydrate_new_messages`]. +/// Forwarding consumer used by the ancestor SSE driver. Skips message +/// hydration because the ancestor stream surfaces lifecycle events only. struct AncestorForwardingConsumer { tx: mpsc::UnboundedSender, } -/// State for an ancestor SSE connection. Mirrors [`SseConnectionState`] -/// but parameterised on [`AncestorSseStreamItem`] because the only -/// current caller (viewer mode) does not hydrate messages. +/// State for an ancestor SSE connection. struct AncestorSseConnectionState { event_receiver: mpsc::UnboundedReceiver, generation: u64, @@ -226,6 +220,11 @@ struct ConversationStreamState { /// Consecutive `get_ambient_agent_task` failure count for the /// post-restore retry loop; resets on success. restore_fetch_failures: usize, + /// Primary-mode child tracker for this orchestrator family. `None` until + /// the first flag-on family drain fires and the tracker is constructed; + /// on the flag-off baseline the legacy `drain_sse_events` path runs + /// without a tracker. + tracker: Option, } /// Per-orchestrator SSE stream state. Parallels [`ConversationStreamState`] @@ -265,6 +264,10 @@ struct OrchestratorStreamState { /// cursor, so a replay does not generate spurious `ChildSpawned` events /// for already-known children. seeded: bool, + /// Observer-mode child tracker for this orchestrator family. `None` until + /// the first flag-on viewer drain fires; on the flag-off baseline the + /// legacy `drain_ancestor_events` path runs without a tracker. + tracker: Option, } /// Async network coordinator for v2 orchestration event delivery via SSE. @@ -331,6 +334,89 @@ enum DesiredSseFilter { NoFilter, } +/// Which family-event consumer role a drain is servicing: parent-self delivery +/// (Primary only) and cursor authority (Primary pushes the server cursor, +/// Observer persists locally only). Says nothing about authenticated ownership +/// or pane capability. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum FamilyDrainMode { + Primary, + Observer, +} + +/// Classification of a single event from a parent-family (`include_self`) +/// SSE stream. Produced by [`classify_family_event`] and fanned out by +/// [`OrchestrationEventStreamer::drain_family_events`]. +#[derive(Debug, PartialEq)] +enum FamilyEvent { + /// Inbox message or lifecycle event on the parent's own run. Delivered by + /// a Primary consumer; dropped by an Observer. + ParentSelf(AgentRunEvent), + /// `child_agent_started` on the parent run; the child run id is the + /// event's `ref_id`. + ChildStarted { child_run_id: String }, + /// `run_session_linked` on a child run; the session UUID is the event's + /// `ref_id`, letting the tracker fill in `session_id` without a fetch. + ChildSessionLinked { + child_run_id: String, + session_uuid: String, + }, + /// A recognised lifecycle event on a child run. + ChildLifecycle { + child_run_id: String, + kind: api::LifecycleEventType, + }, + /// Anything else (unrecognised type, malformed discovery/session event): + /// advances the cursor only, for forward compatibility. + Opaque, +} + +/// Classifies one family-stream event relative to the parent's own +/// `self_run_id`. Discovery (`child_agent_started`) is recognised only on +/// the parent's own run; session links and lifecycle events are recognised +/// only on other (child) runs; the parent's own inbox/lifecycle events +/// become [`FamilyEvent::ParentSelf`]; everything else is +/// [`FamilyEvent::Opaque`]. +fn classify_family_event(event: &AgentRunEvent, self_run_id: &str) -> FamilyEvent { + let is_self = event.run_id == self_run_id; + match (is_self, event.event_type.as_str()) { + (true, EVENT_CHILD_AGENT_STARTED) => match event.ref_id.as_deref() { + Some(child_run_id) if !child_run_id.is_empty() => FamilyEvent::ChildStarted { + child_run_id: child_run_id.to_string(), + }, + // A discovery event with no child run id is unusable. + _ => FamilyEvent::Opaque, + }, + (false, EVENT_RUN_SESSION_LINKED) => match event.ref_id.as_deref() { + Some(session_uuid) if !session_uuid.is_empty() => FamilyEvent::ChildSessionLinked { + child_run_id: event.run_id.clone(), + session_uuid: session_uuid.to_string(), + }, + _ => FamilyEvent::Opaque, + }, + (false, event_type) => match lifecycle_event_type_from_wire(event_type) { + Some(kind) => FamilyEvent::ChildLifecycle { + child_run_id: event.run_id.clone(), + kind, + }, + // A child `new_message` or any unrecognised type: not actionable + // by the tracker (the viewer drops it, the owner has no delivery + // path for another run's inbox). + None => FamilyEvent::Opaque, + }, + (true, EVENT_NEW_MESSAGE) => FamilyEvent::ParentSelf(event.clone()), + (true, event_type) => { + // The parent's own lifecycle events are ParentSelf; unrecognised + // self events advance the cursor only. + if lifecycle_event_type_from_wire(event_type).is_some() { + FamilyEvent::ParentSelf(event.clone()) + } else { + FamilyEvent::Opaque + } + } + } +} + impl OrchestrationEventStreamer { fn message_hydrator_for_run_id(&self, run_id: &str) -> MessageHydrator { match run_id.parse::() { @@ -409,6 +495,479 @@ impl OrchestrationEventStreamer { } } + // ---- Unified family drain (OrchestrationUnifiedStack) -------------- + + /// Primary cursor authority for the family drain: persist the cursor to + /// SQLite and push it to the server. The Primary consumer is the + /// authoritative writer of its run's server cursor. Delegates to + /// [`Self::persist_event_cursor`], whose non-shared-session branch + /// performs exactly this. + fn persist_cursor_local_and_server( + &mut self, + conversation_id: AIConversationId, + sequence: i64, + ctx: &mut ModelContext, + ) { + self.persist_event_cursor(conversation_id, sequence, ctx); + } + + /// Observer cursor authority for the family drain: persist to SQLite + /// only, never pushing the server cursor (only a Primary consumer may + /// write the server-side cursor). Monotonic: folds in the conversation's + /// already-persisted sequence. + fn persist_cursor_local_only( + &mut self, + conversation_id: AIConversationId, + sequence: i64, + ctx: &mut ModelContext, + ) { + let persisted = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&conversation_id) + .and_then(|conversation| conversation.last_event_sequence()) + .unwrap_or(0); + let effective = sequence.max(persisted); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, ctx| { + model.update_event_sequence(conversation_id, effective, ctx); + }); + } + + /// Fans out one family (`include_self`) SSE batch: classifies each event + /// and routes it to the tracker (discovery / session-link / lifecycle) or + /// Primary parent-self delivery (`ParentSelf`), then advances the cursor + /// with consumer-appropriate authority. + /// + /// The tracker is passed by value and returned so callers can keep it in + /// `self` state without a borrow conflict against `handle_event_batch` + /// and `killed_run_ids`. The tracker is the sole status writer for child + /// status and emits `ChildStatusChanged`; child lifecycle events are also + /// forwarded to `handle_event_batch` in Primary mode so the parent's + /// `OrchestrationEventService` receives them for conversation injection. + #[allow(clippy::too_many_arguments)] + fn drain_family_events( + &mut self, + cursor_conversation_id: AIConversationId, + self_run_id: &str, + mode: FamilyDrainMode, + mut tracker: OrchestrationChildTracker, + previous_cursor: i64, + events: Vec, + messages: Vec, + ctx: &mut ModelContext, + ) -> OrchestrationChildTracker { + let max_seq = events + .iter() + .map(|event| event.sequence) + .max() + .unwrap_or(previous_cursor); + + let mut parent_self_events = Vec::new(); + // Child lifecycle events are also forwarded to handle_event_batch in + // Primary mode: `convert_lifecycle_events` (inside handle_event_batch) + // filters by run_id != self_run_id, so it picks up child events and + // injects them into OrchestrationEventService for the parent + // conversation. Without this, the parent's BlocklistAIController + // never receives child lifecycle notifications. + let mut child_lifecycle_for_batch = Vec::new(); + for event in events { + match classify_family_event(&event, self_run_id) { + FamilyEvent::ParentSelf(event) => parent_self_events.push(event), + FamilyEvent::ChildStarted { child_run_id } => { + self.ensure_remote_child_placeholder( + cursor_conversation_id, + child_run_id.clone(), + mode, + ctx, + ); + log::debug!( + "[orch-drain] calling observe_child(Started) for child_run_id={child_run_id}" + ); + tracker.observe_child( + &child_run_id, + ChildSignal::Started, + &self.killed_run_ids, + ctx, + ); + } + FamilyEvent::ChildSessionLinked { + child_run_id, + session_uuid, + } => { + tracker.observe_child( + &child_run_id, + ChildSignal::SessionLinked { + session_uuid: session_uuid.clone(), + }, + &self.killed_run_ids, + ctx, + ); + // Update the AgentConversationsModel task cache with the + // linked session so the next pill click's + // decide_child_pane_materialization gets AttachLive instead + // of Pending (stale Queued/Inactive from the initial fetch). + if let Ok(task_id) = child_run_id.parse::() { + AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { + model.update_task_as_running_with_session(&task_id, session_uuid, ctx); + }); + } + } + FamilyEvent::ChildLifecycle { child_run_id, kind } => { + // Backstop: if this lifecycle arrives before (or instead of) + // `child_agent_started`, ensure a placeholder still exists. + self.ensure_remote_child_placeholder( + cursor_conversation_id, + child_run_id.clone(), + mode, + ctx, + ); + log::debug!( + "[orch-drain] calling observe_child(Lifecycle) for child_run_id={child_run_id}" + ); + // Also collect for handle_event_batch so OrchestrationEventService + // delivers the lifecycle event to the parent conversation. + if mode == FamilyDrainMode::Primary { + child_lifecycle_for_batch.push(event); + } + tracker.observe_child( + &child_run_id, + ChildSignal::Lifecycle(kind), + &self.killed_run_ids, + ctx, + ); + // On terminal lifecycle events, evict the stale cached task + // (typically showing Queued from the initial discovery fetch) + // and re-fetch it so the next pill click's + // decide_child_pane_materialization returns LoadTranscript + // instead of Pending. InProgress events are left alone + // since session-linked handles the running case above. + // Idle is deprecated in the proto (Succeeded supersedes it) + // but can still arrive from older server builds. + #[allow(deprecated)] + let is_terminal = matches!( + kind, + api::LifecycleEventType::Succeeded + | api::LifecycleEventType::Idle + | api::LifecycleEventType::Failed + | api::LifecycleEventType::Errored + | api::LifecycleEventType::Cancelled + ); + if is_terminal && let Ok(task_id) = child_run_id.parse::() { + AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { + model.evict_and_refetch_task(&task_id, ctx); + }); + } + } + FamilyEvent::Opaque => {} + } + } + + match mode { + FamilyDrainMode::Primary => { + let mut events_for_batch = parent_self_events; + events_for_batch.extend(child_lifecycle_for_batch); + if !events_for_batch.is_empty() || !messages.is_empty() { + self.handle_event_batch( + cursor_conversation_id, + self_run_id, + previous_cursor, + events_for_batch, + messages, + ctx, + ); + } + // Ensure a child-only batch still advances the Primary cursor. + self.persist_cursor_local_and_server(cursor_conversation_id, max_seq, ctx); + } + FamilyDrainMode::Observer => { + // Observer drops parent-self events and persists the cursor + // locally only (never pushes the server cursor). + self.persist_cursor_local_only(cursor_conversation_id, max_seq, ctx); + } + } + + tracker + } + + // ---- Remote-child placeholder creation (flag-on family path) --------- + + /// Creates a local `is_remote_child` placeholder for an out-of-band + /// (cloud) child announced by a `child_agent_started` event on the owner's + /// family SSE stream, so the orchestrator pill bar renders the child + /// immediately without waiting for the tracker's async metadata fetch. + /// + /// Idempotent: a no-op if the history model already knows this `run_id` + /// (e.g. an in-band child registered by `StartAgentExecutor`, a restored + /// placeholder, or a race-completed fetch). Passive remote views are also + /// skipped since they are not the authoritative process for the run. + fn ensure_remote_child_placeholder( + &mut self, + parent_conversation_id: AIConversationId, + child_run_id: String, + mode: FamilyDrainMode, + ctx: &mut ModelContext, + ) { + if BlocklistAIHistoryModel::as_ref(ctx) + .conversation_id_for_agent_id(&child_run_id) + .is_some() + { + // Already represented locally; nothing to do. + return; + } + // A Primary passive view must not impersonate the owning process. + // Observer is the explicit exception: its local placeholder and cursor + // are the representation consumed by the viewer hierarchy. + if mode == FamilyDrainMode::Primary && self.is_remote_run_view(parent_conversation_id, ctx) + { + return; + } + let Ok(task_id) = child_run_id.parse::() else { + log::warn!( + "[orch-drain] ensure_remote_child_placeholder: malformed \ + child_run_id={child_run_id:?}; skipping" + ); + return; + }; + let ai_client = self.ai_client.clone(); + ctx.spawn( + async move { ai_client.get_ambient_agent_task(&task_id).await }, + move |me, result, ctx| { + me.finish_remote_child_placeholder( + parent_conversation_id, + child_run_id, + mode, + result, + ctx, + ); + }, + ); + } + + /// Completion callback for [`Self::ensure_remote_child_placeholder`]. + /// Creates the remote-child `AIConversation` from the fetched task + /// metadata, mirroring the shared-session viewer's `register_child`. + /// Marked `is_remote_child` so the streamer opens no redundant per-child + /// SSE — the child is cloud and its events already arrive on the parent's + /// ancestor stream. + fn finish_remote_child_placeholder( + &mut self, + parent_conversation_id: AIConversationId, + child_run_id: String, + _mode: FamilyDrainMode, + result: anyhow::Result, + ctx: &mut ModelContext, + ) { + let task = match result { + Ok(task) => task, + Err(err) => { + log::warn!( + "finish placeholder fetch-error \ + parent_conversation_id={parent_conversation_id:?} \ + child_run_id={child_run_id} error={err:#}" + ); + return; + } + }; + // Re-check: a locally-started child may have stamped this run_id + // while the fetch was in flight. + if BlocklistAIHistoryModel::as_ref(ctx) + .conversation_id_for_agent_id(&child_run_id) + .is_some() + { + return; + } + let Some(terminal_surface_id) = BlocklistAIHistoryModel::as_ref(ctx) + .terminal_surface_id_for_conversation(&parent_conversation_id) + else { + log::warn!( + "[orch-drain] finish_remote_child_placeholder: parent conversation \ + {parent_conversation_id:?} has no terminal surface; \ + cannot create placeholder for child_run_id={child_run_id}" + ); + return; + }; + let name = task.display_name().to_string(); + let fallback_title = task.title.trim().to_string(); + let harness = agent_task_harness(&task); + let task_id = task.task_id; + log::info!( + "[orch-drain] creating remote-child placeholder for \ + child_run_id={child_run_id} name={name:?} parent={parent_conversation_id:?}" + ); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.ensure_remote_child_conversation( + terminal_surface_id, + parent_conversation_id, + child_run_id.clone(), + task_id, + name, + fallback_title, + harness, + ctx, + ) + }); + } + + /// Flag-on owner drain: reads the conversation's family SSE buffer and + /// routes it through [`Self::drain_family_events`]. Falls back to the + /// legacy [`Self::drain_sse_events`] when no `self_run_id` is available + /// yet (nothing to key a tracker on), preserving delivery. + fn drain_owner_family_events( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) { + let Some(self_run_id) = self.self_run_id(conversation_id, ctx) else { + self.drain_sse_events(conversation_id, ctx); + return; + }; + let Ok(parent_task_id) = self_run_id.parse::() else { + self.drain_sse_events(conversation_id, ctx); + return; + }; + + let cursor; + let mut events = Vec::new(); + let mut messages = Vec::new(); + { + let Some(stream) = self.streams.get_mut(&conversation_id) else { + return; + }; + cursor = stream.event_cursor; + let Some(sse) = stream.sse_connection.as_mut() else { + return; + }; + while let Ok(Some(item)) = sse.event_receiver.try_next() { + if item.event.sequence > cursor { + if let Some(message) = item.fetched_message { + messages.push(message); + } + events.push(item.event); + } + } + } + if events.is_empty() { + return; + } + + let tracker = self + .streams + .get_mut(&conversation_id) + .and_then(|stream| stream.tracker.take()) + .unwrap_or_else(|| { + OrchestrationChildTracker::new(parent_task_id) + }); + let tracker = self.drain_family_events( + conversation_id, + &self_run_id, + FamilyDrainMode::Primary, + tracker, + cursor, + events, + messages, + ctx, + ); + if let Some(stream) = self.streams.get_mut(&conversation_id) { + stream.tracker = Some(tracker); + } + } + + /// Flag-on viewer drain: reads the orchestrator's ancestor SSE buffer and + /// routes it through [`Self::drain_family_events`] in viewer mode, then + /// mirrors the advanced cursor onto every registered viewer placeholder + /// (local-only) and the in-memory entry cursor. + fn drain_viewer_family_events( + &mut self, + parent_task_id: AmbientAgentTaskId, + ctx: &mut ModelContext, + ) { + let self_run_id = parent_task_id.to_string(); + + let cursor; + let mut events = Vec::new(); + { + let Some(entry) = self.viewer_mode_orchestrators.get_mut(&parent_task_id) else { + return; + }; + cursor = entry.event_cursor; + let Some(sse) = entry.sse_connection.as_mut() else { + return; + }; + while let Ok(Some(item)) = sse.event_receiver.try_next() { + if item.event.sequence > cursor { + events.push(item.event); + } + } + } + if events.is_empty() { + return; + } + + let placeholders = self.viewer_mode_placeholders(parent_task_id); + let Some(primary_placeholder) = placeholders.first().copied() else { + return; + }; + let max_seq = events + .iter() + .map(|event| event.sequence) + .max() + .unwrap_or(cursor); + + let tracker = self + .viewer_mode_orchestrators + .get_mut(&parent_task_id) + .and_then(|entry| entry.tracker.take()) + .unwrap_or_else(|| { + OrchestrationChildTracker::new(parent_task_id) + }); + let tracker = self.drain_family_events( + primary_placeholder, + &self_run_id, + FamilyDrainMode::Observer, + tracker, + cursor, + events, + Vec::new(), + ctx, + ); + if let Some(entry) = self.viewer_mode_orchestrators.get_mut(&parent_task_id) { + entry.tracker = Some(tracker); + entry.event_cursor = entry.event_cursor.max(max_seq); + } + // Mirror the advanced cursor onto every registered viewer placeholder. + for placeholder in placeholders { + self.persist_cursor_local_only(placeholder, max_seq, ctx); + } + } + + /// Owner drain dispatcher: the unified family drain when + /// `OrchestrationUnifiedStack` is on, else the legacy per-conversation + /// drain (flag-off baseline is unchanged). + fn drain_owner_events( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) { + if FeatureFlag::OrchestrationUnifiedStack.is_enabled() { + self.drain_owner_family_events(conversation_id, ctx); + } else { + self.drain_sse_events(conversation_id, ctx); + } + } + + /// Viewer drain dispatcher: the unified family drain when + /// `OrchestrationUnifiedStack` is on, else the legacy ancestor drain + /// (flag-off baseline is unchanged). + fn drain_viewer_events( + &mut self, + parent_task_id: AmbientAgentTaskId, + ctx: &mut ModelContext, + ) { + if FeatureFlag::OrchestrationUnifiedStack.is_enabled() { + self.drain_viewer_family_events(parent_task_id, ctx); + } else { + self.drain_ancestor_events(parent_task_id, ctx); + } + } + #[cfg(not(target_family = "wasm"))] pub(crate) fn persist_dormant_claude_wake_cursor( &mut self, @@ -936,7 +1495,7 @@ impl OrchestrationEventStreamer { if !is_current { return; } - me.drain_ancestor_events(parent_task_id, ctx); + me.drain_viewer_events(parent_task_id, ctx); if let Err(err) = result { log::warn!( "Ancestor SSE driver exited for parent_task_id={parent_task_id} \ @@ -980,7 +1539,7 @@ impl OrchestrationEventStreamer { if !is_current { return; } - me.drain_ancestor_events(parent_task_id, ctx); + me.drain_viewer_events(parent_task_id, ctx); me.start_ancestor_sse_drain_timer(parent_task_id, generation, ctx); }, ); @@ -1066,7 +1625,7 @@ impl OrchestrationEventStreamer { parent_task_id: AmbientAgentTaskId, ctx: &mut ModelContext, ) { - self.drain_ancestor_events(parent_task_id, ctx); + self.drain_viewer_events(parent_task_id, ctx); let cursor; { let Some(entry) = self.viewer_mode_orchestrators.get_mut(&parent_task_id) else { @@ -1944,7 +2503,7 @@ impl OrchestrationEventStreamer { return; } - me.drain_sse_events(conversation_id, ctx); + me.drain_owner_events(conversation_id, ctx); if let Err(err) = result { log::warn!( @@ -2013,7 +2572,7 @@ impl OrchestrationEventStreamer { if !is_current { return; } - me.drain_sse_events(conversation_id, ctx); + me.drain_owner_events(conversation_id, ctx); me.start_sse_drain_timer(conversation_id, generation, ctx); }, ); @@ -2146,7 +2705,7 @@ impl OrchestrationEventStreamer { fn reconnect_sse(&mut self, conversation_id: AIConversationId, ctx: &mut ModelContext) { // Drain buffered events before dropping the channel so we don't // discard already-fetched message bodies. - self.drain_sse_events(conversation_id, ctx); + self.drain_owner_events(conversation_id, ctx); if let Some(stream) = self.streams.get_mut(&conversation_id) && let Some(connection) = stream.sse_connection.take() { @@ -2163,7 +2722,7 @@ impl OrchestrationEventStreamer { /// external state and are pruned through their own paths. fn teardown_sse(&mut self, conversation_id: AIConversationId, ctx: &mut ModelContext) { // Drain anything buffered so we don't lose hydrated messages. - self.drain_sse_events(conversation_id, ctx); + self.drain_owner_events(conversation_id, ctx); if let Some(stream) = self.streams.get_mut(&conversation_id) && let Some(connection) = stream.sse_connection.take() { diff --git a/app/src/ai/blocklist/orchestration_event_streamer_tests.rs b/app/src/ai/blocklist/orchestration_event_streamer_tests.rs index 2d70ed7719b..5e8bc70a7be 100644 --- a/app/src/ai/blocklist/orchestration_event_streamer_tests.rs +++ b/app/src/ai/blocklist/orchestration_event_streamer_tests.rs @@ -45,6 +45,131 @@ fn sse_backoff_escalates_then_caps() { ); } +#[test] +fn restored_observer_registration_hydrates_local_cursor() { + App::test((), |mut app| async move { + let history_model = + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); + let parent_task_id = make_parent_task_id_for_test(0xc8); + let mut parent = AIConversation::new(true, false); + parent.set_task_id(parent_task_id); + parent.set_last_event_sequence(27); + let parent_id = parent.id(); + let terminal_view_id = warpui::EntityId::new(); + history_model.update(&mut app, |history, ctx| { + history.restore_conversations(terminal_view_id, vec![parent], ctx); + }); + + let ai_client: Arc = Arc::new(MockAIClient::new()); + let server_api = ServerApiProvider::new_for_test().get(); + let streamer = app.add_singleton_model(|ctx| { + OrchestrationEventStreamer::new_with_clients_for_test(ai_client, server_api, ctx) + }); + streamer.update(&mut app, |streamer, ctx| { + streamer.register_viewer_mode_consumer( + parent_task_id, + parent_id, + warpui::EntityId::new(), + ctx, + ); + }); + + streamer.read(&app, |streamer, _| { + let entry = streamer + .viewer_mode_orchestrators + .get(&parent_task_id) + .expect("Observer must re-register"); + assert_eq!(entry.event_cursor, 27); + assert!( + entry.tracker.is_none(), + "tracker is initialized lazily by the family drain" + ); + }); + }); +} + +#[test] +fn observer_placeholder_completion_creates_one_named_history_mapping() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + let (sender, _receiver) = std::sync::mpsc::sync_channel::(16); + let mut resources = GlobalResourceHandles::mock(&mut app); + resources.model_event_sender = Some(sender); + app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(resources)); + + let history_model = + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); + let parent_task_id = make_parent_task_id_for_test(0xd1); + let child_task_id = make_parent_task_id_for_test(0xd2); + let parent = { + let mut parent = AIConversation::new(true, false); + parent.set_task_id(parent_task_id); + parent + }; + let parent_id = parent.id(); + let terminal_view_id = warpui::EntityId::new(); + history_model.update(&mut app, |history, ctx| { + history.restore_conversations(terminal_view_id, vec![parent], ctx); + history.set_active_conversation_id(parent_id, terminal_view_id, ctx); + }); + + let ai_client: Arc = Arc::new(MockAIClient::new()); + let server_api = ServerApiProvider::new_for_test().get(); + let streamer = app.add_singleton_model(|ctx| { + OrchestrationEventStreamer::new_with_clients_for_test(ai_client, server_api, ctx) + }); + let mut child_task = make_ambient_task_with_task_id(child_task_id, Some(9)); + child_task.parent_run_id = Some(parent_task_id.to_string()); + child_task.title = "Research observer mapping".to_string(); + + streamer.update(&mut app, |streamer, ctx| { + let mut tracker = OrchestrationChildTracker::new( + parent_task_id, + OrchestrationEventConsumer::Observer { + placeholder_conversation_id: parent_id, + }, + ); + tracker.observe_child( + &child_task_id.to_string(), + ChildSignal::Started, + &HashSet::new(), + ctx, + ); + streamer + .viewer_mode_orchestrators + .entry(parent_task_id) + .or_default() + .tracker = Some(tracker); + streamer.finish_remote_child_placeholder( + parent_id, + child_task_id.to_string(), + FamilyDrainMode::Observer, + Ok(child_task.clone()), + ctx, + ); + streamer.finish_remote_child_placeholder( + parent_id, + child_task_id.to_string(), + FamilyDrainMode::Observer, + Ok(child_task.clone()), + ctx, + ); + }); + + history_model.read(&app, |history, _| { + let child_id = history + .conversation_id_for_agent_id(&child_task_id.to_string()) + .expect("Observer child run id must resolve for attribution"); + assert_eq!(history.child_conversation_ids_of(&parent_id), &[child_id]); + let child = history.conversation(&child_id).unwrap(); + assert_eq!(child.agent_name(), Some("Research observer mapping")); + assert_eq!(child.parent_conversation_id(), Some(parent_id)); + assert!(child.is_remote_child()); + assert!(!child.is_viewing_shared_session()); + }); + }); +} + #[test] fn sse_backoff_zero_failures_uses_first_step() { // Defensive: 0 failures should still return a valid backoff. @@ -79,6 +204,18 @@ fn make_run_event(event_type: &str, run_id: &str, ref_id: Option<&str>) -> Agent } } +fn make_seq_event( + event_type: &str, + run_id: &str, + ref_id: Option<&str>, + sequence: i64, +) -> AgentRunEvent { + AgentRunEvent { + sequence, + ..make_run_event(event_type, run_id, ref_id) + } +} + #[test] fn convert_lifecycle_events_includes_run_blocked() { let events = vec![make_run_event("run_blocked", "child-run", None)]; @@ -157,6 +294,7 @@ fn ai_conversation_new_restored_preserves_last_event_sequence() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -199,6 +337,7 @@ fn make_ambient_task_with_event_seq( session_link: None, creator: None, executor: None, + scope: None, conversation_id: None, request_usage: None, agent_config_snapshot: None, @@ -2479,6 +2618,269 @@ fn register_parent_on_wait_flag_off_is_noop() { }); } +// ---- classify_family_event (QUALITY-928 M1 T2) -------------------------- + +#[test] +fn classify_child_agent_started_on_self_is_child_started() { + let event = make_run_event(EVENT_CHILD_AGENT_STARTED, "parent-run", Some("child-run")); + assert_eq!( + classify_family_event(&event, "parent-run"), + FamilyEvent::ChildStarted { + child_run_id: "child-run".to_string(), + } + ); +} + +#[test] +fn classify_child_agent_started_without_ref_id_is_opaque() { + // A discovery event with no child run id carries nothing actionable. + let event = make_run_event(EVENT_CHILD_AGENT_STARTED, "parent-run", None); + assert_eq!( + classify_family_event(&event, "parent-run"), + FamilyEvent::Opaque + ); +} + +#[test] +fn classify_run_session_linked_on_child_is_session_linked() { + let event = make_run_event(EVENT_RUN_SESSION_LINKED, "child-run", Some("session-uuid")); + assert_eq!( + classify_family_event(&event, "parent-run"), + FamilyEvent::ChildSessionLinked { + child_run_id: "child-run".to_string(), + session_uuid: "session-uuid".to_string(), + } + ); +} + +#[test] +fn classify_run_in_progress_on_child_is_child_lifecycle() { + let event = make_run_event("run_in_progress", "child-run", None); + assert_eq!( + classify_family_event(&event, "parent-run"), + FamilyEvent::ChildLifecycle { + child_run_id: "child-run".to_string(), + kind: api::LifecycleEventType::InProgress, + } + ); +} + +#[test] +fn classify_new_message_on_self_is_parent_self() { + let event = make_run_event("new_message", "parent-run", Some("msg-1")); + assert_eq!( + classify_family_event(&event, "parent-run"), + FamilyEvent::ParentSelf(event.clone()) + ); +} + +#[test] +fn classify_lifecycle_on_self_is_parent_self() { + // The parent's own lifecycle events belong to the parent (ParentSelf), + // not the child tracker. + let event = make_run_event("run_in_progress", "parent-run", None); + assert_eq!( + classify_family_event(&event, "parent-run"), + FamilyEvent::ParentSelf(event.clone()) + ); +} + +#[test] +fn classify_unknown_type_is_opaque() { + let event = make_run_event("some_unknown_event", "child-run", None); + assert_eq!( + classify_family_event(&event, "parent-run"), + FamilyEvent::Opaque + ); +} + +// ---- drain_family_events (QUALITY-928 M1 T2) ---------------------------- + +#[test] +fn drain_family_events_primary_routes_mixed_batch_and_delivers_inbox() { + // A mixed family batch under Primary consumption routes discovery/lifecycle + // to the tracker, delivers parent-self through handle_event_batch, and + // advances the Primary cursor (local + server). + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + let (sender, _receiver) = std::sync::mpsc::sync_channel::(4); + let mut global_resource_handles = GlobalResourceHandles::mock(&mut app); + global_resource_handles.model_event_sender = Some(sender); + app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles)); + + let history_model = + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); + let event_service = app.add_singleton_model(|_| OrchestrationEventService::default()); + + let parent_task_id = make_parent_task_id_for_test(0xf1); + let parent_run_id = parent_task_id.to_string(); + let child_a = make_parent_task_id_for_test(0xf2).to_string(); + let child_b = make_parent_task_id_for_test(0xf3).to_string(); + + let mut conversation = AIConversation::new(false, false); + conversation.set_run_id(parent_run_id.clone()); + let conversation_id = conversation.id(); + let terminal_view_id = warpui::EntityId::new(); + history_model.update(&mut app, |model, ctx| { + model.restore_conversations(terminal_view_id, vec![conversation], ctx); + }); + + let mut mock = MockAIClient::new(); + // Primary consumer is the authoritative server-cursor writer. + mock.expect_update_event_sequence_on_server() + .returning(|_, _| Ok(())); + let ai_client: Arc = Arc::new(mock); + let server_api = ServerApiProvider::new_for_test().get(); + let streamer = app.add_singleton_model(|ctx| { + OrchestrationEventStreamer::new_with_clients_for_test(ai_client, server_api, ctx) + }); + + let events = vec![ + make_seq_event( + EVENT_CHILD_AGENT_STARTED, + &parent_run_id, + Some(&child_a), + 10, + ), + make_seq_event("run_in_progress", &child_a, None, 11), + make_seq_event("new_message", &parent_run_id, Some("msg-1"), 12), + make_seq_event("some_unknown_event", &child_b, None, 13), + ]; + let messages = vec![ReceivedMessageInput { + message_id: "msg-1".to_string(), + sender_agent_id: child_a.clone(), + addresses: vec![parent_run_id.clone()], + subject: "hello parent".to_string(), + message_body: "body".to_string(), + }]; + + streamer.update(&mut app, |me, ctx| { + let tracker = OrchestrationChildTracker::new( + parent_task_id, + OrchestrationEventConsumer::Primary { + orchestrator_conversation_id: conversation_id, + }, + ); + let tracker = me.drain_family_events( + conversation_id, + &parent_run_id, + FamilyDrainMode::Primary, + tracker, + 0, + events, + messages, + ctx, + ); + assert!( + tracker.has_in_flight_fetch(&child_a), + "ChildStarted / ChildLifecycle must route into the tracker" + ); + assert_eq!( + tracker.metadata_fetch_dispatch_count(), + 1, + "a Started followed by Lifecycle for the same run must fetch once" + ); + assert!( + !tracker.has_in_flight_fetch(&child_b), + "an Opaque event must not touch the tracker" + ); + }); + + event_service.read(&app, |service, _| { + assert!( + service.has_pending_events(conversation_id), + "ParentSelf inbox message must be delivered through handle_event_batch" + ); + }); + history_model.read(&app, |model, _| { + assert_eq!( + model + .conversation(&conversation_id) + .and_then(|c| c.last_event_sequence()), + Some(13), + "Primary cursor must advance to the batch max sequence" + ); + }); + }); +} + +#[test] +fn drain_family_events_observer_advances_cursor_without_server_push() { + // Observer routes child lifecycle to the tracker and persists the cursor + // locally, but must NEVER push the server cursor (only Primary may write + // it). The bare MockAIClient panics if update_event_sequence_on_server is + // called — that is the proof Observer never pushes server cursor. + // TaskOwnership is deliberately not an input to this API, so an + // authenticated owner observing via a shared link remains an Observer. + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + let (sender, _receiver) = std::sync::mpsc::sync_channel::(4); + let mut global_resource_handles = GlobalResourceHandles::mock(&mut app); + global_resource_handles.model_event_sender = Some(sender); + app.add_singleton_model(|_| GlobalResourceHandlesProvider::new(global_resource_handles)); + + let history_model = + app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], vec![], &[])); + + let parent_task_id = make_parent_task_id_for_test(0xf4); + let parent_run_id = parent_task_id.to_string(); + let child = make_parent_task_id_for_test(0xf5).to_string(); + + // Viewer placeholder: a shared-session view (is_viewing_shared_session). + let placeholder = AIConversation::new(true, false); + let placeholder_id = placeholder.id(); + let terminal_view_id = warpui::EntityId::new(); + history_model.update(&mut app, |model, ctx| { + model.restore_conversations(terminal_view_id, vec![placeholder], ctx); + }); + + // No expect_update_event_sequence_on_server: any call panics the mock. + let ai_client: Arc = Arc::new(MockAIClient::new()); + let server_api = ServerApiProvider::new_for_test().get(); + let streamer = app.add_singleton_model(|ctx| { + OrchestrationEventStreamer::new_with_clients_for_test(ai_client, server_api, ctx) + }); + + let events = vec![ + make_seq_event("run_in_progress", &child, None, 7), + make_seq_event("new_message", &parent_run_id, Some("msg-9"), 8), + ]; + + streamer.update(&mut app, |me, ctx| { + let tracker = OrchestrationChildTracker::new( + parent_task_id, + OrchestrationEventConsumer::Observer { + placeholder_conversation_id: placeholder_id, + }, + ); + let tracker = me.drain_family_events( + placeholder_id, + &parent_run_id, + FamilyDrainMode::Observer, + tracker, + 0, + events, + Vec::new(), + ctx, + ); + assert!( + tracker.has_in_flight_fetch(&child), + "child lifecycle must route into the Observer tracker" + ); + }); + + history_model.read(&app, |model, _| { + assert_eq!( + model + .conversation(&placeholder_id) + .and_then(|c| c.last_event_sequence()), + Some(8), + "Observer cursor must advance locally to the batch max sequence" + ); + }); + }); +} + #[test] fn register_parent_on_wait_child_short_circuits() { // One-level-tree invariant: a child (is_child_agent_conversation) can diff --git a/app/src/ai/conversation_details_panel_tests.rs b/app/src/ai/conversation_details_panel_tests.rs index 197df19bb7d..b7c2a79f423 100644 --- a/app/src/ai/conversation_details_panel_tests.rs +++ b/app/src/ai/conversation_details_panel_tests.rs @@ -41,6 +41,7 @@ fn create_test_task(task_id: &str) -> AmbientAgentTask { display_name: Some("User 1".to_string()), }), executor: None, + scope: None, conversation_id: None, request_usage: None, agent_config_snapshot: None, @@ -70,6 +71,7 @@ fn test_from_conversation_prefers_server_creator_profile() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -233,6 +235,7 @@ fn test_from_task_includes_linked_directory_when_run_id_matches() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(task_id.to_string()), autoexecute_override: None, @@ -382,6 +385,7 @@ fn test_from_conversation_populates_local_conversation_fields() { autoexecute_override: None, last_event_sequence: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, pinned: false, }, @@ -493,6 +497,7 @@ fn test_from_task_includes_linked_directory_when_server_token_matches() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, diff --git a/app/src/pane_group/ambient_pane_restoration.rs b/app/src/pane_group/ambient_pane_restoration.rs index 2b1d3f96ae8..d65584a1ec6 100644 --- a/app/src/pane_group/ambient_pane_restoration.rs +++ b/app/src/pane_group/ambient_pane_restoration.rs @@ -9,6 +9,7 @@ use crate::ai::agent_conversations_model::{ }; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::blocklist::BlocklistAIHistoryModel; +use crate::features::FeatureFlag; use crate::pane_group::{PaneGroup, PaneId, TerminalPane, TerminalViewResources}; use crate::terminal::TerminalView; use crate::workspace::WorkspaceAction; @@ -134,6 +135,32 @@ impl PaneGroup { .insert(task_id, pane_id); } }, + Some(WorkspaceAction::RestoreOrNavigateToConversation { + conversation_id, .. + }) => { + let unified_stack = FeatureFlag::OrchestrationUnifiedStack.is_enabled(); + let existing_conversation = + BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id); + let durable_marker = existing_conversation + .is_some_and(|conversation| conversation.is_durable_observer_parent()); + let durable_parent = if unified_stack && durable_marker { + existing_conversation.cloned() + } else { + None + }; + if let Some(conversation) = durable_parent { + self.replace_loading_pane_with_restored_ambient_cloud_mode_pane( + pane_id, + crate::ai::blocklist::history_model::CloudConversationData::Oz( + Box::new(conversation), + ), + task_id, + ctx, + ); + } else { + self.replace_pane_with_new_cloud_conversation(pane_id, ctx); + } + } _ => { self.replace_pane_with_new_cloud_conversation(pane_id, ctx); } diff --git a/app/src/pane_group/child_agent/hydration.rs b/app/src/pane_group/child_agent/hydration.rs index 330386af265..dad9136e0db 100644 --- a/app/src/pane_group/child_agent/hydration.rs +++ b/app/src/pane_group/child_agent/hydration.rs @@ -1,6 +1,11 @@ +use session_sharing_protocol::common::SessionId; +use uuid::Uuid; use warp_errors::report_error; use warpui::{SingletonEntity, ViewContext}; +use super::materialization::{ + ChildPaneMaterialization, ChildPaneOrigin, decide_child_pane_materialization, +}; use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::conversation::{AIConversation, AIConversationId}; use crate::ai::agent_conversations_model::AgentConversationsModel; @@ -10,10 +15,19 @@ use crate::ai::ambient_agents::{ use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; use crate::ai::blocklist::history_model::CloudConversationData; -use crate::pane_group::{AmbientAgentViewModelHandleExt, PaneGroup, PaneId}; +use crate::pane_group::{ + AmbientAgentViewModelHandleExt, PaneGroup, PaneId, TerminalPane, TerminalViewResources, +}; +use crate::terminal::model::terminal_model::ConversationTranscriptViewerStatus; use crate::terminal::view::load_ai_conversation::{ RestoreConversationEntryBehavior, RestoredAIConversation, }; +use crate::terminal::view::{ + CompletedChildPresentation, ConversationAccess, completed_child_conversation_access, + completed_child_presentation, +}; + +// flag-OFF path (OrchestrationUnifiedStack disabled) /// How to hydrate a restored hidden remote-child pane given its /// [`AmbientAgentTask`]. See [`decide_remote_child_hydration_action`]. @@ -35,7 +49,7 @@ pub(in crate::pane_group) enum RemoteChildHydrationAction { Fallback { task_is_terminal: bool }, } -/// Pure decision function backing [`PaneGroup::attempt_remote_child_hydration`]. +/// Pure decision function backing [`PaneGroup::hydrate_task_backed_hidden_child_pane`]. /// Free-standing so it's unit-testable without a `PaneGroup`. pub(in crate::pane_group) fn decide_remote_child_hydration_action( task: &AmbientAgentTask, @@ -68,12 +82,979 @@ pub(in crate::pane_group) fn decide_remote_child_hydration_action( } impl PaneGroup { + /// Applies the unified viewer materialization decision to a task snapshot + /// supplied by the parent shared-session viewer. + pub(in crate::pane_group) fn materialize_viewer_child_pane_from_task( + &mut self, + child_id: AIConversationId, + task: AmbientAgentTask, + ctx: &mut ViewContext, + ) { + let Some(child_conversation) = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&child_id) + .cloned() + else { + log::warn!( + "materialize_viewer_child_pane_from_task: no child conversation {child_id:?}" + ); + return; + }; + let task_id = child_conversation.task_id().or(Some(task.task_id)); + let materialization = decide_child_pane_materialization(&task); + self.materialize_viewer_child_pane(child_conversation, task_id, Some(materialization), ctx); + } + /// Single dispatch for every placeholder-child pane — the `is_remote_child` + /// (owner) and `is_viewing_shared_session` (viewer) branches of + /// [`Self::create_hidden_child_agent_pane`] both funnel here. + /// + /// Fetches the child's [`AmbientAgentTask`](crate::ai::ambient_agents::AmbientAgentTask) + /// and dispatches on [`decide_child_pane_materialization`], which makes the + /// same live / transcript / pending choice for both origins. `origin` selects + /// only the pane *construction* strategy. + /// + /// Idempotent: skipped when the placeholder already has a live tracked + /// pane, so repeat calls from `restore_missing_child_agent_panes_for_parent` + /// don't create a duplicate pane and orphan the first. + pub(in crate::pane_group) fn materialize_child_placeholder_pane( + &mut self, + child_conversation: AIConversation, + origin: ChildPaneOrigin, + ctx: &mut ViewContext, + ) { + let child_id = child_conversation.id(); + + // Idempotency guard — see fn doc. + if let Some(existing_pane_id) = self.child_agent_panes.get(&child_id).copied() + && self.has_pane_id(existing_pane_id) + { + return; + } + + let task_id = child_conversation.task_id(); + let task_for_decision = task_id.and_then(|task_id| { + AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { + model.get_or_async_fetch_task_data(&task_id, ctx) + }) + }); + let materialization = task_for_decision + .as_ref() + .map(decide_child_pane_materialization); + + match origin { + ChildPaneOrigin::HostedConversation => { + self.materialize_owner_child_pane( + child_conversation, + task_id, + materialization, + ctx, + ); + } + ChildPaneOrigin::SharedSession => { + self.materialize_viewer_child_pane( + child_conversation, + task_id, + materialization, + ctx, + ); + } + } + } + + /// Owner-mode arm of [`Self::materialize_child_placeholder_pane`]. + /// `AttachLive` constructs a live shared-session pane, `LoadTranscript` + /// keeps a loading pane visible until the cloud transcript has merged, and + /// `Pending` shows the same child loading presentation while task state is + /// refreshed. No owner path exposes the generic cloud-agent composing + /// zero state. + fn materialize_owner_child_pane( + &mut self, + child_conversation: AIConversation, + task_id: Option, + materialization: Option, + ctx: &mut ViewContext, + ) { + let child_id = child_conversation.id(); + let Some(task_id) = task_id else { + log::warn!("Cannot restore remote child conversation {child_id:?} without a task ID"); + return; + }; + + match materialization { + Some(ChildPaneMaterialization::AttachLive { session_id }) => { + self.attach_child_session( + child_id, + session_id, + ChildPaneOrigin::HostedConversation, + ctx, + ); + } + Some(ChildPaneMaterialization::LoadTranscript { server_token }) => { + let Some(pane_id) = + self.create_owner_loading_child_placeholder(child_conversation, ctx) + else { + return; + }; + self.hydrate_owner_child_transcript(pane_id, child_id, task_id, server_token, ctx); + } + Some(ChildPaneMaterialization::Pending) | None => { + // Pending: show the child loading presentation rather than the + // generic cloud-agent composing zero state. Register so that + // process_pending_remote_child_hydrations re-drives when + // evict_and_refetch_task fires TasksUpdated with fresh data. + if self + .create_owner_loading_child_placeholder(child_conversation, ctx) + .is_none() + { + return; + } + self.pending_remote_child_hydrations + .insert(task_id, child_id); + self.ensure_pending_ambient_restoration_subscription(ctx); + } + } + } + + /// Viewer-mode arm of [`Self::materialize_child_placeholder_pane`]. + /// `AttachLive` creates a dedicated shared-session viewer pane; + /// `LoadTranscript` loads the cloud transcript into a hidden ambient pane + /// (a terminal child has no live session to join, so both modes load the + /// transcript identically — the server ACL prerequisite grants viewers + /// access); `Pending` leaves a loading placeholder that + /// `OrchestrationViewerModel` re-drives via + /// `EnsureSharedSessionViewerChildPane` once a session id surfaces. + fn materialize_viewer_child_pane( + &mut self, + child_conversation: AIConversation, + task_id: Option, + materialization: Option, + ctx: &mut ViewContext, + ) { + let child_id = child_conversation.id(); + match (materialization, task_id) { + (Some(ChildPaneMaterialization::AttachLive { session_id }), task_id) => { + if self.failed_viewer_child_sessions.get(&child_id) == Some(&session_id) { + if let Some(task_id) = task_id { + self.pending_viewer_child_hydrations + .insert(task_id, child_id); + self.ensure_pending_ambient_restoration_subscription(ctx); + } + if let Some(pane_id) = self.child_agent_panes.get(&child_id).copied() + && let Some(view) = self.terminal_view_from_pane_id(pane_id, ctx) + { + view.update(ctx, |view, ctx| { + view.set_orchestration_child_live_unavailable(true, ctx); + }); + } + return; + } + self.failed_viewer_child_sessions.remove(&child_id); + if let Some(task_id) = task_id { + self.pending_viewer_child_hydrations.remove(&task_id); + } + self.attach_child_session( + child_id, + session_id, + ChildPaneOrigin::SharedSession, + ctx, + ); + } + (Some(ChildPaneMaterialization::LoadTranscript { server_token }), Some(task_id)) => { + let pane_id = self + .child_agent_panes + .get(&child_id) + .copied() + .filter(|pane_id| self.has_pane_id(*pane_id)) + .or_else(|| { + self.create_child_loading_placeholder( + child_conversation, + AgentViewEntryOrigin::SharedSessionSelection, + ctx, + ) + }); + let Some(pane_id) = pane_id else { + return; + }; + self.pending_viewer_child_hydrations.remove(&task_id); + self.failed_viewer_child_sessions.remove(&child_id); + self.hydrate_viewer_child_transcript_in_place( + pane_id, + child_id, + task_id, + server_token, + ctx, + ); + } + _ => { + // Pending / no task data yet: render a loading placeholder. The + // task subscription re-drives through this same decision once + // current metadata arrives. + if !self + .child_agent_panes + .get(&child_id) + .is_some_and(|pane_id| self.has_pane_id(*pane_id)) + { + self.create_viewer_loading_child_placeholder(child_conversation, ctx); + } + if let Some(task_id) = task_id { + self.pending_viewer_child_hydrations + .insert(task_id, child_id); + self.ensure_pending_ambient_restoration_subscription(ctx); + } + } + } + } + + /// Converged live-session attach for both modes (replaces the owner's old + /// in-place ambient attach and the viewer's dedicated-pane creation). + /// + /// Owner and viewer modes each materialize a dedicated shared-session + /// viewer pane with their appropriate ambient/viewer model configuration. + pub(in crate::pane_group) fn attach_child_session( + &mut self, + child_id: AIConversationId, + session_id: SessionId, + origin: ChildPaneOrigin, + ctx: &mut ViewContext, + ) { + match origin { + ChildPaneOrigin::HostedConversation => { + self.attach_owner_child_session(child_id, session_id, ctx); + } + ChildPaneOrigin::SharedSession => { + self.attach_viewer_child_session(child_id, session_id, ctx); + } + } + } + + /// Owner arm of [`Self::attach_child_session`]. Constructs the pane with + /// the live session from the start, matching normal ambient restoration. + /// A visible Pending loading pane is discarded and the new pane is swapped + /// into the same anchor only after its session manager, ambient model, and + /// conversation have all been initialized. + fn attach_owner_child_session( + &mut self, + child_id: AIConversationId, + session_id: SessionId, + ctx: &mut ViewContext, + ) { + let Some(child_conversation) = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&child_id) + .cloned() + else { + log::warn!( + "owner live replacement: no conversation \ + child_conversation_id={child_id:?}" + ); + return; + }; + let Some(task_id) = child_conversation.task_id() else { + log::warn!( + "owner live replacement: no task id \ + child_conversation_id={child_id:?}" + ); + return; + }; + + let fallback_was_swapped_anchor = if let Some(prior_pane_id) = self + .child_agent_panes + .get(&child_id) + .copied() + .filter(|pane_id| self.has_pane_id(*pane_id)) + { + let anchor = self.panes.original_pane_for_replacement(prior_pane_id); + self.discard_child_agent_pane_for_conversation(child_id, ctx); + anchor + } else { + None + }; + + let resources = TerminalViewResources { + tips_completed: self.tips_completed.clone(), + server_api: self.server_api.clone(), + model_event_sender: self.model_event_sender.clone(), + }; + let view_size = Self::estimated_view_bounds(ctx).size(); + let (new_terminal_view, terminal_manager) = Self::create_shared_session_viewer( + session_id, resources, view_size, + false, // parent already owns orchestration polling + true, // owner child is an ambient agent + ctx, + ); + let pane_data = TerminalPane::new( + Uuid::new_v4().as_bytes().to_vec(), + terminal_manager, + new_terminal_view.clone(), + self.model_event_sender.clone(), + ctx, + ); + let new_pane_id = pane_data.terminal_pane_id(); + if self + .attach_child_pane_off_tree(Box::new(pane_data), ctx) + .is_none() + { + report_error!( + "attach_owner_child_session: failed to attach pane", + extra: { "child_conversation_id" => ?child_id } + ); + return; + } + + new_terminal_view.update(ctx, |terminal_view, ctx| { + terminal_view.suppress_initial_conversation_details_panel_auto_open(); + terminal_view.restore_conversation_after_view_creation( + RestoredAIConversation::new(child_conversation), + true, + RestoreConversationEntryBehavior::PreserveAgentViewState, + ctx, + ); + terminal_view.enter_agent_view( + None, + Some(child_id), + AgentViewEntryOrigin::CloudAgent, + ctx, + ); + if let Some(ambient_agent_view_model) = + terminal_view.ambient_agent_view_model().cloned() + { + ambient_agent_view_model.update(ctx, |model, ctx| { + model.set_conversation_id(Some(child_id)); + model.enter_viewing_existing_session(task_id, ctx); + model.set_live_execution_session(session_id); + }); + } + }); + + self.child_agent_panes.insert(child_id, new_pane_id.into()); + if let Some(anchor) = fallback_was_swapped_anchor { + self.swap_active_pane_to_conversation(anchor, child_id, ctx); + } + } + + /// Attaches the hidden child pane's ambient agent view model to the live + /// ambient session for `task_id`. Wrapper around + /// `AmbientAgentViewModel::enter_viewing_existing_session` that also sets + /// the active conversation id. + fn apply_existing_ambient_task_to_pane( + &mut self, + pane_id: PaneId, + child_id: AIConversationId, + task_id: AmbientAgentTaskId, + ctx: &mut ViewContext, + ) { + let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) else { + return; + }; + terminal_view.update(ctx, |terminal_view, ctx| { + let Some(ambient_agent_view_model) = terminal_view + .ambient_agent_view_model() + .into_optional_handle() + .cloned() + else { + return; + }; + ambient_agent_view_model.update(ctx, |model, ctx| { + model.set_conversation_id(Some(child_id)); + model.enter_viewing_existing_session(task_id, ctx); + }); + }); + } + + /// Loads a completed hosted-conversation child and chooses continuation + /// or passive presentation from conversation access. + fn hydrate_owner_child_transcript( + &mut self, + pane_id: PaneId, + child_id: AIConversationId, + task_id: AmbientAgentTaskId, + server_token: ServerConversationToken, + ctx: &mut ViewContext, + ) { + let future = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.load_conversation_by_server_token(&server_token, ctx) + }); + ctx.spawn(future, move |group, conversation, ctx| { + let still_canonical = group + .child_agent_panes + .get(&child_id) + .copied() + .is_some_and(|candidate| candidate == pane_id && group.has_pane_id(candidate)); + if !still_canonical { + return; + } + + let Some(CloudConversationData::Oz(cloud)) = conversation else { + log::warn!( + "owner completed replacement missing Oz transcript \ + child_conversation_id={child_id:?}" + ); + return; + }; + let task = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id); + let access = + completed_child_conversation_access(cloud.server_metadata(), task.as_ref(), ctx); + let tasks = cloud + .all_tasks() + .filter_map(|task| task.source().cloned()) + .collect(); + let merged = match BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _ctx| { + history.hydrate_remote_child_placeholder_with_cloud_transcript( + child_id, + tasks, + *cloud, + ) + }) { + Ok(merged) => merged, + Err(err) => { + log::warn!( + "owner completed replacement merge-error \ + child_conversation_id={child_id:?} error={err:#}" + ); + return; + } + }; + let blocks_cloud_followups = + task.as_ref().is_none_or(AmbientAgentTask::blocks_cloud_followups); + match completed_child_presentation(access, blocks_cloud_followups) { + CompletedChildPresentation::Continuation => { + group.replace_child_loading_with_continuation_pane( + pane_id, child_id, task_id, merged, ctx, + ); + } + CompletedChildPresentation::PassiveTranscript => { + group.restore_child_passive_transcript( + pane_id, child_id, task_id, merged, ctx, + ); + } + } + }); + } + + /// Replaces an off-tree child loading pane with the established ambient + /// cloud-mode continuation presentation. + pub(in crate::pane_group) fn replace_child_loading_with_continuation_pane( + &mut self, + pane_id: PaneId, + child_id: AIConversationId, + task_id: AmbientAgentTaskId, + merged: AIConversation, + ctx: &mut ViewContext, + ) { + let fallback_was_swapped_anchor = self.panes.original_pane_for_replacement(pane_id); + self.discard_child_agent_pane_for_conversation(child_id, ctx); + + let resources = TerminalViewResources { + tips_completed: self.tips_completed.clone(), + server_api: self.server_api.clone(), + model_event_sender: self.model_event_sender.clone(), + }; + let view_size = Self::estimated_view_bounds(ctx).size(); + let (terminal_view, terminal_manager) = + Self::create_cloud_mode_terminal(resources, view_size, false, ctx); + Self::load_data_into_restored_ambient_cloud_mode_view( + terminal_view.clone(), + CloudConversationData::Oz(Box::new(merged)), + task_id, + false, + ctx, + ); + terminal_view.update(ctx, |view, ctx| { + view.enable_completed_cloud_continuation(task_id, ctx); + }); + let pane_data = TerminalPane::new( + Uuid::new_v4().as_bytes().to_vec(), + terminal_manager, + terminal_view, + self.model_event_sender.clone(), + ctx, + ); + let replacement_pane_id = pane_data.terminal_pane_id(); + if self + .attach_child_pane_off_tree(Box::new(pane_data), ctx) + .is_none() + { + report_error!( + "replace_child_loading_with_continuation_pane: failed to attach restored child pane", + extra: { "child_conversation_id" => ?child_id } + ); + return; + } + self.child_agent_panes + .insert(child_id, replacement_pane_id.into()); + if let Some(anchor) = fallback_was_swapped_anchor { + self.swap_active_pane_to_conversation(anchor, child_id, ctx); + } + } + + /// Restores a child transcript in place without enabling continuation. + fn restore_child_passive_transcript( + &mut self, + pane_id: PaneId, + child_id: AIConversationId, + task_id: AmbientAgentTaskId, + merged: AIConversation, + ctx: &mut ViewContext, + ) { + if let Some(terminal_manager) = self + .terminal_session_by_id(pane_id) + .map(|session| session.terminal_manager(ctx)) + { + terminal_manager.update(ctx, |manager, _ctx| { + let model_handle = manager.model(); + let mut model = model_handle.lock(); + model.set_shared_session_status( + crate::terminal::shared_session::SharedSessionStatus::FinishedViewer, + ); + model.set_conversation_transcript_viewer_status(Some( + ConversationTranscriptViewerStatus::ViewingAmbientConversation(task_id), + )); + }); + } + if let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _ctx| { + history.mark_terminal_surface_as_conversation_transcript_viewer(terminal_view.id()); + }); + terminal_view.update(ctx, |view, ctx| { + view.set_orchestration_child_live_unavailable(false, ctx); + view.restore_conversation_after_view_creation( + RestoredAIConversation::new(merged), + true, + RestoreConversationEntryBehavior::PreserveAgentViewState, + ctx, + ); + view.insert_conversation_ended_tombstone_with_resolved_cta(ctx); + }); + } + self.child_agent_panes.insert(child_id, pane_id); + } + + /// Loads a completed shared-session child and chooses continuation or + /// passive presentation from conversation access. + fn hydrate_viewer_child_transcript_in_place( + &mut self, + pane_id: PaneId, + child_id: AIConversationId, + task_id: AmbientAgentTaskId, + server_token: ServerConversationToken, + ctx: &mut ViewContext, + ) { + let history_handle = BlocklistAIHistoryModel::handle(ctx); + let future = history_handle.update(ctx, |history_model, ctx| { + history_model.load_conversation_by_server_token(&server_token, ctx) + }); + ctx.spawn(future, move |group, conversation, ctx| { + let still_canonical = group + .child_agent_panes + .get(&child_id) + .copied() + .is_some_and(|p| p == pane_id && group.has_pane_id(p)); + if !still_canonical { + return; + } + let active_conversation = group + .terminal_view_from_pane_id(pane_id, ctx) + .and_then(|view| view.as_ref(ctx).active_conversation_id(ctx)); + if active_conversation != Some(child_id) { + return; + } + let task = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id); + let access = match conversation.as_ref() { + Some(CloudConversationData::Oz(cloud)) => { + completed_child_conversation_access( + cloud.server_metadata(), + task.as_ref(), + ctx, + ) + } + _ => ConversationAccess::Unknown, + }; + + let merged = match conversation { + Some(CloudConversationData::Oz(cloud)) => { + let tasks: Vec = cloud + .all_tasks() + .filter_map(|task| task.source().cloned()) + .collect(); + let cloud_conversation = *cloud; + match BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| { + history.hydrate_remote_child_placeholder_with_cloud_transcript( + child_id, + tasks, + cloud_conversation, + ) + }) { + Ok(merged) => merged, + Err(err) => { + log::warn!( + "viewer transcript upgrade merge-error \ + child_conversation_id={child_id:?} error={err:#}" + ); + return; + } + } + } + Some(CloudConversationData::CLIAgent(_)) => { + log::warn!( + "viewer transcript upgrade unsupported \ + CLI transcript child_conversation_id={child_id:?}" + ); + return; + } + None => { + log::warn!( + "viewer transcript upgrade fetch-empty \ + child_conversation_id={child_id:?}" + ); + return; + } + }; + + let blocks_cloud_followups = + task.as_ref().is_none_or(AmbientAgentTask::blocks_cloud_followups); + match completed_child_presentation(access, blocks_cloud_followups) { + CompletedChildPresentation::Continuation => { + group.replace_child_loading_with_continuation_pane( + pane_id, child_id, task_id, merged, ctx, + ); + return; + } + CompletedChildPresentation::PassiveTranscript => {} + } + + group.restore_child_passive_transcript( + pane_id, child_id, task_id, merged, ctx, + ); + }); + } + + /// Renders the shared loading placeholder presentation used while a child + /// has neither an attachable session nor a loadable transcript. + pub(in crate::pane_group) fn create_child_loading_placeholder( + &mut self, + child_conversation: AIConversation, + origin: AgentViewEntryOrigin, + ctx: &mut ViewContext, + ) -> Option { + let child_id = child_conversation.id(); + let resources = TerminalViewResources { + tips_completed: self.tips_completed.clone(), + server_api: self.server_api.clone(), + model_event_sender: self.model_event_sender.clone(), + }; + let view_size = Self::estimated_view_bounds(ctx).size(); + let (loading_view, loading_manager) = Self::create_loading_terminal_manager_and_view( + resources, + view_size, + ctx.window_id(), + ctx, + ); + let pane_data = TerminalPane::new( + Uuid::new_v4().as_bytes().to_vec(), + loading_manager, + loading_view.clone(), + self.model_event_sender.clone(), + ctx, + ); + let new_pane_id = pane_data.terminal_pane_id(); + if self + .attach_child_pane_off_tree(Box::new(pane_data), ctx) + .is_none() + { + report_error!( + "create_child_loading_placeholder: failed to attach child loading pane", + extra: { "child_id" => ?child_id } + ); + return None; + } + + // Restore the conversation and enter agent view so the pill bar renders + // (its gate requires `is_fullscreen()`). The output area stays a loading + // spinner because the loading view's + // `ConversationTranscriptViewerStatus::Loading` short-circuits the + // block list render in `TerminalView::render`. + loading_view.update(ctx, |terminal_view, ctx| { + terminal_view.restore_conversation_after_view_creation( + RestoredAIConversation::new(child_conversation), + true, + RestoreConversationEntryBehavior::PreserveAgentViewState, + ctx, + ); + terminal_view.enter_agent_view(None, Some(child_id), origin, ctx); + }); + + self.child_agent_panes.insert(child_id, new_pane_id.into()); + Some(new_pane_id.into()) + } + + /// Owner-side Pending state. Uses the child loading presentation instead + /// of exposing the generic cloud-agent composing zero state. + fn create_owner_loading_child_placeholder( + &mut self, + child_conversation: AIConversation, + ctx: &mut ViewContext, + ) -> Option { + self.create_child_loading_placeholder( + child_conversation, + AgentViewEntryOrigin::CloudAgent, + ctx, + ) + } + + /// Viewer-side Pending state retained for the flag-off path and shared + /// viewer orchestration. + pub(super) fn create_viewer_loading_child_placeholder( + &mut self, + child_conversation: AIConversation, + ctx: &mut ViewContext, + ) { + let _ = self.create_child_loading_placeholder( + child_conversation, + AgentViewEntryOrigin::SharedSessionSelection, + ctx, + ); + } + + /// Viewer arm of [`Self::attach_child_session`]: materializes a dedicated + /// hidden shared-session viewer pane for a viewer-discovered child agent. + /// Triggered from the unified dispatch (`AttachLive`) and from + /// `Event::EnsureSharedSessionViewerChildPane`, which + /// `OrchestrationViewerModel` emits the first time it observes a + /// `session_id` for a child. The new pane gets its own + /// `BlocklistAIController` and viewer-side `Network` so child traffic + /// doesn't cross the parent's single-stream state. + fn attach_viewer_child_session( + &mut self, + child_conversation_id: AIConversationId, + child_session_id: SessionId, + ctx: &mut ViewContext, + ) { + // Race recovery: a pill click / restore before materialization had a + // `session_id` falls through to the viewer loading placeholder, which + // leaves an entry in `child_agent_panes`. The emission gate in + // `OrchestrationViewerModel` guarantees the viewer attach runs at most + // once per child per model lifetime, so any existing entry must be that + // fallback — safe to discard. + let fallback_was_swapped_anchor = if let Some(prior_pane_id) = self + .child_agent_panes + .get(&child_conversation_id) + .copied() + .filter(|pane_id| self.has_pane_id(*pane_id)) + { + let anchor = self.panes.original_pane_for_replacement(prior_pane_id); + self.discard_child_agent_pane_for_conversation(child_conversation_id, ctx); + anchor + } else { + None + }; + + let Some(child_conversation) = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&child_conversation_id) + .cloned() + else { + log::warn!( + "attach_viewer_child_session: no local conversation {child_conversation_id:?}" + ); + return; + }; + let child_task_id = child_conversation.task_id(); + + let resources = TerminalViewResources { + tips_completed: self.tips_completed.clone(), + server_api: self.server_api.clone(), + model_event_sender: self.model_event_sender.clone(), + }; + let view_size = Self::estimated_view_bounds(ctx).size(); + // Per-child viewer: parent's model already discovers descendants, and + // hidden child viewers aren't snapshotted, so `is_cloud_mode` stays + // `false` (no `ambient_agent_view_model` needed for snapshot round-trip). + let (new_terminal_view, terminal_manager) = + Self::create_orchestration_child_shared_session_viewer( + child_session_id, + child_conversation_id, + resources, + view_size, + ctx, + ); + + let pane_data = TerminalPane::new( + Uuid::new_v4().as_bytes().to_vec(), + terminal_manager, + new_terminal_view.clone(), + self.model_event_sender.clone(), + ctx, + ); + let new_pane_id = pane_data.terminal_pane_id(); + if self + .attach_child_pane_off_tree(Box::new(pane_data), ctx) + .is_none() + { + report_error!( + "attach_viewer_child_session: failed to attach pane", + extra: { "child_conversation_id" => ?child_conversation_id } + ); + return; + } + + new_terminal_view.update(ctx, |terminal_view, ctx| { + terminal_view.suppress_initial_conversation_details_panel_auto_open(); + terminal_view.restore_conversation_after_view_creation( + RestoredAIConversation::new(child_conversation), + true, + RestoreConversationEntryBehavior::PreserveAgentViewState, + ctx, + ); + terminal_view.enter_agent_view( + None, + Some(child_conversation_id), + AgentViewEntryOrigin::SharedSessionSelection, + ctx, + ); + // Shared-session viewer is `is_cloud_mode=false`, so + // `ambient_agent_view_model()` is typically `None`. Update + // opportunistically; the network's `JoinedSuccessfully` is the + // authoritative source for ambient agent state. + if let Some(ambient_agent_view_model) = terminal_view + .ambient_agent_view_model() + .into_optional_handle() + .cloned() + { + ambient_agent_view_model.update(ctx, |model, ctx| { + model.set_conversation_id(Some(child_conversation_id)); + if let Some(task_id) = child_task_id { + model.enter_viewing_existing_session(task_id, ctx); + } + }); + } + }); + + self.child_agent_panes + .insert(child_conversation_id, new_pane_id.into()); + // If the discarded fallback was occupying a tree slot via temporary + // replacement, re-swap so the user lands on the new pane. + if let Some(anchor) = fallback_was_swapped_anchor { + self.swap_active_pane_to_conversation(anchor, child_conversation_id, ctx); + } + } + + /// Recovers a child viewer whose live session no longer exists or is not + /// accessible. The same session is not retried; refreshed task metadata + /// can upgrade this pane to a transcript or attach a later execution. + pub(in crate::pane_group) fn recover_viewer_child_join_failure( + &mut self, + pane_id: PaneId, + child_id: AIConversationId, + session_id: SessionId, + ctx: &mut ViewContext, + ) { + if self.child_agent_panes.get(&child_id) != Some(&pane_id) { + return; + } + let Some(task_id) = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&child_id) + .filter(|conversation| conversation.is_viewing_shared_session()) + .and_then(|conversation| conversation.task_id()) + else { + return; + }; + + self.failed_viewer_child_sessions + .insert(child_id, session_id); + self.pending_viewer_child_hydrations + .insert(task_id, child_id); + self.ensure_pending_ambient_restoration_subscription(ctx); + if let Some(view) = self.terminal_view_from_pane_id(pane_id, ctx) { + view.update(ctx, |view, ctx| { + view.set_orchestration_child_live_unavailable(true, ctx); + }); + } + AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { + model.evict_and_refetch_task(&task_id, ctx); + }); + } + + /// Re-drives viewer children after task metadata changes. A failed live + /// session stays unavailable without retrying, while terminal tasks + /// upgrade the existing pane to a passive transcript. + pub(in crate::pane_group) fn process_pending_viewer_child_hydrations( + &mut self, + ctx: &mut ViewContext, + ) { + if !crate::features::FeatureFlag::OrchestrationUnifiedStack.is_enabled() + || self.pending_viewer_child_hydrations.is_empty() + { + return; + } + + let ready_tasks: Vec<_> = self + .pending_viewer_child_hydrations + .keys() + .filter(|task_id| { + AgentConversationsModel::as_ref(ctx) + .get_task_data(task_id) + .is_some() + }) + .copied() + .collect(); + + for task_id in ready_tasks { + let Some(child_id) = self.pending_viewer_child_hydrations.remove(&task_id) else { + continue; + }; + let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) else { + continue; + }; + let Some(pane_id) = self + .child_agent_panes + .get(&child_id) + .copied() + .filter(|pane_id| self.has_pane_id(*pane_id)) + else { + continue; + }; + + match decide_child_pane_materialization(&task) { + ChildPaneMaterialization::AttachLive { session_id } + if self.failed_viewer_child_sessions.get(&child_id) == Some(&session_id) => + { + self.pending_viewer_child_hydrations + .insert(task_id, child_id); + } + ChildPaneMaterialization::AttachLive { session_id } => { + self.failed_viewer_child_sessions.remove(&child_id); + self.attach_child_session( + child_id, + session_id, + ChildPaneOrigin::SharedSession, + ctx, + ); + } + ChildPaneMaterialization::LoadTranscript { server_token } => { + self.failed_viewer_child_sessions.remove(&child_id); + self.hydrate_viewer_child_transcript_in_place( + pane_id, + child_id, + task_id, + server_token, + ctx, + ); + } + ChildPaneMaterialization::Pending => { + self.pending_viewer_child_hydrations + .insert(task_id, child_id); + } + } + } + } + + // ========================================================================= + // flag-OFF path (OrchestrationUnifiedStack disabled) + // ========================================================================= + /// Task-backed restore path for the `is_remote_child` branch of - /// `create_hidden_child_agent_pane`. Always creates the hidden ambient - /// pane, registers it in `child_agent_panes` keyed by the placeholder's - /// local `AIConversationId`, then dispatches via - /// `attempt_remote_child_hydration` (or queues a pending entry while - /// task data is fetched). + /// `create_hidden_child_agent_pane` when `OrchestrationUnifiedStack` is + /// disabled. Always creates the hidden ambient pane, registers it in + /// `child_agent_panes` keyed by the placeholder's local + /// `AIConversationId`, then dispatches via `attempt_remote_child_hydration` + /// (or queues a pending entry while task data is fetched). /// /// Idempotent: skipped when the placeholder already has a live tracked /// pane, so repeat calls from `restore_missing_child_agent_panes_for_parent` @@ -88,7 +1069,6 @@ impl PaneGroup { ) { let child_id = child_conversation.id(); - // Idempotency guard — see fn doc. if let Some(existing_pane_id) = self.child_agent_panes.get(&child_id).copied() && self.has_pane_id(existing_pane_id) { @@ -161,10 +1141,8 @@ impl PaneGroup { } /// Dispatches the hydration action chosen by - /// [`decide_remote_child_hydration_action`]. Inspects the - /// [`AmbientAgentTask`] directly because `resolve_open_action` collapses - /// the navigate-to-local and hydrate-cloud-transcript intents into one - /// variant once `conversations_by_id` carries the placeholder. + /// [`decide_remote_child_hydration_action`] for a restored hidden child + /// pane when `OrchestrationUnifiedStack` is disabled. fn attempt_remote_child_hydration( &mut self, child_id: AIConversationId, @@ -220,42 +1198,9 @@ impl PaneGroup { } } - /// Attaches the hidden child pane's ambient agent view model to the - /// live ambient session for `task_id`. Wrapper around - /// `AmbientAgentViewModel::enter_viewing_existing_session` that also - /// sets the active conversation id. - fn apply_existing_ambient_task_to_pane( - &mut self, - pane_id: PaneId, - child_id: AIConversationId, - task_id: AmbientAgentTaskId, - ctx: &mut ViewContext, - ) { - let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) else { - return; - }; - terminal_view.update(ctx, |terminal_view, ctx| { - let Some(ambient_agent_view_model) = terminal_view - .ambient_agent_view_model() - .into_optional_handle() - .cloned() - else { - return; - }; - ambient_agent_view_model.update(ctx, |model, ctx| { - model.set_conversation_id(Some(child_id)); - model.enter_viewing_existing_session(task_id, ctx); - }); - }); - } - - /// Fetches the cloud transcript identified by `server_token`, hydrates - /// the placeholder via - /// `hydrate_remote_child_placeholder_with_cloud_transcript`, and - /// re-restores the merged conversation into the pane. - /// `task_is_terminal` gates the conversation-ended tombstone in - /// `attach_ambient_session_and_maybe_tombstone` so an - /// `ActiveUnattachable` run isn't visually marked as ended. + /// Fetches the cloud transcript for a restored hidden child pane when + /// `OrchestrationUnifiedStack` is disabled. `task_is_terminal` gates + /// the conversation-ended tombstone in the post-match step. fn hydrate_remote_child_transcript_in_place( &mut self, pane_id: PaneId, @@ -343,10 +1288,9 @@ impl PaneGroup { }); } - /// Post-match step for `hydrate_remote_child_transcript_in_place`: - /// attach the live ambient session and insert the conversation-ended - /// tombstone iff `task_is_terminal`. Centralised so the gate stays - /// consistent across the Ok-merge / Err-merge / non-Oz fallback arms. + /// Post-match step for `hydrate_remote_child_transcript_in_place` when + /// `OrchestrationUnifiedStack` is disabled: attaches the live ambient + /// session and conditionally inserts the conversation-ended tombstone. fn attach_ambient_session_and_maybe_tombstone( &mut self, pane_id: PaneId, @@ -368,6 +1312,15 @@ impl PaneGroup { /// Drains entries from `pending_remote_child_hydrations` for which task /// data is now available, hydrating each hidden child pane in place. + /// + /// When `OrchestrationUnifiedStack` is enabled (M2), the fresh task data + /// is obtained from `AgentConversationsModel` and re-dispatched via the + /// unified path: `AttachLive` calls `attach_child_session` directly on the + /// existing pane; `LoadTranscript` restores an owner ambient continuation + /// pane; `Pending` leaves the entry for the next `TasksUpdated` cycle. + /// + /// When the flag is off, the original `attempt_remote_child_hydration` path + /// is used unchanged. pub(in crate::pane_group) fn process_pending_remote_child_hydrations( &mut self, ctx: &mut ViewContext, @@ -388,12 +1341,53 @@ impl PaneGroup { .collect(); for task_id in ready_tasks { - let Some(placeholder_conversation_id) = - self.pending_remote_child_hydrations.remove(&task_id) - else { + let Some(child_id) = self.pending_remote_child_hydrations.remove(&task_id) else { continue; }; - self.attempt_remote_child_hydration(placeholder_conversation_id, task_id, ctx); + + if crate::features::FeatureFlag::OrchestrationUnifiedStack.is_enabled() { + // M2 unified path: call attach/transcript directly on the + // existing pane without going through the idempotency-guarded + // materialize_child_placeholder_pane. + let Some(task) = AgentConversationsModel::as_ref(ctx).get_task_data(&task_id) + else { + continue; + }; + match decide_child_pane_materialization(&task) { + ChildPaneMaterialization::AttachLive { session_id } => { + self.attach_child_session( + child_id, + session_id, + ChildPaneOrigin::HostedConversation, + ctx, + ); + } + ChildPaneMaterialization::LoadTranscript { server_token } => { + let pane_id = self + .child_agent_panes + .get(&child_id) + .copied() + .filter(|p| self.has_pane_id(*p)); + if let Some(pane_id) = pane_id { + self.hydrate_owner_child_transcript( + pane_id, + child_id, + task_id, + server_token, + ctx, + ); + } + } + ChildPaneMaterialization::Pending => { + // Still pending: re-register and wait for the next + // TasksUpdated (e.g. a second evict_and_refetch cycle). + self.pending_remote_child_hydrations + .insert(task_id, child_id); + } + } + } else { + self.attempt_remote_child_hydration(child_id, task_id, ctx); + } } } } diff --git a/app/src/pane_group/child_agent/materialization.rs b/app/src/pane_group/child_agent/materialization.rs new file mode 100644 index 00000000000..64e224ccf3a --- /dev/null +++ b/app/src/pane_group/child_agent/materialization.rs @@ -0,0 +1,66 @@ +use session_sharing_protocol::common::SessionId; + +use crate::ai::agent::api::ServerConversationToken; +use crate::ai::ambient_agents::{AmbientAgentLiveSessionState, AmbientAgentTask}; + +/// The context from which a child pane is being constructed. +/// +/// Selects the pane *construction* strategy in +/// [`PaneGroup::attach_child_session`]; the materialization *decision* +/// ([`decide_child_pane_materialization`]) is origin-agnostic. Origin never +/// grants live input or terminal continuation capability. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ChildPaneOrigin { + /// Child discovered from a conversation hosted by this process. + HostedConversation, + /// Child discovered while observing a shared session. + SharedSession, +} + +/// How to materialize a child agent pane given its [`AmbientAgentTask`]. +/// See [`decide_child_pane_materialization`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ChildPaneMaterialization { + /// Attachable live session — join it in place using `session_id`. + AttachLive { session_id: SessionId }, + /// No live session but a server conversation token is available; load + /// the cloud transcript for it. + LoadTranscript { + server_token: ServerConversationToken, + }, + /// Neither a live session nor a loadable transcript is available yet; + /// leave the pane pending until task data changes. + Pending, +} + +/// Origin-agnostic pane dispatch: identical task state produces the same +/// materialization action. +/// +/// Free-standing so it's unit-testable without a `PaneGroup`. +pub(crate) fn decide_child_pane_materialization( + task: &AmbientAgentTask, +) -> ChildPaneMaterialization { + if let AmbientAgentLiveSessionState::Attachable { session_id } = + task.active_live_session_state() + { + return ChildPaneMaterialization::AttachLive { session_id }; + } + + // Only terminal runs load a transcript. Empty/whitespace tokens would + // drive a no-op cloud fetch, so treat them as absent. + if task.is_terminal_run_state() + && let Some(server_token) = task + .conversation_id() + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(|t| ServerConversationToken::new(t.to_string())) + { + return ChildPaneMaterialization::LoadTranscript { server_token }; + } + + ChildPaneMaterialization::Pending +} + +#[cfg(test)] +#[path = "materialization_tests.rs"] +mod tests; diff --git a/app/src/pane_group/child_agent/materialization_tests.rs b/app/src/pane_group/child_agent/materialization_tests.rs new file mode 100644 index 00000000000..bb896fd0474 --- /dev/null +++ b/app/src/pane_group/child_agent/materialization_tests.rs @@ -0,0 +1,140 @@ +use chrono::Utc; + +use super::{ChildPaneMaterialization, decide_child_pane_materialization}; +use crate::ai::agent::api::ServerConversationToken; +use crate::ai::ambient_agents::{AmbientAgentTask, AmbientAgentTaskState}; + +/// Builds a minimal [`AmbientAgentTask`] for materialization tests. +/// +/// `state`, `is_sandbox_running`, and `session_id` combine to determine the +/// task's live-session state (see +/// [`AmbientAgentTask::active_live_session_state`]): an `InProgress` task +/// with a running sandbox and a parseable UUID-shaped `session_id` is +/// `Attachable`. `conversation_id` populates the token used by the +/// `LoadTranscript` branch. +fn task( + state: AmbientAgentTaskState, + is_sandbox_running: bool, + session_id: Option<&str>, + conversation_id: Option<&str>, +) -> AmbientAgentTask { + let now = Utc::now(); + AmbientAgentTask { + task_id: "11111111-1111-1111-1111-111111111111".parse().unwrap(), + parent_run_id: None, + title: "Task".to_string(), + state, + prompt: String::new(), + created_at: now, + started_at: Some(now), + updated_at: now, + run_time: None, + status_message: None, + source: None, + session_id: session_id.map(str::to_string), + session_link: None, + creator: None, + executor: None, + scope: None, + conversation_id: conversation_id.map(str::to_string), + request_usage: None, + is_sandbox_running, + agent_config_snapshot: None, + artifacts: vec![], + last_event_sequence: None, + children: vec![], + } +} + +#[test] +fn terminal_task_with_stale_session_id_loads_transcript() { + let task = task( + AmbientAgentTaskState::Succeeded, + false, + Some("22222222-2222-2222-2222-222222222222"), + Some("completed-child-token"), + ); + + assert_eq!( + decide_child_pane_materialization(&task), + ChildPaneMaterialization::LoadTranscript { + server_token: ServerConversationToken::new("completed-child-token".to_string()), + }, + "a terminal child must never join its stale execution session", + ); +} + +#[test] +fn attachable_task_attaches_live_with_session_id() { + let task = task( + AmbientAgentTaskState::InProgress, + true, + Some("22222222-2222-2222-2222-222222222222"), + // A conversation_id is present but must be ignored in favor of the + // live session. + Some("server-token-irrelevant-for-attach"), + ); + + assert_eq!( + decide_child_pane_materialization(&task), + ChildPaneMaterialization::AttachLive { + session_id: "22222222-2222-2222-2222-222222222222".parse().unwrap(), + }, + ); +} + +#[test] +fn terminal_task_with_conversation_id_loads_transcript() { + let task = task( + AmbientAgentTaskState::Succeeded, + false, + None, + Some("my-server-token"), + ); + + assert_eq!( + decide_child_pane_materialization(&task), + ChildPaneMaterialization::LoadTranscript { + server_token: ServerConversationToken::new("my-server-token".to_string()), + }, + ); +} + +#[test] +fn non_terminal_non_attachable_tasks_are_pending() { + for state in [ + AmbientAgentTaskState::Queued, + AmbientAgentTaskState::Pending, + AmbientAgentTaskState::Claimed, + ] { + // A conversation_id is present but the run is not terminal, so there + // is no transcript to load yet. + let task = task(state.clone(), false, None, Some("server-token")); + assert_eq!( + decide_child_pane_materialization(&task), + ChildPaneMaterialization::Pending, + "state={state:?} should be Pending", + ); + } +} + +#[test] +fn terminal_task_without_conversation_id_is_pending() { + let task = task(AmbientAgentTaskState::Succeeded, false, None, None); + assert_eq!( + decide_child_pane_materialization(&task), + ChildPaneMaterialization::Pending, + ); +} + +#[test] +fn terminal_task_with_blank_conversation_id_is_pending() { + for blank in ["", " ", "\t\n"] { + let task = task(AmbientAgentTaskState::Succeeded, false, None, Some(blank)); + assert_eq!( + decide_child_pane_materialization(&task), + ChildPaneMaterialization::Pending, + "blank conversation_id={blank:?} should be Pending", + ); + } +} diff --git a/app/src/pane_group/child_agent/mod.rs b/app/src/pane_group/child_agent/mod.rs index e8767916ae2..dedbecc7f3f 100644 --- a/app/src/pane_group/child_agent/mod.rs +++ b/app/src/pane_group/child_agent/mod.rs @@ -1,4 +1,5 @@ pub(in crate::pane_group) mod hydration; +pub(crate) mod materialization; mod restoration; use std::collections::HashMap; diff --git a/app/src/pane_group/child_agent/restoration.rs b/app/src/pane_group/child_agent/restoration.rs index e4e175c4f40..099b918666f 100644 --- a/app/src/pane_group/child_agent/restoration.rs +++ b/app/src/pane_group/child_agent/restoration.rs @@ -6,11 +6,13 @@ use uuid::Uuid; use warp_errors::report_error; use warpui::{SingletonEntity, ViewContext}; +use super::materialization::ChildPaneOrigin; use super::{HiddenChildAgentTaskContext, apply_hidden_child_agent_task_context}; use crate::ai::agent::conversation::{AIConversation, AIConversationId}; use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; use crate::ai::restored_conversations::RestoredAgentConversations; +use crate::features::FeatureFlag; use crate::pane_group::{ AmbientAgentViewModelHandleExt, PaneGroup, PaneId, TerminalPane, TerminalViewResources, }; @@ -174,82 +176,53 @@ impl PaneGroup { ctx: &mut ViewContext, ) { let child_id = child_conversation.id(); + let flag_on = FeatureFlag::OrchestrationUnifiedStack.is_enabled(); - // Viewer-side child clicked before `OrchestrationViewerModel` - // surfaced a `session_id`: render a loading placeholder; the real - // pane gets swapped in by `ensure_shared_session_viewer_child_pane`. - if child_conversation.is_viewing_shared_session() { - let resources = TerminalViewResources { - tips_completed: self.tips_completed.clone(), - server_api: self.server_api.clone(), - model_event_sender: self.model_event_sender.clone(), - }; - let view_size = Self::estimated_view_bounds(ctx).size(); - let (loading_view, loading_manager) = Self::create_loading_terminal_manager_and_view( - resources, - view_size, - ctx.window_id(), - ctx, - ); - let pane_data = TerminalPane::new( - Uuid::new_v4().as_bytes().to_vec(), - loading_manager, - loading_view.clone(), - self.model_event_sender.clone(), - ctx, - ); - let new_pane_id = pane_data.terminal_pane_id(); - if self - .attach_child_pane_off_tree(Box::new(pane_data), ctx) - .is_none() + if flag_on { + // flag-ON (M2): unified placeholder dispatch — viewer and owner + // both route through `materialize_child_placeholder_pane`, which + // fetches the task and routes on `decide_child_pane_materialization`. + // The local in-process child branch below stays separate. + let parent_is_shared_observer = child_conversation + .parent_conversation_id() + .and_then(|parent_id| BlocklistAIHistoryModel::as_ref(ctx).conversation(&parent_id)) + .is_some_and(|parent| parent.is_viewing_shared_session()); + let pane_origin = if child_conversation.is_viewing_shared_session() + || (child_conversation.is_remote_child() && parent_is_shared_observer) { - report_error!( - "create_hidden_child_agent_pane: failed to attach loading placeholder for \ - viewer-side child", - extra: { "child_id" => ?child_id } - ); + Some(ChildPaneOrigin::SharedSession) + } else if child_conversation.is_remote_child() { + Some(ChildPaneOrigin::HostedConversation) + } else { + None + }; + if let Some(origin) = pane_origin { + self.materialize_child_placeholder_pane(child_conversation, origin, ctx); return; } - - // Restore the conversation and enter agent view so the pill bar - // renders (its gate requires `is_fullscreen()`). The output area - // stays a loading spinner because the loading view's - // `ConversationTranscriptViewerStatus::Loading` short-circuits - // the block list render in `TerminalView::render`. - loading_view.update(ctx, |terminal_view, ctx| { - terminal_view.restore_conversation_after_view_creation( - RestoredAIConversation::new(child_conversation), - true, - RestoreConversationEntryBehavior::PreserveAgentViewState, - ctx, - ); - terminal_view.enter_agent_view( - None, - Some(child_id), - AgentViewEntryOrigin::SharedSessionSelection, + } else { + // flag-OFF: original dispatch preserved + if child_conversation.is_viewing_shared_session() { + self.create_viewer_loading_child_placeholder(child_conversation, ctx); + return; + } + if child_conversation.is_remote_child() { + let Some(task_id) = child_conversation.task_id() else { + log::warn!( + "Cannot restore remote child conversation {child_id:?} without a task ID" + ); + return; + }; + self.hydrate_task_backed_hidden_child_pane( + child_conversation, + parent_pane_id, + task_id, ctx, ); - }); - - self.child_agent_panes.insert(child_id, new_pane_id.into()); - return; - } - - if child_conversation.is_remote_child() { - let Some(task_id) = child_conversation.task_id() else { - log::warn!( - "Cannot restore remote child conversation {child_id:?} without a task ID" - ); return; - }; - self.hydrate_task_backed_hidden_child_pane( - child_conversation, - parent_pane_id, - task_id, - ctx, - ); - return; + } } + let child_task_context = child_conversation .task_id() @@ -302,13 +275,15 @@ impl PaneGroup { } } + // ========================================================================= + // flag-OFF path (OrchestrationUnifiedStack disabled) + // ========================================================================= + /// Materializes a hidden shared-session viewer pane for a viewer- - /// discovered child agent. Triggered by - /// `Event::EnsureSharedSessionViewerChildPane`, which - /// `OrchestrationViewerModel` emits on the parent's view the first - /// time it observes a `session_id` for a child. The new pane gets its - /// own `BlocklistAIController` and viewer-side `Network` so child - /// traffic doesn't cross the parent's single-stream state. + /// discovered child agent when `OrchestrationUnifiedStack` is disabled. + /// Triggered by `Event::EnsureSharedSessionViewerChildPane`, which + /// `OrchestrationViewerModel` emits on the parent's view the first time + /// it observes a `session_id` for a child. pub(in crate::pane_group) fn ensure_shared_session_viewer_child_pane( &mut self, child_conversation_id: AIConversationId, diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 179cbf2e0e6..bd1165b8f3d 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -173,6 +173,9 @@ use crate::{cmd_or_ctrl_shift, send_telemetry_from_ctx}; mod ambient_pane_restoration; mod child_agent; +pub(crate) use child_agent::materialization::{ + ChildPaneMaterialization, decide_child_pane_materialization, +}; pub mod focus_state; pub mod pane; pub mod tree; @@ -944,8 +947,19 @@ pub struct PaneGroup { /// `child_agent_panes` key. Kept separate from /// `pending_ambient_agent_conversation_restorations` so the /// visible-tree `replace_pane` flow doesn't swap a hidden child pane. + /// Only populated when `OrchestrationUnifiedStack` is disabled. pending_remote_child_hydrations: HashMap, + /// Unified-stack viewer children waiting for a task state that can be + /// materialized. Unlike owner restorations, these remain passive and + /// re-drive through the viewer construction path. + pending_viewer_child_hydrations: HashMap, + + /// The most recent live session that failed to join for each viewer child. + /// Re-drive does not retry the same session, but a later execution with a + /// new session id may still attach. + failed_viewer_child_sessions: HashMap, + /// Whether `ensure_pending_ambient_restoration_subscription` has been /// called; the subscription is shared by both pending maps. pending_ambient_restoration_subscription_installed: bool, @@ -3154,6 +3168,8 @@ impl PaneGroup { is_right_panel_maximized: false, pending_ambient_agent_conversation_restorations: HashMap::new(), pending_remote_child_hydrations: HashMap::new(), + pending_viewer_child_hydrations: HashMap::new(), + failed_viewer_child_sessions: HashMap::new(), pending_ambient_restoration_subscription_installed: false, child_agent_panes: HashMap::new(), transitively_shared_child_panes: HashMap::new(), @@ -3297,6 +3313,7 @@ impl PaneGroup { self.process_pending_ambient_restorations(ctx); self.process_pending_remote_child_hydrations(ctx); + self.process_pending_viewer_child_hydrations(ctx); } /// Initial layout for a [`PaneGroup`] with a single ambient agent pane. @@ -3703,6 +3720,7 @@ impl PaneGroup { terminal_view, cloud_conversation, task_id, + true, ctx, ); ctx.notify(); @@ -4615,6 +4633,9 @@ impl PaneGroup { let children = self.child_pane_ids_for_parent(parent_terminal_view_id, ctx); for (conv_id, child_pane_id) in children { self.child_agent_panes.remove(&conv_id); + self.failed_viewer_child_sessions.remove(&conv_id); + self.pending_viewer_child_hydrations + .retain(|_, child_id| *child_id != conv_id); self.panes.remove_hidden_pane(child_pane_id); self.discard_pane(child_pane_id, ctx); } @@ -4627,6 +4648,9 @@ impl PaneGroup { ctx: &mut ViewContext, ) -> bool { let tracked_child_pane = self.child_agent_panes.remove(&conversation_id); + self.failed_viewer_child_sessions.remove(&conversation_id); + self.pending_viewer_child_hydrations + .retain(|_, child_id| *child_id != conversation_id); let split_off_child_pane = self.child_agent_origin.as_ref().and_then(|origin| { (origin.conversation_id == conversation_id) .then(|| self.pane_id_for_conversation_owner(conversation_id, ctx)) @@ -5299,6 +5323,23 @@ impl PaneGroup { cloud_conversation: CloudConversationData, task_id: AmbientAgentTaskId, ctx: &mut ViewContext, + ) -> bool { + self.replace_loading_pane_with_restored_ambient_cloud_mode_pane_inner( + loading_pane_id, + cloud_conversation, + task_id, + true, + ctx, + ) + } + + fn replace_loading_pane_with_restored_ambient_cloud_mode_pane_inner( + &mut self, + loading_pane_id: PaneId, + cloud_conversation: CloudConversationData, + task_id: AmbientAgentTaskId, + mark_as_viewing_shared_session: bool, + ctx: &mut ViewContext, ) -> bool { let resources = TerminalViewResources { tips_completed: self.tips_completed.clone(), @@ -5314,6 +5355,7 @@ impl PaneGroup { terminal_view.clone(), cloud_conversation, task_id, + mark_as_viewing_shared_session, ctx, ); @@ -5340,6 +5382,7 @@ impl PaneGroup { terminal_view: ViewHandle, cloud_conversation: CloudConversationData, task_id: AmbientAgentTaskId, + mark_as_viewing_shared_session: bool, ctx: &mut ViewContext, ) { // URL-loaded conversation transcripts (e.g. Warp-on-Web deep links) @@ -5367,7 +5410,7 @@ impl PaneGroup { match cloud_conversation { CloudConversationData::Oz(mut conversation) => { let id = conversation.id(); - conversation.set_is_viewing_shared_session(true); + conversation.set_is_viewing_shared_session(mark_as_viewing_shared_session); view.restore_conversation_after_view_creation( RestoredAIConversation::new(*conversation), true, @@ -6155,6 +6198,30 @@ impl PaneGroup { (terminal_view, terminal_manager) } + fn create_orchestration_child_shared_session_viewer( + session_id: SessionId, + conversation_id: AIConversationId, + resources: TerminalViewResources, + initial_size: Vector2F, + ctx: &mut ViewContext, + ) -> ( + ViewHandle, + ModelHandle>, + ) { + let terminal_init = shared_session::viewer::TerminalManager::new_for_orchestration_child( + session_id, + conversation_id, + resources, + initial_size, + ctx.window_id(), + ctx, + ); + let terminal_view = terminal_init.view; + let terminal_manager = + ctx.add_model(|_ctx| Box::new(terminal_init.manager) as Box); + (terminal_view, terminal_manager) + } + fn create_conversation_viewer( conversation: AIConversation, ambient_agent_task_id: Option, @@ -6955,7 +7022,10 @@ impl PaneGroup { ctx: &mut ViewContext, ) -> bool { let Some(terminal_view) = self.terminal_view_from_pane_id(pane_id, ctx) else { - log::warn!("Tried to attach execution session to non-terminal pane {pane_id:?}"); + log::warn!( + "attach_execution_session: no terminal view for \ + pane_id={pane_id:?}" + ); return false; }; @@ -6974,7 +7044,10 @@ impl PaneGroup { .terminal_session_by_id(pane_id) .map(|session| session.terminal_manager(ctx)) else { - log::warn!("Tried to attach execution session to pane without terminal manager"); + log::warn!( + "attach_execution_session: no terminal manager for \ + pane_id={pane_id:?}" + ); return false; }; @@ -6983,7 +7056,10 @@ impl PaneGroup { .as_any_mut() .downcast_mut::() else { - log::warn!("Tried to attach execution session to non-viewer terminal manager"); + log::warn!( + "attach_execution_session: non-viewer \ + terminal manager for pane_id={pane_id:?}" + ); return; }; manager.attach_execution_session(session_id, ctx); diff --git a/app/src/pane_group/mod_tests.rs b/app/src/pane_group/mod_tests.rs index 34c0805e0f5..86158755258 100644 --- a/app/src/pane_group/mod_tests.rs +++ b/app/src/pane_group/mod_tests.rs @@ -22,9 +22,6 @@ use warpui::windowing::state::ApplicationStage; use warpui::{App, ModelHandle}; use watcher::HomeDirectoryWatcher; -use super::child_agent::hydration::{ - RemoteChildHydrationAction, decide_remote_child_hydration_action, -}; use super::child_agent::{ HiddenChildAgentConversationRequest, HiddenChildAgentTaskContext, create_hidden_child_agent_conversation, @@ -38,10 +35,9 @@ use crate::ai::agent::conversation::{ }; use crate::ai::agent_conversations_model::AgentConversationsModel; use crate::ai::ambient_agents::github_auth_notifier::GitHubAuthNotifier; -use crate::ai::ambient_agents::task::TaskPrincipalInfo; +use crate::ai::ambient_agents::task::{TaskPrincipalInfo, TaskScope}; use crate::ai::ambient_agents::{ - AgentSource, AmbientAgentLiveSessionState, AmbientAgentTask, AmbientAgentTaskId, - AmbientAgentTaskState, + AgentSource, AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState, }; use crate::ai::blocklist::agent_view::AgentViewEntryOrigin; use crate::ai::blocklist::history_model::CloudConversationData; @@ -61,6 +57,7 @@ use crate::ai::outline::RepoOutlines; use crate::ai::persisted_workspace::PersistedWorkspace; use crate::ai::restored_conversations::RestoredAgentConversations; use crate::ai::skills::SkillManager; +use crate::app_state::AmbientAgentPaneSnapshot; use crate::auth::auth_manager::AuthManager; use crate::auth::user::TEST_USER_UID; use crate::changelog_model::ChangelogModel; @@ -111,6 +108,203 @@ use crate::{ }; fn initialize_app(app: &mut App) { + initialize_app_with_history(app, Vec::new()); +} + +#[test] +fn running_durable_observer_snapshot_selects_shared_session_reattach() { + let _unified_stack = FeatureFlag::OrchestrationUnifiedStack.override_enabled(true); + App::test((), |mut app| async move { + let parent_task_id = new_ambient_agent_task_id(); + let parent_conversation_id = AIConversationId::new(); + initialize_app_with_history( + &mut app, + vec![persisted_durable_observer_parent( + parent_conversation_id, + parent_task_id, + )], + ); + AgentConversationsModel::handle(&app).update(&mut app, |model, _| { + model.insert_task_for_test(attachable_ambient_agent_task(parent_task_id)); + }); + + let pane_group = mock_pane_group( + &mut app, + MockOptions { + layout: PanesLayout::Snapshot(Box::new(PaneNodeSnapshot::Leaf(LeafSnapshot { + is_focused: true, + custom_vertical_tabs_title: None, + contents: LeafContents::AmbientAgent(AmbientAgentPaneSnapshot { + uuid: Uuid::new_v4().as_bytes().to_vec(), + task_id: Some(parent_task_id), + }), + }))), + ..Default::default() + }, + ); + + pane_group.read(&app, |panes, ctx| { + assert!( + panes + .pending_ambient_agent_conversation_restorations + .is_empty(), + "an attachable parent selects shared-session restore immediately", + ); + let view = panes + .active_session_view(ctx) + .expect("running Observer restore has a terminal view"); + let view = view.as_ref(ctx); + assert!(view.ambient_agent_view_model().is_some()); + assert!( + !view.has_agent_view_zero_state_for_test(), + "running Observer restore must not enter fresh compose", + ); + assert!(matches!( + view.model.lock().shared_session_status(), + SharedSessionStatus::ViewPending + )); + let parent = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&parent_conversation_id) + .expect("durable parent stays eagerly hydrated for JoinedSuccessfully"); + assert_eq!(parent.exchange_count(), 1); + assert_eq!(parent.last_event_sequence(), Some(41)); + }); + }); +} + +#[test] +fn terminal_durable_observer_snapshot_restores_existing_parent_and_children() { + let _unified_stack = FeatureFlag::OrchestrationUnifiedStack.override_enabled(true); + let _agent_view = FeatureFlag::AgentView.override_enabled(true); + let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true); + let _setup_v2 = FeatureFlag::CloudModeSetupV2.override_enabled(true); + let _handoff = FeatureFlag::HandoffCloudCloud.override_enabled(true); + + App::test((), |mut app| async move { + let parent_task_id = new_ambient_agent_task_id(); + let child_task_id = new_ambient_agent_task_id(); + let parent_conversation_id = AIConversationId::new(); + let child_conversation_id = AIConversationId::new(); + initialize_app_with_history( + &mut app, + vec![ + persisted_durable_observer_parent(parent_conversation_id, parent_task_id), + persisted_remote_child_conversation( + child_conversation_id, + Some(parent_conversation_id), + Some(parent_task_id.to_string()), + child_task_id, + ), + ], + ); + + let layout = PanesLayout::Snapshot(Box::new(PaneNodeSnapshot::Leaf(LeafSnapshot { + is_focused: true, + custom_vertical_tabs_title: None, + contents: LeafContents::AmbientAgent(AmbientAgentPaneSnapshot { + uuid: Uuid::new_v4().as_bytes().to_vec(), + task_id: Some(parent_task_id), + }), + }))); + let pane_group = mock_pane_group( + &mut app, + MockOptions { + layout, + ..Default::default() + }, + ); + + pane_group.read(&app, |panes, ctx| { + assert!( + panes + .pending_ambient_agent_conversation_restorations + .contains_key(&parent_task_id), + "the app-state pane must wait for task data", + ); + let parent = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&parent_conversation_id) + .expect("durable parent is eagerly hydrated before pane restore"); + assert!(parent.is_durable_observer_parent()); + assert_eq!(parent.last_event_sequence(), Some(41)); + assert_eq!(parent.exchange_count(), 1); + assert_eq!( + BlocklistAIHistoryModel::as_ref(ctx) + .child_conversation_ids_of(&parent_conversation_id), + &[child_conversation_id], + ); + }); + + let mut task = ambient_agent_task_for_current_user(parent_task_id); + task.conversation_id = Some("observer-parent-token".to_string()); + AgentConversationsModel::handle(&app).update(&mut app, |model, _| { + model.insert_task_for_test(task); + }); + let action = pane_group.read(&app, |_panes, ctx| { + AgentConversationsModel::resolve_open_action( + AgentConversationNavigationSubject::Entry(AgentConversationEntryId::AmbientRun( + parent_task_id, + )), + None, + ctx, + ) + }); + assert!(matches!( + action, + Some(WorkspaceAction::RestoreOrNavigateToConversation { + conversation_id, + .. + }) if conversation_id == parent_conversation_id + )); + + pane_group.update(&mut app, |panes, ctx| { + panes.process_pending_ambient_restorations(ctx); + }); + for _ in 0..3 { + futures_lite::future::yield_now().await; + } + pane_group.update(&mut app, |panes, ctx| { + panes.process_pending_ambient_restorations(ctx); + }); + + pane_group.read(&app, |panes, ctx| { + let view = panes + .active_session_view(ctx) + .expect("restored observer pane has an active terminal view"); + let view = view.as_ref(ctx); + assert_eq!( + view.active_conversation_id(ctx), + Some(parent_conversation_id), + "app-state restoration must install the existing parent conversation", + ); + assert!( + !view.has_agent_view_zero_state_for_test(), + "existing Observer parent must not expose New cloud agent compose", + ); + let ambient = view + .ambient_agent_view_model() + .expect("restored Observer parent uses the ambient presentation") + .as_ref(ctx); + assert_eq!(ambient.task_id(), Some(parent_task_id)); + assert_eq!( + panes + .child_agent_panes + .keys() + .filter(|id| **id == child_conversation_id) + .count(), + 1, + "the restored child hierarchy is materialized once", + ); + assert_eq!( + BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&parent_conversation_id) + .and_then(AIConversation::last_event_sequence), + Some(41), + ); + }); + }); +} + +fn initialize_app_with_history(app: &mut App, conversations: Vec) { initialize_settings_for_tests(app); app.add_singleton_model(|_ctx| ServerApiProvider::new_for_test()); @@ -164,7 +358,7 @@ fn initialize_app(app: &mut App) { app.add_singleton_model(|_| KeybindingChangedNotifier::new()); app.add_singleton_model(NotebookKeybindings::new); app.add_singleton_model(TerminalKeybindings::new); - app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + app.add_singleton_model(move |_| BlocklistAIHistoryModel::new(vec![], vec![], &conversations)); // QueuedQueryModel subscribes to history events; register after the // history model is in place. app.add_singleton_model(QueuedQueryModel::new); @@ -222,6 +416,61 @@ fn initialize_app(app: &mut App) { app.add_singleton_model(remote_server::manager::RemoteServerManager::new); } +fn persisted_durable_observer_parent( + conversation_id: AIConversationId, + task_id: AmbientAgentTaskId, +) -> AgentConversation { + let root_task_id = Uuid::new_v4().to_string(); + AgentConversation { + conversation: AgentConversationRecord { + id: 0, + conversation_id: conversation_id.to_string(), + conversation_data: serde_json::to_string(&AgentConversationData { + server_conversation_token: Some("observer-parent-token".to_string()), + conversation_usage_metadata: None, + reverted_action_ids: None, + forked_from_server_conversation_token: None, + artifacts_json: None, + parent_agent_id: None, + agent_name: None, + orchestration_harness_type: None, + parent_conversation_id: None, + is_remote_child: false, + is_durable_observer_parent: true, + root_task_is_optimistic: None, + run_id: Some(task_id.to_string()), + autoexecute_override: None, + last_event_sequence: Some(41), + pinned: false, + }) + .expect("conversation data should serialize"), + last_modified_at: Utc::now().naive_utc(), + summary: None, + }, + tasks: vec![warp_multi_agent_api::Task { + id: root_task_id.clone(), + messages: vec![warp_multi_agent_api::Message { + fetched_memories: vec![], + id: Uuid::new_v4().to_string(), + task_id: root_task_id, + server_message_data: String::new(), + citations: vec![], + message: Some(warp_multi_agent_api::message::Message::AgentOutput( + warp_multi_agent_api::message::AgentOutput { + text: "Restored observer output".to_string(), + }, + )), + request_id: "observer-request".to_string(), + timestamp: None, + }], + dependencies: None, + description: "Observer parent".to_string(), + summary: String::new(), + server_data: String::new(), + }], + } +} + struct MockOptions { layout: PanesLayout, window_bounds: WindowBounds, @@ -302,6 +551,7 @@ fn ambient_agent_task_for_current_user(task_id: AmbientAgentTaskId) -> AmbientAg session_id: None, session_link: None, executor: None, + scope: None, creator: Some(TaskPrincipalInfo { creator_type: "USER".to_string(), uid: TEST_USER_UID.to_string(), @@ -317,6 +567,17 @@ fn ambient_agent_task_for_current_user(task_id: AmbientAgentTaskId) -> AmbientAg } } +/// Builds an *attachable* ambient task (InProgress + running sandbox + +/// parseable session id) so the unified child-pane dispatch resolves to +/// `AttachLive`. +fn attachable_ambient_agent_task(task_id: AmbientAgentTaskId) -> AmbientAgentTask { + let mut task = ambient_agent_task_for_current_user(task_id); + task.state = AmbientAgentTaskState::InProgress; + task.is_sandbox_running = true; + task.session_id = Some("22222222-2222-2222-2222-222222222222".to_string()); + task +} + fn mock_server_metadata() -> ServerMetadata { ServerMetadata { uid: ServerId::default(), @@ -394,6 +655,7 @@ fn persisted_remote_child_conversation( orchestration_harness_type: None, parent_conversation_id: parent_conversation_id.map(|id| id.to_string()), is_remote_child: true, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: Some(task_id.to_string()), autoexecute_override: None, @@ -935,15 +1197,12 @@ fn test_restored_remote_hidden_child_pane_enters_existing_ambient_session() { let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); let task_id = new_ambient_agent_task_id(); - // Fix B: inject mock cloud task data so the task-backed hydration - // path resolves to the live ambient session via - // `resolve_open_action` -> `OpenOrAttachAmbientAgentConversation`. - // The default mock task has `is_sandbox_running = false` so it - // resolves to the fallback path; we leave it at the default to - // ensure the pre-Fix-B "attach to existing ambient session + - // tombstone" behavior is preserved. + // Inject an *attachable* task (InProgress + running sandbox + + // parseable session id) so the unified dispatch resolves to + // `AttachLive` and routes through `attach_child_session` (owner + // arm), joining the live ambient session in place. AgentConversationsModel::handle(ctx).update(ctx, |model, _| { - model.insert_task_for_test(ambient_agent_task_for_current_user(task_id)); + model.insert_task_for_test(attachable_ambient_agent_task(task_id)); }); let mut child_conversation = AIConversation::new(false, false); @@ -995,16 +1254,16 @@ fn test_restored_remote_hidden_child_pane_enters_existing_ambient_session() { }); } -/// Fix B: when task data for a restored remote child is NOT yet cached at -/// `create_hidden_child_agent_pane` time, the placeholder must still be -/// registered in `child_agent_panes` keyed by its local AIConversationId, -/// attached to the live ambient session (preserving today's behavior so -/// streaming runs continue to attach), AND deferred via -/// `pending_remote_child_hydrations` so the subscription handler can retry -/// when `TasksUpdated` / `ConversationsLoaded` fires. The pane group must -/// never produce a worse state than the pre-Fix-B fallback. +/// When task data for a restored remote child is NOT yet cached at +/// `create_hidden_child_agent_pane` time, the unified dispatch resolves to +/// `Pending`: the hidden pane is still created and registered in +/// `child_agent_panes` keyed by its local AIConversationId (so the pill can +/// reveal it), using a passive loading transcript vehicle with no live attach. +/// The tracker re-drives materialization on the next lifecycle / +/// session-linked event. #[test] -fn test_restored_remote_hidden_child_pane_fallback_when_task_data_unavailable() { +fn test_restored_remote_hidden_child_pane_pending_when_task_data_unavailable() { + let _unified_stack = FeatureFlag::OrchestrationUnifiedStack.override_enabled(true); App::test((), |mut app| async move { initialize_app(&mut app); let pane_group = mock_pane_group(&mut app, Default::default()); @@ -1015,12 +1274,10 @@ fn test_restored_remote_hidden_child_pane_fallback_when_task_data_unavailable() let task_id = new_ambient_agent_task_id(); // Deliberately do NOT inject a task into AgentConversationsModel. - // `get_or_async_fetch_task_data` will return `None`, which forces - // the hydration to: - // 1. enter the existing ambient session in place (live-attach - // preserved per Fix B contract); - // 2. register a pending hydration entry so the subscription - // handler can retry once task data lands. + // `get_or_async_fetch_task_data` returns `None`, so the unified + // dispatch resolves to `Pending`: the hidden passive loading pane + // is created and tracked so the pill can reveal it. + // A later TasksUpdated re-drives the retained pending hydration. let mut child_conversation = AIConversation::new(false, false); child_conversation.set_parent_conversation_id(parent_conversation_id); @@ -1042,30 +1299,265 @@ fn test_restored_remote_hidden_child_pane_fallback_when_task_data_unavailable() "placeholder AIConversationId must stay the child_agent_panes key in fallback path", ); - // Live-attach preserved: the ambient agent view model is - // configured to view the existing session for `task_id`. This - // matches the pre-Fix-B behavior so streaming runs continue to - // attach. - let (ambient_task_id, _is_agent_running, active_conversation_id) = - ambient_child_session_state(panes, child_pane_id, ctx); + // Pending uses the passive loading presentation: no ambient + // composer is exposed before task metadata can select live or + // transcript materialization. + let terminal_view = panes + .terminal_view_from_pane_id(child_pane_id, ctx) + .expect("pending child pane has a terminal view"); + let view = terminal_view.as_ref(ctx); + assert!(view.ambient_agent_view_model().is_none()); + assert!( + !view.has_agent_view_zero_state_for_test(), + "pending child must not expose the cloud composition zero state", + ); assert_eq!( - ambient_task_id, - Some(task_id), - "fallback path must still call enter_viewing_existing_session on the placeholder", + view.active_conversation_id(ctx), + Some(child_conversation_id) ); + let model = view.model.lock(); + assert!(model.is_conversation_transcript_viewer()); + assert!(model.is_read_only()); + assert_eq!( + model.conversation_transcript_viewer_status(), + Some(&ConversationTranscriptViewerStatus::Loading), + ); + }); + }); +} + +/// A terminal owner remote child (`Succeeded` run with a server +/// `conversation_id`, no live session) resolves to `LoadTranscript`: the +/// unified dispatch still materializes the hidden ambient pane keyed by the +/// placeholder's local id, into which the cloud transcript merges +/// asynchronously. +#[test] +fn test_restored_remote_hidden_child_pane_terminal_owner_loads_transcript() { + App::test((), |mut app| async move { + initialize_app(&mut app); + let pane_group = mock_pane_group(&mut app, Default::default()); + + pane_group.update(&mut app, |panes, ctx| { + let parent_pane_id = get_newly_created_pane_id(panes, &[]); + let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); + let task_id = new_ambient_agent_task_id(); + + // Terminal task + server conversation id -> LoadTranscript. + let mut task = ambient_agent_task_for_current_user(task_id); + task.state = AmbientAgentTaskState::Succeeded; + task.is_sandbox_running = false; + task.conversation_id = Some("owner-child-server-token".to_string()); + AgentConversationsModel::handle(ctx).update(ctx, |model, _| { + model.insert_task_for_test(task); + }); + + let mut child_conversation = AIConversation::new(false, false); + child_conversation.set_parent_conversation_id(parent_conversation_id); + child_conversation.set_task_id(task_id); + child_conversation.mark_as_remote_child(); + let child_conversation_id = child_conversation.id(); + + panes.create_hidden_child_agent_pane(child_conversation, parent_pane_id, ctx); + + let child_pane_id = panes + .child_agent_panes + .get(&child_conversation_id) + .copied() + .expect("terminal owner remote child must materialize an ambient transcript pane"); + // The transcript branch builds a cloud-mode ambient pane (so the + // pill can reveal it) keyed by the placeholder's local id. + let (_task_id, _running, active_conversation_id) = + ambient_child_session_state(panes, child_pane_id, ctx); assert_eq!(active_conversation_id, Some(child_conversation_id)); + }); + }); +} - // Fix B: pending hydration is recorded so the subscription handler - // can re-run hydration when task data lands. The map value is the - // placeholder's local AIConversationId, which must match the - // conversation we just restored. - let pending_placeholder_id = - panes.pending_remote_child_hydrations.get(&task_id).copied().expect( - "task-data-unavailable hydration must register a pending entry keyed by task id", - ); +/// A terminal *viewer* child (`is_viewing_shared_session`, `Succeeded` run +/// with a server `conversation_id`, no live session) resolves to +/// `LoadTranscript` in a passive transcript pane. It must not expose the +/// ambient cloud-composition model or its new-conversation zero state while +/// the transcript fetch is in flight. +#[test] +fn test_restored_viewer_hidden_child_pane_terminal_loads_transcript() { + let _unified_stack = FeatureFlag::OrchestrationUnifiedStack.override_enabled(true); + App::test((), |mut app| async move { + initialize_app(&mut app); + let pane_group = mock_pane_group(&mut app, Default::default()); + + pane_group.update(&mut app, |panes, ctx| { + let parent_pane_id = get_newly_created_pane_id(panes, &[]); + let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); + let task_id = new_ambient_agent_task_id(); + + let mut task = ambient_agent_task_for_current_user(task_id); + task.state = AmbientAgentTaskState::Succeeded; + task.is_sandbox_running = false; + task.conversation_id = Some("viewer-child-server-token".to_string()); + AgentConversationsModel::handle(ctx).update(ctx, |model, _| { + model.insert_task_for_test(task); + }); + + let mut child_conversation = AIConversation::new(false, false); + child_conversation.set_parent_conversation_id(parent_conversation_id); + child_conversation.set_task_id(task_id); + child_conversation.set_is_viewing_shared_session(true); + let child_conversation_id = child_conversation.id(); + + panes.create_hidden_child_agent_pane(child_conversation, parent_pane_id, ctx); + + let child_pane_id = panes + .child_agent_panes + .get(&child_conversation_id) + .copied() + .expect("terminal viewer child must materialize a transcript pane"); + let terminal_view = panes + .terminal_view_from_pane_id(child_pane_id, ctx) + .expect("terminal viewer child pane has a terminal view"); + let view = terminal_view.as_ref(ctx); + assert_eq!( + view.active_conversation_id(ctx), + Some(child_conversation_id), + ); + assert!( + view.ambient_agent_view_model().is_none(), + "passive viewer transcripts must not retain a configuring cloud-agent model", + ); + assert!( + !view.has_agent_view_zero_state_for_test(), + "viewer child placeholders must not insert new-cloud composition zero state", + ); + let model = view.model.lock(); + assert!(model.is_conversation_transcript_viewer()); + assert!(model.is_read_only()); + assert_eq!( + model.conversation_transcript_viewer_status(), + Some(&ConversationTranscriptViewerStatus::Loading), + ); + }); + }); +} + +#[test] +fn completed_shared_session_child_with_edit_access_uses_continuation_pane() { + let _unified_stack = FeatureFlag::OrchestrationUnifiedStack.override_enabled(true); + let _handoff = FeatureFlag::HandoffCloudCloud.override_enabled(true); + let _cloud_mode = FeatureFlag::CloudMode.override_enabled(true); + let _setup_v2 = FeatureFlag::CloudModeSetupV2.override_enabled(true); + App::test((), |mut app| async move { + initialize_app(&mut app); + let pane_group = mock_pane_group(&mut app, Default::default()); + + pane_group.update(&mut app, |panes, ctx| { + let task_id = new_ambient_agent_task_id(); + let mut task = ambient_agent_task_for_current_user(task_id); + task.creator = Some(TaskPrincipalInfo { + creator_type: "USER".to_string(), + uid: "other-user".to_string(), + display_name: None, + }); + task.scope = Some(TaskScope::User { + uid: "other-user".to_string(), + }); + task.conversation_id = Some("test-server-token".to_string()); + AgentConversationsModel::handle(ctx).update(ctx, |model, _| { + model.insert_task_for_test(task); + }); + + let mut child = AIConversation::new(true, false); + child.set_task_id(task_id); + let child_id = child.id(); + let mut merged = child.clone(); + merged.set_server_metadata(test_server_conversation_metadata(Some(task_id))); + + let loading_pane_id = panes + .create_child_loading_placeholder( + child, + AgentViewEntryOrigin::SharedSessionSelection, + ctx, + ) + .expect("viewer child loading pane"); + panes.replace_child_loading_with_continuation_pane( + loading_pane_id, + child_id, + task_id, + merged, + ctx, + ); + + let pane_id = panes.child_agent_panes[&child_id]; + assert_ne!(pane_id, loading_pane_id); + let view = panes + .terminal_view_from_pane_id(pane_id, ctx) + .expect("continuation pane"); + assert!(view.as_ref(ctx).ambient_agent_view_model().is_some()); + let model = view.as_ref(ctx).model.lock(); + assert!(!model.is_conversation_transcript_viewer()); + assert!(!model.is_read_only()); + assert!(matches!( + model.shared_session_status(), + SharedSessionStatus::NotShared + )); + }); + }); +} + +#[test] +fn failed_viewer_child_session_stays_unavailable_without_retrying_same_session() { + let _unified_stack = FeatureFlag::OrchestrationUnifiedStack.override_enabled(true); + App::test((), |mut app| async move { + initialize_app(&mut app); + let pane_group = mock_pane_group(&mut app, Default::default()); + let task_id = new_ambient_agent_task_id(); + let failed_session_id = SessionId::new(); + + pane_group.update(&mut app, |panes, ctx| { + let parent_pane_id = get_newly_created_pane_id(panes, &[]); + let parent_conversation_id = start_parent_conversation(panes, parent_pane_id, ctx); + + let mut pending_task = ambient_agent_task_for_current_user(task_id); + pending_task.state = AmbientAgentTaskState::Pending; + pending_task.is_sandbox_running = false; + pending_task.session_id = None; + AgentConversationsModel::handle(ctx).update(ctx, |model, _| { + model.insert_task_for_test(pending_task); + }); + + let mut child_conversation = AIConversation::new(false, false); + child_conversation.set_parent_conversation_id(parent_conversation_id); + child_conversation.set_task_id(task_id); + child_conversation.set_is_viewing_shared_session(true); + let child_id = child_conversation.id(); + panes.create_hidden_child_agent_pane(child_conversation, parent_pane_id, ctx); + let pane_id = panes.child_agent_panes[&child_id]; + + panes.recover_viewer_child_join_failure(pane_id, child_id, failed_session_id, ctx); + + let mut running_task = ambient_agent_task_for_current_user(task_id); + running_task.state = AmbientAgentTaskState::InProgress; + running_task.is_sandbox_running = true; + running_task.session_id = Some(failed_session_id.to_string()); + AgentConversationsModel::handle(ctx).update(ctx, |model, _| { + model.insert_task_for_test(running_task); + }); + panes.process_pending_viewer_child_hydrations(ctx); + + assert_eq!(panes.child_agent_panes[&child_id], pane_id); + assert_eq!( + panes.failed_viewer_child_sessions.get(&child_id), + Some(&failed_session_id), + ); assert_eq!( - pending_placeholder_id, child_conversation_id, - "pending hydration must record the placeholder's local AIConversationId", + panes.pending_viewer_child_hydrations.get(&task_id), + Some(&child_id), + ); + let view = panes + .terminal_view_from_pane_id(pane_id, ctx) + .expect("pending child pane remains available"); + assert!( + view.as_ref(ctx) + .is_orchestration_child_live_unavailable_for_test(), + "failed child join should leave bounded non-error unavailable UI", ); }); }); @@ -1282,6 +1774,11 @@ fn test_create_missing_child_agent_panes_restores_remote_child_from_history_mode ), ]); }); + // Attachable task so restoration live-attaches (was the old + // task-data-unavailable fallback; now an explicit AttachLive). + AgentConversationsModel::handle(ctx).update(ctx, |model, _| { + model.insert_task_for_test(attachable_ambient_agent_task(task_id)); + }); panes.restore_missing_child_agent_panes_for_parent( parent_conversation_id, @@ -1631,6 +2128,11 @@ fn test_entering_remote_parent_agent_view_lazily_restores_remote_hidden_child_pa .contains_key(&remote_child_conversation_id) ); + // Attachable task so the lazily-restored remote child live-attaches. + AgentConversationsModel::handle(ctx).update(ctx, |model, _| { + model.insert_task_for_test(attachable_ambient_agent_task(remote_child_task_id)); + }); + enter_agent_view_for_conversation( panes, parent_pane_id, @@ -1918,6 +2420,10 @@ fn test_ensure_hidden_child_agent_pane_materializes_restored_remote_child_linked ), ]); }); + // Attachable task so the on-demand restore live-attaches. + AgentConversationsModel::handle(ctx).update(ctx, |model, _| { + model.insert_task_for_test(attachable_ambient_agent_task(task_id)); + }); assert!(!panes.child_agent_panes.contains_key(&child_conversation_id)); assert!( @@ -3164,177 +3670,3 @@ fn test_focused_pane_is_synchronized_with_application_focus() { }); }); } - -/// Builds an [`AmbientAgentTask`] tailored for unit-testing -/// [`decide_remote_child_hydration_action`]. -/// -/// `state`, `is_sandbox_running`, and `session_id` combine to determine the -/// task's [`AmbientAgentLiveSessionState`]: -/// - `state == InProgress`, `is_sandbox_running == true`, and a parseable -/// UUID-shaped `session_id` resolve to -/// [`AmbientAgentLiveSessionState::Attachable`]. -/// - `state == InProgress`, `is_sandbox_running == true`, and an -/// unparseable `session_id` resolve to -/// [`AmbientAgentLiveSessionState::ActiveUnattachable`]. -/// - Any other shape resolves to [`AmbientAgentLiveSessionState::Inactive`]. -/// -/// `conversation_id` populates the server conversation token used by the -/// `LoadTranscript` branch; pass `None` to exercise `Fallback`. -/// -/// Note: `session_link` is unconditionally set to `None` in this helper. -/// `AmbientAgentTask::active_live_session_state` falls back to parsing the -/// session id out of `session_link` when `session_id` is absent, so a test -/// that exercises that branch would need a different helper. -fn hydration_decision_task( - state: AmbientAgentTaskState, - is_sandbox_running: bool, - session_id: Option<&str>, - conversation_id: Option<&str>, -) -> AmbientAgentTask { - let mut task = ambient_agent_task_for_current_user(new_ambient_agent_task_id()); - task.state = state; - task.is_sandbox_running = is_sandbox_running; - task.session_id = session_id.map(str::to_string); - task.session_link = None; - task.conversation_id = conversation_id.map(str::to_string); - task -} - -#[test] -fn decide_remote_child_hydration_attachable_live_session_chooses_live_attach() { - // InProgress + sandbox running + parseable session id -> Attachable. - let task = hydration_decision_task( - AmbientAgentTaskState::InProgress, - true, - Some("11111111-1111-1111-1111-111111111111"), - Some("server-token-irrelevant-for-attach"), - ); - assert_eq!( - task.active_live_session_state(), - AmbientAgentLiveSessionState::Attachable { - session_id: "11111111-1111-1111-1111-111111111111".parse().unwrap(), - }, - ); - - assert_eq!( - decide_remote_child_hydration_action(&task), - RemoteChildHydrationAction::LiveAttach, - ); -} - -#[test] -fn decide_remote_child_hydration_inactive_with_token_loads_transcript() { - // Terminal state -> Inactive, server token present -> LoadTranscript. - let task = hydration_decision_task( - AmbientAgentTaskState::Succeeded, - false, - None, - Some("my-server-token"), - ); - assert_eq!( - task.active_live_session_state(), - AmbientAgentLiveSessionState::Inactive, - ); - - assert_eq!( - decide_remote_child_hydration_action(&task), - RemoteChildHydrationAction::LoadTranscript { - server_token: ServerConversationToken::new("my-server-token".to_string()), - task_is_terminal: true, - }, - ); -} - -#[test] -fn decide_remote_child_hydration_active_unattachable_with_token_loads_transcript() { - // InProgress + sandbox running + unparseable session id -> - // ActiveUnattachable. With a server token we still prefer LoadTranscript - // over Fallback so the user sees the merged transcript instead of a bare - // tombstone. - let task = hydration_decision_task( - AmbientAgentTaskState::InProgress, - true, - Some("not-a-valid-uuid"), - Some("unattachable-server-token"), - ); - assert_eq!( - task.active_live_session_state(), - AmbientAgentLiveSessionState::ActiveUnattachable, - ); - - assert_eq!( - decide_remote_child_hydration_action(&task), - RemoteChildHydrationAction::LoadTranscript { - server_token: ServerConversationToken::new("unattachable-server-token".to_string()), - task_is_terminal: false, - }, - ); -} - -#[test] -fn decide_remote_child_hydration_inactive_without_token_falls_back() { - // Terminal state, no server token -> nothing to attach to and nothing to - // load. Terminal => tombstone is appropriate. - let task = hydration_decision_task(AmbientAgentTaskState::Succeeded, false, None, None); - assert_eq!( - task.active_live_session_state(), - AmbientAgentLiveSessionState::Inactive, - ); - - assert_eq!( - decide_remote_child_hydration_action(&task), - RemoteChildHydrationAction::Fallback { - task_is_terminal: true, - }, - ); -} - -/// `ActiveUnattachable` + no server token: the run is still in progress but -/// the client can't attach and has nothing to load. Fallback must carry -/// `task_is_terminal: false` so the dispatch arm skips the -/// conversation-ended tombstone. -#[test] -fn decide_remote_child_hydration_active_unattachable_without_token_falls_back_non_terminal() { - let task = hydration_decision_task( - AmbientAgentTaskState::InProgress, - true, - Some("not-a-valid-uuid"), - None, - ); - assert_eq!( - task.active_live_session_state(), - AmbientAgentLiveSessionState::ActiveUnattachable, - ); - - assert_eq!( - decide_remote_child_hydration_action(&task), - RemoteChildHydrationAction::Fallback { - task_is_terminal: false, - }, - ); -} - -/// An `AmbientAgentTask` whose `conversation_id` is `Some("")` (or -/// whitespace-only) is treated the same as `None`: the dispatch must not -/// route to a no-op cloud fetch wrapped in a misleading tombstone. The -/// `Fallback` arm handles "nothing to attach to, nothing to load" -/// correctly. Terminal here => tombstone is appropriate. -#[test] -fn decide_remote_child_hydration_empty_token_falls_back() { - for empty_token in [Some(""), Some(" "), Some("\t\n")] { - let task = - hydration_decision_task(AmbientAgentTaskState::Succeeded, false, None, empty_token); - assert_eq!( - task.active_live_session_state(), - AmbientAgentLiveSessionState::Inactive, - "empty/whitespace token={empty_token:?} should still resolve to Inactive", - ); - assert_eq!( - decide_remote_child_hydration_action(&task), - RemoteChildHydrationAction::Fallback { - task_is_terminal: true, - }, - "empty/whitespace token={empty_token:?} must fall through to Fallback", - ); - } -} diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 8c9e844ba9d..5b7117747b0 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -40,9 +40,11 @@ use crate::ai::llms::LLMPreferences; use crate::ai::orchestration::{RemoteChildLaunchConfig, prepare_remote_child_launch}; use crate::app_state::{AmbientAgentPaneSnapshot, LeafContents, TerminalPaneSnapshot}; use crate::code::buffer_location::LocalOrRemotePath; +use crate::features::FeatureFlag; #[cfg(feature = "local_fs")] use crate::pane_group::CodeSource; use crate::pane_group::Event::OpenConversationHistory; +use crate::pane_group::child_agent::materialization::ChildPaneOrigin; use crate::pane_group::child_agent::{ ErrorChildAgentConversationRequest, create_error_child_agent_conversation, }; @@ -1336,6 +1338,31 @@ fn handle_terminal_view_event( ); } } + Event::EnsureUnifiedViewerChildPane { + conversation_id, + task, + } => { + if FeatureFlag::OrchestrationUnifiedStack.is_enabled() { + group.materialize_viewer_child_pane_from_task( + *conversation_id, + task.as_ref().clone(), + ctx, + ); + } + } + Event::OrchestrationChildSharedSessionJoinFailed { + conversation_id, + session_id, + } => { + if FeatureFlag::OrchestrationUnifiedStack.is_enabled() { + group.recover_viewer_child_join_failure( + pane_id, + *conversation_id, + *session_id, + ctx, + ); + } + } Event::HideAIDocumentPanes => { group.close_all_ai_document_panes(ctx); } @@ -1425,7 +1452,22 @@ fn handle_terminal_view_event( // shared-session viewer pane for the child so subsequent pill // clicks land on a populated agent view rather than an empty // cloud-mode shell. - group.ensure_shared_session_viewer_child_pane(*conversation_id, *session_id, ctx); + if FeatureFlag::OrchestrationUnifiedStack.is_enabled() { + // flag-ON (M2): converged attach + group.attach_child_session( + *conversation_id, + *session_id, + ChildPaneOrigin::SharedSession, + ctx, + ); + } else { + // flag-OFF: original dedicated pane creation + group.ensure_shared_session_viewer_child_pane( + *conversation_id, + *session_id, + ctx, + ); + } } Event::OpenChildAgentInNewTab { conversation_id } => { // Pane group can't add tabs; forward to the workspace. @@ -1639,8 +1681,8 @@ fn launch_local_no_harness_child( // so the share-reporter in // `local_tty/terminal_manager.rs` can resolve it from // the selected conversation when the share handshake - // succeeds. Mirrors the pattern used by - // `OrchestrationViewerModel::apply_children_fetch`. + // succeeds. Mirrors how `OrchestrationViewerModel` + // stamps run/task ids onto viewer child placeholders. BlocklistAIHistoryModel::handle(ctx).update(ctx, |model, ctx| { if let Some(conversation) = model.conversation_mut(&conversation_id) { conversation.set_task_id(child_task_id); diff --git a/app/src/server/server_api/ai.rs b/app/src/server/server_api/ai.rs index e4853cd18fe..76c69bd2de0 100644 --- a/app/src/server/server_api/ai.rs +++ b/app/src/server/server_api/ai.rs @@ -361,7 +361,7 @@ pub struct AgentMessageHeader { pub read_at: Option, } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct AgentRunEvent { pub event_type: String, pub run_id: String, diff --git a/app/src/terminal/shared_session/viewer/event_loop.rs b/app/src/terminal/shared_session/viewer/event_loop.rs index 06456f29299..7d2777ced5e 100644 --- a/app/src/terminal/shared_session/viewer/event_loop.rs +++ b/app/src/terminal/shared_session/viewer/event_loop.rs @@ -123,6 +123,28 @@ impl EventLoop { }); } + let should_suppress_existing_agent_conversation_replay = matches!( + load_mode, + SharedSessionInitialLoadMode::AppendFollowupScrollback + ); + // Append mode means the local conversation already contains the prior + // transcript. Arm both halves of the replay gate before this event + // loop can dispatch its first ordered response event: some sessions + // deliver replayed Init/CreateTask events before (or without) the + // explicit ReplayStarted marker. The request-aware controller gate + // still allows new live request IDs through. + if should_suppress_existing_agent_conversation_replay { + terminal_model + .lock() + .set_is_receiving_agent_conversation_replay(true); + if let Some(view) = terminal_view.upgrade(ctx) { + view.update(ctx, |view, ctx| { + view.ai_controller().update(ctx, |controller, _ctx| { + controller.set_should_suppress_existing_agent_conversation_replay(true); + }); + }); + } + } let mut event_loop = Self { terminal_model, terminal_view, @@ -134,10 +156,7 @@ impl EventLoop { next_event_no: 0, buffer: HashMap::new(), catching_up_to_event_no, - should_suppress_existing_agent_conversation_replay: matches!( - load_mode, - SharedSessionInitialLoadMode::AppendFollowupScrollback - ), + should_suppress_existing_agent_conversation_replay, }; // Respect the sharer's window size. diff --git a/app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs b/app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs index 32470dc37b4..a29805a18a3 100644 --- a/app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs +++ b/app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs @@ -6,10 +6,11 @@ //! by a one-shot REST snapshot) and broadcasts `ChildSpawned` / //! `ChildStatusChanged` events. //! -//! Each viewer pane has its own model with its own placeholder -//! conversations; the streamer is a shared singleton. +//! Each viewer pane has its own materialization model; durable child identity +//! is shared through `BlocklistAIHistoryModel`, and the streamer is a shared +//! singleton. //! Pill clicks navigate via `SwapPaneToConversation`. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::Duration; use session_sharing_protocol::common::SessionId; @@ -17,12 +18,17 @@ use warpui::r#async::{SpawnedFutureHandle, Timer}; use warpui::{Entity, EntityId, ModelContext, SingletonEntity, WeakViewHandle}; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; -use crate::ai::ambient_agents::{AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState}; +use crate::ai::agent_conversations_model::{AgentConversationsModel, AgentConversationsModelEvent}; +use crate::ai::ambient_agents::{ + AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState, TaskOwnership, +}; use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::ai::blocklist::history_model::BlocklistAIHistoryEvent; use crate::ai::blocklist::orchestration_event_streamer::{ OrchestrationEventStreamer, OrchestrationEventStreamerEvent, }; +use crate::features::FeatureFlag; +use crate::pane_group::{ChildPaneMaterialization, decide_child_pane_materialization}; use crate::server::server_api::ServerApiProvider; use crate::terminal::{Event as TerminalViewEvent, TerminalView}; @@ -52,6 +58,14 @@ pub struct OrchestrationViewerModel { /// Secondary index keyed by stringified `run_id`, used by the streamer /// broadcast event handler. Kept in sync with `children`. children_by_run_id: HashMap, + /// Task metadata requests in flight (flag-OFF only). Discovery and + /// lifecycle can race; only one request may create/adopt the durable + /// run-id mapping. + metadata_fetches: HashSet, + /// Task IDs discovered via `ChildSpawned` that are waiting for task data + /// to arrive in the `AgentConversationsModel` cache (flag-ON only). + /// Drained on `TasksUpdated` events. + pending_task_ids_for_discovery: HashSet, /// Periodic timer fetching the claim-time `session_id` for /// not-yet-claimed children. pending_session_id_poll_handle: Option, @@ -89,6 +103,22 @@ impl OrchestrationViewerModel { me.handle_history_event(event, ctx); }, ); + ctx.subscribe_to_model( + &AgentConversationsModel::handle(ctx), + |me, _, event, ctx| match event { + AgentConversationsModelEvent::ConversationsLoaded + | AgentConversationsModelEvent::NewTasksReceived + | AgentConversationsModelEvent::TasksUpdated => { + me.register_viewer_mode_consumer_if_possible(ctx); + // Flag-ON: drain children waiting for task data. + if FeatureFlag::OrchestrationUnifiedStack.is_enabled() { + me.drain_pending_task_discoveries(ctx); + } + } + AgentConversationsModelEvent::ConversationUpdated { .. } + | AgentConversationsModelEvent::ConversationArtifactsUpdated { .. } => {} + }, + ); let model = Self { parent_task_id, @@ -96,6 +126,8 @@ impl OrchestrationViewerModel { terminal_view, children: HashMap::new(), children_by_run_id: HashMap::new(), + metadata_fetches: HashSet::new(), + pending_task_ids_for_discovery: HashSet::new(), pending_session_id_poll_handle: None, #[cfg(test)] metadata_fetch_dispatch_count: 0, @@ -168,6 +200,25 @@ impl OrchestrationViewerModel { return; } + if FeatureFlag::OrchestrationUnifiedStack.is_enabled() + && AgentConversationsModel::as_ref(ctx) + .get_task_data(&self.parent_task_id) + .is_some_and(|task| { + matches!( + task.ownership_for_current_principal(ctx), + TaskOwnership::Owned + ) + }) + { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.mark_conversation_as_durable_observer_parent( + parent_conversation_id, + self.parent_task_id, + ctx, + ); + }); + } + let parent_task_id = self.parent_task_id; let consumer_id = ctx.model_id(); OrchestrationEventStreamer::handle(ctx).update(ctx, move |streamer, ctx| { @@ -222,10 +273,9 @@ impl OrchestrationViewerModel { self.spawn_task_metadata_fetch(task_id, "ChildSpawned", ctx); } - /// Writes the new status through `BlocklistAIHistoryModel`. If the - /// entry hasn't been fully materialized yet (no `session_id` or no - /// pane), also kicks a metadata refetch so the claim-time - /// `session_id` eventually lands. + /// Writes the new status through `BlocklistAIHistoryModel`. If the entry + /// has not reached a live, transcript, or legacy-session materialization + /// yet, also refreshes its task metadata. fn handle_child_status_changed( &mut self, run_id: &str, @@ -233,15 +283,17 @@ impl OrchestrationViewerModel { ctx: &mut ModelContext, ) { let Some(task_id) = self.children_by_run_id.get(run_id).copied() else { - // No placeholder yet; the ChildSpawned handler will create one. + // Lifecycle may arrive before (or instead of) ChildStarted. + if FeatureFlag::OrchestrationUnifiedStack.is_enabled() { + self.handle_child_spawned(run_id.to_string(), ctx); + } return; }; let Some(entry) = self.children.get(&task_id) else { return; }; let conversation_id = entry.conversation_id; - let needs_metadata_refetch = - entry.session_id.is_none() || !entry.pane_materialization_requested; + let needs_metadata_refetch = Self::entry_needs_materialization_metadata(entry); let terminal_view_id = self.terminal_view_id; BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { history.update_conversation_status(terminal_view_id, conversation_id, status, ctx); @@ -261,6 +313,27 @@ impl OrchestrationViewerModel { trigger: &'static str, ctx: &mut ModelContext, ) { + if FeatureFlag::OrchestrationUnifiedStack.is_enabled() { + // Use the shared ACM fetch authority instead of a direct ai_client + // call. A cache hit registers the child immediately; a miss adds to + // pending_task_ids_for_discovery and resolves on the next + // TasksUpdated event. + let cached = AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| { + model.get_or_async_fetch_task_data(&task_id, ctx) + }); + if let Some(task) = cached { + self.register_child(task, ctx); + } else { + self.pending_task_ids_for_discovery.insert(task_id); + } + #[cfg(test)] + { + self.metadata_fetch_dispatch_count += 1; + } + return; + } + // Flag-OFF: direct fetch path. + self.metadata_fetches.insert(task_id); #[cfg(test)] { self.metadata_fetch_dispatch_count += 1; @@ -270,6 +343,7 @@ impl OrchestrationViewerModel { ctx.spawn( async move { ai_client.get_ambient_agent_task(&task_id).await }, move |me, result, ctx| { + me.metadata_fetches.remove(&task_id); let task = match result { Ok(task) => task, Err(err) => { @@ -286,13 +360,30 @@ impl OrchestrationViewerModel { ); } + /// Drains children in `pending_task_ids_for_discovery` whose task data + /// has arrived in the `AgentConversationsModel` cache and registers them. + fn drain_pending_task_discoveries(&mut self, ctx: &mut ModelContext) { + let ready: Vec<_> = self + .pending_task_ids_for_discovery + .iter() + .filter_map(|&task_id| { + AgentConversationsModel::as_ref(ctx) + .get_task_data(&task_id) + .map(|task| (task_id, task)) + }) + .collect(); + for (task_id, task) in ready { + self.pending_task_ids_for_discovery.remove(&task_id); + self.register_child(task, ctx); + } + } + // ---- Shared child registration (used by both paths) ----------------- - /// Creates the local placeholder conversation for a child task, - /// records it in the per-pane map, and emits - /// `EnsureSharedSessionViewerChildPane` if a session id is already - /// known. Idempotent: a second call for the same `task_id` updates - /// status / session-id only. + /// Creates the local placeholder conversation for a child task, records + /// it in the per-pane map, and requests materialization when current task + /// state is attachable or transcript-loadable. Idempotent: a second call + /// for the same `task_id` updates status and materialization state only. fn register_child(&mut self, task: AmbientAgentTask, ctx: &mut ModelContext) { // The server-side ancestor endpoint includes the parent itself in // the response; skip it. @@ -305,6 +396,7 @@ impl OrchestrationViewerModel { .session_id .as_deref() .and_then(|s| s.parse::().ok()); + let materialization_ready = Self::materialization_is_ready(&task); let new_state = task.state.clone(); let conversation_status = conversation_status_from_state(&new_state); @@ -326,18 +418,13 @@ impl OrchestrationViewerModel { }); entry.last_state = new_state; } - let was_missing_session_id = entry.session_id.is_none(); - if entry.session_id.is_none() { - entry.session_id = session_id; - } - if was_missing_session_id - && entry.session_id.is_some() - && !entry.pane_materialization_requested - { + entry.session_id = session_id; + let should_request_materialization = + materialization_ready && !entry.pane_materialization_requested; + if should_request_materialization { let conversation_id = entry.conversation_id; - let sid = entry.session_id.expect("session_id checked just above"); entry.pane_materialization_requested = true; - self.request_child_pane_materialization(conversation_id, sid, ctx); + self.request_child_pane_materialization(conversation_id, task, ctx); } // Re-arm the session_id timer; no-op once all children are materialized. self.maybe_schedule_pending_session_id_poll(ctx); @@ -370,31 +457,42 @@ impl OrchestrationViewerModel { let terminal_view_id = self.terminal_view_id; let status_for_initial = conversation_status.clone(); + let unified_stack = FeatureFlag::OrchestrationUnifiedStack.is_enabled(); let conversation_id = BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { - let conversation_id = history.start_new_child_conversation( - terminal_view_id, - name, - parent_conversation_id, - harness, - ctx, - ); - // Suppress server-side status reporting (viewer-side); also - // disambiguates viewer-spawned children downstream. - history.set_viewing_shared_session_for_conversation(conversation_id, true); - if let Some(conversation) = history.conversation_mut(&conversation_id) - && !fallback_title.is_empty() - { - conversation.set_fallback_display_title(fallback_title); - } - // Stamp run_id/task_id and populate the agent_id index so - // transcript references resolve to this child. - history.assign_run_id_for_conversation( - conversation_id, - task_id.to_string(), - Some(task_id), - terminal_view_id, - ctx, - ); + let conversation_id = if unified_stack { + history.ensure_remote_child_conversation( + terminal_view_id, + parent_conversation_id, + task_id.to_string(), + task_id, + name, + fallback_title, + harness, + ctx, + ) + } else { + let conversation_id = history.start_new_child_conversation( + terminal_view_id, + name, + parent_conversation_id, + harness, + ctx, + ); + history.set_viewing_shared_session_for_conversation(conversation_id, true); + if !fallback_title.is_empty() + && let Some(conversation) = history.conversation_mut(&conversation_id) + { + conversation.set_fallback_display_title(fallback_title); + } + history.assign_run_id_for_conversation( + conversation_id, + task_id.to_string(), + Some(task_id), + terminal_view_id, + ctx, + ); + conversation_id + }; history.update_conversation_status( terminal_view_id, conversation_id, @@ -404,7 +502,7 @@ impl OrchestrationViewerModel { conversation_id }); - let pane_materialization_requested = session_id.is_some(); + let pane_materialization_requested = materialization_ready; self.children.insert( task_id, ChildAgentEntry { @@ -422,8 +520,8 @@ impl OrchestrationViewerModel { self.parent_task_id, ); - if let Some(sid) = session_id { - self.request_child_pane_materialization(conversation_id, sid, ctx); + if pane_materialization_requested { + self.request_child_pane_materialization(conversation_id, task, ctx); } // Arm the session_id refetch timer if the child arrived pre-claim. @@ -436,7 +534,7 @@ impl OrchestrationViewerModel { fn has_pending_session_id_children(&self) -> bool { self.children .values() - .any(|entry| entry.session_id.is_none() || !entry.pane_materialization_requested) + .any(Self::entry_needs_materialization_metadata) } /// Schedules the next session_id refetch tick. @@ -460,16 +558,13 @@ impl OrchestrationViewerModel { self.pending_session_id_poll_handle = Some(handle); } - /// Body of the session_id timer tick. Refetches metadata for every - /// child still missing a `session_id`/pane, then reschedules until - /// the pending set is empty. + /// Body of the metadata timer tick. Refetches every child that has not + /// reached a materializable state, then reschedules until none remain. fn run_pending_session_id_poll(&mut self, ctx: &mut ModelContext) { let pending: Vec = self .children .iter() - .filter(|(_, entry)| { - entry.session_id.is_none() || !entry.pane_materialization_requested - }) + .filter(|(_, entry)| Self::entry_needs_materialization_metadata(entry)) .map(|(task_id, _)| *task_id) .collect(); @@ -537,12 +632,33 @@ impl OrchestrationViewerModel { BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.terminal_view_id) } - /// Tells the parent's `TerminalView` to materialize a hidden - /// shared-session viewer pane for this child. + fn materialization_is_ready(task: &AmbientAgentTask) -> bool { + if FeatureFlag::OrchestrationUnifiedStack.is_enabled() { + return !matches!( + decide_child_pane_materialization(task), + ChildPaneMaterialization::Pending + ); + } + + task.session_id + .as_deref() + .and_then(|session_id| session_id.parse::().ok()) + .is_some() + } + + fn entry_needs_materialization_metadata(entry: &ChildAgentEntry) -> bool { + !entry.pane_materialization_requested + || (!FeatureFlag::OrchestrationUnifiedStack.is_enabled() && entry.session_id.is_none()) + } + + /// Tells the parent's `TerminalView` to materialize a hidden viewer pane + /// for this child. Unified-stack routing carries the task snapshot so the + /// pane group can distinguish live, transcript, and pending state; the + /// legacy flag-off route preserves its raw-session-id behavior. fn request_child_pane_materialization( &self, conversation_id: AIConversationId, - session_id: SessionId, + task: AmbientAgentTask, ctx: &mut ModelContext, ) { let Some(view) = self.terminal_view.upgrade(ctx) else { @@ -553,10 +669,21 @@ impl OrchestrationViewerModel { return; }; view.update(ctx, |_view, ctx| { - ctx.emit(TerminalViewEvent::EnsureSharedSessionViewerChildPane { - conversation_id, - session_id, - }); + if FeatureFlag::OrchestrationUnifiedStack.is_enabled() { + ctx.emit(TerminalViewEvent::EnsureUnifiedViewerChildPane { + conversation_id, + task: Box::new(task), + }); + } else if let Some(session_id) = task + .session_id + .as_deref() + .and_then(|session_id| session_id.parse::().ok()) + { + ctx.emit(TerminalViewEvent::EnsureSharedSessionViewerChildPane { + conversation_id, + session_id, + }); + } }); } } diff --git a/app/src/terminal/shared_session/viewer/orchestration_viewer_model_tests.rs b/app/src/terminal/shared_session/viewer/orchestration_viewer_model_tests.rs index e835f8883c9..2c2a1a3cea4 100644 --- a/app/src/terminal/shared_session/viewer/orchestration_viewer_model_tests.rs +++ b/app/src/terminal/shared_session/viewer/orchestration_viewer_model_tests.rs @@ -40,6 +40,95 @@ fn maps_working_states_to_in_progress() { } } +#[test] +fn flag_off_preserves_viewing_shared_session_child_flavor() { + let _unified_stack = FeatureFlag::OrchestrationUnifiedStack.override_enabled(false); + App::test((), |mut app| async move { + let parent = task_id(PARENT_TASK_ID); + let (_, parent_conv_id, model) = setup_model(&mut app, parent); + let model_handle = app.add_model(|_| model); + model_handle.update(&mut app, |model, ctx| { + model.register_child( + make_task( + CHILD_A_TASK_ID, + AmbientAgentTaskState::InProgress, + "Worker", + None, + ), + ctx, + ); + }); + + BlocklistAIHistoryModel::handle(&app).read(&app, |history, _| { + let child_id = history.child_conversation_ids_of(&parent_conv_id)[0]; + let child = history.conversation(&child_id).unwrap(); + assert!(child.is_viewing_shared_session()); + assert!(!child.is_remote_child()); + }); + }); +} + +#[test] +fn child_status_changed_before_spawn_retries_valid_run_once() { + let _unified_stack = FeatureFlag::OrchestrationUnifiedStack.override_enabled(true); + App::test((), |mut app| async move { + let parent = task_id(PARENT_TASK_ID); + let (_, _, model) = setup_model(&mut app, parent); + let model_handle = app.add_model(|_| model); + + model_handle.update(&mut app, |model, ctx| { + model.handle_child_status_changed(CHILD_A_TASK_ID, ConversationStatus::InProgress, ctx); + model.handle_child_status_changed(CHILD_A_TASK_ID, ConversationStatus::Success, ctx); + }); + + model_handle.read(&app, |model, _| { + assert_eq!( + model.metadata_fetch_dispatch_count, 1, + "lifecycle-before-spawn retries through the same in-flight fetch", + ); + assert!(model.metadata_fetches.contains(&task_id(CHILD_A_TASK_ID))); + }); + }); +} + +#[test] +fn unified_terminal_child_with_stale_session_requests_current_state_materialization() { + let _unified_stack = FeatureFlag::OrchestrationUnifiedStack.override_enabled(true); + App::test((), |mut app| async move { + let parent = task_id(PARENT_TASK_ID); + let (_, _, model) = setup_model(&mut app, parent); + let model_handle = app.add_model(|_| model); + let mut task = make_task( + CHILD_A_TASK_ID, + AmbientAgentTaskState::Succeeded, + "Worker", + Some(SESSION_A), + ); + task.conversation_id = Some("completed-child-token".to_string()); + + model_handle.update(&mut app, |model, ctx| { + model.register_child(task, ctx); + }); + + model_handle.read(&app, |model, _| { + let entry = model.children.get(&task_id(CHILD_A_TASK_ID)).unwrap(); + assert!( + entry.pane_materialization_requested, + "terminal child transcript materialization should be requested eagerly", + ); + assert_eq!( + entry.session_id, + Some(SESSION_A.parse().unwrap()), + "the stale id remains metadata, but must not determine the unified route", + ); + assert!( + !model.has_pending_session_id_children(), + "terminal transcript materialization must stop legacy session-id polling", + ); + }); + }); +} + #[test] fn maps_succeeded_to_success() { assert!(matches!( @@ -134,6 +223,7 @@ fn make_task_with_name( session_link: None, creator: None, executor: None, + scope: None, conversation_id: None, request_usage: None, is_sandbox_running: false, @@ -168,6 +258,7 @@ fn setup_model( terminal_view: terminal_view.downgrade(), children: HashMap::new(), children_by_run_id: HashMap::new(), + metadata_fetches: HashSet::new(), pending_session_id_poll_handle: None, metadata_fetch_dispatch_count: 0, }; @@ -179,6 +270,7 @@ fn setup_model( #[test] fn registers_new_child_conversation() { + let _unified_stack = FeatureFlag::OrchestrationUnifiedStack.override_enabled(true); App::test((), |mut app| async move { let parent = task_id(PARENT_TASK_ID); let (_, parent_conv_id, model) = setup_model(&mut app, parent); @@ -229,7 +321,8 @@ fn registers_new_child_conversation() { Some(parent_conv_id), "child linked to parent conversation" ); - assert!(child.is_viewing_shared_session()); + assert!(child.is_remote_child()); + assert!(!child.is_viewing_shared_session()); assert!(matches!(child.status(), ConversationStatus::InProgress)); }); }); @@ -290,6 +383,7 @@ fn skips_child_when_no_active_parent_conversation() { terminal_view: terminal_view.downgrade(), children: HashMap::new(), children_by_run_id: HashMap::new(), + metadata_fetches: HashSet::new(), pending_session_id_poll_handle: None, metadata_fetch_dispatch_count: 0, }; diff --git a/app/src/terminal/shared_session/viewer/terminal_manager.rs b/app/src/terminal/shared_session/viewer/terminal_manager.rs index f15932d4791..1c2d40c1ec7 100644 --- a/app/src/terminal/shared_session/viewer/terminal_manager.rs +++ b/app/src/terminal/shared_session/viewer/terminal_manager.rs @@ -21,13 +21,13 @@ use warpui::{ use super::event_loop::SharedSessionInitialLoadMode; use super::network::{ - Network, NetworkEvent, agent_prompt_failure_reason_string, + FailedToJoinReason, Network, NetworkEvent, agent_prompt_failure_reason_string, command_execution_failure_reason_string, control_action_failure_reason_string, session_ended_reason_string, viewer_removed_reason_string, write_to_pty_failure_reason_string, }; use super::orchestration_viewer_model::OrchestrationViewerModel; use crate::ai::active_agent_views_model::ActiveAgentViewsModel; -use crate::ai::agent::conversation::ConversationStatus; +use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::blocklist::agent_view::{AgentViewController, AgentViewControllerEvent}; use crate::ai::blocklist::orchestration_event_streamer::OrchestrationEventStreamer; @@ -110,6 +110,10 @@ pub struct TerminalManager { /// duplicated REST traffic and grandchild double-registration via the /// transitive `ancestor_run_id` filter. enable_orchestration_polling: bool, + /// Dedicated orchestration child viewers recover missing or inaccessible + /// live sessions through their pane group instead of the generic join + /// failure UI. + orchestration_child_conversation_id: Option, } pub struct TerminalManagerInit { @@ -141,6 +145,41 @@ impl TerminalManager { ); } + /// Creates the dedicated live-session viewer for an orchestration child. + /// Ordinary shared-session viewers use [`Self::new`] and retain their + /// existing failure behavior. + #[allow(clippy::new_ret_no_self)] + pub fn new_for_orchestration_child( + session_id: SessionId, + conversation_id: AIConversationId, + resources: TerminalViewResources, + initial_size: Vector2F, + window_id: WindowId, + ctx: &mut AppContext, + ) -> TerminalManagerInit { + let TerminalManagerInit { + manager: mut terminal_manager, + view: terminal_view, + } = Self::new_internal( + resources, + initial_size, + window_id, + false, + false, + Some(conversation_id), + ctx, + ); + terminal_manager.connect_session( + session_id, + SharedSessionInitialLoadMode::ReplaceFromSessionScrollback, + ctx, + ); + TerminalManagerInit { + manager: terminal_manager, + view: terminal_view, + } + } + fn current_network( current_network: &Arc>>>, ) -> Option> { @@ -206,6 +245,7 @@ impl TerminalManager { window_id: WindowId, enable_orchestration_polling: bool, is_ambient_agent: bool, + orchestration_child_conversation_id: Option, ctx: &mut AppContext, ) -> TerminalManagerInit { // Create all the necessary channels we need for communication. @@ -324,6 +364,7 @@ impl TerminalManager { outbound_handlers_registered: false, orchestration_viewer_model: Arc::new(FairMutex::new(None)), enable_orchestration_polling, + orchestration_child_conversation_id, }; TerminalManagerInit { manager, @@ -360,6 +401,7 @@ impl TerminalManager { window_id, enable_orchestration_polling, is_ambient_agent, + None, ctx, ); @@ -391,6 +433,7 @@ impl TerminalManager { window_id, enable_orchestration_polling, true, // is_ambient_agent + None, ctx, ) } @@ -524,6 +567,7 @@ impl TerminalManager { self.viewer_remote_update_guard.clone(), self.orchestration_viewer_model.clone(), self.enable_orchestration_polling, + self.orchestration_child_conversation_id, ctx, ); if !self.outbound_handlers_registered { @@ -767,6 +811,7 @@ impl TerminalManager { viewer_remote_update_guard: RemoteUpdateGuard, orchestration_viewer_model: Arc>>>, enable_orchestration_polling: bool, + orchestration_child_conversation_id: Option, ctx: &mut AppContext, ) { // We use a weak view handle instead of a strong reference because we may add a subscription to the view which moves a strong reference of the Model into the callback, @@ -828,6 +873,21 @@ impl TerminalManager { .orchestrator_task_id() .and_then(|s| s.parse().ok()); + // Ambient-pane app state restores the exact task id. Reattach + // any durable local Observer conversation before constructing + // the OVM or receiving response replay so its hierarchy and + // local-only cursor remain authoritative on this client. + if let Some(task_id) = ambient_task_id { + let terminal_view_id = view.id(); + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + history.restore_durable_observer_parent_for_task( + task_id, + terminal_view_id, + ctx, + ); + }); + } + // Mark terminal view as a shared ambient agent session view. if matches!(&source.source_type, SessionSourceType::AmbientAgent { .. }) { let terminal_view_id = view.id(); @@ -973,6 +1033,24 @@ impl TerminalManager { let Some(view) = weak_view_handle.upgrade(ctx) else { return; }; + if FeatureFlag::OrchestrationUnifiedStack.is_enabled() + && matches!( + reason, + FailedToJoinReason::SessionNotFound + | FailedToJoinReason::SessionNotAccessible + ) + && let Some(conversation_id) = orchestration_child_conversation_id + { + view.update(ctx, |_terminal_view, ctx| { + ctx.emit( + TerminalViewEvent::OrchestrationChildSharedSessionJoinFailed { + conversation_id, + session_id, + }, + ); + }); + return; + } view.update(ctx, |terminal_view, ctx| { terminal_view.show_persistent_toast( reason.user_facing_error_message().to_string(), diff --git a/app/src/terminal/shared_session/viewer/terminal_manager_tests.rs b/app/src/terminal/shared_session/viewer/terminal_manager_tests.rs index 10c1e5d7d76..7b4f919b273 100644 --- a/app/src/terminal/shared_session/viewer/terminal_manager_tests.rs +++ b/app/src/terminal/shared_session/viewer/terminal_manager_tests.rs @@ -97,6 +97,7 @@ fn build_manager_with_registered_ovm(app: &mut App) -> (TerminalManager, Ambient outbound_handlers_registered: false, orchestration_viewer_model: Arc::new(FairMutex::new(Some(ovm_handle))), enable_orchestration_polling: true, + orchestration_child_conversation_id: None, }; (manager, parent) } diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 1bf7e3888e3..6f197e62ac6 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -122,7 +122,8 @@ use session_sharing_protocol::sharer::{ use settings::{Setting, ToggleableSetting}; use shared_session::cloud_conversation_continuation::CloudConversationContinuationUiState; pub(crate) use shared_session::cloud_conversation_continuation::{ - AIQueryRouting, resolve_ai_query_routing, + AIQueryRouting, CompletedChildPresentation, ConversationAccess, + completed_child_conversation_access, completed_child_presentation, resolve_ai_query_routing, }; use shared_session::{SharedSessionAdapter, Viewer}; use ssh_file_upload::{FileUpload, FileUploadEvent}; @@ -217,7 +218,8 @@ use crate::ai::agent::{ use crate::ai::agent::{CurrentHead, DiffBase}; use crate::ai::agent_conversations_model::{AgentConversationsModel, AgentConversationsModelEvent}; use crate::ai::ambient_agents::{ - AmbientAgentTaskId, AmbientConversationStatus, conversation_output_status_from_conversation, + AmbientAgentTask, AmbientAgentTaskId, AmbientConversationStatus, + conversation_output_status_from_conversation, }; use crate::ai::blocklist::agent_view::agent_input_footer::toolbar_item::AgentToolbarItemKind; use crate::ai::blocklist::agent_view::{ @@ -2014,6 +2016,20 @@ pub enum Event { conversation_id: AIConversationId, session_id: session_sharing_protocol::common::SessionId, }, + /// Unified-stack counterpart to [`Self::EnsureSharedSessionViewerChildPane`]. + /// Carries the fetched task snapshot so pane construction uses the same + /// current-state materialization decision as pill-click restoration. + EnsureUnifiedViewerChildPane { + conversation_id: AIConversationId, + task: Box, + }, + /// A unified-stack child viewer could not join its dedicated live + /// execution session. The pane group keeps the child passive and + /// re-drives it from current task metadata. + OrchestrationChildSharedSessionJoinFailed { + conversation_id: AIConversationId, + session_id: session_sharing_protocol::common::SessionId, + }, /// Emitted when "Open in new tab" is picked from a child pill's 3-dot menu. /// Bubbles up to the workspace to create the new tab. OpenChildAgentInNewTab { @@ -2824,6 +2840,9 @@ pub struct TerminalView { ambient_agent_view_model: Option>, pending_cloud_followup_task_id: Option, + /// A passive orchestration child whose live execution session could not + /// be joined. Task refresh may later replace this with a transcript. + orchestration_child_live_unavailable: bool, /// Conversation details panel (side panel showing conversation/task metadata). /// Available for cloud Oz runs and for any active local AI conversation. @@ -4385,6 +4404,7 @@ impl TerminalView { has_auto_opened_conversation_details_panel: false, conversation_details_panel_auto_open_policy: Default::default(), pending_cloud_followup_task_id: None, + orchestration_child_live_unavailable: false, #[cfg(not(target_arch = "wasm32"))] conversation_details_panel_toggle_mouse_state: Default::default(), ambient_agent_cancel_mouse_state: Default::default(), @@ -8044,6 +8064,29 @@ impl TerminalView { ConversationDetailsPanelAutoOpenPolicy::DefaultClosed; } + pub(crate) fn set_orchestration_child_live_unavailable( + &mut self, + unavailable: bool, + ctx: &mut ViewContext, + ) { + if self.orchestration_child_live_unavailable == unavailable { + return; + } + self.orchestration_child_live_unavailable = unavailable; + ctx.notify(); + } + + #[cfg(test)] + pub(crate) fn is_orchestration_child_live_unavailable_for_test(&self) -> bool { + self.orchestration_child_live_unavailable + } + #[cfg(test)] + pub(crate) fn has_agent_view_zero_state_for_test(&self) -> bool { + self.rich_content_views + .iter() + .any(|view| view.is_agent_view_zero_state()) + } + #[cfg(test)] pub(crate) fn is_initial_conversation_details_panel_auto_open_suppressed_for_test( &self, @@ -23290,6 +23333,42 @@ impl TerminalView { .finish() } + fn render_orchestration_child_live_unavailable(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let color = appearance + .theme() + .sub_text_color(appearance.theme().background()); + + SavePosition::new( + Align::new( + Flex::column() + .with_child( + Text::new_inline( + "Live session unavailable", + appearance.ui_font_family(), + 14., + ) + .with_color(color.into()) + .finish(), + ) + .with_child( + Text::new_inline( + "The transcript will appear when this child finishes.", + appearance.ui_font_family(), + 12., + ) + .with_color(color.into()) + .finish(), + ) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .finish(), + ) + .finish(), + &self.content_element_position_id, + ) + .finish() + } + fn render_bookmark_element( index: BlockIndex, bookmark_mouse_state: MouseStateHandle, @@ -27510,7 +27589,9 @@ impl View for TerminalView { && !self.is_ambient_agent_session(app); let is_loading_transcript = model.is_loading_conversation_transcript(); let should_show_loading = is_view_pending_clause || is_loading_transcript; - let output_area = if should_show_loading { + let output_area = if self.orchestration_child_live_unavailable { + self.render_orchestration_child_live_unavailable(app) + } else if should_show_loading { self.render_viewer_loading(app) } else if is_alt_screen_active { did_wrap_terminal_size = true; diff --git a/app/src/terminal/view/load_ai_conversation.rs b/app/src/terminal/view/load_ai_conversation.rs index 4b007ace23d..baeb07e6943 100644 --- a/app/src/terminal/view/load_ai_conversation.rs +++ b/app/src/terminal/view/load_ai_conversation.rs @@ -939,6 +939,7 @@ impl TerminalView { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, diff --git a/app/src/terminal/view/shared_session/cloud_conversation_continuation.rs b/app/src/terminal/view/shared_session/cloud_conversation_continuation.rs index 5848e220acf..44df8874515 100644 --- a/app/src/terminal/view/shared_session/cloud_conversation_continuation.rs +++ b/app/src/terminal/view/shared_session/cloud_conversation_continuation.rs @@ -7,13 +7,14 @@ use crate::ai::agent::conversation::{ }; use crate::ai::agent_conversations_model::AgentConversationsModel; use crate::ai::ambient_agents::{ - AmbientAgentTask, AmbientAgentTaskId, AmbientConversationStatus, + AmbientAgentTask, AmbientAgentTaskId, AmbientConversationStatus, TaskOwnership, conversation_output_status_from_conversation, }; use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::auth::AuthStateProvider; use crate::cloud_object::{Owner, ServerGuestSubject}; use crate::drive::sharing::SharingAccessLevel; +use crate::features::FeatureFlag; use crate::terminal::TerminalModel; use crate::terminal::view::ambient_agent::AmbientAgentViewModel; use crate::workspaces::user_workspaces::UserWorkspaces; @@ -80,12 +81,30 @@ impl CloudConversationContinuationError { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ConversationAccess { +pub(crate) enum ConversationAccess { Edit, ViewOnly, Unknown, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CompletedChildPresentation { + Continuation, + PassiveTranscript, +} + +pub(crate) fn completed_child_presentation( + access: ConversationAccess, + blocks_cloud_followups: bool, +) -> CompletedChildPresentation { + match (access, blocks_cloud_followups) { + (ConversationAccess::Edit, false) => CompletedChildPresentation::Continuation, + (ConversationAccess::Edit, true) + | (ConversationAccess::ViewOnly, _) + | (ConversationAccess::Unknown, _) => CompletedChildPresentation::PassiveTranscript, + } +} + pub(in crate::terminal::view) fn resolve_cloud_conversation_continuation_ui_state( terminal_view_id: EntityId, task_id: AmbientAgentTaskId, @@ -127,7 +146,7 @@ pub(in crate::terminal::view) fn resolve_cloud_conversation_continuation_ui_stat ); } - let access = task_creator_access(&task, app); + let access = task_ownership_access(&task, app); if access == ConversationAccess::Edit { return continuation_ui_state_for_harness_and_access( task_harness(&task), @@ -263,7 +282,7 @@ fn continuation_ui_state_for_harness_and_access( } } -fn conversation_access( +pub(crate) fn conversation_access( metadata: &ServerAIConversationMetadata, app: &AppContext, ) -> ConversationAccess { @@ -328,19 +347,35 @@ fn conversation_access( } } -fn task_creator_access(task: &AmbientAgentTask, app: &AppContext) -> ConversationAccess { - let Some(current_user_uid) = AuthStateProvider::as_ref(app).get().user_id() else { - return ConversationAccess::Unknown; - }; +pub(crate) fn completed_child_conversation_access( + metadata: Option<&ServerAIConversationMetadata>, + task: Option<&AmbientAgentTask>, + app: &AppContext, +) -> ConversationAccess { + match metadata { + Some(metadata) => conversation_access(metadata, app), + None => task + .map(|task| task_ownership_access(task, app)) + .unwrap_or(ConversationAccess::Unknown), + } +} - if task - .creator - .as_ref() - .is_some_and(|creator| creator.uid == current_user_uid.as_str()) - { - ConversationAccess::Edit - } else { - ConversationAccess::Unknown +fn task_ownership_access(task: &AmbientAgentTask, app: &AppContext) -> ConversationAccess { + if !FeatureFlag::OrchestrationUnifiedStack.is_enabled() { + let current_user_uid = AuthStateProvider::as_ref(app).get().user_id(); + return if task + .creator + .as_ref() + .is_some_and(|creator| current_user_uid.is_some_and(|uid| creator.uid == uid.as_str())) + { + ConversationAccess::Edit + } else { + ConversationAccess::Unknown + }; + } + match task.ownership_for_current_principal(app) { + TaskOwnership::Owned => ConversationAccess::Edit, + TaskOwnership::NotOwned | TaskOwnership::Unknown => ConversationAccess::Unknown, } } diff --git a/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs b/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs index 193003af7c9..cfbb8fc0bea 100644 --- a/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs +++ b/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs @@ -12,7 +12,8 @@ use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::conversation::{AIAgentHarness, ServerAIConversationMetadata}; use crate::ai::agent_conversations_model::AgentConversationsModel; use crate::ai::ambient_agents::task::{ - AgentConfigSnapshot, HarnessConfig, TaskPrincipalInfo, TaskStatusErrorCode, TaskStatusMessage, + AgentConfigSnapshot, HarnessConfig, TaskPrincipalInfo, TaskScope, TaskStatusErrorCode, + TaskStatusMessage, }; use crate::ai::ambient_agents::{ AgentSource, AmbientAgentTask, AmbientAgentTaskId, AmbientAgentTaskState, @@ -40,6 +41,31 @@ enum AuthFixture { LoggedOut, } +#[test] +fn routing_allows_live_input_only_for_executable_shared_session_role() { + App::test((), |mut app| async move { + let task_id = ambient_task_id(1); + let reader = ambient_pane_model(task_id, SharedSessionStatus::reader()); + let executor = ambient_pane_model(task_id, SharedSessionStatus::executor()); + app.update(|ctx| { + assert_eq!( + resolve_ai_query_routing(EntityId::new(), None, &reader, ctx), + AIQueryRouting::LiveRemoteVm { + is_executor: false, + ambient_agent_task_id: Some(task_id), + } + ); + assert_eq!( + resolve_ai_query_routing(EntityId::new(), None, &executor, ctx), + AIQueryRouting::LiveRemoteVm { + is_executor: true, + ambient_agent_task_id: Some(task_id), + } + ); + }); + }); +} + #[derive(Clone, Copy)] enum ConversationPermissionFixture { CurrentUserOwner, @@ -186,6 +212,7 @@ fn ambient_agent_task( display_name: None, }), executor: None, + scope: None, conversation_id: Some(conversation_token.to_string()), request_usage: None, is_sandbox_running: false, @@ -210,6 +237,7 @@ fn active_ambient_agent_task(task_id: AmbientAgentTaskId) -> AmbientAgentTask { trait AmbientAgentTaskTestExt { fn with_creator(self, creator_uid: &str) -> Self; fn with_harness(self, harness: Harness) -> Self; + fn with_scope(self, scope: TaskScope) -> Self; } impl AmbientAgentTaskTestExt for AmbientAgentTask { @@ -233,6 +261,11 @@ impl AmbientAgentTaskTestExt for AmbientAgentTask { }); self } + + fn with_scope(mut self, scope: TaskScope) -> Self { + self.scope = Some(scope); + self + } } fn test_team_uid() -> ServerId { @@ -683,6 +716,24 @@ fn missing_metadata_returns_error() { }); } +#[test] +fn completed_child_presentation_requires_edit_access() { + assert_eq!( + completed_child_presentation(ConversationAccess::Edit, false), + CompletedChildPresentation::Continuation + ); + for access in [ConversationAccess::ViewOnly, ConversationAccess::Unknown] { + assert_eq!( + completed_child_presentation(access, false), + CompletedChildPresentation::PassiveTranscript + ); + } + assert_eq!( + completed_child_presentation(ConversationAccess::Edit, true), + CompletedChildPresentation::PassiveTranscript + ); +} + #[test] fn owned_oz_task_without_metadata_shows_inline_followup_input() { App::test((), |mut app| async move { @@ -736,6 +787,99 @@ fn owned_third_party_task_without_metadata_shows_continue_in_cloud_tombstone() { }); } +#[test] +fn authoritative_owned_scope_allows_metadata_free_fallback() { + let _unified_stack = FeatureFlag::OrchestrationUnifiedStack.override_enabled(true); + App::test((), |mut app| async move { + let TestHandles { + terminal_view_id, + task_id, + } = setup_task_without_server_metadata(&mut app); + AgentConversationsModel::handle(&app).update(&mut app, |model, _| { + model.insert_task_for_test( + ambient_agent_task( + task_id, + CONVERSATION_TOKEN, + AmbientAgentTaskState::Succeeded, + ) + .with_creator("other-user") + .with_scope(TaskScope::User { + uid: TEST_USER_UID.to_string(), + }), + ); + }); + + app.update(|ctx| { + assert_eq!( + resolve_cloud_conversation_continuation_ui_state(terminal_view_id, task_id, ctx), + Ok(CloudConversationContinuationUiState::FollowupInput) + ); + }); + }); +} + +#[test] +fn task_scope_does_not_change_flag_off_creator_fallback() { + let _unified_stack = FeatureFlag::OrchestrationUnifiedStack.override_enabled(false); + App::test((), |mut app| async move { + let TestHandles { + terminal_view_id, + task_id, + } = setup_task_without_server_metadata(&mut app); + AgentConversationsModel::handle(&app).update(&mut app, |model, _| { + model.insert_task_for_test( + ambient_agent_task( + task_id, + CONVERSATION_TOKEN, + AmbientAgentTaskState::Succeeded, + ) + .with_creator("other-user") + .with_scope(TaskScope::User { + uid: TEST_USER_UID.to_string(), + }), + ); + }); + + app.update(|ctx| { + assert_eq!( + resolve_cloud_conversation_continuation_ui_state(terminal_view_id, task_id, ctx), + Err(CloudConversationContinuationError::MissingServerConversationMetadata) + ); + }); + }); +} + +#[test] +fn authoritative_non_owned_scope_overrides_creator_fallback() { + let _unified_stack = FeatureFlag::OrchestrationUnifiedStack.override_enabled(true); + App::test((), |mut app| async move { + let TestHandles { + terminal_view_id, + task_id, + } = setup_task_without_server_metadata(&mut app); + AgentConversationsModel::handle(&app).update(&mut app, |model, _| { + model.insert_task_for_test( + ambient_agent_task( + task_id, + CONVERSATION_TOKEN, + AmbientAgentTaskState::Succeeded, + ) + .with_creator(TEST_USER_UID) + .with_scope(TaskScope::User { + uid: "other-user".to_string(), + }), + ); + }); + + app.update(|ctx| { + assert_eq!( + resolve_cloud_conversation_continuation_ui_state(terminal_view_id, task_id, ctx), + Err(CloudConversationContinuationError::MissingServerConversationMetadata) + ); + }); + }); +} + #[test] fn active_task_execution_returns_error() { App::test((), |mut app| async move { diff --git a/app/src/terminal/view/shared_session/conversation_ended_tombstone_view_tests.rs b/app/src/terminal/view/shared_session/conversation_ended_tombstone_view_tests.rs index 166e87fd361..ea2250c0f54 100644 --- a/app/src/terminal/view/shared_session/conversation_ended_tombstone_view_tests.rs +++ b/app/src/terminal/view/shared_session/conversation_ended_tombstone_view_tests.rs @@ -32,6 +32,7 @@ fn task_with_run_time_and_credits() -> AmbientAgentTask { display_name: Some("User 1".to_string()), }), executor: None, + scope: None, conversation_id: None, request_usage: Some(RequestUsage { inference_cost: Some(INFERENCE_COST), diff --git a/app/src/terminal/view/shared_session/view_impl.rs b/app/src/terminal/view/shared_session/view_impl.rs index e98d81daac3..938685ad91c 100644 --- a/app/src/terminal/view/shared_session/view_impl.rs +++ b/app/src/terminal/view/shared_session/view_impl.rs @@ -185,23 +185,26 @@ impl TerminalView { ctx: &AppContext, ) -> Option { let task_id = self.ambient_agent_task_id_for_details_panel(ctx)?; - self.is_current_user_creator_of_ambient_task(task_id, ctx) - .then_some(task_id) - } - - fn is_current_user_creator_of_ambient_task( - &self, - task_id: AmbientAgentTaskId, - ctx: &AppContext, - ) -> bool { - let Some(current_user_uid) = self.auth_state.user_id().map(|uid| uid.as_string()) else { - return false; - }; AgentConversationsModel::as_ref(ctx) .get_task_data(&task_id) - .and_then(|task| task.creator.map(|creator| creator.uid)) - .is_some_and(|creator_uid| creator_uid == current_user_uid) + .is_some_and(|task| { + if !FeatureFlag::OrchestrationUnifiedStack.is_enabled() { + let Some(current_user_uid) = + self.auth_state.user_id().map(|uid| uid.as_string()) + else { + return false; + }; + return task + .creator + .is_some_and(|creator| creator.uid == current_user_uid); + } + matches!( + task.ownership_for_current_principal(ctx), + crate::ai::ambient_agents::TaskOwnership::Owned + ) + }) + .then_some(task_id) } pub(in crate::terminal::view) fn enable_cloud_followup_input( @@ -238,6 +241,16 @@ impl TerminalView { self.enable_cloud_followup_input(task_id, ctx); } + /// Enables the established continuation input after pane hydration has + /// already resolved explicit conversation Edit access. + pub(crate) fn enable_completed_cloud_continuation( + &mut self, + task_id: AmbientAgentTaskId, + ctx: &mut ViewContext, + ) { + self.enable_cloud_followup_input_after_conversation_end(task_id, ctx); + } + pub(super) fn handle_viewer_role_change_menu_event( &mut self, event: &MenuEvent, diff --git a/app/src/terminal/view/shared_session/view_impl_tests.rs b/app/src/terminal/view/shared_session/view_impl_tests.rs index 97b141b20a3..309b93069df 100644 --- a/app/src/terminal/view/shared_session/view_impl_tests.rs +++ b/app/src/terminal/view/shared_session/view_impl_tests.rs @@ -698,6 +698,7 @@ fn create_cloud_mode_task_for_user(creator_uid: &str) -> AmbientAgentTask { display_name: None, }), executor: None, + scope: None, conversation_id: None, request_usage: None, is_sandbox_running: false, diff --git a/app/src/terminal/view_tests.rs b/app/src/terminal/view_tests.rs index 454df4b37f4..f24e4d4d92e 100644 --- a/app/src/terminal/view_tests.rs +++ b/app/src/terminal/view_tests.rs @@ -126,6 +126,7 @@ fn owned_resumable_oz_task(task_id: AmbientAgentTaskId) -> AmbientAgentTask { display_name: None, }), executor: None, + scope: None, conversation_id: None, request_usage: None, is_sandbox_running: false, diff --git a/crates/persistence/src/model.rs b/crates/persistence/src/model.rs index 866a8467d20..3d125e16257 100644 --- a/crates/persistence/src/model.rs +++ b/crates/persistence/src/model.rs @@ -1194,6 +1194,12 @@ pub struct AgentConversationData { /// agent executing on a remote worker. #[serde(default, skip_serializing_if = "is_false")] pub is_remote_child: bool, + /// True for an owned cloud parent whose execution is hosted remotely and + /// observed through a local shared-session pane. This narrowly permits + /// persisting/restoring the observer cursor and hierarchy without making + /// arbitrary shared-session conversations durable. + #[serde(default, skip_serializing_if = "is_false")] + pub is_durable_observer_parent: bool, /// Legacy marker that previously recorded whether the root task was still /// optimistic when this conversation was persisted. Retained on the struct /// for backward-compatible deserialization of rows written by older builds; diff --git a/crates/persistence/src/model_tests.rs b/crates/persistence/src/model_tests.rs index 0ac1fc5617e..0c15e8cc712 100644 --- a/crates/persistence/src/model_tests.rs +++ b/crates/persistence/src/model_tests.rs @@ -26,6 +26,37 @@ fn parentless_task(id: &str, message_count: usize) -> api::Task { } } +#[test] +fn agent_conversation_data_roundtrips_durable_observer_parent_marker() { + let data = AgentConversationData { + server_conversation_token: None, + conversation_usage_metadata: None, + reverted_action_ids: None, + forked_from_server_conversation_token: None, + artifacts_json: None, + parent_agent_id: None, + agent_name: None, + orchestration_harness_type: None, + parent_conversation_id: None, + is_remote_child: false, + is_durable_observer_parent: true, + root_task_is_optimistic: None, + run_id: Some("11111111-1111-1111-1111-111111111111".to_string()), + autoexecute_override: None, + last_event_sequence: Some(37), + pinned: false, + }; + let json = serde_json::to_string(&data).expect("serialize"); + let roundtripped: AgentConversationData = serde_json::from_str(&json).expect("deserialize"); + assert!(roundtripped.is_durable_observer_parent); + assert_eq!(roundtripped.last_event_sequence, Some(37)); + + let legacy: AgentConversationData = + serde_json::from_str(r#"{"server_conversation_token":null}"#) + .expect("legacy rows must deserialize"); + assert!(!legacy.is_durable_observer_parent); +} + fn child_task(id: &str, parent_id: &str) -> api::Task { api::Task { id: id.to_string(), @@ -235,6 +266,7 @@ fn agent_conversation_data_roundtrips_last_event_sequence() { orchestration_harness_type: Some("claude".to_string()), parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -272,6 +304,7 @@ fn agent_conversation_data_roundtrips_remote_child_marker() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: true, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -296,6 +329,7 @@ fn agent_conversation_data_roundtrips_optimistic_root_marker() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: Some(true), run_id: None, autoexecute_override: None, @@ -332,6 +366,7 @@ fn agent_conversation_data_skips_serializing_none_last_event_sequence() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -358,6 +393,7 @@ fn agent_conversation_data_roundtrips_pinned() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, @@ -382,6 +418,7 @@ fn agent_conversation_data_skips_serializing_unpinned() { orchestration_harness_type: None, parent_conversation_id: None, is_remote_child: false, + is_durable_observer_parent: false, root_task_is_optimistic: None, run_id: None, autoexecute_override: None, diff --git a/crates/warp_features/src/lib.rs b/crates/warp_features/src/lib.rs index 041b8d6f060..6048a154ff5 100644 --- a/crates/warp_features/src/lib.rs +++ b/crates/warp_features/src/lib.rs @@ -708,6 +708,13 @@ pub enum FeatureFlag { /// receives events for children created out-of-band (Oz CLI / web API). WaitForEventsParentRegistration, + /// Gates the unified north-star orchestration child-tracking stack: + /// a single `OrchestrationChildTracker` as the sole entry point for + /// child state, one `include_self` ancestor SSE per parent family, and + /// the unified `is_remote_child` placeholder for both owner and viewer. + /// Flag-off is identical to the pre-unification master baseline. + OrchestrationUnifiedStack, + /// Shows a pending user query indicator during summarization when a follow-up /// prompt is queued via `/fork-and-compact` or `/compact-and`. PendingUserQueryIndicator, @@ -1003,6 +1010,7 @@ pub const DOGFOOD_FLAGS: &[FeatureFlag] = &[ FeatureFlag::ContextWindowUsageBreakdown, FeatureFlag::JupyterNotebookRendering, FeatureFlag::WaitForEventsParentRegistration, + FeatureFlag::OrchestrationUnifiedStack, FeatureFlag::McpJsonTreeView, FeatureFlag::GeminiEnterprise, FeatureFlag::BoxDrawingGlyphs, diff --git a/specs/QUALITY-928/TECH.md b/specs/QUALITY-928/TECH.md new file mode 100644 index 00000000000..91d798d760f --- /dev/null +++ b/specs/QUALITY-928/TECH.md @@ -0,0 +1,1022 @@ +# TECH: Orchestration Child Tracking — Unified North-Star Implementation + +Linear: QUALITY-928 — Emit a `child_agent_started` event so parents discover +children via push, and implement the full north-star orchestration tracking +architecture in a two-PR client stack. +Follow-up to QUALITY-919 (PR #13208), whose spec sketched this work under +"Always-on child discovery (lazy listening at first wait)". + +## 1. Scope and status +This document is the single spec for orchestration child tracking. It covers: +- **M1 — Core tracker + unified stream** (branch `matthew/orch-unified-m1`, + base `origin/master`): `OrchestrationChildTracker` as the sole entry point + for child state; `classify_family_event` + `drain_family_events` replacing + both separate drain pipelines; unified `is_remote_child` placeholder; + `OrchestrationUnifiedStack` dogfood flag; rolled-out flag cleanup. §3 + specifies it. +- **M2 — Pane path + transcript** (branch `matthew/orch-unified-m2`, base M1 + branch): `ChildPaneMaterialization` unified dispatch; converged attach; + transcript for both owner and viewer. §7.5 specifies the design. +- **Phases 1–3 of the earlier incremental roadmap are superseded**: the full + north-star is implemented directly, avoiding ~600 lines of intermediate + scaffold that would have been written and then deleted. + +The server-side emits (S1–S5 + ACL propagation, §3.2) are in a separate +warp-server PR against `develop`; they are additive and safe to ship first. +Pinned research SHAs: warp-server `9eba7d0932`. No `warp-proto-apis` change +is needed: event types are Go string constants surfaced via `openapi.yaml`, +and the client deserializes generically into `AgentRunEvent`. + +A reader should come away with: (a) a working mental model of how child +agents are discovered, represented, and shown in the north-star system; +(b) what M1 changes and why; and (c) the design decisions behind M2. + +## 2. Background: concepts and vocabulary +- **Run / task**: a server-side agent run (`ai_tasks` row), identified by a + `run_id` (stringified `AmbientAgentTaskId`). Client-side, an + `AIConversation` may be linked to a run via `run_id`/`task_id`. +- **Parent / child**: a child run has `parent_run_id = P`. **One-level-tree + invariant** (carried from QUALITY-919, load-bearing): a run is either a + root orchestrator or a leaf child; the server ancestor query is + single-level (`parent_run_id = $1`), consistent end-to-end. Revisit + alongside the server query if multi-level trees are introduced. +- **Event consumer**: the *Primary* process hosts the orchestrator + conversation (local root, or the cloud worker's driver), consumes the + parent's inbox, and writes the authoritative server cursor. An *Observer* + watches through a shared session, drops parent-self events, and persists + only a local cursor. Authenticated task ownership never changes this role. +- **Pane origin**: `ChildPaneOrigin::{HostedConversation, SharedSession}` + records the construction context for a child pane. Origin never grants + live input or terminal continuation. +- **Task ownership**: `TaskOwnership::{Owned, NotOwned, Unknown}` is derived + from the public run API's authoritative user/team `scope`. Exact creator + equality is a compatibility fallback only when older payloads omit scope. +- **Conversation access**: `ConversationAccess::{Edit, ViewOnly, Unknown}` + is derived from conversation object permissions. Terminal Edit access + enables continuation; ViewOnly and Unknown remain passive. +- **Live role**: a child shared-session join's returned `Role` is the sole + authority for live input. Task ownership and pane origin cannot promote a + Reader or bypass a failed/inaccessible join. +- **Event log + SSE**: the server keeps an append-only `ai_run_event_log` + with a monotonic global `sequence`, a publish path + (`PublishLifecycleEvent` → `publishAgentRunEvent`), and an SSE handler + with `RunIds([...])` and `AncestorRunId { ancestor_run_id, include_self }` + filters whose ancestor query JOINs the children's `parent_run_id` + (`include_self` adds the parent's own events). Children are created (any + path: `run_agents`, Oz CLI, web API) through one funnel, `AddTask`. + Relevant server code @ 9eba7d0932: `logic/ai/ambient_agents/add_task.go` + (348-388, the child insert), `logic/agent_lifecycle.go` (13-81, event-type + constants + `PublishLifecycleEvent`), `logic/agent_event_publish.go` + (14-79, payload + PubSub), `model/ai_run_event_log.go` (35-120, + `InsertEvent` + ancestor JOIN). +- **Cursor**: each consumer tracks the last fully-handled `sequence` and + resumes SSE from it (`since=`). Primary-side it is per-conversation + (`ConversationStreamState::event_cursor`), persisted to SQLite and pushed + to the server; Observer-side it is per-orchestrator + (`OrchestratorStreamState::event_cursor`), persisted to each viewer + placeholder row but **not** pushed to the server. +- **Placeholder flavors**: a child that is not a local conversation is + represented by a placeholder `AIConversation` in one of two flavors: + - `is_remote_child` (hosted-conversation origin): **persisted** in + `AgentConversationData` (`crates/persistence/src/model.rs:1196`), alongside + `parent_conversation_id`, `parent_agent_id`, `run_id`, `agent_name`. + - `is_viewing_shared_session` (shared-session origin): **runtime-only** — + the flavor is a constructor argument (`AIConversation::new(true, ...)`) and is not + written to `AgentConversationData`, so viewer children do not survive + restart (§6, item 3). + The current M2 implementation still uses this viewer flavor in + `OrchestrationViewerModel`; the persisted single-flavor north star below + has not fully replaced that path yet. +- **Owner-side child kinds.** Not every owner-side child is out-of-band: + 1. *Local in-band children* (`run_agents` local execution): real + conversations running in this process with real hidden terminal panes — + not placeholders. Each also holds its own child-role SSE + (`RunIds([self])`) for its inbox. + 2. *Cloud in-band children* (`run_agents`/`start_agent` with cloud + execution): started by this process. The `StartAgentExecutor` creates an + `is_remote_child` placeholder up-front and stamps the run id via + `assign_run_id_for_conversation` when the server responds. + 3. *Out-of-band cloud children* (Oz CLI, web API, another client): + discovered only via `child_agent_started`/lifecycle events; the + discovery path (§3.4) creates the same `is_remote_child` flavor. + Kinds 2 and 3 converge on one representation and one hydration path — the + discovery machinery only *creates* for kind 3, but refetch and pane + hydration serve both. Kind 1 is deliberately different (§9.4). + +## 3. M1 — Core Tracker + Unified Stream +**Behavioral contract.** Whenever a child task is created with +`parent_run_id = P` (by any method), a parent client watching `P` discovers +that child within one SSE round-trip — no polling — and surfaces its +subsequent lifecycle and inbox events. Children render as named child pills +with inbox messages attributed correctly. Clicking a child pill hydrates its +pane: live session join while running; transcript for both owner and viewer +once terminal (M2 implements the unified pane path). + +### 3.1 Precondition: rolled-out flag cleanup +M1 deletes `FeatureFlag::OrchestrationViewerStreamer` and +`FeatureFlag::OwnerOrchestrationAncestorStreamer` (both already in the +`default` cargo set, i.e. on for all channels) along with the legacy viewer +REST polling path (`fetch_children` / `schedule_next_poll` / `maybe_kick_polling` +/ `apply_children_fetch`). The SSE-driven path is now unconditional on the +flag-off baseline. + +### 3.2 Server: emit `child_agent_started` (separate warp-server PR) +**S1 — event-type constant.** In `logic/agent_lifecycle.go`, alongside the +existing `LifecycleEvent*` constants: +```go +const ( + LifecycleEventRunInProgress = "run_in_progress" + // ... existing constants unchanged ... + LifecycleEventRunCancelled = "run_cancelled" + + // EventChildAgentStarted is emitted on a PARENT run when a child task is + // created with parent_run_id = . The child run id is carried in + // ref_id. This is a discovery signal, not a run status. + EventChildAgentStarted = "child_agent_started" +) +``` +**S2 — emit after the child is committed.** In `AddTask` +(`logic/ai/ambient_agents/add_task.go`) the child row is inserted inside +`database.TransactionWithNoResult(...)`. Add the emit *after* that block +returns successfully, next to the other post-commit side effects: +```go +// Notify the parent (if any) that a child was created so its client discovers +// the child via push instead of polling. Emitted on the PARENT run with the +// child run id in ref_id. Best-effort: a failure must not fail child creation. +// Placed after the commit because PublishLifecycleEvent both inserts and +// publishes and must not run inside the caller's transaction. +if params.ParentRunID != nil && *params.ParentRunID != "" { + if _, err := logic.PublishLifecycleEvent( + ctx, + td.db, + td.datastores, + *params.ParentRunID, // run_id the event is recorded on + nil, // execution_id: the parent has none here + logic.EventChildAgentStarted, // event_type + &task.ID, // ref_id: the new child run id + ); err != nil { + log.Warnf(ctx, "Failed to emit %s on parent %s for child %s: %v", + logic.EventChildAgentStarted, *params.ParentRunID, task.ID, err) + } +} +``` +`PublishLifecycleEvent` inserts into `ai_run_event_log` (assigning the +monotonic `sequence`) and publishes to PubSub/SSE. Its +`resolveParentRunIDForPublish` looks up the *parent's own* parent for +routing metadata, which is `nil` under the one-level-tree invariant. + +**S3 — document the type** in the events schemas in +`public_api/openapi.yaml`. + +**S4 — tests.** In the `AddTask` suite, inject a mock via +`getEventPubSubClient` and assert: a task created with `ParentRunID` set +produces exactly one published event with `event_type=child_agent_started`, +`run_id=`, `ref_id=`; a task with `ParentRunID` nil produces +none. Verify the event surfaces on both a `run_ids=[P]` stream and an +`ancestor_run_id=P&include_self=true` stream. + +No schema/migration changes: the event lives on the parent run in the +existing log, so both filter shapes deliver it. **No server feature flag**: +the event is additive; old clients ignore unknown `event_type` values +(`lifecycle_event_type_from_wire` returns `None`; the cursor still advances +harmonlessly). Consumption is gated client-side. + +**S5 — emit `run_session_linked` when a sandbox session links** (also in the +warp-server PR). In `updateSharedSessionLink` +(`logic/ai/ambient_agents/execution.go`), after the commit, best-effort emit +on the **child** run (session UUID in `ref_id`): +```go +if sharedSessionUUID != nil { + if _, emitErr := logic.PublishLifecycleEvent( + ctx, db, td.datastores, + runID, nil, logic.EventRunSessionLinked, sharedSessionUUID, + ); emitErr != nil { + log.Warnf(ctx, "Failed to emit %s for run %s: %v", + logic.EventRunSessionLinked, runID, emitErr) + } +} +``` +Old clients ignore this via the `_ => None` catch-all. The session UUID in +`ref_id` is consumed directly by M1: `classify_family_event` produces +`FamilyEvent::ChildSessionLinked { session_uuid }` and `observe_child` fills +in `session_id` without a metadata fetch. The event surfaces on the child's +run in both owner (`include_self=true`) and viewer (same stream, `ParentSelf` +drops) ancestor streams. + +### 3.3 Client: OrchestrationUnifiedStack flag and stream opening +`FeatureFlag::OrchestrationUnifiedStack` (dogfood-only) gates the entire M1 +system. Flag-off: behavior identical to the pre-M1 master baseline. Flag-on: +one `include_self: true` ancestor SSE per parent (`drain_family_events`), tracker +owns all child state. + +`register_root_on_wait` is preserved: a root orchestrator registers for the +family stream at its first `wait_for_events` when the flag is on, before any +child exists. `WaitForEventsParentRegistration` continues to guard this +mechanism on the flag-off baseline and is superseded (not deleted) in M1. + +On the flag-on path, both owner and viewer open a single `include_self: true` +ancestor SSE. The viewer drops `ParentSelf` events (no inbox). Owner and +viewer each hold one `OrchestrationChildTracker` — the streamer hosts it in +`ConversationStreamState` (owner) and `OrchestratorStreamState` (viewer). + +`register_root_on_wait` (flag-on path): +```rust +pub fn register_root_on_wait(&mut self, conversation_id: AIConversationId, ctx: ...) { + if !FeatureFlag::WaitForEventsParentRegistration.is_enabled() { return; } + // guards: not a child (one-level tree), not a passive remote-run view, + // has a self_run_id ... + let stream = self.streams.entry(conversation_id).or_default(); + if stream.ancestor_on_wait { return; } + stream.ancestor_on_wait = true; + stream.watched_run_ids.insert(self_run_id); + self.reevaluate_eligibility(conversation_id, ctx); +} +``` +`is_eligible` treats a wait-registered root (`ancestor_on_wait`) as having +an orchestration role, and `desired_sse_filter` selects +`AncestorRunId { ancestor_run_id: self_run_id, include_self: true }` — one +connection carrying the parent's own inbox (`new_message`), child lifecycle +events, and `child_agent_started`. The call site is +`wait_for_events.rs::execute`. The method does **no network fetch** +(replacing QUALITY-919's per-wait `get_ambient_agent_task`). + +**Design decision — open the superset stream up front.** The QUALITY-919 +follow-up sketched opening a cheap `RunIds([self])` stream and *upgrading* +to the ancestor filter on the first `child_agent_started`. That introduces a +cursor-handoff gap: the per-conversation `event_cursor` is a single scalar +over the *global* sequence space, but a self stream only delivers run-`P` +events, so a parent-self event can advance the cursor past a lower-sequenced +child event the narrow filter never delivered; the ancestor reconnect then +resumes from the advanced cursor and skips it. Opening the ancestor +(superset) stream from the start means the filter never widens, so the +cursor always covers the full watched set. The cost — a childless waiting +root holds a JOIN stream rather than a run-ids stream — is one idle SSE +either way. Consequence: `child_agent_started` is a discovery-latency +optimization, not a correctness-critical upgrade trigger; a child created +during an already-blocked wait before the stream opens is caught by replay +from the cursor when it connects (self-healing). + +**Gating.** `OrchestrationUnifiedStack` gates the whole system. Off ⇒ +behavior identical to master: roots discovered only via `run_agents`/restore, +`drain_family_events` never called, `observe_child` never called. Gating the +consumption is necessary because a `run_agents`/restore parent holds an open +ancestor stream even with the flag off; without consumption gates the new +machinery would ship ungated to production the moment the server starts emitting. + +**`WaitForEventsParentRegistration`** remains as a secondary gate on the +`register_root_on_wait` call site (flag-off baseline), superseded when +`OrchestrationUnifiedStack` is on. Safe to promote/remove separately. + +**None-handling.** When a parent or wait-root has no `self_run_id` yet, +`desired_sse_filter` returns `NoFilter` (with a warn) and defers until +`on_server_token_assigned` re-evaluates. Safe in practice because the run id +arrives via StreamInit / task creation before the model can emit any tool call. + +### 3.4 Client: OrchestrationChildTracker and observe_child +All child state changes on both owner and viewer funnel through +`OrchestrationChildTracker::observe_child` (see §7.2 for the full design +and `ChildSignal` variant list). The four-step logic: + +0. Drop tombstoned runs and runs owned by a non-placeholder local conversation. +1. Create-or-update child membership, then converge the fetched task metadata + through `BlocklistAIHistoryModel::ensure_remote_child_conversation` + (`is_remote_child = true`, both modes). +2. Write status through on `Lifecycle` signals and emit the shared status event. +3. Refetch metadata via the shared task cache while + `session_id` is missing or pane not materialized. +4. Request pane materialization once `session_id` is known, or transcript once terminal. + +`ChildSignal::SessionLinked { session_uuid }` is handled directly from +`run_session_linked` events: the session UUID is extracted from `ref_id` +and fills in `session_id` without a metadata fetch, then pane materialization +is requested immediately. This eliminates the metadata-fetch round-trip +for the attach-time window. + +`ChildSignal::Started` (from `child_agent_started`) is idempotent: +calling it again for the same run id is a no-op (explicit tracker state +replaces the old `conversation_id_for_agent_id(...).is_none()` implicit guard). +An unknown `ChildSignal::Lifecycle` performs the same eager membership insert +and emits `ChildSpawned` before the metadata fetch, so lifecycle-before-started +is a complete discovery backstop rather than a tracker-only fetch. +`ChildSignal::Registered` (from `StartAgentExecutor`) prevents placeholder +creation for in-band children — tracker marks them as already-represented. +Tombstoned runs are checked at step 0 so kills mid-fetch cannot resurrect placeholders. + +```mermaid +flowchart TD + Create["AddTask(parent_run_id=P)"] --> Emit["server: emit child_agent_started on run P (ref_id=child)"] + Wait["client: first wait_for_events (root)"] --> Anc["register_root_on_wait: open AncestorRunId include_self=true"] + Emit --> Recv["drain_family_events: ChildStarted → tracker.observe_child(Started)"] + Anc --> Recv + Anc --> Track["child lifecycle + inbox delivered via drain_family_events"] +``` + +### 3.5 M1 drain: drain_family_events and classify_family_event +`drain_family_events` replaces both `drain_sse_events` (owner) and +`drain_ancestor_events` (viewer). Events are classified by +`classify_family_event(event, self_run_id)` into `FamilyEvent` variants +(see §7.3 for the full sketch): + +- **`ChildStarted`** → start/dedupe real task metadata hydration and + `tracker.observe_child(Started)` +- **`ChildSessionLinked`** → `tracker.observe_child(SessionLinked { session_uuid })` + (extracts UUID from `ref_id`; no metadata fetch needed) +- **`ChildLifecycle`** → the same metadata backstop plus + `tracker.observe_child(Lifecycle(kind))` +- **`ParentSelf`** → Primary: `handle_event_batch` (inbox + lifecycle); + Observer: dropped (no parent-self delivery) +- **`Opaque`** → cursor advances only (forward compat) + +Cursor authority: Primary calls `persist_cursor_local_and_server`; Observer +calls `persist_cursor_local_only`. `refresh_task_data` coalesces in-flight +fetches: a refetch arriving mid-fetch is recorded and one follow-up issues +on completion. + +### 3.6 M1 validation +`cargo nextest run -p warp --no-fail-fast`, `./script/format`, and clippy +(`-D warnings`) all pass. +- Flag OFF: all pre-M1 tests pass; `OrchestrationEventStreamer` keeps the two + drain paths; viewer children NOT persisted. Behavior identical to master. +- Flag ON: `drain_family_events` is the sole drain; `observe_child` is the + sole entry point for child state; viewer children persisted as + `is_remote_child = true`. +- `observe_child` idempotency: two `Started` signals for the same run id + issue exactly one metadata fetch. +- Tombstoned-run skip: `observe_child(Lifecycle)` for a killed run id is a no-op. +- `Registered` prevents placeholder creation for in-band children. +- `SessionLinked { session_uuid }` fills in `session_id` without a fetch. +- `classify_family_event`: all five variants covered by unit tests. +- Cursor authority: flag-ON + Observer → cursor advance does NOT push to server. +- Rolled-out flags deleted; legacy REST polling path absent. + +Manual (dogfood, `OrchestrationUnifiedStack` on, server PR deployed): create +a child via Oz CLI/web API with `parent_run_id`, have the parent +`wait_for_events`; verify the child surfaces without polling latency as a +named pill with attributed messages. Click a child pill at three lifecycle +moments (early/Queued, running, completed) and verify re-drive → live join → +transcript respectively (§4.5 empirical contract). + +## 4. North-star architecture +### 4.1 At a glance +```mermaid +flowchart LR + LOG[("server
ai_run_event_log")] --> FS["one family SSE
AncestorRunId include_self=true"] + subgraph STREAMER["OrchestrationEventStreamer"] + FS --> CF["classify_family_event"] + CF -->|ChildStarted/SessionLinked/Lifecycle| TRK["OrchestrationChildTracker
observe_child"] + CF -->|ParentSelf| HEB["handle_event_batch
inbox + lifecycle; cursor authority"] + TRK --> PILL["pill bar (both modes)"] + TRK --> PANE["create_hidden_child_agent_pane
ChildPaneMaterialization dispatch (M2)"] + end +``` + +One SSE per parent family; `OrchestrationEventStreamer` hosts both Primary and +Observer tracker instances. The streamer's state maps (`streams` for Primary, +`viewer_mode_orchestrators` for Observer, retaining its legacy field name) +each carry an `OrchestrationChildTracker`. The tracker is the sole entry point +for child state changes; `OrchestrationViewerModel` and the Primary drain both +delegate to it. + +### 4.2 Delivery path +`handle_event_batch` is called for `ParentSelf` events by Primary only. +It advances and persists the cursor (SQLite + server for Primary, SQLite-only +for Observer), drops killed-run events, and enqueues inbox messages and +lifecycle items into `OrchestrationEventService` for the parent's LLM +input path (`drain_and_convert_events`). The tracker, not `handle_event_batch`, +writes child `ConversationStatus` — this fixes the owner-side pill-staleness +gap (§6, item 3) where status lagged until pane attach. + +### 4.3 Pane path (M2) +See §7.5. `ChildPaneMaterialization` with three variants: +- **`AttachLive { session_id }`**: `attach_child_session` using the pane + origin's construction path. The joined shared-session `Role`, not origin or + task ownership, controls live input. +- **`LoadTranscript { server_token }`**: fetch transcript and permissions. + Explicit `ConversationAccess::Edit` uses the continuation-capable ambient + presentation when the task source permits cloud follow-ups; blocked sources, + `ViewOnly`, and `Unknown` use the passive read-only transcript. + When permissions metadata is unavailable, authoritative + `TaskOwnership::Owned` is the compatibility fallback; it cannot override + explicit ViewOnly. +- **`Pending`**: tracker re-drives when state changes via `observe_child`. + +`ChildPaneOrigin::{HostedConversation, SharedSession}` is orthogonal to this +state decision and to capabilities. + +### 4.4 Empirical grounding (three click-timing cases) +Validated against a healthy session-sharing server: +- **Early click (Queued/Pending)**: child not attachable for ~10s; pane + re-drives as the task advances. `run_session_linked` fires at sandbox claim; + `SessionLinked` signal fills in `session_id` directly without a metadata fetch. +- **Running click**: single immediate `AttachLive`. +- **Completed click**: single terminal `LoadTranscript` (owner and viewer). + +## 5. Differences that drove the unified design +*These were the gaps in the pre-M1 baseline; all are closed by M1 + M2.* + +1. **Consumer gating.** OVM registered only in the viewer context; the owner + drain maintained separate helpers. M1: both delegate to `observe_child`. +2. **Placeholder flavor.** `is_remote_child` (owner, persisted) vs + `is_viewing_shared_session` (viewer, runtime-only). M1: unified to + `is_remote_child = true` for all child placeholders, fixing the viewer + restore-after-restart bug. +3. **Broadcast events were viewer-only.** `ChildSpawned`/`ChildStatusChanged` + emitted only by `drain_ancestor_events`; owner drain fed `handle_event_batch` + directly (no status writes). M1: tracker emits them for both modes and is + the sole status writer. +4. **Two ancestor SSEs with different wire filters and cursor authority.** + M1: one `include_self: true` family SSE; viewer drops `ParentSelf` events; + cursor authority dispatched by mode inside `drain_family_events`. +5. **Pane materialization differed.** Owner had `LoadTranscript`; viewer + dead-ended at loading state for completed children. M2: `ChildPaneMaterialization` + with `LoadTranscript` for both modes. + +## 6. Why unify (the value) +1. **Duplication and drift.** Six near-identical concerns implemented + twice, in one file plus two pane paths. Each fix must be discovered and + applied twice. Historical evidence: the pre-M1 owner side had to re-grow + refetch, self-heal, and placeholder logic that OVM already had. +2. **Two ancestor SSE connections per parent** when an owner and a viewer run + in the same process family (and always two server-side query shapes to + maintain). One JOIN-backed stream per parent family is strictly cheaper + and removes a whole class of "which stream saw it first" reasoning. +3. **Capability gaps are side-of-origin accidents, not decisions.** + - The **restore-after-restart bug**: a `/cloud-agent` shared-session parent + restores without its children — no pills, children render as "Unknown + agent" — because viewer placeholders are runtime-only (§2) and OVM's + registration precondition isn't re-established on restore. The owner + flavor survives restart; the viewer flavor does not. + - The **terminal-transcript gap**: clicking a finished child works + owner-side (`LoadTranscript`) but dead-ends viewer-side (loading + placeholder forever), because only one stack grew the branch. + - The **owner-side pill-staleness gap**: owner-side cloud-child + placeholders had no event-driven status writer (lifecycle events were + consumed as LLM inputs, not status writes). M1 fix: tracker is sole + status writer for both modes. +4. **Bespoke machinery outlives its cause.** The pending/settle re-drive + (`pending_remote_child_hydrations`, `settles()`) existed because the owner + pane path could be entered before task data was complete. M2 fix: tracker + re-drives `Pending` children from `observe_child`; no bespoke machinery. +5. **Reviewability.** `orchestration_event_streamer.rs` is ~2600 lines + hosting two parallel pipelines with different key types, cursor rules, + and event contracts. Collapsing them is the single biggest lever on + comprehension and future orchestration work (e.g. multi-level trees would + today need to be implemented twice). + +## 7. North star architecture +### 7.1 Overview +One of each mechanism: +- **One discovery signal**: `child_agent_started` (creation-time) plus child + lifecycle events as the self-healing backstop, consumed identically for + owner and viewer. +- **One ancestor stream per parent family**: a single + `AncestorRunId { include_self: true }` SSE whose drain fans out by event + kind — parent inbox to the owner's inbox consumer, discovery/lifecycle to + the child tracker — while respecting cursor authority. This is the + `AncestorForwardingConsumer` generalization the code already anticipates. +- **One child tracker**: an `OrchestrationChildTracker` owning discovery, + claim-time refetch, placeholder creation, and materialization requests for + both Primary and Observer consumers. +- **One placeholder flavor**: a single persisted conversation kind with a + mode tag, fixing the viewer restore bug by construction. +- **One pane path**: a state-only materialization function with live-session, + terminal-transcript, and pending branches, followed by independent origin + and access presentation decisions. +- **Refresh**: event-driven with a bounded fallback (already true after + Phase 0 on both sides). + +```mermaid +flowchart LR + LOG[("server
ai_run_event_log")] --> FS["one family SSE per parent
AncestorRunId include_self=true"] + FS --> FD["family drain
(AncestorForwardingConsumer)"] + FD --> INBOX["parent inbox delivery
(Primary only)"] + FD --> TRK["OrchestrationChildTracker
observe_child()"] + FD --> CUR["cursor advance
Primary → SQLite + server
Observer → SQLite only"] + TRK --> PLH["one placeholder flavor
(persisted, mode-tagged)"] + TRK --> PB["pill bar
ChildSpawned / ChildStatusChanged"] + TRK --> MAT["one pane path
live / transcript / pending"] +``` + +### 7.2 `OrchestrationChildTracker` (sketch) +Extract OVM's core into a model keyed on the orchestrator, running in both +modes. The mode captures the only real behavioral differences: +```rust +/// Family-event consumption role (not authenticated ownership / permissions). +enum OrchestrationEventConsumer { + /// Primary family-event consumer: deliver parent-self events and + /// persist local + authoritative server cursor. + Primary { orchestrator_conversation_id: AIConversationId }, + /// Observer family-event consumer: drop parent-self events; persist + /// local cursor only (never push server cursor). + Observer { placeholder_conversation_id: AIConversationId }, +} + +struct TrackedChild { + conversation_id: AIConversationId, // the unified placeholder + session_id: Option, // None until claim time + last_state: AmbientAgentTaskState, + pane_materialized: bool, +} + +pub struct OrchestrationChildTracker { + parent_task_id: AmbientAgentTaskId, + mode: OrchestrationEventConsumer, + children: HashMap, + children_by_run_id: HashMap, + /// In-flight metadata fetches (today's `remote_child_placeholder_fetches` + /// and OVM's dispatch guard, unified). + metadata_fetches: HashSet, +} + +/// Every way a child can become known funnels into one entry point. +enum ChildSignal { + Started, // child_agent_started (ref_id) + Lifecycle(api::LifecycleEventType), // any recognised lifecycle event + Seeded(AmbientAgentTask), // REST seed / restore fetch row + /// Created by this process (run_agents / start_agent): the executor + /// registers the child it just made, with its existing conversation. + Registered { conversation_id: AIConversationId }, +} + +impl OrchestrationChildTracker { + fn observe_child(&mut self, child_run_id: &str, signal: ChildSignal, ctx: ...) { + // 0. drop tombstoned (locally killed) runs, and runs owned by a + // non-placeholder local conversation (local in-band children) + // 1. ensure placeholder exists (create-or-update; self-healing by + // construction since every signal funnels here) + // 2. write status through on lifecycle signals (sole writer, §7.3) + // 3. refetch metadata while session_id is missing or pane not + // materialized (claim-time wait) + // 4. request pane materialization once session_id is known, or a + // transcript view once terminal (§7.5) + } +} +``` +This subsumes, on the owner side: `register_children_from_events`'s +placeholder work, +`ensure_remote_child_placeholder`/`finish_remote_child_placeholder`, +`ensure_placeholders_for_child_lifecycle_events`, and +`trigger_child_task_refreshes`; on the viewer side: `handle_child_spawned`, +`handle_child_status_changed`, `spawn_task_metadata_fetch`, `register_child`. + +**Child membership has one writer.** The streamer keeps only wire concerns. +Under the family (ancestor) filter the wire shape needs just the parent's +`self_run_id` (`desired_sse_filter`'s ancestor branch already uses nothing +else), so per-child run-id sets stop being filter inputs: child membership +lives in the tracker alone, and the streamer's parent-role check and +`RunIds`-fallback derivation read tracker state instead of maintaining +`watched_run_ids` copies. `watched_run_ids` shrinks to self-inbox watching +for the legacy fallback. This avoids re-creating the dual-source-of-truth +problem §7.6's fifth item warns about. + +In-band children flow through the same funnel: the `StartAgentExecutor` +registers each child it spawns (`ChildSignal::Registered`), so later +`Started`/`Lifecycle` signals for that run id are idempotent status updates +rather than placeholder creation — replacing today's implicit +`conversation_id_for_agent_id(...).is_none()` guards with explicit tracker +state. Local in-process children are observed for status only and never get +placeholders or metadata fetches (§9.4). All tracker metadata fetches route +through `AgentConversationsModel`, not raw client calls (§7.6, item 1). + +**Cardinality, mode resolution, and lifetime.** One tracker per +`parent_task_id` per process, hosted in a singleton registry with refcounted +consumers — exactly the shape of today's `viewer_mode_orchestrators` entries. +OVM and the owner's agent view become thin per-pane consumers that register +and unregister. Mode is *derived*, not configured: `Primary` when this process is the +family-event primary (delivers parent-self + server cursor); `Observer` +otherwise. A second local pane on the same family registers as another +consumer of the existing tracker rather than creating a second tracker. +Primary trackers live as long as the orchestrator conversation; Observer +trackers tear down when the last consumer unregisters (today's refcounting +rule). This type describes family-event consumption and cursor +responsibility only — not authenticated ownership, permissions, or pane +capability. + +### 7.3 One family stream per parent (sketch) +The streamer keeps one connection per parent family, always +`include_self: true`, and the drain classifies rather than duplicates: +```rust +enum FamilyEvent { + /// Event on the parent's own run: inbox message or parent lifecycle. + ParentSelf(AgentRunEvent), + /// child_agent_started on the parent run; child run id in ref_id. + ChildStarted { child_run_id: String }, + /// Lifecycle event on a child run. + ChildLifecycle { child_run_id: String, kind: api::LifecycleEventType }, + /// Unrecognised event type: advances the cursor only (forward compat). + Opaque, +} + +fn drain_family_events(&mut self, parent_task_id: AmbientAgentTaskId, ctx: ...) { + for event in buffered { + match classify(&event, &self_run_id) { + // Primary only; an Observer drops parent-self events. + // hydration is skipped, or receives-and-drops them (see §9.2). + FamilyEvent::ParentSelf(e) => self.deliver_owner_inbox(e, ctx), + FamilyEvent::ChildStarted { child_run_id } => + tracker.observe_child(&child_run_id, ChildSignal::Started, ctx), + FamilyEvent::ChildLifecycle { child_run_id, kind } => { + tracker.observe_child(&child_run_id, ChildSignal::Lifecycle(kind), ctx); + ctx.emit(ChildStatusChanged { .. }); // pill bar, both modes + } + FamilyEvent::Opaque => {} + } + } + // Cursor authority: one scalar per family stream. + match mode { + Primary { .. } => self.persist_cursor_local_and_server(max_seq, ctx), + Observer { .. } => self.persist_cursor_local_only(max_seq, ctx), + } +} +``` +Message hydration becomes an opt-in on the forwarding consumer (exactly the +flag `AncestorForwardingConsumer`'s doc comment anticipates), enabled for +Primary and disabled for Observer. + +The tracker maps lifecycle status and writes through when the durable mapping +already exists. OVM consumes the same broadcast and writes the identical +status for its pane; task-snapshot registration also initializes status. +These writes are idempotent and converge on the one history conversation. +Local in-band children keep their own controller as their status authority. + +### 7.4 One placeholder flavor +Persist a single child-placeholder kind; keep the on-disk shape +backward-compatible by reusing the existing fields: +- Keep `is_remote_child: bool` in `AgentConversationData` as the persisted + marker for "child placeholder without a local run" (rows written by today's + builds already have it). +- Represent viewer-ness as a **runtime mode on the tracker**, not a persisted + conversation flavor. Viewer-created placeholders start persisting with the + same marker, which fixes child restore and run-id attribution. +- Server-status-report suppression keys off the unified + `is_remote_child` marker instead of `is_viewing_shared_session()`. +`is_viewing_shared_session` remains for the *parent* viewer placeholder (a +genuine shared-session concept); only the child-placeholder use retires. +`BlocklistAIHistoryModel::ensure_remote_child_conversation` is the atomic +run-id mapping authority. The Primary placeholder callback and OVM metadata +callback may race, but both create-or-adopt this mapping, so exactly one named +conversation populates `agent_id_to_conversation_id`. OVM retains only its +per-pane materialization state and adopts the durable `is_remote_child` +conversation. Restored pane origin is derived from the restored parent being +a shared-session Observer, never from child ownership. + +**Durable Observer parent.** `is_viewing_shared_session` stays runtime-only +and passive links remain ephemeral. When OVM resolves the parent task as +`TaskOwnership::Owned`, it stamps the narrow, serde-defaulted +`is_durable_observer_parent` marker and the real task/run ID. This exception +allows the shared parent conversation, its local-only cursor, and child links +to persist. Startup eagerly hydrates only marked parents; the existing +`AmbientAgentPaneSnapshot.task_id` identifies the exact pane/task. Running +parents select the shared-session attach path; `TerminalManager` reattaches +the local conversation before OVM registration and response replay. Terminal +parents resolve to `RestoreOrNavigateToConversation`; the ambient app-state +restorer recognizes that action only for the durable marker and replaces the +loading pane with the established restored cloud-mode conversation. This +installs the existing conversation ID/exchanges before agent view entry and +prevents the New cloud agent zero state. Arbitrary shared links never receive +the marker, and flag-off retains the fresh-pane fallback. Older rows default +the field to false. + +### 7.5 One pane path +`create_hidden_child_agent_pane` collapses to a single child-placeholder +branch that dispatches on observable state, unifying today's +`decide_remote_child_hydration_action` with the viewer materialization and +adding the missing transcript branch for viewers: +```rust +enum ChildPaneMaterialization { + /// Attachable live session: join it. Returned SSS Role controls input. + AttachLive { session_id: SessionId }, + /// Terminal run with a server conversation: load transcript + permissions. + LoadTranscript { server_token: ServerConversationToken }, + /// Not yet attachable: show pending state; the tracker re-drives on the + /// next lifecycle-driven refetch. + Pending, +} +``` +`ChildPaneOrigin::{HostedConversation, SharedSession}` selects construction +context only. After `LoadTranscript`, `ConversationAccess::Edit` selects the +continuation-capable ambient presentation; ViewOnly/Unknown remain passive. +Authoritative task scope is used only when conversation permissions metadata +is unavailable. +`settles()`/`pending_remote_child_hydrations` disappear: "pending" is simply a +tracked child whose `pane_materialized` is false, re-driven by +`observe_child`. The local-child branch of `create_hidden_child_agent_pane` +(a real hidden terminal pane for an in-process child) is untouched: the +unified path replaces only the two placeholder branches. + +### 7.6 Adjacent consolidations across all child kinds +Walking the full taxonomy (§2) surfaces four further consolidations that the +tracker makes cheap; the first three belong to Phase 1, the fourth to +Phase 3+. +1. **Task-metadata fetch convergence.** `get_ambient_agent_task` for + children runs through five independent paths with three different + retry/dedup schemes: the post-restore fetch (own exponential backoff, + `RESTORE_FETCH_BACKOFF_STEPS`), the harness fetch + (`spawn_task_harness_fetch_if_needed`), the placeholder fetch (own + in-flight guard), OVM's `spawn_task_metadata_fetch` (raw client, no + dedup), and `AgentConversationsModel::async_fetch_task` — the only one + with in-flight dedup, failure cooldowns, a cache, and a `TasksUpdated` + signal. The tracker and pane hydration use `AgentConversationsModel`. + Streamer placeholder completion and OVM still have raw fetch adapters, but + OVM dedupes in flight and both callbacks converge atomically through + `ensure_remote_child_conversation`; a later cleanup can move the remaining + requests behind the shared cache without changing identity semantics. +2. **One status-mapping module.** Child status exists in three + representations — wire `event_type`, REST `AmbientAgentTaskState`, client + `ConversationStatus` — with mirrored mappings in two files: + `conversation_status_from_lifecycle_event_type` (streamer) documents that + it mirrors `conversation_status_from_state` (OVM), and hydration + separately consults `is_terminal_run_state()`. One mapping module, owned + alongside the tracker, replaces the mirror-comment contract with a single + function set. +3. **One cold-start seed.** The post-restore fetch + (`finish_restore_fetch`/`apply_task_children`), the viewer REST seed + (`finish_ancestor_seed_fetch`), and wait-time registration are all + "cold-start: fetch children, merge cursor, install" with different + retry and cursor-merge logic. `ChildSignal::Seeded` makes them one + mode-agnostic seed routine (already implied by the Phase 3 scorecard's + "seed-vs-restore duality"; the seed routine itself can unify in Phase 1). +4. **Deduplicate local-child event delivery.** With the parent's family + stream open (`include_self=true`), every local in-band child's events are + already delivered to this process — and delivered *again* on that child's + own `RunIds([self])` stream (disjoint consumption: the parent takes + lifecycle, the child takes its inbox). N local children means N+1 + connections carrying overlapping data. Folding child inbox delivery into + the family drain collapses this to one connection — and the dormant-Claude + wake listener becomes a drain classification case instead of a third + connection type. Complication: each child's own per-run server cursor must + still advance (or be explicitly retired) — see §9.2. This is the §11 open + question, promoted to a named opportunity. +A fifth, softer one: child identity/relationship maps proliferate +(`watched_run_ids`, `known_children`, OVM's `children`/`children_by_run_id`, +`child_agent_panes`, `pending_remote_child_hydrations`, history's +`children_by_parent`/`agent_id_to_conversation_id`). The end state should +declare exactly two sources of truth — the history model (identity/linkage) +and the tracker (orchestration state) — with everything else derived. + +## 8. Migration plan +**Unified north-star implementation — two-PR stack from master.** Rather +than shipping an intermediate Phase 0 layer and then layering Phases 1–3 on +top (which would require writing and then deleting ~600 lines of scaffold), +the full north-star architecture is implemented directly in two stacked PRs +behind a single `OrchestrationUnifiedStack` dogfood flag. + +**M1 — Core tracker + unified stream (PR targets master).** `OrchestrationChildTracker` +(§7.2) as the sole entry point for child state; `classify_family_event` + +`drain_family_events` replacing both `drain_sse_events` and `drain_ancestor_events` +(§7.3); unified `is_remote_child` placeholder including viewer-created children +(§7.4); `ChildSignal::SessionLinked` carries the session UUID directly from +`run_session_linked` events, eliminating metadata fetches for the attach-time +window; rolled-out flag removal (`OrchestrationViewerStreamer`, +`OwnerOrchestrationAncestorStreamer`) + legacy viewer REST polling deletion. +Flag-off: behavior identical to master before this PR. Flag-on: one SSE per +parent, tracker owns all child state. + +**M2 — Pane path + transcript (PR targets M1 branch).** `ChildPaneMaterialization` +(§7.5) as the single dispatch for all placeholder children; converged +`attach_child_session` for both pane origins; state-independent +`ChildPaneOrigin`; typed task ownership; and capability-aware transcript +presentation (`LoadTranscript` when terminal + `conversation_id`, +authorization resolved per §9.1). Edit access restores the established +ambient continuation pane; ViewOnly/Unknown stays passive. Deletes old +dispatch machinery: +`decide_remote_child_hydration_action`, `RemoteChildHydrationAction`, +`settles()`, `pending_remote_child_hydrations`, +`process_pending_remote_child_hydrations`, `hydrate_task_backed_hidden_child_pane`, +`live_attach_ambient_session_to_pane`, `ensure_shared_session_viewer_child_pane`. + +```mermaid +flowchart LR + MASTER([master]) --> M1["M1 (PR1)
OrchestrationChildTracker
+ family drain
+ placeholder unification"] + M1 --> M2["M2 (PR2)
ChildPaneMaterialization
+ converge attach
+ transcript both modes"] + M2 --> DONE(["North star"]) +``` + +### Flag-gating strategy +- **One flag (`OrchestrationUnifiedStack`)** gates the entire system. Flag-off + preserves exact master baseline; flag-on is the full north-star. No + intermediate states to maintain. +- **Persisted format is forward-compatible**: `is_remote_child = true` rows + written by the new system are treated as owner-side pills by old builds + (click-through degrades gracefully per §9.3). The flag only controls whether + viewer-created rows are written; the encoding is unchanged. +- **`WaitForEventsParentRegistration`** is preserved in M1 (it guards the + `register_root_on_wait` mechanism used by the flag-off path) but superseded + by `OrchestrationUnifiedStack` when the flag is on. Promote/remove it + separately after `OrchestrationUnifiedStack` is fully rolled out. + +## 9. Hard sub-problems and design decisions +### 9.1 Terminal child transcript (Phase 2a) +The viewer path materializes only on a live `session_id`. Clicking a finished +child must show its transcript; the unified path adds the transcript branch +(terminal + `conversation_id`, no live session) — additive to OVM and +effectively the surviving piece of today's `LoadTranscript`. The empirical +contract (§4.5) is the acceptance test. + +**Authorization (resolved).** Policy decision: if a user has access to view +a parent orchestrator session, they have access to view the transcripts of +that session's direct children. Implementation: when a child run's conversation +object is created (in `UpsertAIConversationMetadata` or +`CreateThirdPartyConversation` in warp-server), propagate the *parent run's* +shared session ACLs to the child conversation, in addition to the child's own +session ACLs. This gives parent-session viewers `ViewAction` on child +conversation objects, making `getAndVerifyManifest`'s `ViewAction` check +pass for them. The server change is a prerequisite for Phase 2a's viewer +transcript branch. Client-side: both pane origins return `LoadTranscript` from +the unified dispatch when the run is terminal and a `conversation_id` exists. + +**Ownership-aware presentation.** Family-event consumer authority remains +Primary/Observer regardless of authenticated ownership. Pane construction +records `ChildPaneOrigin`, also without granting permissions. Task payloads +deserialize authoritative `scope: { type: User|Team, uid }` and resolve +tri-state `TaskOwnership`; exact creator equality is used only when older +payloads omit scope. + +After transcript fetch, conversation object permissions resolve +`ConversationAccess::{Edit, ViewOnly, Unknown}`. Explicit Edit selects the +continuation-capable restored ambient cloud-mode pane when task source policy +allows follow-ups; blocked sources, ViewOnly, and Unknown select the passive +read-only transcript. When permissions metadata is absent, +`TaskOwnership::Owned` may provide a compatibility fallback to Edit, but it +never overrides explicit ViewOnly. + +**Live child authorization.** A successful child shared-session join's +returned role is authoritative. Reader stays read-only; executable roles may +send input. Task ownership and pane origin never override Reader, +`SessionNotAccessible`, or join failure. `SessionNotFound` is a stale/missing +session signal: evict/refetch task state and transition to transcript if the +run is terminal. Parent-to-child live authorization for non-owners is a +separate future server policy and is not part of M2. + +### 9.2 One stream serving inbox + lifecycle with split cursor authority (Phase 3) +Primary needs `include_self=true` + hydrated `new_message` delivery *and* +the lifecycle broadcasts; Observer must get lifecycle without paying for +inbox hydration and without pushing the server cursor. Decisions to make: +- Hydration opt-in on the forwarding consumer (Primary on, Observer off) — the + direction `AncestorForwardingConsumer`'s doc already sketches. +- Whether an Observer's `include_self=true` stream simply drops `ParentSelf` + events client-side (simplest; costs the parent's event volume on the wire) + or keeps `include_self=false` as a viewer-only optimization (two query + shapes survive, but only as a parameter, not two pipelines). +- Cursor: one scalar per family stream; `persist_event_cursor`'s Observer + short-circuit becomes the mode dispatch in §7.3. +- Local in-band children (§7.6, item 4): if their inbox delivery moves onto + the family stream, each child's own per-run server cursor must still + advance (or be explicitly retired); until then their per-child streams stay + for inbox while lifecycle rides the family stream. + +### 9.3 Placeholder persistence compatibility (Phase 1) +Old builds must restore rows written by new builds and vice versa. Reusing +`is_remote_child` as the persisted marker (§7.4) makes new viewer-child rows +look like owner placeholders to old builds — acceptable (they render as +pills; click-through degrades to transcript-when-terminal). New builds +restoring old rows see no viewer children (status quo). The new parent +`is_durable_observer_parent` field is serde-defaulted and skipped when false; +old builds ignore it, while new builds treat absent as false. No migration +is needed. + +### 9.4 What stays deliberately un-unified +- The **wake-only listener** for dormant local Claude children + (`DormantClaudeWakeConsumer`) — a different lifecycle problem (folds into + the family drain only if §7.6 item 4 proceeds). +- **Local (same-process) in-band children**: their conversations, terminal + panes, and child-role inbox SSEs (`RunIds([self])`) are real and unchanged. + The tracker treats them as already-represented — no placeholder, no + metadata fetch — and only their lifecycle status flows through it (pill + updates). Whether their inbox delivery could later ride the family stream + too is deliberately out of scope here (§11). +- The **parent viewer placeholder** (`is_viewing_shared_session` on the + orchestrator conversation itself) — a shared-session concept, not a child + representation. + +## 10. Deletion scorecard +**M1 deletes (never written or deleted from baseline):** +- `FeatureFlag::OrchestrationViewerStreamer`, `FeatureFlag::OwnerOrchestrationAncestorStreamer` + and all usage sites (fully rolled out, deleted from `features.rs`) +- Legacy viewer REST polling path: `fetch_children`, `schedule_next_poll`, + `maybe_kick_polling`, `apply_children_fetch` + interval constants +- Both separate drain pipelines: `drain_sse_events` + `drain_ancestor_events` + replaced by `drain_family_events`; `drain_sse_events`' helpers + `register_children_from_events`, `ensure_placeholders_for_child_lifecycle_events`, + `trigger_child_task_refreshes` (all subsumed by `observe_child`) +- OVM child creation no longer writes a second + `is_viewing_shared_session` flavor; its fetch/status handlers remain thin + pane-state adapters and adopt the history model's mapping. +- `is_viewing_shared_session` child-placeholder flavor for new writes +- `WatchedRunIds` per-child run-id sets as filter inputs (child membership + lives in the tracker; streamer uses only `self_run_id` for the ancestor filter) + +**M2 deletes:** +- `decide_remote_child_hydration_action`, `RemoteChildHydrationAction`, `settles()` +- `pending_remote_child_hydrations`, `process_pending_remote_child_hydrations` +- `hydrate_task_backed_hidden_child_pane` +- `live_attach_ambient_session_to_pane`, `ensure_shared_session_viewer_child_pane` + (converged into `attach_child_session`) +- Second live-attach construction path; `is_remote_child` and + `is_viewing_shared_session` separate branches of `create_hidden_child_agent_pane` + (unified to one placeholder branch) + +## 11. Risks, validation, open questions +### Follow-up cleanup +- **Single task metadata fetch authority.** Flag-on discovery currently has two + ways to learn child task metadata: the streamer's placeholder-creation path + fetches the child task so it can create a named history row, while the + tracker asks `AgentConversationsModel` to fetch or refresh task state for + session/transcript materialization. Both paths are idempotent, but they can + duplicate network requests and maintain overlapping task snapshots. A + follow-up should make `AgentConversationsModel` the only fetch/in-flight + authority and have placeholder creation, tracker state, and pane + materialization re-drive from that cache. +- **Child registry consolidation.** Child identity and live state are still + split across `BlocklistAIHistoryModel` (persisted conversation/run mapping), + `OrchestrationChildTracker` (family event state), `OrchestrationViewerModel` + (observer pane/status adapters), and `PaneGroup` (pane materialization and + pending hydration maps). The current implementation uses explicit + idempotency guards at each boundary, but the long-term shape should be: + history as the durable identity source of truth, tracker as transient event + state, OVM as a thin observer adapter, and PaneGroup as pane lifecycle only. + Defer this until the dogfood behavior stabilizes so the actual invariants are + clear. + +**Risks** +- *Viewer regression*: OVM is load-bearing. M1 keeps all pre-M1 tests + green and adds tracker coverage; flag-off is byte-identical to master. +- *Cursor authority*: the owner is the authoritative server-cursor writer; a + shared stream must preserve the viewer's read-only cursor (mode dispatch, + §7.3), else a viewer could fast-forward the owner's resume point. +- *One-level-tree invariant*: discovery assumes direct children; preserve + `register_root_on_wait`'s child guard and revisit alongside the server JOIN + if multi-level trees arrive. +- *Forward/backward compat*: old clients ignore `child_agent_started` and + `run_session_linked` (unknown event types, cursor advances harmlessly). The + server PR is safe to ship before the client. `OrchestrationUnifiedStack` + off ⇒ flag-off baseline, no exposure. +- *`include_self` semantics*: resolved in M1 — viewer receives the same + `include_self: true` stream and drops `ParentSelf` events client-side. + See `classify_family_event` in §3.5. +- *Kill tombstones*: `observe_child` step 0 is the sole tombstone gate; + it runs before any placeholder creation or pane request, including across + the metadata-fetch await and the cancel-during-spawn race. +- *Reconciliation SSE churn (known transient)*: dropping a stale placeholder + in `assign_run_id_for_conversation` emits removal events whose run id the + streamer prunes from every watched set — including the parent mid-claiming + that run for its real local child. For a single-child parent this tears + down and reopens the parent SSE (the executor's `register_watched_run_id` + re-adds it); drain-before-teardown prevents data loss and the cursor is + preserved, but correctness leans on the emission order of three history + events. M1 should make re-pointing explicit (prune the run-id index without + treating it as child death) rather than relying on event ordering. + +**Validation (M1 validation in §3.6; M2 below)** +- Task scope serde and ownership: user match/mismatch; team member/nonmember; + service-account team; absent scope creator fallback; unknown/malformed + scope remains Unknown. +- An authenticated owner observing through a shared link remains an Observer: + no parent-self delivery and no server cursor write. +- Completed child presentation: Edit → continuation-capable ambient pane; + ViewOnly/Unknown → passive transcript; explicit ViewOnly overrides task + ownership fallback. +- Live role: Reader cannot send input; executable SSS roles can. Ownership and + pane origin do not affect this result. +- Re-run the three click-timing cases (early / running / completed) for + HostedConversation and SharedSession origins after M2 lands; the completed + shared-session case is new coverage delivered by M2. +- Restart-restore case: an owned `/cloud-agent` Observer parent restores from + its ambient pane task ID with the persisted local cursor, re-registers OVM, + and reconstructs named child pills from persisted `is_remote_child` rows. + App-state tests cover both running shared-session selection and terminal + existing-conversation restoration with exchanges and no compose zero state. +- Owner-side pill status updates while the child pane stays closed (M1: + tracker is sole status writer in both modes). +- Unit surfaces: tracker state machine (`observe_child` idempotency, signal + ordering, tombstone skip, fetch dedup), drain classification, + cursor-authority dispatch, pane-path branch selection, stale terminal + session, bounded SessionNotFound recovery, and empty-transcript/no-compose + presentation. +- Run native and WASM checks. If WASM fails before compiling Warp code due to + the local C/clang target, record that pre-Warp toolchain blocker explicitly. +- Observability: counters/logs for placeholder creations, metadata-fetch + failures, and family-stream opens per mode, so a flag-on regression shows + up in dogfood telemetry rather than only in bug reports. + +**Open questions** +- **RESOLVED.** Does the server reliably emit a lifecycle event at (or just + after) `session_id` linking? Yes: `run_session_linked` (S5) is emitted + and M1 consumes it via `ChildSignal::SessionLinked`, filling in `session_id` + without a metadata fetch. No polling fallback needed. +- **RESOLVED.** Phase 3 topology: M1 ships one `include_self: true` family + SSE per parent; viewer drops `ParentSelf` events client-side (simplest; + avoids a second wire shape). Resolved by implementation decision in M1. +- Should the unified placeholder eventually rename `is_remote_child` to a + neutral `is_child_placeholder` (serde alias for compatibility), or is the + legacy name acceptable indefinitely? +- Should local in-band children's inbox delivery eventually ride the family + stream as well (retiring their per-child `RunIds([self])` streams, per the + `AncestorForwardingConsumer` sketch), or is per-child stream isolation + worth keeping? +- **RESOLVED.** Viewer transcript authorization (§9.1): parent-session + viewers are granted access to child transcripts. Server must propagate + parent session ACLs to child conversation objects at creation time. +- Viewer seed pagination: the cold-start REST seed caps at 100 children + (server cap); fine today, but the unified seed should define behavior + beyond it.