Skip to main content

flowcat_core/pipeline/
task.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! `PipelineTask` — one running pipeline's lifecycle (PROCESSOR-DESIGN §4.1).
4//!
5//! Owns the wrapped pipeline (Source + user + Sink), the push queue, the clock,
6//! the observer fan-out, idle detection, heartbeat/watchdog, and start/end
7//! signalling. Mirrors pipecat `PipelineTask` (`task.py:142`).
8//!
9//! **Lifecycle:** `run()` builds channels + spawns every processor task, injects
10//! `Start` and blocks until it reaches the Sink (every processor ran `start()`),
11//! pumps queued frames into the head, and exits when a terminal frame
12//! (`End`/`Stop`/`Cancel`) reaches the Sink.
13
14use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
15use std::sync::Arc;
16use std::time::Duration;
17
18use tokio::sync::mpsc;
19use tokio::task::JoinSet;
20use tokio_util::sync::CancellationToken;
21
22use crate::error::Result;
23use crate::observer::{FrameObserver, IdleFrameObserver, Observer};
24use crate::processor::frame::{Direction, Frame, FrameKind, StartParams};
25use crate::processor::runtime::ProcessorTx;
26use crate::processor::{into_topology, Clock, Envelope, ProcessorSetup, StopReason, Topology};
27
28use super::{link_topology, Sink, Source};
29
30/// Parameters for a [`PipelineTask`]. Mirrors pipecat (`task.py:59-62`).
31///
32/// **flowcat-core's default keeps heartbeats OFF** (pipecat parity). An embedder
33/// can opt in later (PROCESSOR-DESIGN §10 Q3) — do not flip the core
34/// default here.
35#[derive(Debug, Clone)]
36pub struct PipelineTaskParams {
37    pub audio_in_sample_rate: u32,
38    pub audio_out_sample_rate: u32,
39    pub enable_metrics: bool,
40    pub enable_usage_metrics: bool,
41    pub enable_tracing: bool,
42    pub enable_heartbeats: bool,
43    pub heartbeat_period: Duration,
44    pub heartbeat_monitor: Duration,
45    pub idle_timeout: Option<Duration>,
46    pub cancel_on_idle: bool,
47    pub idle_timeout_frames: Vec<FrameKind>,
48}
49
50impl Default for PipelineTaskParams {
51    fn default() -> Self {
52        Self {
53            audio_in_sample_rate: 16_000,
54            audio_out_sample_rate: 24_000,
55            enable_metrics: false,
56            enable_usage_metrics: false,
57            enable_tracing: false,
58            // pipecat parity: OFF by default. (Do not flip — §10 Q3.)
59            enable_heartbeats: false,
60            heartbeat_period: Duration::from_secs(1),
61            heartbeat_monitor: Duration::from_secs(10),
62            idle_timeout: Some(Duration::from_secs(300)),
63            cancel_on_idle: true,
64            idle_timeout_frames: vec![FrameKind::BotSpeaking, FrameKind::UserSpeaking],
65        }
66    }
67}
68
69impl PipelineTaskParams {
70    fn start_params(&self) -> StartParams {
71        StartParams {
72            audio_in_sample_rate: self.audio_in_sample_rate,
73            audio_out_sample_rate: self.audio_out_sample_rate,
74            enable_metrics: self.enable_metrics,
75            enable_usage_metrics: self.enable_usage_metrics,
76            enable_tracing: self.enable_tracing,
77            report_only_initial_ttfb: true,
78        }
79    }
80}
81
82type CallbackUnit = Box<dyn Fn() + Send + Sync>;
83type CallbackReason = Box<dyn Fn(StopReason) + Send + Sync>;
84type CallbackError = Box<dyn Fn(&str, bool) + Send + Sync>;
85
86/// One running pipeline's lifecycle handle.
87pub struct PipelineTask {
88    topo: Option<Topology>,
89    params: PipelineTaskParams,
90    observers: Vec<Arc<dyn FrameObserver>>,
91    cancel: CancellationToken,
92    finished: Arc<AtomicBool>,
93    // Queue of frames to inject into the head once ready.
94    queue_tx: mpsc::UnboundedSender<Frame>,
95    queue_rx: Option<mpsc::UnboundedReceiver<Frame>>,
96    // Event hooks.
97    on_started: Vec<CallbackUnit>,
98    on_finished: Vec<CallbackReason>,
99    on_error: Vec<CallbackError>,
100    on_idle_timeout: Vec<CallbackUnit>,
101    // The idle observer is auto-registered so the watcher can read activity.
102    idle_observer: Arc<IdleFrameObserver>,
103}
104
105impl PipelineTask {
106    /// Build a task over `pipeline` with `params` and external `observers`.
107    pub fn new(
108        pipeline: super::Pipeline,
109        params: PipelineTaskParams,
110        observers: Vec<Arc<dyn FrameObserver>>,
111    ) -> Self {
112        let (queue_tx, queue_rx) = mpsc::unbounded_channel();
113        let idle_observer = Arc::new(IdleFrameObserver::new(params.idle_timeout_frames.clone()));
114        Self {
115            topo: Some(into_topology(Box::new(pipeline))),
116            params,
117            observers,
118            cancel: CancellationToken::new(),
119            finished: Arc::new(AtomicBool::new(false)),
120            queue_tx,
121            queue_rx: Some(queue_rx),
122            on_started: Vec::new(),
123            on_finished: Vec::new(),
124            on_error: Vec::new(),
125            on_idle_timeout: Vec::new(),
126            idle_observer,
127        }
128    }
129
130    /// Queue a downstream frame into the head of the pipeline.
131    pub async fn queue_frame(&self, frame: Frame) {
132        let _ = self.queue_tx.send(frame);
133    }
134
135    /// Queue several downstream frames.
136    pub async fn queue_frames(&self, frames: impl IntoIterator<Item = Frame>) {
137        for f in frames {
138            let _ = self.queue_tx.send(f);
139        }
140    }
141
142    /// A clonable handle to the head-injection queue, for an external producer
143    /// (e.g. the S2S transport pump) that feeds frames into the pipeline head from
144    /// its own task while [`run`](Self::run) drives the pipeline. Sending after the
145    /// task finished is a harmless no-op (the receiver is drained on shutdown).
146    pub fn queue_sender(&self) -> mpsc::UnboundedSender<Frame> {
147        self.queue_tx.clone()
148    }
149
150    /// Graceful: queue an `End` so the pipeline drains then shuts down.
151    pub async fn stop_when_done(&self) {
152        let _ = self.queue_tx.send(Frame::End { reason: None });
153    }
154
155    /// Immediate: queue a `Cancel`.
156    pub async fn cancel(&self, reason: Option<String>) {
157        let _ = self.queue_tx.send(Frame::Cancel { reason });
158    }
159
160    /// Whether the task has finished.
161    pub fn has_finished(&self) -> bool {
162        self.finished.load(Ordering::Relaxed)
163    }
164
165    /// The shared cancellation token (a [`PipelineRunner`](super::PipelineRunner)
166    /// holds it to force-abort on signal).
167    pub fn cancel_token(&self) -> CancellationToken {
168        self.cancel.clone()
169    }
170
171    // ---- event hooks ----
172
173    pub fn on_started(&mut self, f: impl Fn() + Send + Sync + 'static) {
174        self.on_started.push(Box::new(f));
175    }
176    pub fn on_finished(&mut self, f: impl Fn(StopReason) + Send + Sync + 'static) {
177        self.on_finished.push(Box::new(f));
178    }
179    pub fn on_error(&mut self, f: impl Fn(&str, bool) + Send + Sync + 'static) {
180        self.on_error.push(Box::new(f));
181    }
182    pub fn on_idle_timeout(&mut self, f: impl Fn() + Send + Sync + 'static) {
183        self.on_idle_timeout.push(Box::new(f));
184    }
185
186    /// Run to completion (PROCESSOR-DESIGN §4.1): spawn all processor tasks, inject
187    /// `Start`, wait until it reaches the Sink (ready), pump queued frames, and
188    /// exit when a terminal frame reaches the Sink (or the cancel token fires).
189    pub async fn run(mut self) -> Result<()> {
190        // Infallible: `run` consumes `self` (`mut self`), so it can be called at
191        // most once; `topo`/`queue_rx` are `Some` from `new()` until this take.
192        let topo = self.topo.take().expect("run called once");
193        let mut queue_rx = self.queue_rx.take().expect("run called once");
194
195        // Build the observer fan-out: the auto idle-observer + external ones.
196        let mut all_obs: Vec<Arc<dyn FrameObserver>> =
197            vec![self.idle_observer.clone() as Arc<dyn FrameObserver>];
198        all_obs.extend(self.observers.iter().cloned());
199        let observer = Observer::new(all_obs);
200
201        let clock = Clock::new();
202        let setup = ProcessorSetup {
203            clock: clock.clone(),
204            observer: Some(observer.clone()),
205            cancel: self.cancel.clone(),
206            enable_metrics: self.params.enable_metrics,
207            enable_usage_metrics: self.params.enable_usage_metrics,
208        };
209
210        // Sink tap → the task observes downstream frames here.
211        let (sink_tap_tx, mut sink_tap_rx) = mpsc::unbounded_channel::<Frame>();
212
213        // Wrap: [Source, <topo>, Sink].
214        let wrapped = Topology::Chain(vec![
215            Topology::Leaf(Box::new(Source)),
216            topo,
217            Topology::Leaf(Box::new(Sink { tap: sink_tap_tx })),
218        ]);
219
220        let mut tasks = JoinSet::new();
221        let linked = link_topology(wrapped, None, None, &setup, &mut tasks);
222        let head: ProcessorTx = linked.head;
223
224        observer.on_pipeline_started().await;
225
226        // 1) Inject Start and block until it reaches the Sink (ready).
227        head.send(Envelope::new(
228            Frame::Start(self.params.start_params()),
229            Direction::Downstream,
230        ))
231        .await;
232
233        let ready = wait_for(
234            &mut sink_tap_rx,
235            |f| matches!(f, Frame::Start(_)),
236            &self.cancel,
237        )
238        .await;
239        if ready {
240            for cb in &self.on_started {
241                cb();
242            }
243        }
244
245        // Auxiliary (non-processor) watcher tasks — heartbeat + idle — run until
246        // the pump loop exits. They listen on this child token (cancelled the moment
247        // the pipeline finishes, step 5) so the grace-join below only ever waits on
248        // the real processor tasks; otherwise an idle watcher sleeping on its (up to
249        // 300 s) timer would pin every teardown to the full 5 s grace window,
250        // delaying finalize/write-back on *every* call. As a child of `self.cancel`,
251        // it is also cancelled by an external force-cancel.
252        let aux_cancel = self.cancel.child_token();
253
254        // 2) Heartbeat task (OFF by default — pipecat parity).
255        let last_heartbeat = Arc::new(AtomicU64::new(0));
256        if self.params.enable_heartbeats {
257            let hb_head = head.clone();
258            let period = self.params.heartbeat_period;
259            let cancel = aux_cancel.clone();
260            let clk = clock.clone();
261            tasks.spawn(async move {
262                loop {
263                    tokio::select! {
264                        _ = cancel.cancelled() => break,
265                        _ = tokio::time::sleep(period) => {
266                            hb_head
267                                .send(Envelope::new(
268                                    Frame::Heartbeat { timestamp_ns: clk.now_ns() },
269                                    Direction::Downstream,
270                                ))
271                                .await;
272                        }
273                    }
274                }
275            });
276        }
277
278        // 3) Idle watcher.
279        let idle_fired = Arc::new(AtomicBool::new(false));
280        if let Some(timeout) = self.params.idle_timeout {
281            let idle_obs = self.idle_observer.clone();
282            let cancel = aux_cancel.clone();
283            let cancel_on_idle = self.params.cancel_on_idle;
284            let fired = idle_fired.clone();
285            let qtx = self.queue_tx.clone();
286            tasks.spawn(async move {
287                let mut last = idle_obs.activity_count();
288                loop {
289                    tokio::select! {
290                        _ = cancel.cancelled() => break,
291                        _ = tokio::time::sleep(timeout) => {
292                            let now = idle_obs.activity_count();
293                            if now == last {
294                                fired.store(true, Ordering::Relaxed);
295                                if cancel_on_idle {
296                                    let _ = qtx.send(Frame::Cancel {
297                                        reason: Some("idle timeout".into()),
298                                    });
299                                }
300                                break;
301                            }
302                            last = now;
303                        }
304                    }
305                }
306            });
307        }
308
309        // 4) Pump queued frames into the head; watch the Sink for terminal/heartbeat.
310        let mut stop_reason: Option<StopReason> = None;
311        loop {
312            tokio::select! {
313                biased;
314                _ = self.cancel.cancelled() => {
315                    stop_reason = Some(StopReason::Cancelled);
316                    // Drive a Cancel through so processors flush-stop.
317                    head.send(Envelope::new(Frame::Cancel { reason: None }, Direction::Downstream)).await;
318                    break;
319                }
320                maybe = queue_rx.recv() => {
321                    match maybe {
322                        Some(frame) => {
323                            head.send(Envelope::new(frame, Direction::Downstream)).await;
324                        }
325                        None => {
326                            // queue closed (all senders dropped) — keep draining sink.
327                        }
328                    }
329                }
330                maybe = sink_tap_rx.recv() => {
331                    match maybe {
332                        Some(Frame::Heartbeat { timestamp_ns }) => {
333                            last_heartbeat.store(timestamp_ns as u64, Ordering::Relaxed);
334                        }
335                        Some(Frame::Error { fatal, message, .. }) => {
336                            for cb in &self.on_error {
337                                cb(&message, fatal);
338                            }
339                            if fatal {
340                                head.send(Envelope::new(Frame::Cancel { reason: None }, Direction::Downstream)).await;
341                            }
342                        }
343                        Some(f) if f.is_terminal() => {
344                            stop_reason = Some(terminal_reason(&f));
345                            break;
346                        }
347                        Some(_) => {}
348                        None => break,
349                    }
350                }
351            }
352        }
353
354        // 5) Stop the auxiliary watcher tasks immediately (the pipeline is done), so
355        // the grace-join only waits on the real processor tasks draining.
356        aux_cancel.cancel();
357        // Drain remaining processor tasks (bounded by a grace window).
358        let _ = tokio::time::timeout(Duration::from_secs(5), async {
359            while tasks.join_next().await.is_some() {}
360        })
361        .await;
362        // Anything still wedged gets force-cancelled.
363        self.cancel.cancel();
364        tasks.shutdown().await;
365
366        if idle_fired.load(Ordering::Relaxed) {
367            for cb in &self.on_idle_timeout {
368                cb();
369            }
370        }
371
372        let reason = stop_reason.unwrap_or(StopReason::EndOfTask);
373        for cb in &self.on_finished {
374            cb(reason);
375        }
376        self.finished.store(true, Ordering::Relaxed);
377        Ok(())
378    }
379}
380
381fn terminal_reason(f: &Frame) -> StopReason {
382    match f {
383        Frame::End { .. } => StopReason::EndOfTask,
384        Frame::Stop => StopReason::Stopped,
385        Frame::Cancel { .. } => StopReason::Cancelled,
386        _ => StopReason::EndOfTask,
387    }
388}
389
390/// Block until a frame matching `pred` is seen on the sink tap, or the task is
391/// cancelled. Returns whether the predicate matched (false = cancelled/closed).
392async fn wait_for(
393    rx: &mut mpsc::UnboundedReceiver<Frame>,
394    pred: impl Fn(&Frame) -> bool,
395    cancel: &CancellationToken,
396) -> bool {
397    loop {
398        tokio::select! {
399            _ = cancel.cancelled() => return false,
400            maybe = rx.recv() => match maybe {
401                Some(f) if pred(&f) => return true,
402                Some(_) => continue,
403                None => return false,
404            }
405        }
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use crate::error::Result;
413    use crate::processor::{FrameProcessor, Link};
414    use async_trait::async_trait;
415    use std::sync::atomic::{AtomicUsize, Ordering};
416    use std::sync::Mutex;
417
418    fn fast_idle(params: PipelineTaskParams) -> PipelineTaskParams {
419        params
420    }
421
422    // Records the order it saw Start vs data, and forwards.
423    struct Probe {
424        start_seen: Arc<AtomicBool>,
425        data_before_start: Arc<AtomicUsize>,
426    }
427    #[async_trait]
428    impl FrameProcessor for Probe {
429        fn name(&self) -> &str {
430            "Probe"
431        }
432        async fn start(&mut self, _s: &ProcessorSetup, _p: &StartParams) -> Result<()> {
433            self.start_seen.store(true, Ordering::Relaxed);
434            Ok(())
435        }
436        async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
437            if matches!(env.frame, Frame::Text(_)) && !self.start_seen.load(Ordering::Relaxed) {
438                self.data_before_start.fetch_add(1, Ordering::Relaxed);
439            }
440            link.push(env.meta, env.frame, env.direction).await;
441            Ok(())
442        }
443    }
444
445    // Emits an upstream End request when it sees a specific Text ("bye").
446    struct EndRequester;
447    #[async_trait]
448    impl FrameProcessor for EndRequester {
449        fn name(&self) -> &str {
450            "EndRequester"
451        }
452        async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
453            if matches!(&env.frame, Frame::Text(t) if t == "bye") {
454                link.push_up(Frame::End {
455                    reason: Some("requested".into()),
456                })
457                .await;
458                return Ok(());
459            }
460            link.push(env.meta, env.frame, env.direction).await;
461            Ok(())
462        }
463    }
464
465    #[tokio::test]
466    async fn start_reaches_sink_before_any_data_frame() {
467        let start_seen = Arc::new(AtomicBool::new(false));
468        let bad = Arc::new(AtomicUsize::new(0));
469        let pipeline = super::super::Pipeline::new(vec![Box::new(Probe {
470            start_seen: start_seen.clone(),
471            data_before_start: bad.clone(),
472        })]);
473        let started = Arc::new(AtomicBool::new(false));
474        let started2 = started.clone();
475        let mut task = PipelineTask::new(pipeline, PipelineTaskParams::default(), vec![]);
476        task.on_started(move || started2.store(true, Ordering::Relaxed));
477        task.queue_frame(Frame::Text("data".into())).await;
478        task.stop_when_done().await;
479        task.run().await.unwrap();
480        assert!(started.load(Ordering::Relaxed), "on_started must fire");
481        assert_eq!(
482            bad.load(Ordering::Relaxed),
483            0,
484            "data must not precede start"
485        );
486    }
487
488    #[tokio::test]
489    async fn stop_when_done_drains_then_ends() {
490        let count = Arc::new(AtomicUsize::new(0));
491        let count2 = count.clone();
492        let pipeline = super::super::Pipeline::new(vec![Box::new(CountText {
493            count: count.clone(),
494        })]);
495        let mut task = PipelineTask::new(pipeline, PipelineTaskParams::default(), vec![]);
496        let finished_reason = Arc::new(Mutex::new(None));
497        let fr = finished_reason.clone();
498        task.on_finished(move |r| *fr.lock().unwrap() = Some(r));
499        task.queue_frames(vec![
500            Frame::Text("a".into()),
501            Frame::Text("b".into()),
502            Frame::Text("c".into()),
503        ])
504        .await;
505        task.stop_when_done().await;
506        task.run().await.unwrap();
507        assert_eq!(
508            count2.load(Ordering::Relaxed),
509            3,
510            "all data drained before End"
511        );
512        assert_eq!(
513            *finished_reason.lock().unwrap(),
514            Some(StopReason::EndOfTask)
515        );
516        let _ = count;
517    }
518
519    #[tokio::test]
520    async fn cancel_ends_without_draining_everything() {
521        let pipeline = super::super::Pipeline::new(vec![Box::new(CountText {
522            count: Arc::new(AtomicUsize::new(0)),
523        })]);
524        let mut task = PipelineTask::new(pipeline, PipelineTaskParams::default(), vec![]);
525        let reason = Arc::new(Mutex::new(None));
526        let r2 = reason.clone();
527        task.on_finished(move |r| *r2.lock().unwrap() = Some(r));
528        task.cancel(None).await;
529        task.run().await.unwrap();
530        assert_eq!(*reason.lock().unwrap(), Some(StopReason::Cancelled));
531    }
532
533    #[tokio::test]
534    async fn upstream_end_request_converts_to_downstream_end() {
535        let pipeline = super::super::Pipeline::new(vec![Box::new(EndRequester)]);
536        let mut task = PipelineTask::new(pipeline, PipelineTaskParams::default(), vec![]);
537        let reason = Arc::new(Mutex::new(None));
538        let r2 = reason.clone();
539        task.on_finished(move |r| *r2.lock().unwrap() = Some(r));
540        task.queue_frame(Frame::Text("bye".into())).await;
541        // No explicit stop — the upstream End request must end the task.
542        task.run().await.unwrap();
543        assert_eq!(*reason.lock().unwrap(), Some(StopReason::EndOfTask));
544    }
545
546    #[tokio::test]
547    async fn idle_timeout_fires_and_cancels() {
548        let pipeline = super::super::Pipeline::new(vec![Box::new(CountText {
549            count: Arc::new(AtomicUsize::new(0)),
550        })]);
551        let params = fast_idle(PipelineTaskParams {
552            idle_timeout: Some(Duration::from_millis(80)),
553            cancel_on_idle: true,
554            ..Default::default()
555        });
556        let mut task = PipelineTask::new(pipeline, params, vec![]);
557        let idled = Arc::new(AtomicBool::new(false));
558        let i2 = idled.clone();
559        task.on_idle_timeout(move || i2.store(true, Ordering::Relaxed));
560        let reason = Arc::new(Mutex::new(None));
561        let r2 = reason.clone();
562        task.on_finished(move |r| *r2.lock().unwrap() = Some(r));
563        // Never queue activity → goes idle.
564        task.run().await.unwrap();
565        assert!(idled.load(Ordering::Relaxed), "idle hook must fire");
566        assert_eq!(*reason.lock().unwrap(), Some(StopReason::Cancelled));
567    }
568
569    struct CountText {
570        count: Arc<AtomicUsize>,
571    }
572    #[async_trait]
573    impl FrameProcessor for CountText {
574        fn name(&self) -> &str {
575            "CountText"
576        }
577        async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
578            if matches!(env.frame, Frame::Text(_)) {
579                self.count.fetch_add(1, Ordering::Relaxed);
580            }
581            link.push(env.meta, env.frame, env.direction).await;
582            Ok(())
583        }
584    }
585}