Skip to content

Commit 53b8105

Browse files
cephalonautoz-agent
andcommitted
QUALITY-928 M2: Pane path, transcript, OVM, ACM, and hydration simplification
Wires M1's tracker into the client pane group and conversation model: - OrchestrationViewerModel: uses AgentConversationsModel as fetch authority; pending_task_ids_for_discovery drain path; drain_pending_task_discoveries. - Unified child pane materialization: apply_child_pane_materialization, attach_ambient_orchestration_child_session, hydrate_child_transcript, and process_pending_child_hydrations replace the parallel owner/viewer paths. ChildPaneOrigin dropped from the pending map; stale-session guard universal. - new_for_ambient_orchestration_child: TerminalManager constructor combining is_ambient_agent=true with orchestration_child_conversation_id=Some for FailedToJoin recovery routing on all child panes. - TaskOwnership/TaskScope removed; simple creator.uid check used instead. - Dead code removed: create_orchestration_child_shared_session_viewer, new_for_orchestration_child, ChildPaneOrigin enum. - EnsureSharedSessionViewerChildPane handler simplified (flag-ON branch removed). - Orchestration tracker/streamer tests updated for M2 behavioral changes: Started inserts TrackedChild immediately (children non-empty after observe_child). Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent cedb591 commit 53b8105

28 files changed

Lines changed: 3834 additions & 2021 deletions

app/src/ai/agent_conversations_model.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,6 +1273,8 @@ impl AgentConversationsModel {
12731273
self.tasks.values()
12741274
}
12751275

1276+
/// Seeds the task cache so tests can exercise cache-hit paths without a
1277+
/// server round trip.
12761278
#[cfg(test)]
12771279
pub(crate) fn insert_task_for_test(&mut self, task: AmbientAgentTask) {
12781280
self.tasks.insert(task.task_id, task);
@@ -1719,6 +1721,59 @@ impl AgentConversationsModel {
17191721
}
17201722
}
17211723

1724+
/// Updates a cached task to reflect that execution has started and its
1725+
/// session is now known (from a `run_session_linked` wire event). If the
1726+
/// task is not yet cached, starts a fetch to retrieve it.
1727+
///
1728+
/// Mutating the cache entry directly avoids a full round-trip while still
1729+
/// giving `decide_child_pane_materialization` the `InProgress` +
1730+
/// `is_sandbox_running=true` + `session_id` it needs to return `AttachLive`
1731+
/// on the next pill click. `TasksUpdated` is emitted so any pending
1732+
/// re-drives fire immediately.
1733+
pub fn update_task_as_running_with_session(
1734+
&mut self,
1735+
task_id: &AmbientAgentTaskId,
1736+
session_id_str: String,
1737+
ctx: &mut ModelContext<Self>,
1738+
) {
1739+
use crate::ai::ambient_agents::AmbientAgentTaskState;
1740+
if let Some(task) = self.tasks.get_mut(task_id) {
1741+
task.session_id = Some(session_id_str);
1742+
task.is_sandbox_running = true;
1743+
// Only promote to InProgress if still in a queued/pending state;
1744+
// never downgrade a terminal state that may have arrived concurrently.
1745+
match task.state {
1746+
AmbientAgentTaskState::Queued
1747+
| AmbientAgentTaskState::Pending
1748+
| AmbientAgentTaskState::Claimed => {
1749+
task.state = AmbientAgentTaskState::InProgress;
1750+
}
1751+
_ => {}
1752+
}
1753+
ctx.emit(AgentConversationsModelEvent::TasksUpdated);
1754+
} else {
1755+
// Task not cached yet; start a fetch.
1756+
self.async_fetch_task(task_id, ctx);
1757+
}
1758+
}
1759+
1760+
/// Evicts a task from the cache and immediately starts a fresh
1761+
/// `GET /agent/runs/{id}` fetch. Used by the family drain when a terminal
1762+
/// lifecycle event arrives for a child whose cached state is stale (e.g.
1763+
/// still shows `Queued` from the initial discovery fetch). The refreshed
1764+
/// data — including the server conversation token and terminal state —
1765+
/// enables `decide_child_pane_materialization` to return `LoadTranscript`
1766+
/// so subsequent pill clicks load the cloud transcript.
1767+
pub fn evict_and_refetch_task(
1768+
&mut self,
1769+
task_id: &AmbientAgentTaskId,
1770+
ctx: &mut ModelContext<Self>,
1771+
) {
1772+
self.tasks.remove(task_id);
1773+
self.task_fetch_state.remove(task_id);
1774+
self.async_fetch_task(task_id, ctx);
1775+
}
1776+
17221777
/// Get raw task data by task ID, fetching from server if not in memory.
17231778
/// If the task is already in memory, returns it immediately.
17241779
/// If not, spawns an async task to fetch it from the server, stores it in memory,

app/src/ai/blocklist/agent_view/controller.rs

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use crate::ai::agent::conversation::AIConversationId;
1414
use crate::ai::blocklist::orchestration_topology::{
1515
OrchestrationNavigationDirection, adjacent_orchestration_child_conversation_id,
1616
};
17+
use crate::features::FeatureFlag;
1718
use crate::terminal::TerminalModel;
1819
use crate::terminal::input::message_bar::{Message, MessageItem};
1920
use crate::terminal::input::slash_commands::SlashCommandTrigger;
@@ -804,22 +805,29 @@ impl AgentViewController {
804805
}
805806

806807
let history_model = BlocklistAIHistoryModel::handle(ctx);
807-
let (conversation_id, exchange_count) = if let Some(conversation) =
808-
conversation_id.and_then(|id| history_model.as_ref(ctx).conversation(&id))
809-
{
810-
(conversation.id(), conversation.exchange_count())
811-
} else {
812-
let id = history_model.update(ctx, |history_model, ctx| {
813-
history_model.start_new_conversation(
814-
self.terminal_view_id,
815-
false,
816-
matches!(&origin, AgentViewEntryOrigin::CloudAgent),
817-
matches!(&origin, AgentViewEntryOrigin::ThirdPartyCloudAgent),
818-
ctx,
808+
let (conversation_id, exchange_count, is_existing_child_placeholder) =
809+
if let Some(conversation) =
810+
conversation_id.and_then(|id| history_model.as_ref(ctx).conversation(&id))
811+
{
812+
(
813+
conversation.id(),
814+
conversation.exchange_count(),
815+
conversation.is_remote_child()
816+
|| (conversation.is_viewing_shared_session()
817+
&& conversation.parent_conversation_id().is_some()),
819818
)
820-
});
821-
(id, 0)
822-
};
819+
} else {
820+
let id = history_model.update(ctx, |history_model, ctx| {
821+
history_model.start_new_conversation(
822+
self.terminal_view_id,
823+
false,
824+
matches!(&origin, AgentViewEntryOrigin::CloudAgent),
825+
matches!(&origin, AgentViewEntryOrigin::ThirdPartyCloudAgent),
826+
ctx,
827+
)
828+
});
829+
(id, 0, false)
830+
};
823831
history_model.update(ctx, |history_model, ctx| {
824832
history_model.set_active_conversation_id(conversation_id, self.terminal_view_id, ctx)
825833
});
@@ -841,9 +849,17 @@ impl AgentViewController {
841849
.block_list_mut()
842850
.enter_conversation_context(conversation_id, display_mode.is_inline(), is_cloud);
843851

852+
// An empty child placeholder is still an existing run, not a brand-new
853+
// cloud conversation. This applies to owner-side remote children and
854+
// viewer-side shared-session children. Preserve that distinction so
855+
// TerminalView does not insert cloud composition UI while the child is
856+
// restoring or waiting for its first streamed exchange.
857+
let is_new = exchange_count == 0
858+
&& !(FeatureFlag::OrchestrationUnifiedStack.is_enabled()
859+
&& is_existing_child_placeholder);
844860
ctx.emit(AgentViewControllerEvent::EnteredAgentView {
845861
conversation_id,
846-
is_new: exchange_count == 0,
862+
is_new,
847863
origin,
848864
display_mode,
849865
});

app/src/ai/blocklist/history_model.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -568,6 +568,49 @@ impl BlocklistAIHistoryModel {
568568
conversation_id
569569
}
570570

571+
/// Returns the existing run-id mapping for a remote child, creating one
572+
/// from the supplied task metadata if none exists yet. Idempotent: racing
573+
/// `ChildStarted`, lifecycle, and viewer metadata callbacks all converge
574+
/// on the same entry.
575+
#[allow(clippy::too_many_arguments)]
576+
pub fn ensure_remote_child_conversation(
577+
&mut self,
578+
terminal_surface_id: EntityId,
579+
parent_conversation_id: AIConversationId,
580+
run_id: String,
581+
task_id: crate::ai::ambient_agents::AmbientAgentTaskId,
582+
name: String,
583+
fallback_title: String,
584+
orchestration_harness: Option<Harness>,
585+
ctx: &mut ModelContext<Self>,
586+
) -> AIConversationId {
587+
if let Some(conversation_id) = self.conversation_id_for_agent_id(&run_id) {
588+
return conversation_id;
589+
}
590+
591+
let conversation_id = self.start_new_child_conversation(
592+
terminal_surface_id,
593+
name,
594+
parent_conversation_id,
595+
orchestration_harness,
596+
ctx,
597+
);
598+
self.mark_conversation_as_remote_child(conversation_id, ctx);
599+
if !fallback_title.is_empty()
600+
&& let Some(conversation) = self.conversation_mut(&conversation_id)
601+
{
602+
conversation.set_fallback_display_title(fallback_title);
603+
}
604+
self.assign_run_id_for_conversation(
605+
conversation_id,
606+
run_id,
607+
Some(task_id),
608+
terminal_surface_id,
609+
ctx,
610+
);
611+
conversation_id
612+
}
613+
571614
/// Sets the parent conversation ID on a child conversation and updates
572615
/// the `children_by_parent` index. All parent-child relationships should
573616
/// be established through this method so the index stays in sync.

app/src/ai/blocklist/history_model_tests.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,67 @@ fn create_persisted_query(
7070
}
7171
}
7272

73+
#[test]
74+
fn ensure_remote_child_conversation_creates_one_named_run_mapping() {
75+
App::test((), |mut app| async move {
76+
initialize_history_persistence_for_tests(&mut app);
77+
let terminal_view_id = EntityId::new();
78+
let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
79+
let parent_run_id = "11111111-1111-1111-1111-111111111111";
80+
let child_task_id: AmbientAgentTaskId =
81+
"22222222-2222-2222-2222-222222222222".parse().unwrap();
82+
83+
let (parent_id, first, second) = history_model.update(&mut app, |history, ctx| {
84+
let parent_id =
85+
history.start_new_conversation(terminal_view_id, false, true, false, ctx);
86+
history.assign_run_id_for_conversation(
87+
parent_id,
88+
parent_run_id.to_string(),
89+
parent_run_id.parse().ok(),
90+
terminal_view_id,
91+
ctx,
92+
);
93+
let first = history.ensure_remote_child_conversation(
94+
terminal_view_id,
95+
parent_id,
96+
child_task_id.to_string(),
97+
child_task_id,
98+
"Researcher".to_string(),
99+
"Investigate observer restore".to_string(),
100+
Some(Harness::Codex),
101+
ctx,
102+
);
103+
let second = history.ensure_remote_child_conversation(
104+
terminal_view_id,
105+
parent_id,
106+
child_task_id.to_string(),
107+
child_task_id,
108+
"Duplicate".to_string(),
109+
String::new(),
110+
Some(Harness::Oz),
111+
ctx,
112+
);
113+
(parent_id, first, second)
114+
});
115+
116+
assert_eq!(first, second);
117+
history_model.read(&app, |history, _| {
118+
assert_eq!(
119+
history.conversation_id_for_agent_id(&child_task_id.to_string()),
120+
Some(first),
121+
"message sender attribution must resolve through the run-id index",
122+
);
123+
assert_eq!(history.child_conversation_ids_of(&parent_id), &[first]);
124+
let child = history.conversation(&first).unwrap();
125+
assert_eq!(child.agent_name(), Some("Researcher"));
126+
assert_eq!(child.parent_conversation_id(), Some(parent_id));
127+
assert!(child.is_remote_child());
128+
assert!(!child.is_viewing_shared_session());
129+
assert_eq!(child.orchestration_harness(), Some(Harness::Codex));
130+
});
131+
});
132+
}
133+
73134
fn create_user_query_message(
74135
id: &str,
75136
task_id: &str,

app/src/ai/blocklist/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ pub(crate) mod diff_types;
1212
pub(crate) mod handoff;
1313

1414
pub(crate) mod local_agent_task_sync_model;
15-
#[allow(dead_code)]
1615
pub(crate) mod orchestration_child_tracker;
1716
pub(crate) mod orchestration_event_streamer;
1817
pub(crate) mod orchestration_events;

app/src/ai/blocklist/orchestration_child_tracker.rs

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
//! session links, REST seed rows, and in-band registrations — enters through
88
//! the single [`OrchestrationChildTracker::observe_child`] entry point.
99
//!
10+
//! `FamilyDrainMode` captures the one behavioral axis between orchestrator
11+
//! and shared-session observer: who pushes the server cursor and who receives
12+
//! the parent's own inbox events. It says nothing about authenticated
13+
//! ownership, permissions, or pane capability.
14+
//!
1015
//! Pill-bar broadcasts (`ChildSpawned` / `ChildStatusChanged`) are emitted
1116
//! via the `ctx` so downstream views can react without polling.
1217
//!
@@ -50,9 +55,11 @@ pub enum ChildSignal {
5055
Lifecycle(api::LifecycleEventType),
5156
/// A REST seed row (cold-start seed / restore fetch). Boxed because the
5257
/// task row dwarfs the other variants.
58+
#[allow(dead_code)]
5359
Seeded(Box<AmbientAgentTask>),
5460
/// A child created in this process, already backed by a local
5561
/// conversation that its executor hydrates.
62+
#[allow(dead_code)]
5663
Registered,
5764
}
5865

@@ -67,6 +74,7 @@ pub struct TrackedChild {
6774
/// `true` for every placeholder the tracker materializes on behalf of a
6875
/// run hosted elsewhere. `false` only for in-band children, which already
6976
/// own a real local conversation and are tracked for status only.
77+
#[allow(dead_code)]
7078
pub is_remote_child: bool,
7179
}
7280

@@ -160,8 +168,9 @@ impl OrchestrationChildTracker {
160168

161169
/// Discovery via `child_agent_started`. Idempotent: an already-known child
162170
/// only continues hydrating, while the first sighting of a genuinely new
163-
/// out-of-band run kicks off a single metadata fetch to create its
164-
/// placeholder.
171+
/// out-of-band run inserts a pending `TrackedChild` immediately — before
172+
/// the async metadata fetch completes — so later `Lifecycle` and
173+
/// `SessionLinked` signals see a known child and are processed.
165174
fn apply_started(
166175
&mut self,
167176
task_id: AmbientAgentTaskId,
@@ -174,9 +183,24 @@ impl OrchestrationChildTracker {
174183
self.maybe_request_pane_materialization(task_id, ctx);
175184
return;
176185
}
177-
// New out-of-band child: start (or dedupe) discovery. The placeholder
178-
// is created when the fetch completes (a cache hit resolves inline; an
179-
// in-flight fetch resolves on a later re-drive).
186+
// Insert a placeholder TrackedChild immediately so lifecycle and
187+
// session-linked signals that arrive before the async metadata fetch
188+
// completes see tracker_known=true. Any session_id that arrived
189+
// before this signal is also applied now.
190+
let session_id = self.pending_session_ids.remove(&task_id);
191+
self.insert_child(
192+
task_id,
193+
run_id,
194+
TrackedChild {
195+
session_id,
196+
last_state: None,
197+
pane_materialized: false,
198+
is_remote_child: true,
199+
},
200+
ctx,
201+
);
202+
// Also kick the metadata fetch to get real task state, session_id,
203+
// and conversation token for transcript / live-attach decisions.
180204
self.spawn_metadata_fetch(task_id, run_id, ctx);
181205
}
182206

@@ -192,7 +216,8 @@ impl OrchestrationChildTracker {
192216
kind: api::LifecycleEventType,
193217
ctx: &mut ModelContext<OrchestrationEventStreamer>,
194218
) {
195-
if self.children.contains_key(&task_id) {
219+
let tracker_known = self.children.contains_key(&task_id);
220+
if tracker_known {
196221
let status = conversation_status_from_lifecycle_event_type(kind);
197222
// Write status through immediately so the pill bar badge reflects
198223
// the lifecycle transition without waiting for a redraw cycle.
@@ -228,11 +253,17 @@ impl OrchestrationChildTracker {
228253
self.maybe_request_pane_materialization(task_id, ctx);
229254
return;
230255
}
231-
// Lifecycle for an unknown run: only self-heal a real discovery miss,
232-
// not a run whose fetch is already in flight.
233-
if !self.metadata_fetches.contains(run_id) {
234-
self.spawn_metadata_fetch(task_id, run_id, ctx);
235-
}
256+
// Lifecycle for an unknown run is a complete discovery backstop:
257+
// insert once (emitting ChildSpawned), start/dedupe metadata hydration,
258+
// and publish the status immediately. This handles a missed or
259+
// reordered child_agent_started event without a tracker-only ghost.
260+
self.apply_started(task_id, run_id, ctx);
261+
let status = conversation_status_from_lifecycle_event_type(kind);
262+
ctx.emit(OrchestrationEventStreamerEvent::ChildStatusChanged {
263+
parent_task_id: self.parent_task_id,
264+
run_id: run_id.to_string(),
265+
status,
266+
});
236267
}
237268

238269
/// Registers a child created in this process against its existing local

0 commit comments

Comments
 (0)