Skip to content

Commit 71bfd7c

Browse files
cephalonautoz-agent
andcommitted
Add is_durable_observer_parent: observer-parent restore for owned cloud runs
When a user owns a /cloud-agent run but opens it via the shared-session viewer path (e.g. through a shared link), this PR makes that pane restore durably across restarts instead of opening a fresh compose pane. What: - AgentConversationData.is_durable_observer_parent: bool (persisted marker) - mark_conversation_as_durable_observer_parent: stamps marker + task_id on the parent placeholder when TaskOwnership::Owned is confirmed by OVM - restore_durable_observer_parent_for_task: re-attaches the eagerly-hydrated conversation to the restored ambient pane before shared-session replay - Eager hydration in conversation_loader.rs: durable observer parents load at startup with their event cursor, excluded from navigation history - Durable-parent restore in ambient_pane_restoration.rs: RestoreOrNavigate installs the persisted conversation in cloud-mode instead of fresh compose Note: this could alternatively be replaced by making is_viewing_shared_session persistent for all viewer conversations (not just owned ones), which would be simpler. This PR keeps the feature isolated for independent evaluation. Co-Authored-By: Oz <oz-agent@warp.dev>
1 parent 8dd85d8 commit 71bfd7c

17 files changed

Lines changed: 624 additions & 6 deletions

app/src/ai/agent/api/convert_conversation.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ pub fn convert_conversation_data_to_ai_conversation(
8585
orchestration_harness_type: None,
8686
parent_conversation_id: None,
8787
is_remote_child: false,
88+
is_durable_observer_parent: false,
8889
root_task_is_optimistic: None,
8990
run_id: None,
9091
autoexecute_override: None,
@@ -104,6 +105,7 @@ pub fn convert_conversation_data_to_ai_conversation(
104105
orchestration_harness_type: None,
105106
parent_conversation_id: None,
106107
is_remote_child: false,
108+
is_durable_observer_parent: false,
107109
root_task_is_optimistic: None,
108110
run_id: metadata
109111
.ambient_agent_task_id

app/src/ai/agent/conversation.rs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,10 @@ pub struct AIConversation {
346346
/// these conversations — the remote worker's own client handles status
347347
/// reporting.
348348
is_remote_child: bool,
349+
/// True when this is an owned cloud parent hosted by a remote driver and
350+
/// observed locally. Unlike `is_viewing_shared_session`, this marker is
351+
/// durable so the local observer cursor and hierarchy can be restored.
352+
is_durable_observer_parent: bool,
349353

350354
/// The last event sequence number observed from the v2 orchestration
351355
/// event log. Used on restore to resume event delivery without
@@ -411,6 +415,7 @@ impl AIConversation {
411415
orchestration_harness_type: None,
412416
parent_conversation_id: None,
413417
is_remote_child: false,
418+
is_durable_observer_parent: false,
414419
last_event_sequence: None,
415420
orchestration_configs: HashMap::new(),
416421
pinned: false,
@@ -545,6 +550,7 @@ impl AIConversation {
545550
orchestration_harness_type,
546551
parent_conversation_id,
547552
is_remote_child,
553+
is_durable_observer_parent,
548554
run_id,
549555
autoexecute_override,
550556
last_event_sequence,
@@ -596,6 +602,7 @@ impl AIConversation {
596602
data.orchestration_harness_type,
597603
parent_conversation_id,
598604
data.is_remote_child,
605+
data.is_durable_observer_parent,
599606
data.run_id,
600607
autoexecute_override,
601608
data.last_event_sequence,
@@ -613,6 +620,7 @@ impl AIConversation {
613620
None,
614621
None,
615622
false,
623+
false,
616624
None,
617625
AIConversationAutoexecuteMode::default(),
618626
None,
@@ -622,7 +630,7 @@ impl AIConversation {
622630

623631
Ok(Self {
624632
id,
625-
is_viewing_shared_session: false,
633+
is_viewing_shared_session: is_durable_observer_parent,
626634
is_cli_agent_transcript: false,
627635
task_store,
628636
status,
@@ -653,6 +661,7 @@ impl AIConversation {
653661
orchestration_harness_type,
654662
parent_conversation_id,
655663
is_remote_child,
664+
is_durable_observer_parent,
656665
last_event_sequence,
657666
orchestration_configs: HashMap::new(),
658667
pinned,
@@ -683,6 +692,14 @@ impl AIConversation {
683692
self.is_viewing_shared_session = is_viewing_shared_session;
684693
}
685694

695+
pub fn is_durable_observer_parent(&self) -> bool {
696+
self.is_durable_observer_parent
697+
}
698+
699+
pub fn set_is_durable_observer_parent(&mut self, durable: bool) {
700+
self.is_durable_observer_parent = durable;
701+
}
702+
686703
pub fn is_cli_agent_transcript(&self) -> bool {
687704
self.is_cli_agent_transcript
688705
}
@@ -3477,8 +3494,10 @@ impl AIConversation {
34773494
&mut self,
34783495
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
34793496
) {
3480-
// We should not persist non-local conversations (e.g. shared sessions).
3481-
if self.is_viewing_shared_session {
3497+
// Passive shared-session views remain ephemeral. Owned cloud parents
3498+
// are the narrow exception: their local observer cursor and child
3499+
// hierarchy must survive restart.
3500+
if self.is_viewing_shared_session && !self.is_durable_observer_parent {
34823501
return;
34833502
}
34843503

@@ -3546,6 +3565,7 @@ impl AIConversation {
35463565
orchestration_harness_type: self.orchestration_harness_type.clone(),
35473566
parent_conversation_id: self.parent_conversation_id.map(|id| id.to_string()),
35483567
is_remote_child: self.is_remote_child,
3568+
is_durable_observer_parent: self.is_durable_observer_parent,
35493569
// Legacy field; retained for backward-compatible
35503570
// deserialization but no longer written. The optimistic-root
35513571
// case is now handled by `Task::source_for_persistence`

app/src/ai/agent_conversations_model_tests.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@ fn test_title_update_refreshes_shadowing_task_title() {
235235
orchestration_harness_type: None,
236236
parent_conversation_id: None,
237237
is_remote_child: false,
238+
is_durable_observer_parent: false,
238239
root_task_is_optimistic: None,
239240
run_id: None,
240241
autoexecute_override: None,
@@ -342,6 +343,7 @@ fn test_display_status_uses_matching_conversation_for_in_progress_task() {
342343
orchestration_harness_type: None,
343344
parent_conversation_id: None,
344345
is_remote_child: false,
346+
is_durable_observer_parent: false,
345347
root_task_is_optimistic: None,
346348
run_id: Some(task_id.clone()),
347349
autoexecute_override: None,
@@ -399,6 +401,7 @@ fn test_display_status_uses_active_execution_over_previous_conversation_status()
399401
orchestration_harness_type: None,
400402
parent_conversation_id: None,
401403
is_remote_child: false,
404+
is_durable_observer_parent: false,
402405
root_task_is_optimistic: None,
403406
run_id: Some(task_id.clone()),
404407
autoexecute_override: None,
@@ -463,6 +466,7 @@ fn test_display_status_updates_when_blocked_conversation_resumes() {
463466
orchestration_harness_type: None,
464467
parent_conversation_id: None,
465468
is_remote_child: false,
469+
is_durable_observer_parent: false,
466470
root_task_is_optimistic: None,
467471
run_id: Some(task_id.clone()),
468472
autoexecute_override: None,
@@ -543,6 +547,7 @@ fn test_display_status_terminal_task_state_overrides_matching_conversation() {
543547
orchestration_harness_type: None,
544548
parent_conversation_id: None,
545549
is_remote_child: false,
550+
is_durable_observer_parent: false,
546551
root_task_is_optimistic: None,
547552
run_id: Some(task_id.clone()),
548553
autoexecute_override: None,
@@ -598,6 +603,7 @@ fn test_status_filter_uses_display_status_for_task_backed_conversations() {
598603
orchestration_harness_type: None,
599604
parent_conversation_id: None,
600605
is_remote_child: false,
606+
is_durable_observer_parent: false,
601607
root_task_is_optimistic: None,
602608
run_id: Some(task_id.clone()),
603609
autoexecute_override: None,
@@ -1053,6 +1059,7 @@ fn test_get_entries_excludes_conversation_shadowed_by_child_task() {
10531059
orchestration_harness_type: None,
10541060
parent_conversation_id: None,
10551061
is_remote_child: false,
1062+
is_durable_observer_parent: false,
10561063
root_task_is_optimistic: None,
10571064
run_id: None,
10581065
autoexecute_override: None,
@@ -1203,6 +1210,7 @@ fn test_get_entries_merges_task_and_local_conversation_by_run_id() {
12031210
orchestration_harness_type: None,
12041211
parent_conversation_id: None,
12051212
is_remote_child: false,
1213+
is_durable_observer_parent: false,
12061214
root_task_is_optimistic: None,
12071215
run_id: Some(task_id.clone()),
12081216
autoexecute_override: None,
@@ -1258,6 +1266,7 @@ fn test_get_entries_merges_task_and_local_conversation_by_server_token() {
12581266
orchestration_harness_type: None,
12591267
parent_conversation_id: None,
12601268
is_remote_child: false,
1269+
is_durable_observer_parent: false,
12611270
root_task_is_optimistic: None,
12621271
run_id: None,
12631272
autoexecute_override: None,
@@ -1468,6 +1477,7 @@ fn test_resolve_open_action_returns_none_for_active_unattachable_session() {
14681477
orchestration_harness_type: None,
14691478
parent_conversation_id: None,
14701479
is_remote_child: false,
1480+
is_durable_observer_parent: false,
14711481
root_task_is_optimistic: None,
14721482
run_id: Some(task_id.clone()),
14731483
autoexecute_override: None,
@@ -1754,6 +1764,7 @@ fn test_server_token_assignment_updates_copy_link_resolution() {
17541764
orchestration_harness_type: None,
17551765
parent_conversation_id: None,
17561766
is_remote_child: false,
1767+
is_durable_observer_parent: false,
17571768
root_task_is_optimistic: None,
17581769
run_id: None,
17591770
autoexecute_override: None,
@@ -1916,6 +1927,7 @@ fn test_resolve_copy_link_uses_attached_synced_conversation_for_task_without_tok
19161927
orchestration_harness_type: None,
19171928
parent_conversation_id: None,
19181929
is_remote_child: false,
1930+
is_durable_observer_parent: false,
19191931
root_task_is_optimistic: None,
19201932
run_id: Some(task_id.clone()),
19211933
autoexecute_override: None,
@@ -2245,6 +2257,7 @@ fn test_get_entries_prefers_task_when_task_id_matches_conversation_run_id() {
22452257
orchestration_harness_type: None,
22462258
parent_conversation_id: None,
22472259
is_remote_child: false,
2260+
is_durable_observer_parent: false,
22482261
root_task_is_optimistic: None,
22492262
run_id: Some(task_id.clone()),
22502263
autoexecute_override: None,
@@ -2306,6 +2319,7 @@ fn test_get_entries_prefers_task_when_server_token_matches() {
23062319
orchestration_harness_type: None,
23072320
parent_conversation_id: None,
23082321
is_remote_child: false,
2322+
is_durable_observer_parent: false,
23092323
root_task_is_optimistic: None,
23102324
run_id: None,
23112325
autoexecute_override: None,

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ fn pill_bar_data_layer_finds_restored_children_before_pane_creation() {
166166
orchestration_harness_type: None,
167167
parent_conversation_id: Some(parent_id.to_string()),
168168
is_remote_child: false,
169+
is_durable_observer_parent: false,
169170
root_task_is_optimistic: None,
170171
run_id: Some(child_run_id.clone()),
171172
autoexecute_override: None,
@@ -217,6 +218,7 @@ fn pill_bar_data_layer_finds_restored_children_before_pane_creation() {
217218
orchestration_harness_type: None,
218219
parent_conversation_id: None,
219220
is_remote_child: false,
221+
is_durable_observer_parent: false,
220222
root_task_is_optimistic: None,
221223
run_id: Some(parent_run_id.clone()),
222224
autoexecute_override: None,

app/src/ai/blocklist/block/view_impl/orchestration_tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ fn participant_for_restored_child_run_id_resolves_to_agent_name() {
142142
orchestration_harness_type: None,
143143
parent_conversation_id: Some(parent_id.to_string()),
144144
is_remote_child: false,
145+
is_durable_observer_parent: false,
145146
root_task_is_optimistic: None,
146147
run_id: Some(child_run_id.clone()),
147148
autoexecute_override: None,
@@ -196,6 +197,7 @@ fn participant_for_restored_child_run_id_resolves_to_agent_name() {
196197
orchestration_harness_type: None,
197198
parent_conversation_id: None,
198199
is_remote_child: false,
200+
is_durable_observer_parent: false,
199201
root_task_is_optimistic: None,
200202
run_id: Some(parent_run_id.clone()),
201203
autoexecute_override: None,

app/src/ai/blocklist/history_model.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,48 @@ impl BlocklistAIHistoryModel {
611611
conversation_id
612612
}
613613

614+
/// Marks an owned remote-driver parent as a durable local Observer.
615+
/// Passive shared links never call this path.
616+
pub fn mark_conversation_as_durable_observer_parent(
617+
&mut self,
618+
conversation_id: AIConversationId,
619+
task_id: crate::ai::ambient_agents::AmbientAgentTaskId,
620+
ctx: &mut ModelContext<Self>,
621+
) {
622+
let Some(conversation) = self.conversations_by_id.get_mut(&conversation_id) else {
623+
return;
624+
};
625+
if conversation.parent_conversation_id().is_some() {
626+
return;
627+
}
628+
conversation.set_is_durable_observer_parent(true);
629+
conversation.set_task_id(task_id);
630+
if let Some(key) = agent_id_key(conversation) {
631+
self.agent_id_to_conversation_id
632+
.insert(key, conversation_id);
633+
}
634+
self.persist_conversation_state(conversation_id, ctx);
635+
}
636+
637+
/// Attaches an eagerly hydrated durable Observer parent to the restored
638+
/// ambient pane before shared-session replay begins.
639+
pub fn restore_durable_observer_parent_for_task(
640+
&mut self,
641+
task_id: crate::ai::ambient_agents::AmbientAgentTaskId,
642+
terminal_surface_id: EntityId,
643+
ctx: &mut ModelContext<Self>,
644+
) -> Option<AIConversationId> {
645+
let conversation_id = self.conversation_id_for_agent_id(&task_id.to_string())?;
646+
let mut conversation = self.conversation(&conversation_id)?.clone();
647+
if !conversation.is_durable_observer_parent() {
648+
return None;
649+
}
650+
conversation.set_is_viewing_shared_session(true);
651+
self.restore_conversations(terminal_surface_id, vec![conversation], ctx);
652+
self.set_active_conversation_id(conversation_id, terminal_surface_id, ctx);
653+
Some(conversation_id)
654+
}
655+
614656
/// Sets the parent conversation ID on a child conversation and updates
615657
/// the `children_by_parent` index. All parent-child relationships should
616658
/// be established through this method so the index stays in sync.
@@ -1672,6 +1714,7 @@ impl BlocklistAIHistoryModel {
16721714
orchestration_harness_type: None,
16731715
parent_conversation_id: None,
16741716
is_remote_child: false,
1717+
is_durable_observer_parent: false,
16751718
root_task_is_optimistic: None,
16761719
run_id: None,
16771720
autoexecute_override: Some(source_conversation.autoexecute_override().into()),
@@ -1850,6 +1893,7 @@ impl BlocklistAIHistoryModel {
18501893
orchestration_harness_type: None,
18511894
parent_conversation_id: None,
18521895
is_remote_child: false,
1896+
is_durable_observer_parent: false,
18531897
root_task_is_optimistic: None,
18541898
run_id: None,
18551899
autoexecute_override: Some(conversation.autoexecute_override().into()),
@@ -2899,6 +2943,7 @@ fn merged_remote_child_placeholder_conversation_data(
28992943
.parent_conversation_id()
29002944
.map(|id| id.to_string()),
29012945
is_remote_child: placeholder.is_remote_child(),
2946+
is_durable_observer_parent: false,
29022947
pinned: placeholder.is_pinned(),
29032948

29042949
// Reset on merge.

app/src/ai/blocklist/history_model/conversation_loader.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,32 @@ impl BlocklistAIHistoryModel {
544544
}
545545
}
546546

547+
// Durable Observer parents are hidden shared-session vehicles,
548+
// not navigation rows. Hydrate them eagerly so ambient-pane
549+
// restore can attach the exact local conversation (and its
550+
// cursor) before response replay and OVM registration.
551+
if conversation_data
552+
.as_ref()
553+
.is_some_and(|data| data.is_durable_observer_parent)
554+
{
555+
let observer_parent = if agent_conversation.tasks.is_empty() {
556+
self.load_conversation_from_db(&conversation_id)
557+
} else {
558+
convert_persisted_conversation_to_ai_conversation_with_metadata(
559+
agent_conversation.clone(),
560+
)
561+
};
562+
if let Some(observer_parent) = observer_parent {
563+
self.conversations_by_id
564+
.insert(conversation_id, observer_parent);
565+
} else {
566+
log::warn!(
567+
"Failed to eagerly hydrate durable Observer parent {conversation_id}"
568+
);
569+
}
570+
return None;
571+
}
572+
547573
Some(HistoricalConversationRow {
548574
agent_conversation,
549575
conversation_id,

0 commit comments

Comments
 (0)