Skip to main content

clawft_kernel/
dead_letter.rs

1//! Dead letter queue for undeliverable messages.
2//!
3//! When a [`KernelMessage`] cannot be delivered (target not found, inbox full,
4//! timeout, governance denied, agent exited), it is routed to the
5//! [`DeadLetterQueue`] instead of being silently dropped.
6
7use std::collections::VecDeque;
8use std::sync::{Arc, RwLock};
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::ipc::KernelMessage;
14use crate::process::Pid;
15
16/// Default maximum number of dead letters retained.
17pub const DEFAULT_DLQ_CAPACITY: usize = 10_000;
18
19/// Reason a message was dead-lettered.
20#[non_exhaustive]
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub enum DeadLetterReason {
23    /// Target PID not found in ProcessTable.
24    TargetNotFound {
25        /// The PID that was not found.
26        pid: Pid,
27    },
28    /// Target inbox channel is full.
29    InboxFull {
30        /// The PID whose inbox was full.
31        pid: Pid,
32    },
33    /// Delivery timed out.
34    Timeout {
35        /// How long we waited before timing out (milliseconds).
36        duration_ms: u64,
37    },
38    /// GovernanceGate denied delivery.
39    GovernanceDenied {
40        /// Reason the gate denied the message.
41        reason: String,
42    },
43    /// Target agent exited before delivery.
44    AgentExited {
45        /// The PID of the exited agent.
46        pid: Pid,
47    },
48}
49
50impl std::fmt::Display for DeadLetterReason {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            Self::TargetNotFound { pid } => write!(f, "target_not_found(pid={pid})"),
54            Self::InboxFull { pid } => write!(f, "inbox_full(pid={pid})"),
55            Self::Timeout { duration_ms } => write!(f, "timeout({duration_ms}ms)"),
56            Self::GovernanceDenied { reason } => write!(f, "governance_denied({reason})"),
57            Self::AgentExited { pid } => write!(f, "agent_exited(pid={pid})"),
58        }
59    }
60}
61
62/// A dead-lettered message with metadata.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct DeadLetter {
65    /// The original undeliverable message.
66    pub message: KernelMessage,
67    /// Why delivery failed.
68    pub reason: DeadLetterReason,
69    /// When the message was dead-lettered.
70    pub timestamp: DateTime<Utc>,
71    /// Number of retry attempts made.
72    pub retry_count: u32,
73}
74
75/// Queue for messages that could not be delivered.
76///
77/// Bounded FIFO queue: when full, the oldest entry is evicted.
78/// Queryable by target PID, reason variant, and time range.
79///
80/// When an optional [`ChainManager`](crate::chain::ChainManager) is
81/// attached, every intake is recorded as an `ipc.dead_letter` event
82/// in the ExoChain audit trail.
83pub struct DeadLetterQueue {
84    letters: RwLock<VecDeque<DeadLetter>>,
85    max_size: usize,
86    /// Optional chain manager for audit logging.
87    #[cfg(feature = "exochain")]
88    chain: Option<Arc<crate::chain::ChainManager>>,
89}
90
91impl DeadLetterQueue {
92    /// Create a new dead letter queue with the given capacity.
93    pub fn new(max_size: usize) -> Self {
94        Self {
95            letters: RwLock::new(VecDeque::with_capacity(max_size.min(1024))),
96            max_size,
97            #[cfg(feature = "exochain")]
98            chain: None,
99        }
100    }
101
102    /// Create a dead letter queue with the default capacity (10,000).
103    pub fn with_default_capacity() -> Self {
104        Self::new(DEFAULT_DLQ_CAPACITY)
105    }
106
107    /// Attach a chain manager for audit logging (builder pattern).
108    ///
109    /// When set, every `intake` call emits an `ipc.dead_letter` event
110    /// to the ExoChain.
111    #[cfg(feature = "exochain")]
112    pub fn with_chain(mut self, cm: Arc<crate::chain::ChainManager>) -> Self {
113        self.chain = Some(cm);
114        self
115    }
116
117    /// Intake a message that could not be delivered.
118    ///
119    /// If the queue is at capacity, the oldest entry is evicted (FIFO).
120    /// When a [`ChainManager`](crate::chain::ChainManager) is attached,
121    /// the dead letter is recorded as an `ipc.dead_letter` chain event.
122    pub fn intake(&self, message: KernelMessage, reason: DeadLetterReason) {
123        // Log to chain before moving the message into the letter.
124        #[cfg(feature = "exochain")]
125        if let Some(ref cm) = self.chain {
126            use crate::chain::ChainLoggable;
127            let event = crate::chain::IpcDeadLetterEvent {
128                message_id: message.id.clone(),
129                from_pid: message.from,
130                target: format!("{:?}", message.target),
131                payload_type: message.payload.type_name().to_owned(),
132                reason: format!("{reason}"),
133                timestamp: Utc::now(),
134            };
135            cm.append_loggable(&event);
136        }
137
138        let letter = DeadLetter {
139            message,
140            reason,
141            timestamp: Utc::now(),
142            retry_count: 0,
143        };
144
145        let mut letters = self.letters.write().expect("DLQ write lock poisoned");
146        while letters.len() >= self.max_size {
147            letters.pop_front();
148        }
149        letters.push_back(letter);
150    }
151
152    /// Number of dead letters currently in the queue.
153    pub fn len(&self) -> usize {
154        self.letters.read().expect("DLQ read lock poisoned").len()
155    }
156
157    /// Whether the queue is empty.
158    pub fn is_empty(&self) -> bool {
159        self.len() == 0
160    }
161
162    /// Maximum capacity of the queue.
163    pub fn capacity(&self) -> usize {
164        self.max_size
165    }
166
167    /// Query dead letters by target PID.
168    pub fn query_by_target(&self, pid: Pid) -> Vec<DeadLetter> {
169        let letters = self.letters.read().expect("DLQ read lock poisoned");
170        letters
171            .iter()
172            .filter(|l| match &l.reason {
173                DeadLetterReason::TargetNotFound { pid: p } => *p == pid,
174                DeadLetterReason::InboxFull { pid: p } => *p == pid,
175                DeadLetterReason::AgentExited { pid: p } => *p == pid,
176                _ => {
177                    // Also match on the message target
178                    matches!(&l.message.target, crate::ipc::MessageTarget::Process(p) if *p == pid)
179                }
180            })
181            .cloned()
182            .collect()
183    }
184
185    /// Query dead letters by reason variant name.
186    pub fn query_by_reason(&self, reason_name: &str) -> Vec<DeadLetter> {
187        let letters = self.letters.read().expect("DLQ read lock poisoned");
188        letters
189            .iter()
190            .filter(|l| {
191                let variant = match &l.reason {
192                    DeadLetterReason::TargetNotFound { .. } => "TargetNotFound",
193                    DeadLetterReason::InboxFull { .. } => "InboxFull",
194                    DeadLetterReason::Timeout { .. } => "Timeout",
195                    DeadLetterReason::GovernanceDenied { .. } => "GovernanceDenied",
196                    DeadLetterReason::AgentExited { .. } => "AgentExited",
197                };
198                variant == reason_name
199            })
200            .cloned()
201            .collect()
202    }
203
204    /// Query dead letters within a time range (inclusive).
205    pub fn query_by_time_range(
206        &self,
207        since: DateTime<Utc>,
208        until: DateTime<Utc>,
209    ) -> Vec<DeadLetter> {
210        let letters = self.letters.read().expect("DLQ read lock poisoned");
211        letters
212            .iter()
213            .filter(|l| l.timestamp >= since && l.timestamp <= until)
214            .cloned()
215            .collect()
216    }
217
218    /// Remove and return a dead letter by message ID for retry.
219    ///
220    /// The `retry_count` on the returned letter is incremented.
221    pub fn take_for_retry(&self, msg_id: &str) -> Option<DeadLetter> {
222        let mut letters = self.letters.write().expect("DLQ write lock poisoned");
223        let idx = letters.iter().position(|l| l.message.id == msg_id)?;
224        let mut letter = letters.remove(idx)?;
225        letter.retry_count += 1;
226        Some(letter)
227    }
228
229    /// Re-add a letter that failed retry back into the queue.
230    pub fn re_add(&self, letter: DeadLetter) {
231        let mut letters = self.letters.write().expect("DLQ write lock poisoned");
232        while letters.len() >= self.max_size {
233            letters.pop_front();
234        }
235        letters.push_back(letter);
236    }
237
238    /// Get all dead letters (snapshot).
239    pub fn snapshot(&self) -> Vec<DeadLetter> {
240        self.letters
241            .read()
242            .expect("DLQ read lock poisoned")
243            .iter()
244            .cloned()
245            .collect()
246    }
247
248    /// Clear all dead letters.
249    pub fn clear(&self) {
250        self.letters
251            .write()
252            .expect("DLQ write lock poisoned")
253            .clear();
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::ipc::{MessagePayload, MessageTarget};
261
262    fn make_msg(from: Pid, target: Pid) -> KernelMessage {
263        KernelMessage::new(
264            from,
265            MessageTarget::Process(target),
266            MessagePayload::Text("test".into()),
267        )
268    }
269
270    #[test]
271    fn intake_and_len() {
272        let dlq = DeadLetterQueue::new(100);
273        assert!(dlq.is_empty());
274
275        dlq.intake(
276            make_msg(0, 99),
277            DeadLetterReason::TargetNotFound { pid: 99 },
278        );
279        assert_eq!(dlq.len(), 1);
280    }
281
282    #[test]
283    fn fifo_eviction_at_capacity() {
284        let dlq = DeadLetterQueue::new(3);
285
286        for i in 0..5u64 {
287            dlq.intake(make_msg(0, i), DeadLetterReason::TargetNotFound { pid: i });
288        }
289
290        assert_eq!(dlq.len(), 3);
291        // Oldest (pid 0 and 1) should have been evicted
292        let snap = dlq.snapshot();
293        let target_pids: Vec<Pid> = snap
294            .iter()
295            .filter_map(|l| match &l.reason {
296                DeadLetterReason::TargetNotFound { pid } => Some(*pid),
297                _ => None,
298            })
299            .collect();
300        assert_eq!(target_pids, vec![2, 3, 4]);
301    }
302
303    #[test]
304    fn query_by_target() {
305        let dlq = DeadLetterQueue::new(100);
306        dlq.intake(
307            make_msg(0, 10),
308            DeadLetterReason::TargetNotFound { pid: 10 },
309        );
310        dlq.intake(make_msg(0, 20), DeadLetterReason::InboxFull { pid: 20 });
311        dlq.intake(make_msg(0, 10), DeadLetterReason::AgentExited { pid: 10 });
312
313        let results = dlq.query_by_target(10);
314        assert_eq!(results.len(), 2);
315    }
316
317    #[test]
318    fn query_by_reason() {
319        let dlq = DeadLetterQueue::new(100);
320        dlq.intake(make_msg(0, 1), DeadLetterReason::TargetNotFound { pid: 1 });
321        dlq.intake(make_msg(0, 2), DeadLetterReason::InboxFull { pid: 2 });
322        dlq.intake(make_msg(0, 3), DeadLetterReason::TargetNotFound { pid: 3 });
323
324        let results = dlq.query_by_reason("TargetNotFound");
325        assert_eq!(results.len(), 2);
326        let results = dlq.query_by_reason("InboxFull");
327        assert_eq!(results.len(), 1);
328    }
329
330    #[test]
331    fn query_by_time_range() {
332        let dlq = DeadLetterQueue::new(100);
333        let before = Utc::now();
334        dlq.intake(make_msg(0, 1), DeadLetterReason::TargetNotFound { pid: 1 });
335        let after = Utc::now();
336
337        let results = dlq.query_by_time_range(before, after);
338        assert_eq!(results.len(), 1);
339
340        // Query with a range that excludes the entry
341        let future = after + chrono::Duration::hours(1);
342        let far_future = future + chrono::Duration::hours(1);
343        let results = dlq.query_by_time_range(future, far_future);
344        assert!(results.is_empty());
345    }
346
347    #[test]
348    fn take_for_retry_increments_count() {
349        let dlq = DeadLetterQueue::new(100);
350        let msg = make_msg(0, 1);
351        let msg_id = msg.id.clone();
352        dlq.intake(msg, DeadLetterReason::TargetNotFound { pid: 1 });
353
354        let letter = dlq.take_for_retry(&msg_id).unwrap();
355        assert_eq!(letter.retry_count, 1);
356        assert!(dlq.is_empty());
357    }
358
359    #[test]
360    fn take_for_retry_not_found() {
361        let dlq = DeadLetterQueue::new(100);
362        assert!(dlq.take_for_retry("nonexistent").is_none());
363    }
364
365    #[test]
366    fn re_add_after_failed_retry() {
367        let dlq = DeadLetterQueue::new(100);
368        let msg = make_msg(0, 1);
369        let msg_id = msg.id.clone();
370        dlq.intake(msg, DeadLetterReason::TargetNotFound { pid: 1 });
371
372        let letter = dlq.take_for_retry(&msg_id).unwrap();
373        assert_eq!(letter.retry_count, 1);
374        dlq.re_add(letter);
375        assert_eq!(dlq.len(), 1);
376
377        let snap = dlq.snapshot();
378        assert_eq!(snap[0].retry_count, 1);
379    }
380
381    #[test]
382    fn clear_removes_all() {
383        let dlq = DeadLetterQueue::new(100);
384        for i in 0..5 {
385            dlq.intake(make_msg(0, i), DeadLetterReason::TargetNotFound { pid: i });
386        }
387        assert_eq!(dlq.len(), 5);
388        dlq.clear();
389        assert!(dlq.is_empty());
390    }
391
392    #[test]
393    fn governance_denied_reason() {
394        let dlq = DeadLetterQueue::new(100);
395        dlq.intake(
396            make_msg(0, 1),
397            DeadLetterReason::GovernanceDenied {
398                reason: "policy violation".into(),
399            },
400        );
401
402        let results = dlq.query_by_reason("GovernanceDenied");
403        assert_eq!(results.len(), 1);
404        match &results[0].reason {
405            DeadLetterReason::GovernanceDenied { reason } => {
406                assert_eq!(reason, "policy violation");
407            }
408            _ => panic!("expected GovernanceDenied"),
409        }
410    }
411
412    #[test]
413    fn timeout_reason() {
414        let dlq = DeadLetterQueue::new(100);
415        dlq.intake(
416            make_msg(0, 1),
417            DeadLetterReason::Timeout { duration_ms: 5000 },
418        );
419
420        let results = dlq.query_by_reason("Timeout");
421        assert_eq!(results.len(), 1);
422    }
423
424    #[test]
425    fn reason_display() {
426        let reason = DeadLetterReason::TargetNotFound { pid: 42 };
427        assert_eq!(format!("{reason}"), "target_not_found(pid=42)");
428
429        let reason = DeadLetterReason::InboxFull { pid: 7 };
430        assert_eq!(format!("{reason}"), "inbox_full(pid=7)");
431    }
432
433    #[test]
434    fn dead_letter_serde_roundtrip() {
435        let letter = DeadLetter {
436            message: make_msg(0, 1),
437            reason: DeadLetterReason::TargetNotFound { pid: 1 },
438            timestamp: Utc::now(),
439            retry_count: 2,
440        };
441        let json = serde_json::to_string(&letter).unwrap();
442        let restored: DeadLetter = serde_json::from_str(&json).unwrap();
443        assert_eq!(restored.retry_count, 2);
444        assert_eq!(restored.message.id, letter.message.id);
445    }
446
447    #[cfg(feature = "exochain")]
448    #[test]
449    fn intake_logs_to_chain() {
450        let cm = std::sync::Arc::new(crate::chain::ChainManager::new(0, 100));
451        let initial_len = cm.len();
452
453        let dlq = DeadLetterQueue::new(100).with_chain(cm.clone());
454        dlq.intake(
455            make_msg(0, 99),
456            DeadLetterReason::TargetNotFound { pid: 99 },
457        );
458
459        assert_eq!(dlq.len(), 1);
460        assert_eq!(cm.len(), initial_len + 1);
461
462        let events = cm.tail(1);
463        assert_eq!(events[0].source, "ipc");
464        assert_eq!(events[0].kind, "ipc.dead_letter");
465
466        let payload = events[0].payload.as_ref().unwrap();
467        assert_eq!(payload["from_pid"], 0);
468        assert!(
469            payload["reason"]
470                .as_str()
471                .unwrap()
472                .contains("target_not_found")
473        );
474    }
475
476    #[cfg(feature = "exochain")]
477    #[test]
478    fn intake_without_chain_does_not_log() {
479        // Without with_chain, intake should still work normally
480        let dlq = DeadLetterQueue::new(100);
481        dlq.intake(
482            make_msg(0, 1),
483            DeadLetterReason::Timeout { duration_ms: 1000 },
484        );
485        assert_eq!(dlq.len(), 1);
486    }
487}