Skip to main content

recursive/
tasks.rs

1//! In-memory background-task registry.
2//!
3//! # Design
4//!
5//! When the `agent` tool is called with `run_in_background: true`, the
6//! work is spawned into a `tokio::task` and a `TaskId` is returned
7//! immediately.  The `TaskRegistry` holds:
8//!
9//! - the live `JoinHandle` (so `task_stop` can cancel),
10//! - the captured `TaskStatus` (so `task_get` / `task_list` can report),
11//! - the output buffer (so `task_output` can stream partial results).
12//!
13//! This is **in-memory only** by design.  Tasks do not survive process
14//! restart.  If a task is still running when the process exits, its
15//! output is lost.  The spec explicitly calls this out as acceptable
16//! for Phase D.
17
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20use std::collections::HashMap;
21use std::sync::Arc;
22use tokio::sync::{mpsc, Mutex, Notify, RwLock};
23use tokio::task::JoinHandle;
24use uuid::Uuid;
25
26// ---------------------------------------------------------------------------
27// TaskStatus
28// ---------------------------------------------------------------------------
29
30/// Lifecycle status of a background task.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum TaskStatus {
34    /// Task is queued or actively running.
35    Running,
36    /// Task completed successfully.
37    Completed,
38    /// Task exited with an error.
39    Failed,
40    /// Task was explicitly stopped by `task_stop`.
41    Stopped,
42}
43
44impl TaskStatus {
45    pub fn as_str(&self) -> &'static str {
46        match self {
47            TaskStatus::Running => "running",
48            TaskStatus::Completed => "completed",
49            TaskStatus::Failed => "failed",
50            TaskStatus::Stopped => "stopped",
51        }
52    }
53
54    /// True if the task will never change state again.
55    pub fn is_terminal(self) -> bool {
56        !matches!(self, TaskStatus::Running)
57    }
58}
59
60// ---------------------------------------------------------------------------
61// TaskState
62// ---------------------------------------------------------------------------
63
64/// Full state of a single background task.
65#[derive(Debug)]
66pub struct TaskState {
67    /// Stable opaque task ID.  Use this for `task_get`, `task_stop`, etc.
68    pub id: TaskId,
69    /// Human-readable description (e.g. the goal the agent was given).
70    pub description: String,
71    /// Team this task belongs to, if any.  Empty string means "no team".
72    pub team: String,
73    /// Teammate name within the team (empty if not a teammate).
74    pub name: String,
75    /// When the task was started.
76    pub started_at: DateTime<Utc>,
77    /// Current status. Behind a Mutex for `&self` mutation through Arc.
78    status: Mutex<TaskStatus>,
79    /// Output buffer: anything the inner agent has printed so far.
80    /// Pushed to from the inner agent via `output_tx`.
81    /// Behind a Mutex so callers can append through `&self` (since the
82    /// state is typically held inside an `Arc<TaskState>`).
83    pub(crate) output: Mutex<Vec<String>>,
84    /// Channel sender for the inner agent to push more output.
85    pub output_tx: mpsc::UnboundedSender<String>,
86    /// Receiver side, held here so the registry can drain output.
87    output_rx: Mutex<Option<mpsc::UnboundedReceiver<String>>>,
88    /// Tokio handle — used to await completion or cancel.
89    handle: Mutex<Option<JoinHandle<()>>>,
90    /// Set when a final result (Ok or Err) is captured.  Read by
91    /// `task_get` / `task_output` and reported as the task's terminal
92    /// status.  Held in an Option so the registry can record the
93    /// outcome even after the JoinHandle finishes.
94    pub final_result: Mutex<Option<Result<String, String>>>,
95    /// Fired once when the task reaches any terminal state (Completed /
96    /// Failed / Stopped). Uses `notify_one()` so a completion that happens
97    /// while a waiter is mid-turn is not lost — the next `.notified()` poll
98    /// consumes the stored permit. Mirrors the `BackgroundJobManager` pattern
99    /// in `src/tools/run_background.rs`. Used by `task_output(block=true)`
100    /// to wait event-driven instead of busy-polling.
101    pub completed_notify: Arc<Notify>,
102}
103
104impl TaskState {
105    /// Create a new in-memory task state.  The caller must call
106    /// `set_handle` to attach the JoinHandle once the task is spawned.
107    pub fn new(
108        description: impl Into<String>,
109        team: impl Into<String>,
110        name: impl Into<String>,
111    ) -> (Self, TaskId) {
112        let id = TaskId::new();
113        let (tx, rx) = mpsc::unbounded_channel();
114        let state = Self {
115            id: id.clone(),
116            description: description.into(),
117            team: team.into(),
118            name: name.into(),
119            started_at: Utc::now(),
120            status: Mutex::new(TaskStatus::Running),
121            output: Mutex::new(Vec::new()),
122            output_tx: tx,
123            output_rx: Mutex::new(Some(rx)),
124            handle: Mutex::new(None),
125            final_result: Mutex::new(None),
126            completed_notify: Arc::new(Notify::new()),
127        };
128        (state, id)
129    }
130
131    /// Attach the JoinHandle for cancellation / awaiting.
132    pub async fn set_handle(&self, handle: JoinHandle<()>) {
133        *self.handle.lock().await = Some(handle);
134    }
135
136    /// Read the current status (cloned).
137    pub async fn status(&self) -> TaskStatus {
138        *self.status.lock().await
139    }
140
141    /// Append a single output line to the buffer.
142    pub async fn append_output(&self, line: String) {
143        self.output.lock().await.push(line);
144    }
145
146    /// Read a snapshot of the output buffer.
147    pub async fn output_snapshot(&self) -> Vec<String> {
148        self.output.lock().await.clone()
149    }
150
151    /// Drain any pending output from the inner channel into `self.output`.
152    /// Returns the number of new lines drained.
153    pub async fn drain_output(&self) -> usize {
154        let mut rx_guard = self.output_rx.lock().await;
155        let Some(rx) = rx_guard.as_mut() else {
156            return 0;
157        };
158        let mut count = 0;
159        // try_recv loop — don't await; we want non-blocking.
160        let mut out = self.output.lock().await;
161        loop {
162            match rx.try_recv() {
163                Ok(line) => {
164                    out.push(line);
165                    count += 1;
166                }
167                Err(mpsc::error::TryRecvError::Empty) => break,
168                Err(mpsc::error::TryRecvError::Disconnected) => {
169                    // Sender dropped — close the receiver so we don't try again.
170                    *rx_guard = None;
171                    break;
172                }
173            }
174        }
175        count
176    }
177
178    /// Cancel the task.  Returns `true` if a running handle was found
179    /// and aborted.
180    pub async fn stop(&self) -> bool {
181        let h = self.handle.lock().await;
182        if let Some(handle) = h.as_ref() {
183            handle.abort();
184            *self.status.lock().await = TaskStatus::Stopped;
185            self.completed_notify.notify_one();
186            true
187        } else {
188            false
189        }
190    }
191
192    /// Mark the task as completed with the given output text.
193    pub async fn mark_completed(&self, output: String) {
194        *self.status.lock().await = TaskStatus::Completed;
195        *self.final_result.lock().await = Some(Ok(output));
196        self.completed_notify.notify_one();
197    }
198
199    /// Mark the task as failed.
200    pub async fn mark_failed(&self, err: String) {
201        *self.status.lock().await = TaskStatus::Failed;
202        *self.final_result.lock().await = Some(Err(err));
203        self.completed_notify.notify_one();
204    }
205
206    /// Return a clone of the completion-notify handle.
207    ///
208    /// Callers can `select!` on `completed_notify().notified()` to wake
209    /// when the task enters any terminal state (Completed / Failed /
210    /// Stopped), instead of polling `status()`. Mirrors
211    /// `BackgroundJobManager::completed_notify`.
212    pub fn completed_notify(&self) -> Arc<Notify> {
213        self.completed_notify.clone()
214    }
215}
216
217// ---------------------------------------------------------------------------
218// TaskId
219// ---------------------------------------------------------------------------
220
221/// Opaque task ID.  Wrapped so callers can't accidentally pass a random
222/// `String` where a task ID is expected.
223#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
224pub struct TaskId(pub String);
225
226impl TaskId {
227    pub fn new() -> Self {
228        Self(format!("task-{}", Uuid::new_v4()))
229    }
230}
231
232impl Default for TaskId {
233    fn default() -> Self {
234        Self::new()
235    }
236}
237
238impl std::fmt::Display for TaskId {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        f.write_str(&self.0)
241    }
242}
243
244// ---------------------------------------------------------------------------
245// TaskRegistry
246// ---------------------------------------------------------------------------
247
248/// Process-wide registry of in-flight background tasks.
249///
250/// Cloneable, backed by `Arc<RwLock<…>>`.  All methods are safe to call
251/// concurrently.
252#[derive(Clone, Default)]
253pub struct TaskRegistry {
254    inner: Arc<RwLock<TaskRegistryInner>>,
255}
256
257#[derive(Default)]
258struct TaskRegistryInner {
259    tasks: HashMap<TaskId, Arc<TaskState>>,
260    /// Track order of insertion so `task_list` returns tasks in a
261    /// stable, predictable order.
262    order: Vec<TaskId>,
263}
264
265impl TaskRegistry {
266    pub fn new() -> Self {
267        Self::default()
268    }
269
270    /// Register a new task.  Returns a clone of the `Arc<TaskState>`
271    /// so callers can write to its output channel.
272    pub async fn register(&self, state: TaskState) -> Arc<TaskState> {
273        let id = state.id.clone();
274        let state = Arc::new(state);
275        let mut inner = self.inner.write().await;
276        inner.order.push(id.clone());
277        inner.tasks.insert(id, state.clone());
278        state
279    }
280
281    /// Get a task by ID.
282    pub async fn get(&self, id: &TaskId) -> Option<Arc<TaskState>> {
283        self.inner.read().await.tasks.get(id).cloned()
284    }
285
286    /// List all known tasks in insertion order.
287    pub async fn list(&self) -> Vec<Arc<TaskState>> {
288        let inner = self.inner.read().await;
289        inner
290            .order
291            .iter()
292            .filter_map(|id| inner.tasks.get(id).cloned())
293            .collect()
294    }
295
296    /// Stop a task by ID.  Returns `true` if a running task was found
297    /// and aborted.
298    pub async fn stop(&self, id: &TaskId) -> bool {
299        match self.get(id).await {
300            Some(t) => t.stop().await,
301            None => false,
302        }
303    }
304
305    /// Drain output for a single task.
306    pub async fn drain_output(&self, id: &TaskId) -> usize {
307        match self.get(id).await {
308            Some(t) => t.drain_output().await,
309            None => 0,
310        }
311    }
312
313    /// Append a single output line for a task.
314    pub async fn append_output(&self, id: &TaskId, line: String) -> bool {
315        match self.get(id).await {
316            Some(t) => {
317                t.append_output(line).await;
318                true
319            }
320            None => false,
321        }
322    }
323
324    /// Drain output for *all* known tasks.  Useful after waking up to
325    /// propagate new state.
326    pub async fn drain_all(&self) -> usize {
327        let tasks = self.list().await;
328        let mut total = 0;
329        for t in tasks {
330            total += t.drain_output().await;
331        }
332        total
333    }
334
335    /// Number of tasks currently tracked.
336    pub async fn len(&self) -> usize {
337        self.inner.read().await.tasks.len()
338    }
339
340    pub async fn is_empty(&self) -> bool {
341        self.inner.read().await.tasks.is_empty()
342    }
343
344    /// Remove a task from the registry.  Used by `task_stop` to clean
345    /// up after cancellation.  Note: the task state may still be alive
346    /// (e.g. if a clone of the `Arc` is held elsewhere), but it will
347    /// be detached from lookups.
348    pub async fn forget(&self, id: &TaskId) -> bool {
349        let mut inner = self.inner.write().await;
350        let removed = inner.tasks.remove(id).is_some();
351        inner.order.retain(|tid| tid != id);
352        removed
353    }
354}
355
356// ---------------------------------------------------------------------------
357// Tests
358// ---------------------------------------------------------------------------
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use std::time::Duration;
364
365    #[test]
366    fn task_id_unique_and_format() {
367        let a = TaskId::new();
368        let b = TaskId::new();
369        assert_ne!(a, b);
370        assert!(a.0.starts_with("task-"));
371        assert!(a.to_string().starts_with("task-"));
372    }
373
374    #[test]
375    fn task_status_as_str_matches_serde() {
376        for s in [
377            TaskStatus::Running,
378            TaskStatus::Completed,
379            TaskStatus::Failed,
380            TaskStatus::Stopped,
381        ] {
382            let j = serde_json::to_string(&s).unwrap();
383            let back: TaskStatus = serde_json::from_str(&j).unwrap();
384            assert_eq!(s, back);
385        }
386    }
387
388    #[test]
389    fn task_status_is_terminal() {
390        assert!(!TaskStatus::Running.is_terminal());
391        assert!(TaskStatus::Completed.is_terminal());
392        assert!(TaskStatus::Failed.is_terminal());
393        assert!(TaskStatus::Stopped.is_terminal());
394    }
395
396    #[tokio::test]
397    async fn register_list_get() {
398        let reg = TaskRegistry::new();
399        let (state, id) = TaskState::new("test task", "alpha", "researcher");
400        let _ = reg.register(state).await;
401        assert_eq!(reg.len().await, 1);
402
403        let listed = reg.list().await;
404        assert_eq!(listed.len(), 1);
405        assert_eq!(listed[0].id, id);
406
407        let got = reg.get(&id).await.unwrap();
408        assert_eq!(got.description, "test task");
409        assert_eq!(got.team, "alpha");
410        assert_eq!(got.name, "researcher");
411        assert_eq!(got.status().await, TaskStatus::Running);
412    }
413
414    #[tokio::test]
415    async fn drain_output_collects_lines() {
416        let reg = TaskRegistry::new();
417        let (state, id) = TaskState::new("t", "", "");
418        let arc = reg.register(state).await;
419
420        arc.output_tx.send("line 1".into()).unwrap();
421        arc.output_tx.send("line 2".into()).unwrap();
422        // Drop the Arc (and the contained sender) so the receiver
423        // sees a disconnected channel after the next drain.
424        drop(arc);
425
426        let drained = reg.drain_output(&id).await;
427        assert_eq!(drained, 2);
428        let got = reg.get(&id).await.unwrap();
429        assert_eq!(got.output_snapshot().await, vec!["line 1", "line 2"]);
430
431        // Second drain is a no-op.
432        assert_eq!(reg.drain_output(&id).await, 0);
433    }
434
435    #[tokio::test]
436    async fn append_output_via_registry() {
437        let reg = TaskRegistry::new();
438        let (state, id) = TaskState::new("t", "", "");
439        let _ = reg.register(state).await;
440        assert!(reg.append_output(&id, "x".into()).await);
441        assert!(!reg.append_output(&TaskId("ghost".into()), "y".into()).await);
442        let task = reg.get(&id).await.unwrap();
443        assert_eq!(task.output_snapshot().await, vec!["x"]);
444    }
445
446    #[tokio::test]
447    async fn mark_completed_and_failed() {
448        let reg = TaskRegistry::new();
449        let (state, id) = TaskState::new("t", "", "");
450        let _ = reg.register(state).await;
451
452        reg.get(&id)
453            .await
454            .unwrap()
455            .mark_completed("done".into())
456            .await;
457        let got = reg.get(&id).await.unwrap();
458        assert_eq!(got.status().await, TaskStatus::Completed);
459        let r = got.final_result.lock().await.clone();
460        assert_eq!(r, Some(Ok("done".into())));
461
462        reg.get(&id).await.unwrap().mark_failed("oops".into()).await;
463        let got = reg.get(&id).await.unwrap();
464        assert_eq!(got.status().await, TaskStatus::Failed);
465        let r = got.final_result.lock().await.clone();
466        assert_eq!(r, Some(Err("oops".into())));
467    }
468
469    #[tokio::test]
470    async fn stop_cancels_join_handle() {
471        let reg = TaskRegistry::new();
472        let (state, id) = TaskState::new("t", "", "");
473        let arc = reg.register(state).await;
474
475        let handle = tokio::spawn(async {
476            tokio::time::sleep(Duration::from_secs(60)).await;
477        });
478        arc.set_handle(handle).await;
479
480        assert!(reg.stop(&id).await);
481        let got = reg.get(&id).await.unwrap();
482        assert_eq!(got.status().await, TaskStatus::Stopped);
483
484        tokio::time::sleep(Duration::from_millis(10)).await;
485    }
486
487    #[tokio::test]
488    async fn completed_notify_wakes_on_mark_completed() {
489        // A waiter that selects on notified() must wake when the task enters a
490        // terminal state. Mirrors BackgroundJobManager's completed_notify test.
491        let reg = TaskRegistry::new();
492        let (state, id) = TaskState::new("t", "", "");
493        let arc = reg.register(state).await;
494        let notify = arc.completed_notify();
495
496        // Spawn a waiter that blocks until notified or times out (failure case).
497        let wait_handle = tokio::spawn(async move {
498            tokio::time::timeout(Duration::from_secs(2), notify.notified())
499                .await
500                .is_ok()
501        });
502
503        // Give the waiter a moment to register, then complete the task.
504        tokio::time::sleep(Duration::from_millis(20)).await;
505        arc.mark_completed("done".to_string()).await;
506
507        let woke = wait_handle.await.unwrap();
508        assert!(woke, "waiter should have been woken by notify_one()");
509        let got = reg.get(&id).await.unwrap();
510        assert_eq!(got.status().await, TaskStatus::Completed);
511    }
512
513    #[tokio::test]
514    async fn completed_notify_not_lost_when_fired_before_wait() {
515        // notify_one() stores a permit, so a completion that happens before
516        // the waiter calls notified() is not lost.
517        let reg = TaskRegistry::new();
518        let (state, _id) = TaskState::new("t", "", "");
519        let arc = reg.register(state).await;
520        let notify = arc.completed_notify();
521
522        // Fire completion first.
523        arc.mark_failed("boom".to_string()).await;
524        // Now wait — must return immediately due to the stored permit.
525        let woke = tokio::time::timeout(Duration::from_millis(100), notify.notified())
526            .await
527            .is_ok();
528        assert!(woke, "stored permit must wake a late waiter immediately");
529    }
530
531    #[tokio::test]
532    async fn forget_removes_from_lookup() {
533        let reg = TaskRegistry::new();
534        let (state, id) = TaskState::new("t", "", "");
535        let _ = reg.register(state).await;
536        assert!(reg.forget(&id).await);
537        assert!(reg.get(&id).await.is_none());
538        assert_eq!(reg.len().await, 0);
539        assert!(!reg.forget(&id).await);
540    }
541
542    // ── Gap-filling tests ────────────────────────────────────────────────────
543
544    #[test]
545    fn task_status_as_str_returns_correct_strings() {
546        assert_eq!(TaskStatus::Running.as_str(), "running");
547        assert_eq!(TaskStatus::Completed.as_str(), "completed");
548        assert_eq!(TaskStatus::Failed.as_str(), "failed");
549        assert_eq!(TaskStatus::Stopped.as_str(), "stopped");
550    }
551
552    #[test]
553    fn task_id_display_format() {
554        let id = TaskId("task-abc123".into());
555        assert_eq!(id.to_string(), "task-abc123");
556        assert_eq!(format!("{id}"), "task-abc123");
557    }
558
559    #[tokio::test]
560    async fn drain_output_returns_correct_count() {
561        let reg = TaskRegistry::new();
562        let (state, id) = TaskState::new("t", "", "");
563        let arc = reg.register(state).await;
564
565        // Append 3 lines via channel
566        arc.output_tx.send("a".into()).unwrap();
567        arc.output_tx.send("b".into()).unwrap();
568        arc.output_tx.send("c".into()).unwrap();
569        drop(arc);
570
571        let count = reg.drain_output(&id).await;
572        assert_eq!(
573            count, 3,
574            "drain must return the exact number of lines drained"
575        );
576
577        // A second drain with no new lines returns 0
578        assert_eq!(reg.drain_output(&id).await, 0);
579    }
580
581    #[tokio::test]
582    async fn stop_without_handle_returns_false() {
583        let reg = TaskRegistry::new();
584        let (state, id) = TaskState::new("t", "", "");
585        let _ = reg.register(state).await;
586
587        // No handle set — stop returns false (nothing to cancel)
588        let stopped = reg.stop(&id).await;
589        assert!(!stopped, "stop without handle must return false");
590    }
591
592    #[tokio::test]
593    async fn task_state_output_snapshot_empty_initially() {
594        let reg = TaskRegistry::new();
595        let (state, id) = TaskState::new("task", "team", "name");
596        let _ = reg.register(state).await;
597        let got = reg.get(&id).await.unwrap();
598        let snap = got.output_snapshot().await;
599        assert!(snap.is_empty(), "output must be empty before any drain");
600    }
601
602    #[tokio::test]
603    async fn registry_is_empty_initially_and_nonempty_after_register() {
604        // kills `is_empty` → always-true / always-false mutations
605        let reg = TaskRegistry::new();
606        assert!(reg.is_empty().await, "new registry must be empty");
607        let (state, _id) = TaskState::new("t", "", "");
608        reg.register(state).await;
609        assert!(
610            !reg.is_empty().await,
611            "registry must not be empty after register"
612        );
613    }
614
615    #[tokio::test]
616    async fn drain_all_drains_multiple_tasks() {
617        // kills `drain_all` function-level replacement
618        // Note: drain_output reads from output_tx channel, not the direct buffer,
619        // so we send via output_tx to test drain_all correctly.
620        let reg = TaskRegistry::new();
621        let (s1, _id1) = TaskState::new("t1", "", "");
622        let (s2, _id2) = TaskState::new("t2", "", "");
623        // Send via the channel before registering so we can keep tx
624        let tx1 = s1.output_tx.clone();
625        let tx2 = s2.output_tx.clone();
626        reg.register(s1).await;
627        reg.register(s2).await;
628        tx1.send("a".into()).unwrap();
629        tx2.send("b".into()).unwrap();
630        // Give the channel messages time to arrive
631        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
632        let total = reg.drain_all().await;
633        assert_eq!(
634            total, 2,
635            "drain_all must return total lines drained across tasks"
636        );
637    }
638
639    #[tokio::test]
640    async fn drain_output_returns_zero_for_missing_task() {
641        // kills `None => 0` → `None => 1` mutation in drain_output
642        let reg = TaskRegistry::new();
643        let ghost_id = TaskId::new();
644        assert_eq!(
645            reg.drain_output(&ghost_id).await,
646            0,
647            "drain_output must return 0 for missing task"
648        );
649    }
650
651    #[tokio::test]
652    async fn append_output_returns_false_for_missing_task() {
653        // kills `None => false` → `None => true` mutation in append_output
654        let reg = TaskRegistry::new();
655        let ghost_id = TaskId::new();
656        let ok = reg.append_output(&ghost_id, "line".into()).await;
657        assert!(!ok, "append_output must return false for missing task");
658    }
659}