Skip to main content

vtcode_desktop_protocol/
live_events.rs

1//! Coalescing live-event channel from the service to desktop clients.
2use std::collections::VecDeque;
3
4use vtcode_exec_events::VersionedThreadEvent;
5
6/// Create the bounded live event channel that streams runtime events from the
7/// agent runloop to the desktop service pump.
8///
9/// Unlike a plain bounded channel, a full queue never fails the producer:
10/// `item.updated` snapshots replace the pending snapshot for the same item,
11/// consecutive `plan.delta` events for one item merge into one entry, and
12/// consecutive tool-output deltas for one item concatenate into one entry, so
13/// a slow consumer degrades the stream instead of failing a healthy turn.
14/// Ordered events are only dropped when the queue holds no coalescable entry,
15/// and every such drop is counted for consumer-side degradation reporting.
16pub fn live_event_channel(capacity: usize) -> (LiveEventSender, LiveEventReceiver) {
17    assert!(capacity > 0, "live event capacity must be positive");
18    let inner = std::sync::Arc::new(LiveEventQueueInner {
19        capacity,
20        queue: std::sync::Mutex::new(VecDeque::new()),
21        senders: std::sync::atomic::AtomicUsize::new(1),
22        receiver_alive: std::sync::atomic::AtomicBool::new(true),
23        dropped_ordered: std::sync::atomic::AtomicU64::new(0),
24        dropped_stale: std::sync::atomic::AtomicU64::new(0),
25        notify: tokio::sync::Notify::new(),
26    });
27    (LiveEventSender { inner: inner.clone() }, LiveEventReceiver { inner })
28}
29
30/// How a streaming event combines with the pending entry for the same key.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32enum CoalesceOp {
33    /// The new event is a full snapshot that supersedes the pending one.
34    Replace,
35    /// The new event carries incremental text appended to the pending one.
36    Merge,
37    /// The new event carries an output chunk appended to the pending one.
38    MergeOutput,
39}
40
41/// One queue entry: a coalescable streaming event keyed by item, or an
42/// ordered event that must keep its relative position.
43struct LiveEventSlot {
44    key: Option<String>,
45    op: CoalesceOp,
46    event: VersionedThreadEvent,
47}
48
49struct LiveEventQueueInner {
50    capacity: usize,
51    queue: std::sync::Mutex<VecDeque<LiveEventSlot>>,
52    senders: std::sync::atomic::AtomicUsize,
53    receiver_alive: std::sync::atomic::AtomicBool,
54    dropped_ordered: std::sync::atomic::AtomicU64,
55    dropped_stale: std::sync::atomic::AtomicU64,
56    notify: tokio::sync::Notify,
57}
58
59impl LiveEventQueueInner {
60    fn classify(event: &VersionedThreadEvent) -> Option<(String, CoalesceOp)> {
61        match event.event() {
62            vtcode_exec_events::ThreadEvent::ItemUpdated(update) => {
63                let op = match &update.item.details {
64                    vtcode_exec_events::ThreadItemDetails::ToolOutput(output) if output.is_delta == Some(true) => {
65                        CoalesceOp::MergeOutput
66                    }
67                    _ => CoalesceOp::Replace,
68                };
69                Some((update.item.id.clone(), op))
70            }
71            vtcode_exec_events::ThreadEvent::PlanDelta(delta) => Some((delta.item_id.clone(), CoalesceOp::Merge)),
72            _ => None,
73        }
74    }
75
76    /// Append an event, coalescing streaming updates and keeping the queue
77    /// bounded. Returns `Err(())` only when the consumer half was dropped.
78    fn publish(&self, event: VersionedThreadEvent) -> Result<(), ()> {
79        if !self.receiver_alive.load(std::sync::atomic::Ordering::Acquire) {
80            return Err(());
81        }
82        let mut queue = self.queue.lock().map_err(|_poisoned| ())?;
83        let classified = Self::classify(&event);
84        let slot_key = classified.as_ref().map(|(key, _)| key.clone());
85        let slot_op = classified.as_ref().map_or(CoalesceOp::Replace, |(_, op)| *op);
86        if let Some((key, op)) = classified {
87            let pending_slot = queue
88                .iter()
89                .position(|slot| slot.key.as_deref() == Some(key.as_str()) && slot.op == op)
90                .and_then(|index| queue.remove(index).map(|removed| (index, removed)));
91            if let Some((index, pending)) = pending_slot {
92                // Re-insert at the removed slot's position so the relative
93                // order of every other queued event is preserved.
94                let merged = match op {
95                    CoalesceOp::Replace => event,
96                    CoalesceOp::Merge => Self::merge_plan_deltas(pending.event, event),
97                    CoalesceOp::MergeOutput => Self::merge_tool_output_deltas(pending.event, event),
98                };
99                queue.insert(index, LiveEventSlot { key: Some(key), op, event: merged });
100                drop(queue);
101                self.notify.notify_waiters();
102                return Ok(());
103            }
104        }
105        if queue.len() >= self.capacity {
106            // Prefer displacing a stale streaming entry over dropping an
107            // ordered event: the latest snapshot for an item always arrives
108            // with its terminal `item.completed` event.
109            let victim = queue.iter().position(|slot| slot.key.is_some());
110            match victim {
111                Some(index) => {
112                    drop(queue.remove(index));
113                    let _ = self.dropped_stale.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
114                }
115                None => {
116                    drop(queue.pop_front());
117                    let _ = self.dropped_ordered.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
118                }
119            }
120        }
121        queue.push_back(LiveEventSlot { key: slot_key, op: slot_op, event });
122        drop(queue);
123        self.notify.notify_waiters();
124        Ok(())
125    }
126
127    /// Concatenate two tool-output deltas for the same item, keeping the
128    /// newest metadata and the freshest non-empty optional fields.
129    fn merge_tool_output_deltas(pending: VersionedThreadEvent, next: VersionedThreadEvent) -> VersionedThreadEvent {
130        let (pending, next) = (pending.into_event(), next.into_event());
131        match (pending, next) {
132            (
133                vtcode_exec_events::ThreadEvent::ItemUpdated(older),
134                vtcode_exec_events::ThreadEvent::ItemUpdated(newer),
135            ) => {
136                let (
137                    vtcode_exec_events::ThreadItemDetails::ToolOutput(older_output),
138                    vtcode_exec_events::ThreadItemDetails::ToolOutput(newer_output),
139                ) = (&older.item.details, &newer.item.details)
140                else {
141                    return VersionedThreadEvent::new(vtcode_exec_events::ThreadEvent::ItemUpdated(newer));
142                };
143                let mut merged = newer.clone();
144                let mut output = older_output.output.clone();
145                output.push_str(&newer_output.output);
146                merged.item.details =
147                    vtcode_exec_events::ThreadItemDetails::ToolOutput(Box::new(vtcode_exec_events::ToolOutputItem {
148                        call_id: newer_output.call_id.clone(),
149                        tool_call_id: newer_output.tool_call_id.clone().or_else(|| older_output.tool_call_id.clone()),
150                        spool_path: newer_output.spool_path.clone().or_else(|| older_output.spool_path.clone()),
151                        output,
152                        is_delta: Some(true),
153                        exit_code: newer_output.exit_code.or(older_output.exit_code),
154                        status: newer_output.status.clone(),
155                        app_previews: if newer_output.app_previews.is_empty() {
156                            older_output.app_previews.clone()
157                        } else {
158                            newer_output.app_previews.clone()
159                        },
160                    }));
161                VersionedThreadEvent::new(vtcode_exec_events::ThreadEvent::ItemUpdated(merged))
162            }
163            (_, fallback) => VersionedThreadEvent::new(fallback),
164        }
165    }
166
167    /// Concatenate two `plan.delta` events for the same item, keeping the
168    /// newest metadata and the freshest non-empty recovery snapshot.
169    fn merge_plan_deltas(pending: VersionedThreadEvent, next: VersionedThreadEvent) -> VersionedThreadEvent {
170        let (pending, next) = (pending.into_event(), next.into_event());
171        match (pending, next) {
172            (vtcode_exec_events::ThreadEvent::PlanDelta(older), vtcode_exec_events::ThreadEvent::PlanDelta(newer)) => {
173                let mut merged = newer.clone();
174                let mut text = older.delta;
175                text.push_str(&newer.delta);
176                merged.delta = text;
177                if merged.recovery.is_none() {
178                    merged.recovery = older.recovery;
179                }
180                VersionedThreadEvent::new(vtcode_exec_events::ThreadEvent::PlanDelta(merged))
181            }
182            (_, fallback) => VersionedThreadEvent::new(fallback),
183        }
184    }
185}
186
187/// Producer half of the live event channel.
188pub struct LiveEventSender {
189    inner: std::sync::Arc<LiveEventQueueInner>,
190}
191
192impl Clone for LiveEventSender {
193    fn clone(&self) -> Self {
194        let _ = self.inner.senders.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
195        Self { inner: std::sync::Arc::clone(&self.inner) }
196    }
197}
198
199impl LiveEventSender {
200    /// Publish one event. Coalesces streaming updates when the queue is
201    /// saturated; fails only when the consumer half is gone.
202    pub fn publish(&self, event: VersionedThreadEvent) -> Result<(), LiveEventSendError> {
203        self.inner.publish(event).map_err(|()| LiveEventSendError(()))
204    }
205}
206
207impl Drop for LiveEventSender {
208    fn drop(&mut self) {
209        let _ = self.inner.senders.fetch_sub(1, std::sync::atomic::Ordering::Release);
210        self.inner.notify.notify_waiters();
211    }
212}
213
214/// Consumer half of the live event channel.
215pub struct LiveEventReceiver {
216    inner: std::sync::Arc<LiveEventQueueInner>,
217}
218
219impl LiveEventReceiver {
220    /// Receive the next event, waiting while the queue is empty.
221    ///
222    /// Returns `None` once every sender was dropped and the queue drained.
223    pub async fn recv(&self) -> Option<VersionedThreadEvent> {
224        loop {
225            {
226                let mut queue = self.inner.queue.lock().ok()?;
227                if let Some(slot) = queue.pop_front() {
228                    return Some(slot.event);
229                }
230                if self.inner.senders.load(std::sync::atomic::Ordering::Acquire) == 0 {
231                    return None;
232                }
233            }
234            let notified = self.inner.notify.notified();
235            {
236                let queue = self.inner.queue.lock().ok()?;
237                if !queue.is_empty() {
238                    continue;
239                }
240                if self.inner.senders.load(std::sync::atomic::Ordering::Acquire) == 0 {
241                    return None;
242                }
243            }
244            notified.await;
245        }
246    }
247
248    /// Take and reset the count of ordered events dropped under saturation.
249    pub fn take_dropped_ordered(&self) -> u64 {
250        self.inner.dropped_ordered.swap(0, std::sync::atomic::Ordering::Relaxed)
251    }
252
253    /// Take and reset the count of superseded streaming snapshots displaced
254    /// under saturation. These are best-effort previews, not stream gaps.
255    pub fn take_dropped_stale(&self) -> u64 {
256        self.inner.dropped_stale.swap(0, std::sync::atomic::Ordering::Relaxed)
257    }
258}
259
260impl Drop for LiveEventReceiver {
261    fn drop(&mut self) {
262        self.inner.receiver_alive.store(false, std::sync::atomic::Ordering::Release);
263        self.inner.notify.notify_waiters();
264    }
265}
266
267/// The consumer half of the live event channel is gone.
268#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub struct LiveEventSendError(());
270
271impl std::fmt::Display for LiveEventSendError {
272    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273        f.write_str("live event consumer disconnected")
274    }
275}
276
277impl std::error::Error for LiveEventSendError {}