Skip to main content

oxicode_sdk/coordination/
work_queue.rs

1//! Work queue — distributed task queue for inter-agent coordination.
2
3use parking_lot::RwLock;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::sync::Arc;
7use std::sync::atomic::{AtomicU64, Ordering};
8use tokio::sync::broadcast;
9
10/// Unique work item ID type.
11type WorkId = String;
12
13/// A unit of work in the queue.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct WorkItem {
16    /// Unique identifier.
17    pub id: WorkId,
18    /// Work type (for filtering).
19    pub work_type: String,
20    /// Payload for the worker.
21    pub payload: serde_json::Value,
22    /// Priority (higher = more urgent).
23    pub priority: i32,
24    /// Current status.
25    pub status: WorkStatus,
26    /// Agent that claimed this item.
27    pub claimed_by: Option<String>,
28    /// Execution result.
29    pub result: Option<WorkResult>,
30    /// Creation timestamp.
31    pub created_at_ms: u64,
32    /// Claim timestamp.
33    pub claimed_at_ms: Option<u64>,
34    /// Completion timestamp.
35    pub completed_at_ms: Option<u64>,
36    /// Maximum retries.
37    pub max_retries: usize,
38    /// Current retry count.
39    pub retry_count: usize,
40}
41
42/// Status of a work item.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44pub enum WorkStatus {
45    /// Waiting to be claimed by a worker.
46    Pending,
47    /// Atomically claimed by an agent but not yet started.
48    Claimed,
49    /// Currently being executed by an agent.
50    InProgress,
51    /// Finished successfully.
52    Completed,
53    /// Finished unsuccessfully after exhausting retries.
54    Failed,
55    /// Cancelled before or during execution.
56    Cancelled,
57}
58
59/// Result of work item execution.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct WorkResult {
62    /// Whether the work completed without error.
63    pub success: bool,
64    /// Output produced by the worker.
65    pub content: String,
66    /// Error message if the work failed.
67    pub error: Option<String>,
68    /// Wall-clock execution time in milliseconds.
69    pub duration_ms: u64,
70    /// Model tokens consumed, if tracked.
71    pub tokens_used: Option<u64>,
72}
73
74/// Events emitted by the work queue.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub enum WorkEvent {
77    /// A work item was added to the queue.
78    Enqueued {
79        /// Identifier of the work item.
80        id: WorkId,
81        /// Type category of the work item.
82        work_type: String,
83    },
84    /// A work item was claimed by an agent.
85    Claimed {
86        /// Identifier of the work item.
87        id: WorkId,
88        /// Identifier of the claiming agent.
89        agent_id: String,
90    },
91    /// Claimed work began executing.
92    Started {
93        /// Identifier of the work item.
94        id: WorkId,
95    },
96    /// A work item finished.
97    Completed {
98        /// Identifier of the work item.
99        id: WorkId,
100        /// Whether the work succeeded.
101        success: bool,
102    },
103    /// A work item was cancelled.
104    Cancelled {
105        /// Identifier of the work item.
106        id: WorkId,
107    },
108}
109
110/// Queue statistics.
111#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct WorkQueueStats {
113    /// Number of items waiting to be claimed.
114    pub pending: usize,
115    /// Number of items claimed but not started.
116    pub claimed: usize,
117    /// Number of items currently executing.
118    pub in_progress: usize,
119    /// Number of items finished successfully.
120    pub completed: usize,
121    /// Number of items that failed.
122    pub failed: usize,
123    /// Number of items cancelled.
124    pub cancelled: usize,
125}
126
127/// Configuration for the work queue.
128#[derive(Debug, Clone)]
129pub struct WorkQueueConfig {
130    /// Maximum items in the queue before eviction.
131    pub max_items: usize,
132}
133
134impl Default for WorkQueueConfig {
135    fn default() -> Self {
136        Self { max_items: 10_000 }
137    }
138}
139
140/// In-memory work queue with priority-based atomic claim.
141pub struct WorkQueue {
142    items: Arc<RwLock<HashMap<WorkId, WorkItem>>>,
143    next_id: AtomicU64,
144    #[allow(dead_code)]
145    config: WorkQueueConfig,
146    tx: broadcast::Sender<WorkEvent>,
147}
148
149impl WorkQueue {
150    /// Create a new work queue.
151    pub fn new(config: WorkQueueConfig) -> Self {
152        let (tx, _) = broadcast::channel(256);
153        Self {
154            items: Arc::new(RwLock::new(HashMap::new())),
155            next_id: AtomicU64::new(1),
156            config,
157            tx,
158        }
159    }
160
161    /// Enqueue a new work item. Returns its ID.
162    pub fn enqueue(
163        &self,
164        work_type: impl Into<String>,
165        payload: serde_json::Value,
166        priority: i32,
167    ) -> WorkId {
168        let id = format!("wq-{}", self.next_id.fetch_add(1, Ordering::SeqCst));
169        let item = WorkItem {
170            id: id.clone(),
171            work_type: work_type.into(),
172            payload,
173            priority,
174            status: WorkStatus::Pending,
175            claimed_by: None,
176            result: None,
177            created_at_ms: now_ms(),
178            claimed_at_ms: None,
179            completed_at_ms: None,
180            max_retries: 3,
181            retry_count: 0,
182        };
183        self.items.write().insert(id.clone(), item);
184        let _ = self.tx.send(WorkEvent::Enqueued {
185            id: id.clone(),
186            work_type: String::new(),
187        });
188        id
189    }
190
191    /// Atomically claim the highest-priority pending item.
192    ///
193    /// If `work_type_filter` is provided, only items of those types are considered.
194    /// Returns `None` if no items are available.
195    pub fn claim(&self, agent_id: &str, work_type_filter: Option<&[String]>) -> Option<WorkItem> {
196        let mut items = self.items.write();
197        let mut best: Option<(WorkId, i32)> = None;
198
199        for (id, item) in items.iter() {
200            if item.status != WorkStatus::Pending {
201                continue;
202            }
203            if let Some(filter) = work_type_filter
204                && !filter.contains(&item.work_type)
205            {
206                continue;
207            }
208            match &best {
209                Some((_, best_pri)) if item.priority <= *best_pri => {}
210                _ => best = Some((id.clone(), item.priority)),
211            }
212        }
213
214        if let Some((id, _)) = best {
215            // SAFETY: `id` was picked from `items.iter()` above, so the key is
216            // present in the map. Infallible by construction.
217            #[allow(clippy::unwrap_used)]
218            let item = items.get_mut(&id).unwrap();
219            item.status = WorkStatus::Claimed;
220            item.claimed_by = Some(agent_id.to_string());
221            item.claimed_at_ms = Some(now_ms());
222            let claimed = item.clone();
223            let _ = self.tx.send(WorkEvent::Claimed {
224                id: id.clone(),
225                agent_id: agent_id.to_string(),
226            });
227            Some(claimed)
228        } else {
229            None
230        }
231    }
232
233    /// Transition claimed item to in-progress.
234    pub fn start(&self, item_id: &str) -> crate::error::SdkResult<()> {
235        let mut items = self.items.write();
236        let item =
237            items
238                .get_mut(item_id)
239                .ok_or_else(|| crate::error::SdkError::WorkItemNotFound {
240                    item_id: item_id.to_string(),
241                })?;
242        if item.status != WorkStatus::Claimed {
243            return Err(crate::error::SdkError::InvalidState {
244                entity: "work_item".into(),
245                reason: format!("item {} not in Claimed state", item_id),
246            });
247        }
248        item.status = WorkStatus::InProgress;
249        let _ = self.tx.send(WorkEvent::Started {
250            id: item_id.to_string(),
251        });
252        Ok(())
253    }
254
255    /// Mark item as completed with result.
256    pub fn complete(&self, item_id: &str, result: WorkResult) -> crate::error::SdkResult<()> {
257        let mut items = self.items.write();
258        let item =
259            items
260                .get_mut(item_id)
261                .ok_or_else(|| crate::error::SdkError::WorkItemNotFound {
262                    item_id: item_id.to_string(),
263                })?;
264        item.status = WorkStatus::Completed;
265        item.result = Some(result);
266        item.completed_at_ms = Some(now_ms());
267        let success = item.result.as_ref().map(|r| r.success).unwrap_or(false);
268        let _ = self.tx.send(WorkEvent::Completed {
269            id: item_id.to_string(),
270            success,
271        });
272        Ok(())
273    }
274
275    /// Retry a failed item.
276    pub fn retry(&self, item_id: &str) -> anyhow::Result<bool> {
277        let mut items = self.items.write();
278        let item =
279            items
280                .get_mut(item_id)
281                .ok_or_else(|| crate::error::SdkError::WorkItemNotFound {
282                    item_id: item_id.to_string(),
283                })?;
284        if item.retry_count >= item.max_retries {
285            return Ok(false);
286        }
287        item.retry_count += 1;
288        item.status = WorkStatus::Pending;
289        item.claimed_by = None;
290        item.claimed_at_ms = None;
291        Ok(true)
292    }
293
294    /// Cancel an item.
295    pub fn cancel(&self, item_id: &str) -> anyhow::Result<()> {
296        let mut items = self.items.write();
297        let item =
298            items
299                .get_mut(item_id)
300                .ok_or_else(|| crate::error::SdkError::WorkItemNotFound {
301                    item_id: item_id.to_string(),
302                })?;
303        item.status = WorkStatus::Cancelled;
304        let _ = self.tx.send(WorkEvent::Cancelled {
305            id: item_id.to_string(),
306        });
307        Ok(())
308    }
309
310    /// Get a work item by ID.
311    pub fn get(&self, item_id: &str) -> Option<WorkItem> {
312        self.items.read().get(item_id).cloned()
313    }
314
315    /// List items, optionally filtered by status.
316    pub fn list(&self, filter: Option<WorkStatus>) -> Vec<WorkItem> {
317        self.items
318            .read()
319            .values()
320            .filter(|item| match filter {
321                Some(s) => item.status == s,
322                None => true,
323            })
324            .cloned()
325            .collect()
326    }
327
328    /// Get queue statistics.
329    pub fn stats(&self) -> WorkQueueStats {
330        let items = self.items.read();
331        let mut stats = WorkQueueStats::default();
332        for item in items.values() {
333            match item.status {
334                WorkStatus::Pending => stats.pending += 1,
335                WorkStatus::Claimed => stats.claimed += 1,
336                WorkStatus::InProgress => stats.in_progress += 1,
337                WorkStatus::Completed => stats.completed += 1,
338                WorkStatus::Failed => stats.failed += 1,
339                WorkStatus::Cancelled => stats.cancelled += 1,
340            }
341        }
342        stats
343    }
344
345    /// Subscribe to work events.
346    pub fn subscribe(&self) -> broadcast::Receiver<WorkEvent> {
347        self.tx.subscribe()
348    }
349}
350
351fn now_ms() -> u64 {
352    std::time::SystemTime::now()
353        .duration_since(std::time::UNIX_EPOCH)
354        .map(|d| d.as_millis() as u64)
355        .unwrap_or(0)
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn enqueue_and_claim() {
364        let q = WorkQueue::new(WorkQueueConfig::default());
365        let id = q.enqueue("review", serde_json::json!({"file": "main.rs"}), 1);
366        let item = q.claim("agent-1", None).unwrap();
367        assert_eq!(item.id, id);
368        assert_eq!(item.claimed_by.unwrap(), "agent-1");
369        assert_eq!(item.status, WorkStatus::Claimed);
370    }
371
372    #[test]
373    fn claim_is_atomic() {
374        let q = WorkQueue::new(WorkQueueConfig::default());
375        q.enqueue("task", serde_json::json!({}), 0);
376        let first = q.claim("a1", None);
377        let second = q.claim("a2", None);
378        assert!(first.is_some());
379        assert!(second.is_none());
380    }
381
382    #[test]
383    fn claim_respects_priority() {
384        let q = WorkQueue::new(WorkQueueConfig::default());
385        q.enqueue("low", serde_json::json!({}), 1);
386        q.enqueue("high", serde_json::json!({}), 10);
387        let item = q.claim("a1", None).unwrap();
388        assert_eq!(item.priority, 10);
389    }
390
391    #[test]
392    fn claim_with_type_filter() {
393        let q = WorkQueue::new(WorkQueueConfig::default());
394        q.enqueue("review", serde_json::json!({}), 0);
395        q.enqueue("build", serde_json::json!({}), 0);
396        let item = q.claim("a1", Some(&["build".into()]));
397        assert!(item.is_some());
398        assert_eq!(item.unwrap().work_type, "build");
399    }
400
401    #[test]
402    fn complete_item() {
403        let q = WorkQueue::new(WorkQueueConfig::default());
404        let id = q.enqueue("task", serde_json::json!({}), 0);
405        let _item = q.claim("a1", None).unwrap();
406        q.start(&id).unwrap();
407        q.complete(
408            &id,
409            WorkResult {
410                success: true,
411                content: "done".into(),
412                error: None,
413                duration_ms: 100,
414                tokens_used: None,
415            },
416        )
417        .unwrap();
418        let item = q.get(&id).unwrap();
419        assert_eq!(item.status, WorkStatus::Completed);
420        assert!(item.result.unwrap().success);
421    }
422
423    #[test]
424    fn retry_item() {
425        let q = WorkQueue::new(WorkQueueConfig::default());
426        let id = q.enqueue("task", serde_json::json!({}), 0);
427        {
428            let mut items = q.items.write();
429            let item = items.get_mut(&id).unwrap();
430            item.status = WorkStatus::Failed;
431            item.max_retries = 2;
432        }
433        assert!(q.retry(&id).unwrap());
434        let item = q.get(&id).unwrap();
435        assert_eq!(item.status, WorkStatus::Pending);
436        assert_eq!(item.retry_count, 1);
437    }
438
439    #[test]
440    fn cancel_item() {
441        let q = WorkQueue::new(WorkQueueConfig::default());
442        let id = q.enqueue("task", serde_json::json!({}), 0);
443        q.cancel(&id).unwrap();
444        assert_eq!(q.get(&id).unwrap().status, WorkStatus::Cancelled);
445    }
446
447    #[test]
448    fn queue_stats() {
449        let q = WorkQueue::new(WorkQueueConfig::default());
450        q.enqueue("t1", serde_json::json!({}), 0);
451        q.enqueue("t2", serde_json::json!({}), 0);
452        q.claim("a1", None);
453        let stats = q.stats();
454        assert_eq!(stats.pending, 1);
455        assert_eq!(stats.claimed, 1);
456    }
457
458    #[test]
459    fn subscribe_events() {
460        let q = WorkQueue::new(WorkQueueConfig::default());
461        let mut rx = q.subscribe();
462        q.enqueue("task", serde_json::json!({}), 0);
463        let event = rx.try_recv().unwrap();
464        assert!(matches!(event, WorkEvent::Enqueued { .. }));
465    }
466}