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