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}