@@ -7,6 +7,7 @@ use async_trait::async_trait;
77use futures:: channel:: mpsc;
88use uuid:: Uuid ;
99use warp_cli:: agent:: Harness ;
10+ use warp_core:: features:: FeatureFlag ;
1011use warp_multi_agent_api as api;
1112use warpui:: r#async:: { SpawnedFutureHandle , Timer } ;
1213use warpui:: {
@@ -41,6 +42,8 @@ const RESTORE_FETCH_PERMANENT_BACKOFF_STEPS: &[u64] = &[30];
4142const SSE_DRAIN_INTERVAL_MS : u64 = 500 ;
4243/// Cap killed-run tombstones while keeping normal sessions well below the limit.
4344const MAX_KILLED_RUN_IDS : usize = 1024 ;
45+ /// Maximum number of explicit run IDs the server accepts on a `run_ids[]` SSE stream.
46+ const MAX_RUN_ID_STREAM_FILTER : usize = 100 ;
4447/// Max child runs fetched per cold-start `?ancestor_run_id=` REST seed in
4548/// viewer mode. Matches the legacy `OrchestrationViewerModel` poller's value
4649/// (the server caps at 100 anyway).
@@ -60,9 +63,8 @@ struct SseConnectionState {
6063 generation : u64 ,
6164 /// Abort handle for the spawned SSE driver task, used to cancel on teardown.
6265 abort_handle : futures:: future:: AbortHandle ,
63- /// Snapshot of `watched_run_ids` at the time this connection was opened;
64- /// compared in `reevaluate_eligibility` to skip same-set reconnects.
65- connected_run_ids : HashSet < String > ,
66+ /// Wire filter this connection was opened with.
67+ connected_filter : AgentEventFilter ,
6668}
6769
6870struct SseForwardingConsumer {
@@ -316,6 +318,19 @@ pub enum OrchestrationEventStreamerEvent {
316318 } ,
317319}
318320
321+ /// Outcome of selecting the SSE wire filter for an owner-side conversation.
322+ enum DesiredSseFilter {
323+ /// Open (or keep) a stream with this filter.
324+ Filter ( AgentEventFilter ) ,
325+ /// Nothing to watch yet (no watched run IDs); do not open a stream.
326+ NoFilter ,
327+ /// The conversation is a parent with more watched children than the
328+ /// explicit `run_ids[]` stream allows and parent-family ancestor
329+ /// streaming is unavailable. The payload is the watched-run-id total,
330+ /// used only for diagnostics.
331+ UnsupportedRunIdCount ( usize ) ,
332+ }
333+
319334impl OrchestrationEventStreamer {
320335 fn message_hydrator_for_run_id ( & self , run_id : & str ) -> MessageHydrator {
321336 match run_id. parse :: < AmbientAgentTaskId > ( ) {
@@ -802,7 +817,13 @@ impl OrchestrationEventStreamer {
802817 (gen={generation}, since={cursor})"
803818 ) ;
804819
805- let filter = AgentEventFilter :: AncestorRunId ( parent_task_id. to_string ( ) ) ;
820+ // Viewer mode subscribes to direct children only: it surfaces child
821+ // lifecycle in the pill bar and never needs the orchestrator's inbox,
822+ // so `include_self` stays false to preserve the existing contract.
823+ let filter = AgentEventFilter :: AncestorRunId {
824+ ancestor_run_id : parent_task_id. to_string ( ) ,
825+ include_self : false ,
826+ } ;
806827 let config = AgentEventDriverConfig :: retry_forever ( filter, cursor) ;
807828 let source = ServerApiAgentEventSource :: new ( server_api) ;
808829
@@ -1345,8 +1366,6 @@ impl OrchestrationEventStreamer {
13451366 // in-flight, the removal handler already cleaned up all
13461367 // streamer state. Return early to avoid recreating
13471368 // state for a deleted conversation.
1348- let had_sse;
1349- let any_new_children;
13501369 {
13511370 let Some ( stream) = self . streams . get_mut ( & conv_id) else {
13521371 return ;
@@ -1362,25 +1381,12 @@ impl OrchestrationEventStreamer {
13621381 let server_seq = task. last_event_sequence . unwrap_or ( 0 ) ;
13631382 stream. event_cursor = sqlite_cursor. max ( server_seq) ;
13641383
1365- // Insert any new children. If new run_ids were added
1366- // and an SSE connection is already open (e.g. a
1367- // status race opened SSE with only the parent's own
1368- // run_id), reconnect so the new run_ids are included
1369- // in the filter; otherwise re-evaluate eligibility.
1370- had_sse = stream. sse_connection . is_some ( ) ;
1371- let mut added = false ;
1384+ // Server-reported children may be absent from local history.
13721385 for child in task. children {
1373- if stream. watched_run_ids . insert ( child) {
1374- added = true ;
1375- }
1386+ stream. watched_run_ids . insert ( child) ;
13761387 }
1377- any_new_children = added;
1378- }
1379- if any_new_children && had_sse {
1380- self . reconnect_sse ( conv_id, ctx) ;
1381- } else {
1382- self . reevaluate_eligibility ( conv_id, ctx) ;
13831388 }
1389+ self . reevaluate_eligibility ( conv_id, ctx) ;
13841390 }
13851391 Err ( err) => {
13861392 // If the conversation was removed mid-flight, drop the
@@ -1564,6 +1570,32 @@ impl OrchestrationEventStreamer {
15641570 . unwrap_or_default ( )
15651571 }
15661572
1573+ /// Selects the owner-side event stream filter for a conversation.
1574+ fn desired_sse_filter (
1575+ & self ,
1576+ conversation_id : AIConversationId ,
1577+ ctx : & warpui:: AppContext ,
1578+ ) -> DesiredSseFilter {
1579+ let is_parent = self . is_parent_agent_conversation ( conversation_id, ctx) ;
1580+ if is_parent && FeatureFlag :: OwnerOrchestrationAncestorStreamer . is_enabled ( ) {
1581+ if let Some ( self_run_id) = self . self_run_id ( conversation_id, ctx) {
1582+ return DesiredSseFilter :: Filter ( AgentEventFilter :: AncestorRunId {
1583+ ancestor_run_id : self_run_id,
1584+ include_self : true ,
1585+ } ) ;
1586+ }
1587+ }
1588+
1589+ let run_ids = self . run_ids_for_sse ( conversation_id) ;
1590+ if run_ids. is_empty ( ) {
1591+ return DesiredSseFilter :: NoFilter ;
1592+ }
1593+ if is_parent && run_ids. len ( ) > MAX_RUN_ID_STREAM_FILTER {
1594+ return DesiredSseFilter :: UnsupportedRunIdCount ( run_ids. len ( ) ) ;
1595+ }
1596+ DesiredSseFilter :: Filter ( AgentEventFilter :: RunIds ( run_ids) )
1597+ }
1598+
15671599 /// Re-evaluates eligibility and either opens / reconnects or tears
15681600 /// down the SSE connection for the given conversation.
15691601 fn reevaluate_eligibility (
@@ -1581,9 +1613,11 @@ impl OrchestrationEventStreamer {
15811613 ( true , false ) => self . start_sse_connection ( conversation_id, ctx) ,
15821614 ( true , true ) => {
15831615 // Status / metadata updates fire `reevaluate_eligibility` on
1584- // every exchange transition; only reconnect when the run-id
1585- // filter actually changed.
1586- if self . watched_run_ids_differ_from_connected ( conversation_id) {
1616+ // every exchange transition; only reconnect when the desired
1617+ // filter shape actually changed. Registering more children
1618+ // while a parent-family ancestor stream is connected leaves
1619+ // the filter unchanged, so it does not reconnect.
1620+ if self . stream_filter_stale ( conversation_id, ctx) {
15871621 self . reconnect_sse ( conversation_id, ctx) ;
15881622 }
15891623 }
@@ -1740,10 +1774,19 @@ impl OrchestrationEventStreamer {
17401774 conversation_id : AIConversationId ,
17411775 ctx : & mut ModelContext < Self > ,
17421776 ) {
1743- let run_ids = self . run_ids_for_sse ( conversation_id) ;
1744- if run_ids. is_empty ( ) {
1745- return ;
1746- }
1777+ let filter = match self . desired_sse_filter ( conversation_id, ctx) {
1778+ DesiredSseFilter :: Filter ( filter) => filter,
1779+ DesiredSseFilter :: NoFilter => return ,
1780+ DesiredSseFilter :: UnsupportedRunIdCount ( count) => {
1781+ log:: error!(
1782+ "Owner-side SSE delivery blocked for {conversation_id:?}: {count} watched \
1783+ run IDs exceed the {MAX_RUN_ID_STREAM_FILTER} explicit-run-id limit and \
1784+ parent-family ancestor streaming is disabled; enable \
1785+ OwnerOrchestrationAncestorStreamer to deliver events for large orchestrators"
1786+ ) ;
1787+ return ;
1788+ }
1789+ } ;
17471790
17481791 let cursor = self
17491792 . streams
@@ -1761,10 +1804,11 @@ impl OrchestrationEventStreamer {
17611804
17621805 log:: info!(
17631806 "Opening SSE stream for {conversation_id:?} (gen={generation}, \
1764- run_ids={run_ids:?}, since={cursor})"
1807+ filter={}, since={cursor})",
1808+ filter. log_label( )
17651809 ) ;
17661810
1767- let config = AgentEventDriverConfig :: retry_forever_run_ids ( run_ids . clone ( ) , cursor) ;
1811+ let config = AgentEventDriverConfig :: retry_forever ( filter . clone ( ) , cursor) ;
17681812 let source = ServerApiAgentEventSource :: new ( server_api) ;
17691813 let hydrator = self . message_hydrator_for_run_id ( & self_run_id) ;
17701814
@@ -1799,29 +1843,42 @@ impl OrchestrationEventStreamer {
17991843 } ,
18001844 ) ;
18011845
1802- let connected_run_ids: HashSet < String > = run_ids. iter ( ) . cloned ( ) . collect ( ) ;
18031846 let stream = self . streams . entry ( conversation_id) . or_default ( ) ;
18041847 stream. sse_connection = Some ( SseConnectionState {
18051848 event_receiver : rx,
18061849 generation,
18071850 abort_handle : handle. abort_handle ( ) ,
1808- connected_run_ids ,
1851+ connected_filter : filter ,
18091852 } ) ;
18101853
18111854 // Start periodic event drain.
18121855 self . start_sse_drain_timer ( conversation_id, generation, ctx) ;
18131856 }
18141857
1815- /// True iff the open SSE's recorded run-id set is stale relative to the
1816- /// conversation's current `watched_run_ids`.
1817- fn watched_run_ids_differ_from_connected ( & self , conversation_id : AIConversationId ) -> bool {
1858+ /// True iff the open SSE's connected filter is stale relative to the
1859+ /// filter the conversation should currently use. Compares the desired
1860+ /// filter shape (run-id set or parent-family ancestor scope) rather than
1861+ /// the raw `watched_run_ids` set, so a parent-family stream is not
1862+ /// reconnected just because additional child IDs were registered.
1863+ fn stream_filter_stale (
1864+ & self ,
1865+ conversation_id : AIConversationId ,
1866+ ctx : & warpui:: AppContext ,
1867+ ) -> bool {
18181868 let Some ( stream) = self . streams . get ( & conversation_id) else {
18191869 return false ;
18201870 } ;
18211871 let Some ( connection) = stream. sse_connection . as_ref ( ) else {
18221872 return false ;
18231873 } ;
1824- stream. watched_run_ids != connection. connected_run_ids
1874+ match self . desired_sse_filter ( conversation_id, ctx) {
1875+ DesiredSseFilter :: Filter ( desired) => {
1876+ !agent_event_filters_equivalent ( & desired, & connection. connected_filter )
1877+ }
1878+ // Nothing watchable tears down through the eligibility predicate.
1879+ DesiredSseFilter :: NoFilter => false ,
1880+ DesiredSseFilter :: UnsupportedRunIdCount ( _) => true ,
1881+ }
18251882 }
18261883
18271884 /// Periodically fires to drain buffered SSE events into the event
@@ -2013,6 +2070,28 @@ async fn resolve_dormant_claude_wake_cursor(
20132070 }
20142071}
20152072
2073+ fn agent_event_filters_equivalent ( a : & AgentEventFilter , b : & AgentEventFilter ) -> bool {
2074+ match ( a, b) {
2075+ ( AgentEventFilter :: RunIds ( a) , AgentEventFilter :: RunIds ( b) ) => {
2076+ a. len ( ) == b. len ( ) && {
2077+ let set: HashSet < & String > = a. iter ( ) . collect ( ) ;
2078+ b. iter ( ) . all ( |id| set. contains ( id) )
2079+ }
2080+ }
2081+ (
2082+ AgentEventFilter :: AncestorRunId {
2083+ ancestor_run_id : a_run,
2084+ include_self : a_self,
2085+ } ,
2086+ AgentEventFilter :: AncestorRunId {
2087+ ancestor_run_id : b_run,
2088+ include_self : b_self,
2089+ } ,
2090+ ) => a_run == b_run && a_self == b_self,
2091+ _ => false ,
2092+ }
2093+ }
2094+
20162095fn agent_task_harness ( task : & crate :: ai:: ambient_agents:: task:: AmbientAgentTask ) -> Option < Harness > {
20172096 task. agent_config_snapshot
20182097 . as_ref ( )
0 commit comments