Skip to main content

car_engine/
tool_handles.rs

1//! Per-runtime registry of detached tool invocations (C2).
2//!
3//! A `ToolCall` action with a detached [`car_ir::tool_stream::ToolInvocationMode`]
4//! (`streaming` / `long_running`) is *started*, not awaited: `dispatch`
5//! registers the invocation here, hands the caller a [`ToolHandle`] as
6//! the action's output, and the DAG proceeds. Chunks the executor
7//! yields are buffered per handle (drained by [`ToolHandleRegistry::poll`])
8//! and fanned out on a broadcast channel so a server can forward them to
9//! subscribed clients as `tools.stream.event` notifications.
10//!
11//! Lifecycle: `register` → zero or more `push_chunk` → a terminal chunk
12//! (`done` / `error`) or `cancel` seals the entry. A sealed entry stays
13//! queryable until a `poll` observes it *after* draining its last chunks;
14//! that poll removes it (the "read your final answer once" contract —
15//! mirrors how idempotency results are consumed).
16
17use std::collections::HashMap;
18use std::sync::Arc;
19
20use car_ir::{ToolHandle, ToolStatus, ToolStreamChunk, ToolStreamEvent};
21use serde::{Deserialize, Serialize};
22use tokio::sync::{broadcast, Mutex};
23use tokio_util::sync::CancellationToken;
24
25/// Broadcast capacity for [`ToolStreamEvent`] fanout. A lagging
26/// subscriber loses oldest events (chunks remain available via `poll`,
27/// which reads the buffer, not the broadcast).
28const EVENT_CHANNEL_CAP: usize = 256;
29
30/// Cap on chunks buffered per handle awaiting a `poll` (linus review D2).
31/// A subscriber-only client (the documented low-latency pattern) never
32/// polls, so an uncapped buffer is an OOM with a long-running tool as
33/// the trigger. Oldest chunks are dropped first; the drop count is
34/// surfaced on the next poll so a late poller knows the gap exists.
35const MAX_BUFFERED_CHUNKS: usize = 1024;
36
37/// How long a terminal (done/failed/cancelled) entry stays queryable when
38/// nobody polls it (linus review D2). Reaped opportunistically on
39/// register/poll/cancel — a never-polled cancel must not live for the
40/// session's lifetime.
41const TERMINAL_TTL: std::time::Duration = std::time::Duration::from_secs(15 * 60);
42
43/// What `poll` returns: the drained chunks since the last poll plus the
44/// invocation's current status and, once terminal, its final result or
45/// error extracted from the terminal chunk.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ToolPollResult {
48    pub handle: String,
49    pub tool: String,
50    pub action_id: String,
51    pub status: ToolStatus,
52    /// Chunks produced since the previous poll (oldest first). Terminal
53    /// chunk included when the stream just finished.
54    pub chunks: Vec<ToolStreamChunk>,
55    /// Chunks dropped from the buffer because it hit its cap before this
56    /// poll drained it (0 for callers that poll promptly). The event
57    /// broadcast may still have delivered them live.
58    #[serde(default, skip_serializing_if = "is_zero")]
59    pub dropped_chunks: u64,
60    /// Set once status is `succeeded` (from the `done` chunk's payload).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub result: Option<serde_json::Value>,
63    /// Set once status is `failed` (from the `error` chunk's message).
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub error: Option<String>,
66}
67
68fn is_zero(n: &u64) -> bool {
69    *n == 0
70}
71
72struct HandleEntry {
73    tool: String,
74    action_id: String,
75    status: ToolStatus,
76    buffered: Vec<ToolStreamChunk>,
77    /// Chunks evicted from `buffered` at the cap since the last poll.
78    dropped_chunks: u64,
79    result: Option<serde_json::Value>,
80    error: Option<String>,
81    cancel: CancellationToken,
82    /// True once a poll has observed the entry in a terminal state —
83    /// the next terminal observation removes it (so a caller always
84    /// sees the terminal status at least once).
85    drained_terminal: bool,
86    /// When the entry reached a terminal state; drives the TTL reap.
87    sealed_at: Option<std::time::Instant>,
88}
89
90/// Registry of live (and recently-terminal) detached tool invocations.
91/// One per [`crate::executor::Runtime`]; cheap to share via `Arc`.
92pub struct ToolHandleRegistry {
93    entries: Mutex<HashMap<String, HandleEntry>>,
94    events: broadcast::Sender<ToolStreamEvent>,
95}
96
97impl Default for ToolHandleRegistry {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103impl ToolHandleRegistry {
104    pub fn new() -> Self {
105        let (events, _) = broadcast::channel(EVENT_CHANNEL_CAP);
106        Self {
107            entries: Mutex::new(HashMap::new()),
108            events,
109        }
110    }
111
112    /// Subscribe to the live event fanout (every chunk from every handle,
113    /// wrapped in a [`ToolStreamEvent`]). Used by the WS layer to forward
114    /// `tools.stream.event` notifications.
115    pub fn subscribe(&self) -> broadcast::Receiver<ToolStreamEvent> {
116        self.events.subscribe()
117    }
118
119    /// Register a new detached invocation. Returns the opaque handle and
120    /// the cancel token the drain task must select on.
121    pub async fn register(&self, tool: &str, action_id: &str) -> (ToolHandle, CancellationToken) {
122        // Full 128-bit id (linus review Q3): the previous 12-hex-char
123        // truncation made a collision a silent cross-wiring of two live
124        // invocations (plain insert overwrites). Full width is free.
125        let id = uuid::Uuid::new_v4().simple().to_string();
126        let cancel = CancellationToken::new();
127        let entry = HandleEntry {
128            tool: tool.to_string(),
129            action_id: action_id.to_string(),
130            status: ToolStatus::Running,
131            buffered: Vec::new(),
132            dropped_chunks: 0,
133            result: None,
134            error: None,
135            cancel: cancel.clone(),
136            drained_terminal: false,
137            sealed_at: None,
138        };
139        let mut entries = self.entries.lock().await;
140        Self::reap_expired(&mut entries);
141        entries.insert(id.clone(), entry);
142        drop(entries);
143        (ToolHandle::new(id), cancel)
144    }
145
146    /// Drop terminal entries older than [`TERMINAL_TTL`] (linus review
147    /// D2): a never-polled terminal handle must not live for the
148    /// session's lifetime. Called opportunistically under the lock from
149    /// register/poll/cancel — no background task needed.
150    fn reap_expired(entries: &mut HashMap<String, HandleEntry>) {
151        entries.retain(|_, e| match e.sealed_at {
152            Some(at) => at.elapsed() < TERMINAL_TTL,
153            None => true,
154        });
155    }
156
157    /// Append a chunk from the executor's stream. A terminal chunk seals
158    /// the status (`done` → `succeeded` + result, `error` → `failed` +
159    /// message). Chunks arriving after a terminal state (e.g. a racing
160    /// executor after cancel) are dropped. Every accepted chunk is also
161    /// broadcast as a [`ToolStreamEvent`].
162    pub async fn push_chunk(&self, handle_id: &str, chunk: ToolStreamChunk) {
163        let mut entries = self.entries.lock().await;
164        let Some(entry) = entries.get_mut(handle_id) else {
165            return;
166        };
167        if entry.status.is_terminal() {
168            return;
169        }
170        match &chunk {
171            ToolStreamChunk::Done { result } => {
172                entry.status = ToolStatus::Succeeded;
173                entry.result = result.clone();
174                entry.sealed_at = Some(std::time::Instant::now());
175            }
176            ToolStreamChunk::Error { message } => {
177                entry.status = ToolStatus::Failed;
178                entry.error = Some(message.clone());
179                entry.sealed_at = Some(std::time::Instant::now());
180            }
181            _ => {}
182        }
183        // Bounded buffer (linus review D2): drop-oldest at the cap and
184        // count the drops. A terminal chunk always fits (it just landed
185        // in status/result above regardless).
186        if entry.buffered.len() >= MAX_BUFFERED_CHUNKS {
187            entry.buffered.remove(0);
188            entry.dropped_chunks += 1;
189        }
190        entry.buffered.push(chunk.clone());
191        // Broadcast after the buffer write so a poll racing the event
192        // never observes the event before the chunk is drainable.
193        let _ = self.events.send(ToolStreamEvent {
194            handle: ToolHandle::new(handle_id.to_string()),
195            chunk,
196        });
197    }
198
199    /// The executor's stream ended without a terminal chunk — a dropped
200    /// sender (panicked task, closed pipe). Treated as failure, unless the
201    /// entry was already sealed (normal after a `done`/`error`/cancel).
202    pub async fn mark_stream_closed(&self, handle_id: &str) {
203        let mut entries = self.entries.lock().await;
204        if let Some(entry) = entries.get_mut(handle_id) {
205            if !entry.status.is_terminal() {
206                entry.status = ToolStatus::Failed;
207                entry.error = Some("tool stream closed without a terminal chunk".to_string());
208                entry.sealed_at = Some(std::time::Instant::now());
209            }
210        }
211    }
212
213    /// Request cancellation: fires the cancel token (the drain task and a
214    /// cooperative executor observe it) and seals the entry as
215    /// `cancelled` unless already terminal. Returns false for an unknown
216    /// handle.
217    pub async fn cancel(&self, handle_id: &str) -> bool {
218        let mut entries = self.entries.lock().await;
219        Self::reap_expired(&mut entries);
220        let Some(entry) = entries.get_mut(handle_id) else {
221            return false;
222        };
223        entry.cancel.cancel();
224        if !entry.status.is_terminal() {
225            entry.status = ToolStatus::Cancelled;
226            entry.sealed_at = Some(std::time::Instant::now());
227        }
228        true
229    }
230
231    /// Cancel every live invocation (linus review D3): wired into the
232    /// daemon's session teardown so a dropped WS connection doesn't
233    /// orphan running detached tools — each session's runtime (and thus
234    /// registry) is unreachable after disconnect, so anything still
235    /// running would be uncancellable until daemon restart. Returns how
236    /// many live invocations were cancelled.
237    pub async fn cancel_all(&self) -> usize {
238        let mut entries = self.entries.lock().await;
239        let mut n = 0;
240        for e in entries.values_mut() {
241            if !e.status.is_terminal() {
242                e.cancel.cancel();
243                e.status = ToolStatus::Cancelled;
244                e.sealed_at = Some(std::time::Instant::now());
245                n += 1;
246            }
247        }
248        n
249    }
250
251    /// Drain buffered chunks + report status. `None` for an unknown (or
252    /// already fully-consumed) handle. The FIRST poll that observes the
253    /// entry in a terminal state marks it consumed (it also drains any
254    /// remaining chunks); the next poll removes it — so a caller always
255    /// gets to see the terminal status at least once.
256    pub async fn poll(&self, handle_id: &str) -> Option<ToolPollResult> {
257        let mut entries = self.entries.lock().await;
258        Self::reap_expired(&mut entries);
259        let entry = entries.get_mut(handle_id)?;
260        let chunks = std::mem::take(&mut entry.buffered);
261        let dropped = std::mem::take(&mut entry.dropped_chunks);
262        let res = ToolPollResult {
263            handle: handle_id.to_string(),
264            tool: entry.tool.clone(),
265            action_id: entry.action_id.clone(),
266            status: entry.status,
267            chunks,
268            dropped_chunks: dropped,
269            result: entry.result.clone(),
270            error: entry.error.clone(),
271        };
272        if entry.status.is_terminal() {
273            if entry.drained_terminal {
274                entries.remove(handle_id);
275            } else {
276                entry.drained_terminal = true;
277            }
278        }
279        Some(res)
280    }
281
282    /// Current status without draining (used by tests and liveness checks).
283    pub async fn status(&self, handle_id: &str) -> Option<ToolStatus> {
284        self.entries.lock().await.get(handle_id).map(|e| e.status)
285    }
286
287    /// Number of live (non-terminal) invocations.
288    pub async fn live_count(&self) -> usize {
289        self.entries
290            .lock()
291            .await
292            .values()
293            .filter(|e| !e.status.is_terminal())
294            .count()
295    }
296}
297
298/// Spawn the drain task tying an executor's chunk stream to the registry:
299/// forwards chunks until a terminal chunk, stream close, or cancellation.
300/// Returns immediately; the task owns the receiver.
301pub fn spawn_drain(
302    registry: Arc<ToolHandleRegistry>,
303    handle_id: String,
304    rx: tokio::sync::mpsc::Receiver<ToolStreamChunk>,
305    cancel: CancellationToken,
306) {
307    drop(spawn_drain_task(registry, handle_id, rx, cancel));
308}
309
310fn spawn_drain_task(
311    registry: Arc<ToolHandleRegistry>,
312    handle_id: String,
313    mut rx: tokio::sync::mpsc::Receiver<ToolStreamChunk>,
314    cancel: CancellationToken,
315) -> tokio::task::JoinHandle<()> {
316    tokio::spawn(async move {
317        loop {
318            tokio::select! {
319                _ = cancel.cancelled() => {
320                    // Status was already sealed by `cancel()`; dropping rx
321                    // is the signal a cooperative executor sees.
322                    break;
323                }
324                chunk = rx.recv() => match chunk {
325                    Some(c) => {
326                        let terminal = c.is_terminal();
327                        registry.push_chunk(&handle_id, c).await;
328                        if terminal {
329                            break;
330                        }
331                    }
332                    None => {
333                        registry.mark_stream_closed(&handle_id).await;
334                        break;
335                    }
336                }
337            }
338        }
339    })
340}
341
342#[cfg(test)]
343mod tests {
344    use super::*;
345
346    #[tokio::test]
347    async fn chunks_buffer_and_drain_in_order() {
348        let reg = ToolHandleRegistry::new();
349        let (h, _tok) = reg.register("tail_log", "a1").await;
350        reg.push_chunk(&h.id, ToolStreamChunk::Text { text: "one".into() })
351            .await;
352        reg.push_chunk(&h.id, ToolStreamChunk::Text { text: "two".into() })
353            .await;
354        let poll = reg.poll(&h.id).await.expect("known handle");
355        assert_eq!(poll.status, ToolStatus::Running);
356        assert_eq!(poll.chunks.len(), 2);
357        // Drained — a second poll returns no chunks but stays queryable.
358        let poll2 = reg.poll(&h.id).await.expect("still live");
359        assert!(poll2.chunks.is_empty());
360    }
361
362    #[tokio::test]
363    async fn done_chunk_seals_success_with_result() {
364        let reg = ToolHandleRegistry::new();
365        let (h, _tok) = reg.register("build", "a2").await;
366        reg.push_chunk(
367            &h.id,
368            ToolStreamChunk::Done {
369                result: Some(serde_json::json!({"exit": 0})),
370            },
371        )
372        .await;
373        let poll = reg.poll(&h.id).await.unwrap();
374        assert_eq!(poll.status, ToolStatus::Succeeded);
375        assert_eq!(poll.result, Some(serde_json::json!({"exit": 0})));
376        // Late chunks after terminal are dropped.
377        reg.push_chunk(
378            &h.id,
379            ToolStreamChunk::Text {
380                text: "late".into(),
381            },
382        )
383        .await;
384        let poll2 = reg.poll(&h.id).await.unwrap();
385        assert!(poll2.chunks.is_empty());
386        // Terminal observed twice with empty buffer → entry consumed.
387        assert!(reg.poll(&h.id).await.is_none());
388    }
389
390    #[tokio::test]
391    async fn cancel_seals_cancelled_and_fires_token() {
392        let reg = ToolHandleRegistry::new();
393        let (h, tok) = reg.register("watch", "a3").await;
394        assert!(reg.cancel(&h.id).await);
395        assert!(tok.is_cancelled());
396        assert_eq!(reg.status(&h.id).await, Some(ToolStatus::Cancelled));
397        // Executor racing past the cancel can't overwrite the status.
398        reg.push_chunk(&h.id, ToolStreamChunk::Done { result: None })
399            .await;
400        assert_eq!(reg.status(&h.id).await, Some(ToolStatus::Cancelled));
401        assert!(!reg.cancel("nope").await);
402    }
403
404    #[tokio::test]
405    async fn drain_task_forwards_until_terminal() {
406        let reg = Arc::new(ToolHandleRegistry::new());
407        let (h, tok) = reg.register("gen", "a4").await;
408        let (tx, rx) = tokio::sync::mpsc::channel(8);
409        let drain = spawn_drain_task(reg.clone(), h.id.clone(), rx, tok);
410        tx.send(ToolStreamChunk::Progress {
411            fraction: 0.5,
412            message: Some("half".into()),
413        })
414        .await
415        .unwrap();
416        tx.send(ToolStreamChunk::Done { result: None })
417            .await
418            .unwrap();
419        drain.await.expect("drain task must observe terminal chunk");
420        let poll = reg.poll(&h.id).await.unwrap();
421        assert_eq!(poll.status, ToolStatus::Succeeded);
422        assert_eq!(poll.chunks.len(), 2);
423    }
424
425    #[tokio::test]
426    async fn dropped_stream_without_terminal_is_failure() {
427        let reg = Arc::new(ToolHandleRegistry::new());
428        let (h, tok) = reg.register("flaky", "a5").await;
429        let (tx, rx) = tokio::sync::mpsc::channel(8);
430        let drain = spawn_drain_task(reg.clone(), h.id.clone(), rx, tok);
431        tx.send(ToolStreamChunk::Text {
432            text: "partial".into(),
433        })
434        .await
435        .unwrap();
436        drop(tx);
437        drain
438            .await
439            .expect("drain task must observe stream closure without terminal chunk");
440        let poll = reg.poll(&h.id).await.unwrap();
441        assert_eq!(poll.status, ToolStatus::Failed);
442        assert!(poll.error.unwrap().contains("without a terminal chunk"));
443    }
444
445    #[tokio::test]
446    async fn events_broadcast_to_subscribers() {
447        let reg = ToolHandleRegistry::new();
448        let mut sub = reg.subscribe();
449        let (h, _tok) = reg.register("emit", "a6").await;
450        reg.push_chunk(&h.id, ToolStreamChunk::Text { text: "x".into() })
451            .await;
452        let ev = sub.recv().await.expect("event delivered");
453        assert_eq!(ev.handle.id, h.id);
454        assert!(matches!(ev.chunk, ToolStreamChunk::Text { .. }));
455    }
456}