Skip to main content

daimon_core/
distributed.rs

1//! Core types and trait for distributed agent execution.
2//!
3//! Provider crates implement [`TaskBroker`] for their cloud-native message
4//! service. The main `daimon` crate re-exports everything from here.
5
6use std::collections::HashMap;
7use std::future::Future;
8use std::pin::Pin;
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::Result;
13
14/// A unit of work submitted to a [`TaskBroker`].
15///
16/// Each task carries a unique ID and the input text for an agent prompt.
17/// Optional metadata lets callers tag tasks with routing hints, priority,
18/// or any application-specific data.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct AgentTask {
21    /// Unique identifier for this task (generated on creation).
22    pub task_id: String,
23    /// The user input to prompt the agent with.
24    pub input: String,
25    /// Optional run ID for resumable execution via checkpoints.
26    pub run_id: Option<String>,
27    /// Arbitrary key-value metadata (routing hints, priority, etc.).
28    pub metadata: HashMap<String, serde_json::Value>,
29}
30
31impl AgentTask {
32    /// Creates a new task with a unique ID derived from the current time and
33    /// a process-wide counter.
34    pub fn new(input: impl Into<String>) -> Self {
35        Self {
36            task_id: Self::generate_id(),
37            input: input.into(),
38            run_id: None,
39            metadata: HashMap::new(),
40        }
41    }
42
43    /// Assigns a checkpoint run ID for resumable execution.
44    pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self {
45        self.run_id = Some(run_id.into());
46        self
47    }
48
49    /// Adds a metadata key-value pair.
50    pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
51        self.metadata.insert(key.into(), value);
52        self
53    }
54
55    /// Generates a task ID of the form `task-{nanos:x}-{counter:x}`.
56    ///
57    /// The nanosecond timestamp alone is not collision-safe: two tasks created
58    /// within the same clock tick (coarse clocks, concurrent callers) would get
59    /// identical IDs. A process-wide atomic counter is appended so every call
60    /// in a process yields a distinct ID, while the timestamp keeps IDs unique
61    /// across processes and restarts.
62    fn generate_id() -> String {
63        use std::sync::atomic::{AtomicU64, Ordering};
64        use std::time::{SystemTime, UNIX_EPOCH};
65
66        static COUNTER: AtomicU64 = AtomicU64::new(0);
67
68        let ts = SystemTime::now()
69            .duration_since(UNIX_EPOCH)
70            .unwrap_or_default()
71            .as_nanos();
72        let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
73        format!("task-{ts:x}-{seq:x}")
74    }
75}
76
77/// The result of a completed agent task.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct TaskResult {
80    /// The task ID this result corresponds to.
81    pub task_id: String,
82    /// The agent's final text output.
83    pub output: String,
84    /// Number of ReAct iterations the agent performed.
85    pub iterations: usize,
86    /// Estimated cost in USD (if a cost model was configured).
87    pub cost: f64,
88    /// Error message if the task failed.
89    pub error: Option<String>,
90}
91
92/// Current status of a distributed task.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub enum TaskStatus {
95    /// Submitted but not yet picked up by a worker.
96    Pending,
97    /// Currently being executed by a worker.
98    Running,
99    /// Completed successfully.
100    Completed(TaskResult),
101    /// Failed with an error.
102    Failed(String),
103}
104
105/// Trait for distributing agent tasks across workers.
106///
107/// Implement this for your message broker (AWS SQS, Google Pub/Sub,
108/// Azure Service Bus, Redis, NATS, RabbitMQ, etc.) to enable
109/// multi-process agent execution.
110pub trait TaskBroker: Send + Sync {
111    /// Submits a task for execution. Returns the task ID.
112    fn submit(&self, task: AgentTask) -> impl Future<Output = Result<String>> + Send;
113
114    /// Queries the current status of a task.
115    fn status(&self, task_id: &str) -> impl Future<Output = Result<TaskStatus>> + Send;
116
117    /// Waits for a task and returns it.
118    ///
119    /// Returns `Ok(None)` when no task was obtained. What that means depends
120    /// on [`none_means_closed`](Self::none_means_closed):
121    ///
122    /// - `none_means_closed() == true` — the broker is permanently closed and
123    ///   no further tasks will ever arrive; callers should stop polling.
124    /// - `none_means_closed() == false` (the default) — the queue was merely
125    ///   idle for one poll interval (e.g. a blocking-pop timeout or an empty
126    ///   fetch); callers should keep polling.
127    fn receive(&self) -> impl Future<Output = Result<Option<AgentTask>>> + Send;
128
129    /// Whether [`receive`](Self::receive) returning `Ok(None)` means the
130    /// broker is permanently closed rather than momentarily idle.
131    ///
132    /// Network brokers (Redis, NATS, SQS, Pub/Sub, Service Bus, …) poll with
133    /// a timeout and legitimately come up empty when the queue is idle, so the
134    /// default is `false`: an empty receive is a transient condition and the
135    /// caller should retry. Brokers with a real end-of-stream signal (e.g. an
136    /// in-process channel whose senders are gone, or a cancelled AMQP
137    /// consumer) override this to `true` so workers can shut down promptly.
138    fn none_means_closed(&self) -> bool {
139        false
140    }
141
142    /// Marks a task as completed with the given result.
143    fn complete(
144        &self,
145        task_id: &str,
146        result: TaskResult,
147    ) -> impl Future<Output = Result<()>> + Send;
148
149    /// Marks a task as failed with an error message.
150    fn fail(&self, task_id: &str, error: String) -> impl Future<Output = Result<()>> + Send;
151}
152
153/// Object-safe wrapper for [`TaskBroker`], enabling `Arc<dyn ErasedTaskBroker>`.
154pub trait ErasedTaskBroker: Send + Sync {
155    fn submit_erased<'a>(
156        &'a self,
157        task: AgentTask,
158    ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>>;
159
160    fn status_erased<'a>(
161        &'a self,
162        task_id: &'a str,
163    ) -> Pin<Box<dyn Future<Output = Result<TaskStatus>> + Send + 'a>>;
164
165    fn receive_erased(
166        &self,
167    ) -> Pin<Box<dyn Future<Output = Result<Option<AgentTask>>> + Send + '_>>;
168
169    /// Object-safe mirror of [`TaskBroker::none_means_closed`].
170    fn none_means_closed_erased(&self) -> bool {
171        false
172    }
173
174    fn complete_erased<'a>(
175        &'a self,
176        task_id: &'a str,
177        result: TaskResult,
178    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
179
180    fn fail_erased<'a>(
181        &'a self,
182        task_id: &'a str,
183        error: String,
184    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
185}
186
187impl<T: TaskBroker> ErasedTaskBroker for T {
188    fn submit_erased<'a>(
189        &'a self,
190        task: AgentTask,
191    ) -> Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>> {
192        Box::pin(self.submit(task))
193    }
194
195    fn status_erased<'a>(
196        &'a self,
197        task_id: &'a str,
198    ) -> Pin<Box<dyn Future<Output = Result<TaskStatus>> + Send + 'a>> {
199        Box::pin(self.status(task_id))
200    }
201
202    fn receive_erased(
203        &self,
204    ) -> Pin<Box<dyn Future<Output = Result<Option<AgentTask>>> + Send + '_>> {
205        Box::pin(self.receive())
206    }
207
208    fn none_means_closed_erased(&self) -> bool {
209        self.none_means_closed()
210    }
211
212    fn complete_erased<'a>(
213        &'a self,
214        task_id: &'a str,
215        result: TaskResult,
216    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
217        Box::pin(self.complete(task_id, result))
218    }
219
220    fn fail_erased<'a>(
221        &'a self,
222        task_id: &'a str,
223        error: String,
224    ) -> Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>> {
225        Box::pin(self.fail(task_id, error))
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_generate_id_unique_across_concurrent_calls() {
235        // Nanosecond timestamps alone can collide under concurrency; the
236        // appended process-wide counter must make every ID distinct.
237        let handles: Vec<_> = (0..8)
238            .map(|_| {
239                std::thread::spawn(|| {
240                    (0..100)
241                        .map(|_| AgentTask::new("x").task_id)
242                        .collect::<Vec<_>>()
243                })
244            })
245            .collect();
246
247        let mut ids = std::collections::HashSet::new();
248        for handle in handles {
249            for id in handle.join().expect("thread panicked") {
250                assert!(ids.insert(id.clone()), "duplicate task id generated: {id}");
251            }
252        }
253        assert_eq!(ids.len(), 800);
254    }
255}