Skip to main content

ravenclaws/
background.rs

1//! Background task manager for long-horizon async runs
2//!
3//! Supports assign-and-walk-away execution with persistence across restarts.
4//! Tasks are serialized to JSON in a configurable directory and can be resumed
5//! on process restart.
6//!
7//! # Architecture
8//!
9//! - `BackgroundTaskManager` — owns the task store and manages lifecycle
10//! - `BackgroundTask` — a single task with status, prompt, and result
11//! - `TaskStatus` — pending → running → completed / failed
12//! - Tasks are persisted as individual JSON files in `tasks/` directory
13//!
14//! # Usage
15//!
16//! ```ignore
17//! let mut manager = BackgroundTaskManager::new("/tmp/ravenclaws-tasks")?;
18//! let task_id = manager.submit("Analyze this data", llm_client).await?;
19//! let status = manager.status(&task_id)?;
20//! ```
21
22use crate::config::RuntimeConfig;
23use crate::error::{RavenClawsError, Result};
24use crate::healing::SelfHealingEngine;
25use crate::llm::LLMProviderTrait;
26use serde::{Deserialize, Serialize};
27use std::collections::HashMap;
28use std::path::{Path, PathBuf};
29use std::sync::Arc;
30use tokio::sync::RwLock;
31use tracing::{info, instrument, warn};
32
33/// Status of a background task
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub enum TaskStatus {
36    /// Task has been submitted but not yet started
37    Pending,
38    /// Task is currently running
39    Running,
40    /// Task completed successfully
41    Completed,
42    /// Task failed with an error
43    Failed,
44    /// Task was cancelled by the user
45    Cancelled,
46}
47
48impl std::fmt::Display for TaskStatus {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            TaskStatus::Pending => write!(f, "pending"),
52            TaskStatus::Running => write!(f, "running"),
53            TaskStatus::Completed => write!(f, "completed"),
54            TaskStatus::Failed => write!(f, "failed"),
55            TaskStatus::Cancelled => write!(f, "cancelled"),
56        }
57    }
58}
59
60/// A single background task with full lifecycle tracking
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct BackgroundTask {
63    /// Unique task identifier (UUID v4)
64    pub id: String,
65    /// User-provided prompt for the agent
66    pub prompt: String,
67    /// System prompt used for this task
68    pub system_prompt: String,
69    /// Current status
70    pub status: TaskStatus,
71    /// Final result (set when completed)
72    pub result: Option<String>,
73    /// Error message (set when failed)
74    pub error: Option<String>,
75    /// When the task was created (ISO 8601)
76    pub created_at: String,
77    /// When the task was last updated (ISO 8601)
78    pub updated_at: String,
79    /// Number of agent loop iterations used
80    pub iterations: usize,
81    /// Provider name that executed this task
82    pub provider: Option<String>,
83    /// Model name that executed this task
84    pub model: Option<String>,
85    /// Serialized checkpoint state for durable execution (v0.9.12+)
86    /// When set, the agent loop can resume from this checkpoint instead of
87    /// starting from scratch after a process restart.
88    pub checkpoint: Option<String>,
89}
90
91impl BackgroundTask {
92    /// Create a new pending task
93    pub fn new(id: String, prompt: String, system_prompt: String) -> Self {
94        let now = chrono::Utc::now().to_rfc3339();
95        Self {
96            id,
97            prompt,
98            system_prompt,
99            status: TaskStatus::Pending,
100            result: None,
101            error: None,
102            created_at: now.clone(),
103            updated_at: now,
104            iterations: 0,
105            provider: None,
106            model: None,
107            checkpoint: None,
108        }
109    }
110}
111
112/// Manager for background tasks with disk persistence
113#[derive(Debug, Clone)]
114pub struct BackgroundTaskManager {
115    /// Directory where task files are stored
116    tasks_dir: PathBuf,
117    /// In-memory task index (id → task)
118    tasks: Arc<RwLock<HashMap<String, BackgroundTask>>>,
119    /// Self-healing engine for automatic failure recovery and circuit breakers
120    healing_engine: Option<Arc<std::sync::Mutex<SelfHealingEngine>>>,
121}
122
123impl BackgroundTaskManager {
124    /// Create a new background task manager.
125    ///
126    /// The `tasks_dir` is where task JSON files are persisted.
127    /// If the directory doesn't exist, it will be created.
128    /// On creation, it loads any existing tasks from disk.
129    pub async fn new(tasks_dir: &Path) -> Result<Self> {
130        let tasks_dir = tasks_dir.to_path_buf();
131
132        // Create the tasks directory if it doesn't exist
133        std::fs::create_dir_all(&tasks_dir).map_err(|e| {
134            RavenClawsError::CommandExecution(format!(
135                "Failed to create tasks directory '{}': {}",
136                tasks_dir.display(),
137                e
138            ))
139        })?;
140
141        let tasks = Arc::new(RwLock::new(HashMap::new()));
142
143        let mut manager = Self {
144            tasks_dir,
145            tasks,
146            healing_engine: None,
147        };
148
149        // Load existing tasks from disk
150        let count = manager.load_tasks().await?;
151        if count > 0 {
152            info!(count, "Loaded existing background tasks from disk");
153        }
154
155        Ok(manager)
156    }
157
158    /// Create from runtime config — uses `workdir/tasks/` as the tasks directory
159    pub async fn from_config(config: &RuntimeConfig) -> Result<Self> {
160        let tasks_dir = PathBuf::from(&config.workdir).join("tasks");
161        Self::new(&tasks_dir).await
162    }
163
164    /// Load all tasks from disk into memory
165    async fn load_tasks(&mut self) -> Result<usize> {
166        let mut count = 0;
167        let read_dir = match std::fs::read_dir(&self.tasks_dir) {
168            Ok(d) => d,
169            Err(_) => return Ok(0),
170        };
171
172        let mut tasks_to_insert = Vec::new();
173        for entry in read_dir.flatten() {
174            let path = entry.path();
175            if path.extension().is_some_and(|ext| ext == "json") {
176                match std::fs::read_to_string(&path) {
177                    Ok(content) => match serde_json::from_str::<BackgroundTask>(&content) {
178                        Ok(task) => {
179                            tasks_to_insert.push(task);
180                            count += 1;
181                        }
182                        Err(e) => {
183                            warn!(
184                                path = %path.display(),
185                                error = %e,
186                                "Failed to deserialize background task"
187                            );
188                        }
189                    },
190                    Err(e) => {
191                        warn!(
192                            path = %path.display(),
193                            error = %e,
194                            "Failed to read background task file"
195                        );
196                    }
197                }
198            }
199        }
200
201        let mut tasks = self.tasks.write().await;
202        for task in tasks_to_insert {
203            tasks.insert(task.id.clone(), task);
204        }
205
206        Ok(count)
207    }
208
209    /// Persist a single task to disk
210    fn save_task(&self, task: &BackgroundTask) -> Result<()> {
211        let path = self.tasks_dir.join(format!("{}.json", task.id));
212        let content = serde_json::to_string_pretty(task).map_err(|e| {
213            RavenClawsError::CommandExecution(format!("Failed to serialize task: {}", e))
214        })?;
215
216        std::fs::write(&path, content).map_err(|e| {
217            RavenClawsError::CommandExecution(format!(
218                "Failed to write task file '{}': {}",
219                path.display(),
220                e
221            ))
222        })?;
223
224        Ok(())
225    }
226
227    /// Submit a new background task and return its ID.
228    /// The task is persisted immediately and will be executed in the background.
229    pub async fn submit(&self, prompt: String, system_prompt: String) -> Result<String> {
230        let id = uuid::Uuid::new_v4().to_string();
231        let task = BackgroundTask::new(id.clone(), prompt, system_prompt);
232
233        // Persist to disk
234        self.save_task(&task)?;
235
236        // Add to in-memory index
237        let mut tasks = self.tasks.write().await;
238        tasks.insert(id.clone(), task);
239
240        info!(task_id = %id, "Background task submitted");
241        Ok(id)
242    }
243
244    /// Execute a background task with the given LLM client.
245    /// Updates the task status to Running, runs the agent loop, and saves the result.
246    #[instrument(skip(self, llm), fields(task_id = %task_id))]
247    pub async fn execute(&self, task_id: &str, llm: Arc<dyn LLMProviderTrait>) -> Result<String> {
248        // Get the task and mark as running
249        {
250            let mut tasks = self.tasks.write().await;
251            let task = tasks.get_mut(task_id).ok_or_else(|| {
252                RavenClawsError::CommandExecution(format!("Task '{}' not found", task_id))
253            })?;
254
255            task.status = TaskStatus::Running;
256            task.provider = Some(llm.provider_name().to_string());
257            task.model = Some(llm.model().to_string());
258            task.updated_at = chrono::Utc::now().to_rfc3339();
259            self.save_task(task)?;
260        }
261
262        info!(
263            task_id = %task_id,
264            provider = llm.provider_name(),
265            model = llm.model(),
266            "Executing background task"
267        );
268
269        // Check self-healing circuit breaker before execution
270        if let Some(ref healing) = self.healing_engine {
271            let healthy = {
272                let mut engine = healing.lock().unwrap();
273                engine.is_healthy(task_id)
274            };
275            if !healthy {
276                let err_msg = format!(
277                    "Background task '{}' blocked by self-healing circuit breaker",
278                    task_id
279                );
280                warn!(task_id = %task_id, "{}", err_msg);
281
282                // Update task status to failed
283                let mut tasks = self.tasks.write().await;
284                if let Some(task) = tasks.get_mut(task_id) {
285                    task.status = TaskStatus::Failed;
286                    task.error = Some(err_msg.clone());
287                    task.updated_at = chrono::Utc::now().to_rfc3339();
288                    self.save_task(task)?;
289                }
290
291                return Err(RavenClawsError::HealingError(err_msg));
292            }
293        }
294
295        // Determine checkpoint directory for durable execution
296        let checkpoint_dir = self.tasks_dir.join("checkpoints");
297        let _ = std::fs::create_dir_all(&checkpoint_dir);
298
299        // Run the agent loop with checkpointing enabled
300        let loop_config = crate::agent::AgentLoopConfig {
301            max_iterations: 10,
302            enable_tools: true,
303            require_approval: false,
304            prompt_injection_protection: true,
305            token_lifetime_secs: 0,
306            no_final_required: true,
307            fallback_chain: None,
308            token_budget: None,
309            ravenfabric: None,
310            checkpoint_dir: Some(checkpoint_dir),
311            session_id: Some(task_id.to_string()),
312            metrics_callback: None,
313            load_manager: None,
314            retry_config: None,
315            healing_engine: self.healing_engine.clone(),
316        };
317
318        let result = crate::agent::run_agent_loop(
319            llm.clone(),
320            &self.get_prompt(task_id).await?,
321            &self.get_system_prompt(task_id).await?,
322            loop_config,
323        )
324        .await;
325
326        // Update task with result
327        let mut tasks = self.tasks.write().await;
328        let task = tasks.get_mut(task_id).ok_or_else(|| {
329            RavenClawsError::CommandExecution(format!("Task '{}' not found", task_id))
330        })?;
331
332        match result {
333            Ok(response) => {
334                task.status = TaskStatus::Completed;
335                task.result = Some(response.clone());
336                task.updated_at = chrono::Utc::now().to_rfc3339();
337                self.save_task(task)?;
338
339                info!(
340                    task_id = %task_id,
341                    iterations = task.iterations,
342                    "Background task completed"
343                );
344
345                Ok(response)
346            }
347            Err(e) => {
348                task.status = TaskStatus::Failed;
349                task.error = Some(e.to_string());
350                task.updated_at = chrono::Utc::now().to_rfc3339();
351                self.save_task(task)?;
352
353                // Record failure in self-healing engine
354                if let Some(ref healing) = self.healing_engine {
355                    let mut engine = healing.lock().unwrap();
356                    engine.record_failure(task_id, &e.to_string());
357                }
358
359                warn!(
360                    task_id = %task_id,
361                    error = %e,
362                    "Background task failed"
363                );
364
365                Err(e)
366            }
367        }
368    }
369
370    /// Get the current status of a task
371    #[allow(dead_code)]
372    pub async fn status(&self, task_id: &str) -> Result<TaskStatus> {
373        let tasks = self.tasks.read().await;
374        let task = tasks.get(task_id).ok_or_else(|| {
375            RavenClawsError::CommandExecution(format!("Task '{}' not found", task_id))
376        })?;
377        Ok(task.status.clone())
378    }
379
380    /// Get the full task details
381    pub async fn get_task(&self, task_id: &str) -> Result<BackgroundTask> {
382        let tasks = self.tasks.read().await;
383        tasks.get(task_id).cloned().ok_or_else(|| {
384            RavenClawsError::CommandExecution(format!("Task '{}' not found", task_id))
385        })
386    }
387
388    /// List all tasks with their status
389    pub async fn list_tasks(&self) -> Vec<BackgroundTask> {
390        let tasks = self.tasks.read().await;
391        let mut task_list: Vec<BackgroundTask> = tasks.values().cloned().collect();
392        // Sort by creation time (newest first)
393        task_list.sort_by(|a, b| b.created_at.cmp(&a.created_at));
394        task_list
395    }
396
397    /// Cancel a pending or running task
398    pub async fn cancel(&self, task_id: &str) -> Result<()> {
399        let mut tasks = self.tasks.write().await;
400        let task = tasks.get_mut(task_id).ok_or_else(|| {
401            RavenClawsError::CommandExecution(format!("Task '{}' not found", task_id))
402        })?;
403
404        match task.status {
405            TaskStatus::Pending | TaskStatus::Running => {
406                task.status = TaskStatus::Cancelled;
407                task.updated_at = chrono::Utc::now().to_rfc3339();
408                self.save_task(task)?;
409                info!(task_id = %task_id, "Background task cancelled");
410                Ok(())
411            }
412            _ => Err(RavenClawsError::CommandExecution(format!(
413                "Cannot cancel task '{}' in status '{}'",
414                task_id, task.status
415            ))),
416        }
417    }
418
419    /// Set the self-healing engine for automatic failure recovery.
420    #[allow(dead_code)]
421    pub fn set_healing_engine(&mut self, engine: Option<Arc<std::sync::Mutex<SelfHealingEngine>>>) {
422        self.healing_engine = engine;
423    }
424
425    /// Resume all incomplete tasks (Pending or Running) from disk.
426    /// Returns the list of task IDs that need execution.
427    pub async fn resume_incomplete(&self) -> Vec<String> {
428        let tasks = self.tasks.read().await;
429        let mut incomplete = Vec::new();
430
431        for task in tasks.values() {
432            if task.status == TaskStatus::Pending || task.status == TaskStatus::Running {
433                incomplete.push(task.id.clone());
434            }
435        }
436
437        if !incomplete.is_empty() {
438            info!(
439                count = incomplete.len(),
440                "Found incomplete background tasks to resume"
441            );
442        }
443
444        incomplete
445    }
446
447    /// Get the prompt for a task (internal helper)
448    async fn get_prompt(&self, task_id: &str) -> Result<String> {
449        let tasks = self.tasks.read().await;
450        let task = tasks.get(task_id).ok_or_else(|| {
451            RavenClawsError::CommandExecution(format!("Task '{}' not found", task_id))
452        })?;
453        Ok(task.prompt.clone())
454    }
455
456    /// Get the system prompt for a task (internal helper)
457    async fn get_system_prompt(&self, task_id: &str) -> Result<String> {
458        let tasks = self.tasks.read().await;
459        let task = tasks.get(task_id).ok_or_else(|| {
460            RavenClawsError::CommandExecution(format!("Task '{}' not found", task_id))
461        })?;
462        Ok(task.system_prompt.clone())
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469    use std::path::PathBuf;
470
471    fn test_dir(name: &str) -> PathBuf {
472        let dir = std::env::temp_dir().join(format!(
473            "ravenclaws-test-bg-{}-{}",
474            name,
475            std::process::id()
476        ));
477        let _ = std::fs::remove_dir_all(&dir);
478        dir
479    }
480
481    #[tokio::test]
482    async fn test_manager_new_creates_directory() {
483        let dir = test_dir("create_dir");
484        let manager = BackgroundTaskManager::new(&dir).await.unwrap();
485        assert!(dir.exists(), "Tasks directory should be created");
486        assert!(manager.tasks.read().await.is_empty());
487        let _ = std::fs::remove_dir_all(&dir);
488    }
489
490    #[tokio::test]
491    async fn test_submit_task() {
492        let dir = test_dir("submit");
493        let manager = BackgroundTaskManager::new(&dir).await.unwrap();
494
495        let task_id = manager
496            .submit("Test prompt".to_string(), "Test system".to_string())
497            .await
498            .unwrap();
499
500        let task = manager.get_task(&task_id).await.unwrap();
501        assert_eq!(task.prompt, "Test prompt");
502        assert_eq!(task.system_prompt, "Test system");
503        assert_eq!(task.status, TaskStatus::Pending);
504        assert!(task.result.is_none());
505
506        // Verify persistence
507        let task_path = dir.join(format!("{}.json", task_id));
508        assert!(task_path.exists(), "Task file should exist on disk");
509
510        let _ = std::fs::remove_dir_all(&dir);
511    }
512
513    #[tokio::test]
514    async fn test_status_transitions() {
515        let dir = test_dir("transitions");
516        let manager = BackgroundTaskManager::new(&dir).await.unwrap();
517
518        let task_id = manager
519            .submit("Test".to_string(), "System".to_string())
520            .await
521            .unwrap();
522
523        assert_eq!(manager.status(&task_id).await.unwrap(), TaskStatus::Pending);
524
525        // Cancel the task
526        manager.cancel(&task_id).await.unwrap();
527        assert_eq!(
528            manager.status(&task_id).await.unwrap(),
529            TaskStatus::Cancelled
530        );
531
532        let _ = std::fs::remove_dir_all(&dir);
533    }
534
535    #[tokio::test]
536    async fn test_list_tasks() {
537        let dir = test_dir("list");
538        let manager = BackgroundTaskManager::new(&dir).await.unwrap();
539
540        manager
541            .submit("Task 1".to_string(), "System".to_string())
542            .await
543            .unwrap();
544        manager
545            .submit("Task 2".to_string(), "System".to_string())
546            .await
547            .unwrap();
548
549        let tasks = manager.list_tasks().await;
550        assert_eq!(tasks.len(), 2);
551
552        let _ = std::fs::remove_dir_all(&dir);
553    }
554
555    #[tokio::test]
556    async fn test_cancel_completed_task_fails() {
557        let dir = test_dir("cancel_fail");
558        let manager = BackgroundTaskManager::new(&dir).await.unwrap();
559
560        let task_id = manager
561            .submit("Test".to_string(), "System".to_string())
562            .await
563            .unwrap();
564
565        // Manually set to completed
566        {
567            let mut tasks = manager.tasks.write().await;
568            let task = tasks.get_mut(&task_id).unwrap();
569            task.status = TaskStatus::Completed;
570        }
571
572        let result = manager.cancel(&task_id).await;
573        assert!(result.is_err(), "Cancelling a completed task should fail");
574
575        let _ = std::fs::remove_dir_all(&dir);
576    }
577
578    #[tokio::test]
579    async fn test_resume_incomplete_tasks() {
580        let dir = test_dir("resume");
581        let manager = BackgroundTaskManager::new(&dir).await.unwrap();
582
583        manager
584            .submit("Task 1".to_string(), "System".to_string())
585            .await
586            .unwrap();
587        manager
588            .submit("Task 2".to_string(), "System".to_string())
589            .await
590            .unwrap();
591
592        // Mark one as completed
593        {
594            let tasks = manager.tasks.read().await;
595            let tasks_vec: Vec<&BackgroundTask> = tasks.values().collect();
596            if let Some(task) = tasks_vec.first() {
597                let id = task.id.clone();
598                drop(tasks);
599                let mut tasks = manager.tasks.write().await;
600                if let Some(t) = tasks.get_mut(&id) {
601                    t.status = TaskStatus::Completed;
602                }
603            }
604        }
605
606        let incomplete = manager.resume_incomplete().await;
607        assert_eq!(incomplete.len(), 1, "One task should be incomplete");
608
609        let _ = std::fs::remove_dir_all(&dir);
610    }
611
612    #[tokio::test]
613    async fn test_task_not_found() {
614        let dir = test_dir("not_found");
615        let manager = BackgroundTaskManager::new(&dir).await.unwrap();
616
617        let result = manager.status("nonexistent").await;
618        assert!(result.is_err());
619
620        let _ = std::fs::remove_dir_all(&dir);
621    }
622
623    #[tokio::test]
624    async fn test_persistence_across_restart() {
625        let dir = test_dir("persist");
626
627        // First session: submit a task
628        {
629            let manager = BackgroundTaskManager::new(&dir).await.unwrap();
630            manager
631                .submit("Persist test".to_string(), "System".to_string())
632                .await
633                .unwrap();
634        } // manager drops
635
636        // Second session: load from disk
637        {
638            let manager = BackgroundTaskManager::new(&dir).await.unwrap();
639            let tasks = manager.list_tasks().await;
640            assert_eq!(tasks.len(), 1, "Task should persist across restarts");
641            assert_eq!(tasks[0].prompt, "Persist test");
642        }
643
644        let _ = std::fs::remove_dir_all(&dir);
645    }
646}