367 lines
13 KiB
Rust
367 lines
13 KiB
Rust
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{anyhow, Result};
|
|
use async_trait::async_trait;
|
|
use futures::future::Either;
|
|
use futures::StreamExt;
|
|
use instant::Instant;
|
|
use warpui::r#async::Timer;
|
|
|
|
use crate::server::server_api::ai::AgentRunEvent;
|
|
use crate::server::server_api::ServerApi;
|
|
|
|
pub(crate) const DEFAULT_AGENT_EVENT_RECONNECT_BACKOFF_STEPS: &[u64] = &[1, 2, 5, 10];
|
|
pub(crate) const DEFAULT_AGENT_EVENT_PROACTIVE_RECONNECT: Duration = Duration::from_secs(14 * 60);
|
|
pub(crate) const DEFAULT_AGENT_EVENT_FAILURES_BEFORE_ERROR_LOG: usize = 5;
|
|
|
|
/// Configuration for the shared agent-event stream driver.
|
|
#[derive(Clone, Debug)]
|
|
pub(crate) struct AgentEventDriverConfig {
|
|
/// Run IDs whose events should be multiplexed into a single stream.
|
|
pub run_ids: Vec<String>,
|
|
/// Last fully handled event sequence. Events at or below this cursor are
|
|
/// ignored on reconnect so the consumer only sees new work.
|
|
pub since_sequence: i64,
|
|
/// Exponential-ish reconnect delays, in seconds, used after stream open
|
|
/// failures, stream errors, and clean stream termination.
|
|
pub reconnect_backoff_steps: &'static [u64],
|
|
/// Optional deadline for proactively recycling an otherwise healthy stream
|
|
/// before upstream infrastructure times it out (for example, before Cloud
|
|
/// Run's 20-minute streaming timeout).
|
|
pub proactive_reconnect_after: Option<Duration>,
|
|
/// Failure count at which reconnect logging is escalated from debug to warn.
|
|
/// This only affects log severity; retry behavior stays the same.
|
|
pub failures_before_error_log: usize,
|
|
}
|
|
|
|
impl AgentEventDriverConfig {
|
|
/// Build the production reconnecting configuration used by long-lived
|
|
/// orchestration and harness listeners.
|
|
pub(crate) fn retry_forever(run_ids: Vec<String>, since_sequence: i64) -> Self {
|
|
Self {
|
|
run_ids,
|
|
since_sequence,
|
|
reconnect_backoff_steps: DEFAULT_AGENT_EVENT_RECONNECT_BACKOFF_STEPS,
|
|
proactive_reconnect_after: Some(DEFAULT_AGENT_EVENT_PROACTIVE_RECONNECT),
|
|
failures_before_error_log: DEFAULT_AGENT_EVENT_FAILURES_BEFORE_ERROR_LOG,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tells the shared driver whether to continue or stop after a handled event.
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub(crate) enum AgentEventConsumerControlFlow {
|
|
Continue,
|
|
#[cfg_attr(not(test), allow(dead_code))]
|
|
Stop,
|
|
}
|
|
|
|
/// High-level connection state updates emitted by the shared driver.
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub(crate) enum AgentEventDriverState {
|
|
Connected,
|
|
RetryScheduled {
|
|
/// Number of consecutive failed reconnect cycles since the last
|
|
/// successful stream open or event delivery.
|
|
consecutive_failures: usize,
|
|
/// Delay before the next reconnect attempt.
|
|
backoff: Duration,
|
|
/// Whether the retry happened before the stream ever connected.
|
|
is_initial_connect: bool,
|
|
},
|
|
/// A healthy stream was intentionally recycled after
|
|
/// `proactive_reconnect_after`.
|
|
ProactiveReconnect,
|
|
}
|
|
|
|
/// Parsed items emitted by an [`AgentEventSource`].
|
|
pub(crate) enum AgentEventSourceItem {
|
|
Open,
|
|
Event(AgentRunEvent),
|
|
}
|
|
|
|
cfg_if::cfg_if! {
|
|
if #[cfg(target_family = "wasm")] {
|
|
type AgentEventSourceStream =
|
|
futures::stream::LocalBoxStream<'static, Result<AgentEventSourceItem>>;
|
|
} else {
|
|
type AgentEventSourceStream =
|
|
futures::stream::BoxStream<'static, Result<AgentEventSourceItem>>;
|
|
}
|
|
}
|
|
|
|
/// Opens a stream of parsed agent events for one or more run IDs.
|
|
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
|
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
|
pub(crate) trait AgentEventSource: Send + Sync {
|
|
async fn open_stream(
|
|
&self,
|
|
run_ids: &[String],
|
|
since_sequence: i64,
|
|
) -> Result<AgentEventSourceStream>;
|
|
}
|
|
|
|
/// [`AgentEventSource`] backed by [`ServerApi::stream_agent_events`].
|
|
pub(crate) struct ServerApiAgentEventSource {
|
|
server_api: Arc<ServerApi>,
|
|
}
|
|
|
|
impl ServerApiAgentEventSource {
|
|
pub(crate) fn new(server_api: Arc<ServerApi>) -> Self {
|
|
Self { server_api }
|
|
}
|
|
}
|
|
|
|
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
|
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
|
impl AgentEventSource for ServerApiAgentEventSource {
|
|
async fn open_stream(
|
|
&self,
|
|
run_ids: &[String],
|
|
since_sequence: i64,
|
|
) -> Result<AgentEventSourceStream> {
|
|
let stream = self
|
|
.server_api
|
|
.stream_agent_events(run_ids, since_sequence)
|
|
.await?;
|
|
|
|
let stream = stream.filter_map(|event_result| async move {
|
|
match event_result {
|
|
Ok(reqwest_eventsource::Event::Open) => Some(Ok(AgentEventSourceItem::Open)),
|
|
Ok(reqwest_eventsource::Event::Message(message)) => {
|
|
match serde_json::from_str::<AgentRunEvent>(&message.data) {
|
|
Ok(event) => Some(Ok(AgentEventSourceItem::Event(event))),
|
|
Err(err) => {
|
|
log::warn!("Skipping malformed agent event from SSE stream: {err}");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
Err(err) => Some(Err(anyhow!("SSE stream error: {err:?}"))),
|
|
}
|
|
});
|
|
|
|
cfg_if::cfg_if! {
|
|
if #[cfg(target_family = "wasm")] {
|
|
Ok(stream.boxed_local())
|
|
} else {
|
|
Ok(stream.boxed())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Consumes events produced by [`run_agent_event_driver`].
|
|
///
|
|
/// Errors from [`on_event`](Self::on_event) are fatal because the event could not
|
|
/// be safely processed. [`persist_cursor`](Self::persist_cursor) and
|
|
/// [`on_driver_state`](Self::on_driver_state) are treated as best-effort hooks:
|
|
/// their errors are logged and the driver continues.
|
|
#[cfg_attr(target_family = "wasm", async_trait(?Send))]
|
|
#[cfg_attr(not(target_family = "wasm"), async_trait)]
|
|
pub(crate) trait AgentEventConsumer: Send {
|
|
async fn on_event(&mut self, event: AgentRunEvent) -> Result<AgentEventConsumerControlFlow>;
|
|
|
|
async fn persist_cursor(&mut self, _sequence: i64) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn on_driver_state(&mut self, _state: AgentEventDriverState) -> Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Runs a reconnecting agent-event stream until the consumer stops it or a
|
|
/// fatal event-processing error occurs.
|
|
pub(crate) async fn run_agent_event_driver<S, C>(
|
|
source: S,
|
|
config: AgentEventDriverConfig,
|
|
consumer: &mut C,
|
|
) -> Result<()>
|
|
where
|
|
S: AgentEventSource,
|
|
C: AgentEventConsumer,
|
|
{
|
|
let mut since_sequence = config.since_sequence;
|
|
let mut failures = 0usize;
|
|
let mut has_connected_once = false;
|
|
|
|
loop {
|
|
let mut stream = match source.open_stream(&config.run_ids, since_sequence).await {
|
|
Ok(stream) => {
|
|
failures = 0;
|
|
has_connected_once = true;
|
|
notify_driver_state(consumer, AgentEventDriverState::Connected).await;
|
|
stream
|
|
}
|
|
Err(err) => {
|
|
failures += 1;
|
|
let backoff = agent_event_backoff(failures, config.reconnect_backoff_steps);
|
|
log_stream_failure(
|
|
&config.run_ids,
|
|
failures,
|
|
backoff,
|
|
&err,
|
|
config.failures_before_error_log,
|
|
);
|
|
notify_driver_state(
|
|
consumer,
|
|
AgentEventDriverState::RetryScheduled {
|
|
consecutive_failures: failures,
|
|
backoff,
|
|
is_initial_connect: !has_connected_once,
|
|
},
|
|
)
|
|
.await;
|
|
Timer::after(backoff).await;
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let proactive_reconnect_deadline = config
|
|
.proactive_reconnect_after
|
|
.map(|duration| Instant::now() + duration);
|
|
|
|
loop {
|
|
let next_item = if let Some(deadline) = proactive_reconnect_deadline {
|
|
let remaining = deadline.saturating_duration_since(Instant::now());
|
|
if remaining.is_zero() {
|
|
NextDriverItem::ProactiveReconnect
|
|
} else {
|
|
let next_stream_item = stream.next();
|
|
let reconnect_timer = Timer::after(remaining);
|
|
futures::pin_mut!(next_stream_item);
|
|
futures::pin_mut!(reconnect_timer);
|
|
match futures::future::select(next_stream_item, reconnect_timer).await {
|
|
Either::Left((stream_item, _)) => NextDriverItem::StreamItem(stream_item),
|
|
Either::Right(_) => NextDriverItem::ProactiveReconnect,
|
|
}
|
|
}
|
|
} else {
|
|
NextDriverItem::StreamItem(stream.next().await)
|
|
};
|
|
|
|
match next_item {
|
|
NextDriverItem::ProactiveReconnect => {
|
|
notify_driver_state(consumer, AgentEventDriverState::ProactiveReconnect).await;
|
|
break;
|
|
}
|
|
NextDriverItem::StreamItem(Some(Ok(AgentEventSourceItem::Open))) => {
|
|
failures = 0;
|
|
log::info!("Agent event stream opened for {:?}", config.run_ids);
|
|
}
|
|
NextDriverItem::StreamItem(Some(Ok(AgentEventSourceItem::Event(event)))) => {
|
|
failures = 0;
|
|
if event.sequence <= since_sequence {
|
|
continue;
|
|
}
|
|
|
|
let event_sequence = event.sequence;
|
|
let control_flow = consumer.on_event(event).await?;
|
|
since_sequence = event_sequence;
|
|
|
|
if let Err(err) = consumer.persist_cursor(since_sequence).await {
|
|
log::warn!(
|
|
"Ignoring agent event cursor persistence failure at sequence {since_sequence}: {err:#}"
|
|
);
|
|
}
|
|
|
|
if matches!(control_flow, AgentEventConsumerControlFlow::Stop) {
|
|
return Ok(());
|
|
}
|
|
}
|
|
NextDriverItem::StreamItem(Some(Err(err))) => {
|
|
failures += 1;
|
|
let backoff = agent_event_backoff(failures, config.reconnect_backoff_steps);
|
|
log_stream_failure(
|
|
&config.run_ids,
|
|
failures,
|
|
backoff,
|
|
&err,
|
|
config.failures_before_error_log,
|
|
);
|
|
notify_driver_state(
|
|
consumer,
|
|
AgentEventDriverState::RetryScheduled {
|
|
consecutive_failures: failures,
|
|
backoff,
|
|
is_initial_connect: false,
|
|
},
|
|
)
|
|
.await;
|
|
Timer::after(backoff).await;
|
|
break;
|
|
}
|
|
NextDriverItem::StreamItem(None) => {
|
|
failures += 1;
|
|
let backoff = agent_event_backoff(failures, config.reconnect_backoff_steps);
|
|
log::warn!(
|
|
"Agent event stream closed for {:?}, reconnecting in {backoff:?}",
|
|
config.run_ids
|
|
);
|
|
notify_driver_state(
|
|
consumer,
|
|
AgentEventDriverState::RetryScheduled {
|
|
consecutive_failures: failures,
|
|
backoff,
|
|
is_initial_connect: false,
|
|
},
|
|
)
|
|
.await;
|
|
Timer::after(backoff).await;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum NextDriverItem {
|
|
StreamItem(Option<Result<AgentEventSourceItem>>),
|
|
ProactiveReconnect,
|
|
}
|
|
|
|
async fn notify_driver_state<C: AgentEventConsumer>(
|
|
consumer: &mut C,
|
|
state: AgentEventDriverState,
|
|
) {
|
|
if let Err(err) = consumer.on_driver_state(state.clone()).await {
|
|
log::warn!("Ignoring agent event driver state callback error for {state:?}: {err:#}");
|
|
}
|
|
}
|
|
|
|
fn log_stream_failure(
|
|
run_ids: &[String],
|
|
failures: usize,
|
|
backoff: Duration,
|
|
err: &anyhow::Error,
|
|
failures_before_error_log: usize,
|
|
) {
|
|
if agent_event_failures_exceeded_threshold(failures, failures_before_error_log) {
|
|
log::error!(
|
|
"Agent event stream failed {failures} consecutive times for {:?}, retrying in {backoff:?}: {err:#}",
|
|
run_ids
|
|
);
|
|
} else {
|
|
log::warn!(
|
|
"Agent event stream failed for {:?}, retrying in {backoff:?}: {err:#}",
|
|
run_ids
|
|
);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn agent_event_backoff(failures: usize, backoff_steps: &[u64]) -> Duration {
|
|
let safe_steps = if backoff_steps.is_empty() {
|
|
DEFAULT_AGENT_EVENT_RECONNECT_BACKOFF_STEPS
|
|
} else {
|
|
backoff_steps
|
|
};
|
|
let index = failures.saturating_sub(1).min(safe_steps.len() - 1);
|
|
Duration::from_secs(safe_steps[index])
|
|
}
|
|
|
|
pub(crate) fn agent_event_failures_exceeded_threshold(failures: usize, threshold: usize) -> bool {
|
|
failures >= threshold
|
|
}
|