Skip to content

Commit 1c2d4cc

Browse files
[QUALITY-772] Use ancestor streams for large orchestrators (#12209)
## Description ### What - Adds a dogfood-gated `OwnerOrchestrationAncestorStreamer` feature flag. - Switches owner-side orchestrator event delivery to a parent-family `ancestor_run_id&include_self=true` stream when the flag is enabled. - Keeps child-only conversations on `RunIds(self)` and viewer-mode streams on `include_self=false`. - Prevents oversized legacy `run_ids[]` streams from retrying forever when the flag is disabled. ### Why Large orchestrations can exceed the server's 100 explicit-run-id SSE limit, causing parents to miss child lifecycle/message events. The parent-family ancestor stream keeps delivery to one ordered stream while preserving the existing cursor model. ### How - Extends `AgentEventFilter::AncestorRunId` with an `include_self` field. - Adds client URL support for `include_self=true` only when requested. - Stores the connected filter shape and reconnects only when the desired filter is stale. - Adds regression coverage for large parent streams, limit-crossing behavior, restore, and child-only filtering. ## Linked Issue - [ ] The linked issue is labeled `ready-to-spec` or `ready-to-implement`. - [ ] Where appropriate, screenshots or a short video of the implementation are included below (especially for user-visible or UI changes). No linked issue; see companion server TECH spec at `warp-server/specs/QUALITY-790/TECH.md`. ## Testing - [x] `./script/format` - [x] `PATH=/usr/local/bin:$PATH cargo clippy --workspace --all-targets --all-features --tests -- -D warnings` - [x] `cargo test -p warp --lib run_id_limit_without_flag` - [x] `cargo test -p warp --lib parent_with_many_children_opens_one_ancestor_include_self_stream` - [x] `cargo test -p warp --lib restored_parent_with_children_opens_ancestor_include_self_stream` - [ ] I have manually tested my changes locally with `./script/run` ## Agent Mode - [x] Warp Agent Mode - This PR was created via Warp's AI Agent Mode CHANGELOG-NONE Co-Authored-By: Oz <oz-agent@warp.dev> --------- Co-authored-by: Oz <oz-agent@warp.dev>
1 parent c2e974b commit 1c2d4cc

7 files changed

Lines changed: 596 additions & 59 deletions

File tree

app/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -959,6 +959,7 @@ active_conversation_requires_interaction = []
959959
incremental_auto_reload = []
960960
run_agents_tool = []
961961
orchestration_viewer_streamer = []
962+
owner_orchestration_ancestor_streamer = []
962963
pending_user_query_indicator = []
963964
queue_slash_command = []
964965
queued_prompts_v2 = ["queue_slash_command"]

app/src/ai/agent_events/driver.rs

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,25 @@ pub(crate) const DEFAULT_AGENT_EVENT_FAILURES_BEFORE_ERROR_LOG: usize = 5;
2323
/// when opening a stream.
2424
///
2525
/// `RunIds` maps to the `?run_ids[]=` query parameter on the SSE endpoint
26-
/// and is used by the orchestrator-owner per-conversation stream and the
27-
/// dormant Claude wake listener. `AncestorRunId` maps to the
28-
/// `?ancestor_run_id=` shape and streams events for every direct child of
29-
/// the supplied parent run; today only the shared-session viewer's
30-
/// pill bar consumes it.
26+
/// and is used by child-only per-conversation streams and the dormant
27+
/// Claude wake listener. `AncestorRunId` maps to the `?ancestor_run_id=`
28+
/// shape: with `include_self=false` it streams events for every direct
29+
/// child of the supplied parent run (the shared-session viewer's pill bar),
30+
/// and with `include_self=true` it additionally streams the parent run's
31+
/// own events so an owner-side orchestrator can receive child lifecycle
32+
/// events plus its own inbox on one ordered stream.
3133
#[derive(Clone, Debug)]
3234
pub(crate) enum AgentEventFilter {
3335
/// One stream per multiplexed set of run IDs. Matches today's
3436
/// `?run_ids[]=` endpoint.
3537
RunIds(Vec<String>),
36-
/// Stream events for every direct child of the supplied parent run.
37-
/// Matches the `?ancestor_run_id=` endpoint.
38-
AncestorRunId(String),
38+
/// Stream events for every direct child of the supplied parent run, and
39+
/// (when `include_self` is true) the parent run itself. Matches the
40+
/// `?ancestor_run_id=` endpoint.
41+
AncestorRunId {
42+
ancestor_run_id: String,
43+
include_self: bool,
44+
},
3945
}
4046

4147
impl AgentEventFilter {
@@ -44,7 +50,10 @@ impl AgentEventFilter {
4450
pub(crate) fn log_label(&self) -> String {
4551
match self {
4652
AgentEventFilter::RunIds(ids) => format!("run_ids={ids:?}"),
47-
AgentEventFilter::AncestorRunId(id) => format!("ancestor_run_id={id}"),
53+
AgentEventFilter::AncestorRunId {
54+
ancestor_run_id,
55+
include_self,
56+
} => format!("ancestor_run_id={ancestor_run_id} include_self={include_self}"),
4857
}
4958
}
5059
}
@@ -195,9 +204,16 @@ impl AgentEventSource for ServerApiAgentEventSource {
195204
.stream_agent_events(run_ids, since_sequence)
196205
.await?
197206
}
198-
AgentEventFilter::AncestorRunId(ancestor_run_id) => {
207+
AgentEventFilter::AncestorRunId {
208+
ancestor_run_id,
209+
include_self,
210+
} => {
199211
self.server_api
200-
.stream_agent_events_for_ancestor(ancestor_run_id, since_sequence)
212+
.stream_agent_events_for_ancestor(
213+
ancestor_run_id,
214+
*include_self,
215+
since_sequence,
216+
)
201217
.await?
202218
}
203219
};

app/src/ai/blocklist/orchestration_event_streamer.rs

Lines changed: 116 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use async_trait::async_trait;
77
use futures::channel::mpsc;
88
use uuid::Uuid;
99
use warp_cli::agent::Harness;
10+
use warp_core::features::FeatureFlag;
1011
use warp_multi_agent_api as api;
1112
use warpui::r#async::{SpawnedFutureHandle, Timer};
1213
use warpui::{
@@ -41,6 +42,8 @@ const RESTORE_FETCH_PERMANENT_BACKOFF_STEPS: &[u64] = &[30];
4142
const SSE_DRAIN_INTERVAL_MS: u64 = 500;
4243
/// Cap killed-run tombstones while keeping normal sessions well below the limit.
4344
const 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

6870
struct 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+
319334
impl 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+
20162095
fn agent_task_harness(task: &crate::ai::ambient_agents::task::AmbientAgentTask) -> Option<Harness> {
20172096
task.agent_config_snapshot
20182097
.as_ref()

0 commit comments

Comments
 (0)