Skip to main content

lean_ctx/core/a2a/
dlq.rs

1use std::collections::BTreeMap;
2use std::fs::{File, OpenOptions};
3use std::io::{Read, Write};
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex};
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10use crate::core::agents::{AgentRegistry, FileLock};
11use crate::core::ocla::types::{OclaError, OclaResult};
12
13const STORE_VERSION: u16 = 1;
14const MAX_ENTRIES_PER_SCOPE: usize = 1_000;
15const MAX_STORE_BYTES: u64 = 8 * 1024 * 1024;
16const MAX_MESSAGE_BYTES: usize = 2 * 1024 * 1024;
17const MAX_IDENTIFIER_BYTES: usize = 512;
18pub(crate) const MAX_ERROR_BYTES: usize = 64 * 1024;
19const LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
20
21#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields, rename_all = "snake_case")]
23pub enum DeadLetterDelivery {
24    LocalAgentBus,
25    RemoteHttp { endpoint_url: String },
26}
27
28#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct DlqScope {
31    pub tenant_id: String,
32    pub project_id: String,
33}
34
35impl DlqScope {
36    pub fn new(tenant_id: impl Into<String>, project_id: impl Into<String>) -> OclaResult<Self> {
37        let scope = Self {
38            tenant_id: tenant_id.into(),
39            project_id: project_id.into(),
40        };
41        scope.validate()?;
42        Ok(scope)
43    }
44
45    fn validate(&self) -> OclaResult<()> {
46        validate_identifier("tenant_id", &self.tenant_id)?;
47        validate_identifier("project_id", &self.project_id)
48    }
49}
50
51#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct DeadLetter {
54    pub id: String,
55    #[serde(default)]
56    pub peer_id: String,
57    #[serde(default)]
58    pub delivery_id: String,
59    pub tenant_id: String,
60    pub project_id: String,
61    pub delivery: DeadLetterDelivery,
62    pub original_message: String,
63    pub target_agent: String,
64    pub error: String,
65    pub attempts: u8,
66    pub first_failed_at: String,
67    pub last_failed_at: String,
68}
69
70impl DeadLetter {
71    fn validate(&self) -> OclaResult<()> {
72        validate_identifier("id", &self.id)?;
73        validate_identifier("peer_id", &self.peer_id)?;
74        validate_identifier("delivery_id", &self.delivery_id)?;
75        DlqScope::new(self.tenant_id.clone(), self.project_id.clone())?;
76        validate_identifier("target_agent", &self.target_agent)?;
77        if self.original_message.is_empty() || self.original_message.len() > MAX_MESSAGE_BYTES {
78            return Err(OclaError::InvalidRequest(
79                "dead letter message must be non-empty and bounded".into(),
80            ));
81        }
82        if self.error.is_empty() || self.error.len() > MAX_ERROR_BYTES {
83            return Err(OclaError::InvalidRequest(
84                "dead letter error must be non-empty and bounded".into(),
85            ));
86        }
87        if self.attempts == 0 {
88            return Err(OclaError::InvalidRequest(
89                "dead letter attempts must be greater than zero".into(),
90            ));
91        }
92        let first_failed_at = DateTime::parse_from_rfc3339(&self.first_failed_at)
93            .map_err(|_| OclaError::InvalidRequest("invalid first_failed_at".into()))?;
94        let last_failed_at = DateTime::parse_from_rfc3339(&self.last_failed_at)
95            .map_err(|_| OclaError::InvalidRequest("invalid last_failed_at".into()))?;
96        if last_failed_at < first_failed_at {
97            return Err(OclaError::InvalidRequest(
98                "last_failed_at must not precede first_failed_at".into(),
99            ));
100        }
101        if let DeadLetterDelivery::RemoteHttp { endpoint_url } = &self.delivery {
102            let url = reqwest::Url::parse(endpoint_url).map_err(|_| {
103                OclaError::InvalidRequest("invalid dead letter endpoint_url".into())
104            })?;
105            if !matches!(url.scheme(), "http" | "https")
106                || url.host_str().is_none()
107                || !url.username().is_empty()
108                || url.password().is_some()
109            {
110                return Err(OclaError::InvalidRequest(
111                    "dead letter endpoint_url must be absolute HTTP(S)".into(),
112                ));
113            }
114        }
115        Ok(())
116    }
117}
118
119#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
120pub struct DlqStats {
121    pub total: usize,
122    pub max_scope_depth: usize,
123    pub oldest_age_seconds: u64,
124    pub by_target_agent: BTreeMap<String, usize>,
125}
126
127#[derive(Debug, Serialize, Deserialize)]
128#[serde(deny_unknown_fields)]
129struct DurableDlqV1 {
130    version: u16,
131    entries: Vec<DeadLetter>,
132}
133
134impl Default for DurableDlqV1 {
135    fn default() -> Self {
136        Self {
137            version: STORE_VERSION,
138            entries: Vec::new(),
139        }
140    }
141}
142
143#[derive(Clone)]
144enum QueueBackend {
145    Memory(Arc<Mutex<Vec<DeadLetter>>>),
146    Durable {
147        root: PathBuf,
148        process_lock: Arc<Mutex<()>>,
149    },
150    Unavailable(String),
151}
152
153#[derive(Clone)]
154pub struct DeadLetterQueue {
155    backend: QueueBackend,
156}
157
158pub struct DlqRetryGuard {
159    _lock: Option<FileLock>,
160}
161
162impl Default for DeadLetterQueue {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168impl DeadLetterQueue {
169    #[must_use]
170    pub fn new() -> Self {
171        Self {
172            backend: QueueBackend::Memory(Arc::new(Mutex::new(Vec::new()))),
173        }
174    }
175
176    pub fn persistent_default() -> std::io::Result<Self> {
177        let path = crate::core::data_dir::lean_ctx_data_dir()
178            .map_err(std::io::Error::other)?
179            .join("agents")
180            .join("a2a")
181            .join("dead-letters-v1.json");
182        Self::persistent(path)
183    }
184
185    pub fn persistent(path: impl AsRef<Path>) -> std::io::Result<Self> {
186        let path = path.as_ref();
187        let root = path.with_extension("d");
188        if path.exists() {
189            prepare_parent(&path)?;
190            prepare_store_directory(&root)?;
191            migrate_legacy_store(&path, &root)?;
192        } else {
193            for directory in [path.parent(), path.parent().and_then(Path::parent)]
194                .into_iter()
195                .flatten()
196            {
197                validate_directory(directory)?;
198            }
199            validate_directory(&root)?;
200        }
201        let queue = Self {
202            backend: QueueBackend::Durable {
203                root,
204                process_lock: Arc::new(Mutex::new(())),
205            },
206        };
207        Ok(queue)
208    }
209
210    #[must_use]
211    pub fn unavailable(error: impl Into<String>) -> Self {
212        Self {
213            backend: QueueBackend::Unavailable(error.into()),
214        }
215    }
216
217    pub fn enqueue(&self, letter: DeadLetter) -> OclaResult<()> {
218        letter.validate()?;
219        let scope = DlqScope::new(letter.tenant_id.clone(), letter.project_id.clone())?;
220        self.with_entries(Some(&scope), true, |entries| {
221            if let Some(existing) = entries.iter_mut().find(|entry| {
222                entry.peer_id == letter.peer_id
223                    && entry.delivery_id == letter.delivery_id
224                    && entry.tenant_id == letter.tenant_id
225                    && entry.project_id == letter.project_id
226            }) {
227                existing.error = letter.error;
228                existing.attempts = existing.attempts.max(letter.attempts);
229                existing.last_failed_at = letter.last_failed_at;
230                return Ok(());
231            }
232            if entries
233                .iter()
234                .filter(|entry| {
235                    entry.tenant_id == letter.tenant_id && entry.project_id == letter.project_id
236                })
237                .count()
238                >= MAX_ENTRIES_PER_SCOPE
239                && let Some(position) = entries.iter().position(|entry| {
240                    entry.tenant_id == letter.tenant_id && entry.project_id == letter.project_id
241                })
242            {
243                entries.remove(position);
244            }
245            entries.push(letter);
246            while serialized_store_size(entries)? > MAX_STORE_BYTES {
247                if entries.len() == 1 {
248                    return Err(OclaError::InvalidRequest(
249                        "dead letter exceeds per-scope store size limit".into(),
250                    ));
251                }
252                entries.remove(0);
253            }
254            Ok(())
255        })
256    }
257
258    pub fn dequeue(&self, scope: &DlqScope, id: &str) -> OclaResult<Option<DeadLetter>> {
259        scope.validate()?;
260        validate_identifier("id", id)?;
261        self.with_entries(Some(scope), true, |entries| {
262            let position = entries.iter().position(|letter| {
263                letter.id == id
264                    && letter.tenant_id == scope.tenant_id
265                    && letter.project_id == scope.project_id
266            });
267            Ok(position.map(|position| entries.remove(position)))
268        })
269    }
270
271    pub fn peek(&self, scope: &DlqScope) -> OclaResult<Vec<DeadLetter>> {
272        scope.validate()?;
273        self.with_entries(Some(scope), false, |entries| {
274            Ok(entries
275                .iter()
276                .filter(|letter| {
277                    letter.tenant_id == scope.tenant_id && letter.project_id == scope.project_id
278                })
279                .cloned()
280                .collect())
281        })
282    }
283
284    pub fn get(&self, scope: &DlqScope, id: &str) -> OclaResult<Option<DeadLetter>> {
285        scope.validate()?;
286        validate_identifier("id", id)?;
287        self.with_entries(Some(scope), false, |entries| {
288            Ok(entries
289                .iter()
290                .find(|letter| {
291                    letter.id == id
292                        && letter.tenant_id == scope.tenant_id
293                        && letter.project_id == scope.project_id
294                })
295                .cloned())
296        })
297    }
298
299    pub fn lock_retry(&self, scope: &DlqScope, id: &str) -> OclaResult<DlqRetryGuard> {
300        scope.validate()?;
301        validate_identifier("id", id)?;
302        match &self.backend {
303            QueueBackend::Durable { root, .. } => {
304                // One stable lock file per scope prevents duplicate retries across
305                // processes without globally serializing unrelated tenants or
306                // accumulating one persistent lock artifact per dead letter.
307                let path = scope_store_path(root, scope).with_extension("retry.lock");
308                let lock = FileLock::acquire_with_timeout(&path, LOCK_TIMEOUT)
309                    .map_err(|error| store_error(&std::io::Error::other(error)))?;
310                Ok(DlqRetryGuard { _lock: Some(lock) })
311            }
312            QueueBackend::Memory(_) => Ok(DlqRetryGuard { _lock: None }),
313            QueueBackend::Unavailable(error) => Err(OclaError::InvalidRequest(format!(
314                "dead letter store unavailable: {error}"
315            ))),
316        }
317    }
318
319    pub fn retry(&self, scope: &DlqScope, id: &str) -> OclaResult<()> {
320        scope.validate()?;
321        validate_identifier("id", id)?;
322        let retry_error = self.with_entries(Some(scope), true, |entries| {
323            let position = entries
324                .iter()
325                .position(|letter| letter.id == id)
326                .ok_or_else(|| OclaError::InvalidRequest(format!("dead letter not found: {id}")))?;
327            if !matches!(
328                entries[position].delivery,
329                DeadLetterDelivery::LocalAgentBus
330            ) {
331                return Err(OclaError::InvalidRequest(
332                    "remote dead letters require the configured authenticated transport".into(),
333                ));
334            }
335            match resend_local(&entries[position]) {
336                Ok(()) => {
337                    entries.remove(position);
338                    Ok(None)
339                }
340                Err(error) => {
341                    entries[position].attempts = entries[position].attempts.saturating_add(1);
342                    entries[position].last_failed_at = Utc::now().to_rfc3339();
343                    Ok(Some(error))
344                }
345            }
346        })?;
347        retry_error.map_or(Ok(()), Err)
348    }
349
350    pub fn stats(&self, scope: Option<&DlqScope>) -> OclaResult<DlqStats> {
351        if let Some(scope) = scope {
352            scope.validate()?;
353        }
354        self.with_entries(scope, false, |entries| {
355            let now = Utc::now();
356            let mut oldest_age_seconds = 0;
357            let mut by_target_agent = BTreeMap::new();
358            let mut by_scope = BTreeMap::new();
359            let mut total = 0;
360            for letter in entries.iter().filter(|letter| {
361                scope.is_none_or(|scope| {
362                    letter.tenant_id == scope.tenant_id && letter.project_id == scope.project_id
363                })
364            }) {
365                total += 1;
366                *by_scope
367                    .entry((&letter.tenant_id, &letter.project_id))
368                    .or_insert(0_usize) += 1;
369                *by_target_agent
370                    .entry(letter.target_agent.clone())
371                    .or_insert(0) += 1;
372                if let Ok(failed_at) = DateTime::parse_from_rfc3339(&letter.first_failed_at) {
373                    let age = u64::try_from((now - failed_at.with_timezone(&Utc)).num_seconds())
374                        .unwrap_or(0);
375                    oldest_age_seconds = oldest_age_seconds.max(age);
376                }
377            }
378            Ok(DlqStats {
379                total,
380                max_scope_depth: by_scope.values().copied().max().unwrap_or(0),
381                oldest_age_seconds,
382                by_target_agent,
383            })
384        })
385    }
386
387    pub fn ensure_writable(&self, scope: &DlqScope) -> OclaResult<()> {
388        scope.validate()?;
389        match &self.backend {
390            QueueBackend::Memory(_) => Ok(()),
391            QueueBackend::Durable { root, process_lock } => {
392                let _process_guard = process_lock
393                    .lock()
394                    .unwrap_or_else(std::sync::PoisonError::into_inner);
395                prepare_store_directory(root).map_err(|error| store_error(&error))?;
396                let path = scope_store_path(root, scope);
397                let _lock =
398                    FileLock::acquire_with_timeout(&path.with_extension("lock"), LOCK_TIMEOUT)
399                        .map_err(|error| store_error(&std::io::Error::other(error)))?;
400                load_store(&path, Some(scope)).map_err(|error| store_error(&error))?;
401                probe_store_directory(root).map_err(|error| store_error(&error))
402            }
403            QueueBackend::Unavailable(error) => Err(OclaError::InvalidRequest(format!(
404                "dead letter store unavailable: {error}"
405            ))),
406        }
407    }
408
409    fn with_entries<R>(
410        &self,
411        scope: Option<&DlqScope>,
412        mutate: bool,
413        operation: impl FnOnce(&mut Vec<DeadLetter>) -> OclaResult<R>,
414    ) -> OclaResult<R> {
415        match &self.backend {
416            QueueBackend::Memory(entries) => {
417                let mut entries = entries
418                    .lock()
419                    .unwrap_or_else(std::sync::PoisonError::into_inner);
420                operation(&mut entries)
421            }
422            QueueBackend::Durable { root, process_lock } => {
423                let _process_guard = process_lock
424                    .lock()
425                    .unwrap_or_else(std::sync::PoisonError::into_inner);
426                if !root.exists() && !mutate {
427                    return operation(&mut Vec::new());
428                }
429                prepare_store_directory(root).map_err(|error| store_error(&error))?;
430                if let Some(scope) = scope {
431                    let path = scope_store_path(root, scope);
432                    let _lock =
433                        FileLock::acquire_with_timeout(&path.with_extension("lock"), LOCK_TIMEOUT)
434                            .map_err(|error| store_error(&std::io::Error::other(error)))?;
435                    let mut store =
436                        load_store(&path, Some(scope)).map_err(|error| store_error(&error))?;
437                    let result = operation(&mut store.entries)?;
438                    if mutate {
439                        save_store(&path, &store).map_err(|error| store_error(&error))?;
440                    }
441                    Ok(result)
442                } else {
443                    if mutate {
444                        return Err(OclaError::InvalidRequest(
445                            "unscoped durable DLQ mutation is forbidden".into(),
446                        ));
447                    }
448                    let mut entries =
449                        load_all_scope_entries(root).map_err(|error| store_error(&error))?;
450                    operation(&mut entries)
451                }
452            }
453            QueueBackend::Unavailable(error) => Err(OclaError::InvalidRequest(format!(
454                "dead letter store unavailable: {error}"
455            ))),
456        }
457    }
458}
459
460fn validate_identifier(label: &str, value: &str) -> OclaResult<()> {
461    if value.trim().is_empty() || value.len() > MAX_IDENTIFIER_BYTES || value.contains('\0') {
462        return Err(OclaError::InvalidRequest(format!("invalid {label}")));
463    }
464    Ok(())
465}
466
467fn store_error(error: &std::io::Error) -> OclaError {
468    OclaError::InvalidRequest(format!("dead letter store unavailable: {error}"))
469}
470
471fn resend_local(letter: &DeadLetter) -> OclaResult<()> {
472    AgentRegistry::mutate_locked(|registry| {
473        registry
474            .post_message(
475                "dead-letter-queue",
476                Some(&letter.target_agent),
477                "retry",
478                &letter.original_message,
479            )
480            .map(|_| ())
481    })
482    .and_then(|(_, result)| result)
483    .map_err(|error| OclaError::InvalidRequest(format!("dead letter retry failed: {error}")))
484}
485
486fn prepare_parent(path: &Path) -> std::io::Result<()> {
487    let parent = path.parent().ok_or_else(|| {
488        std::io::Error::new(std::io::ErrorKind::InvalidInput, "DLQ store has no parent")
489    })?;
490    #[cfg(unix)] // only the unix permission branch below reads it
491    let parent_existed = parent.exists();
492    for directory in [Some(parent), parent.parent()].into_iter().flatten() {
493        validate_directory(directory)?;
494    }
495    std::fs::create_dir_all(parent)?;
496    for directory in [Some(parent), parent.parent()].into_iter().flatten() {
497        validate_directory(directory)?;
498    }
499    #[cfg(unix)]
500    if !parent_existed {
501        use std::os::unix::fs::PermissionsExt;
502        std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
503    }
504    Ok(())
505}
506
507fn validate_directory(path: &Path) -> std::io::Result<()> {
508    match std::fs::symlink_metadata(path) {
509        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_dir() => {
510            Err(std::io::Error::new(
511                std::io::ErrorKind::InvalidData,
512                "DLQ path components must be real directories",
513            ))
514        }
515        Ok(_) => Ok(()),
516        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
517        Err(error) => Err(error),
518    }
519}
520
521fn prepare_store_directory(root: &Path) -> std::io::Result<()> {
522    let parent = root.parent().ok_or_else(|| {
523        std::io::Error::new(std::io::ErrorKind::InvalidInput, "DLQ root has no parent")
524    })?;
525    validate_directory(parent)?;
526    validate_directory(root)?;
527    std::fs::create_dir_all(root)?;
528    validate_directory(root)?;
529    #[cfg(unix)]
530    {
531        use std::os::unix::fs::MetadataExt;
532        let metadata = std::fs::metadata(root)?;
533        // SAFETY: `geteuid` has no arguments and only reads process credentials.
534        if metadata.uid() != unsafe { libc::geteuid() } {
535            return Err(std::io::Error::new(
536                std::io::ErrorKind::PermissionDenied,
537                "DLQ store directory must be owned by the current user",
538            ));
539        }
540        crate::core::secure_fs::restrict_to_owner(root)?;
541    }
542    Ok(())
543}
544
545fn scope_store_path(root: &Path, scope: &DlqScope) -> PathBuf {
546    let mut material = Vec::with_capacity(scope.tenant_id.len() + scope.project_id.len() + 1);
547    material.extend_from_slice(scope.tenant_id.as_bytes());
548    material.push(0);
549    material.extend_from_slice(scope.project_id.as_bytes());
550    root.join(format!("{}.json", blake3::hash(&material).to_hex()))
551}
552
553fn serialized_store_size(entries: &[DeadLetter]) -> OclaResult<u64> {
554    serde_json::to_vec_pretty(&DurableDlqV1 {
555        version: STORE_VERSION,
556        entries: entries.to_vec(),
557    })
558    .map(|json| json.len() as u64)
559    .map_err(|error| OclaError::InvalidRequest(format!("failed to size dead letters: {error}")))
560}
561
562fn load_all_scope_entries(root: &Path) -> std::io::Result<Vec<DeadLetter>> {
563    let mut entries = Vec::new();
564    for item in std::fs::read_dir(root)? {
565        let item = item?;
566        let file_type = item.file_type()?;
567        let name = item.file_name();
568        let name = name.to_str().ok_or_else(|| {
569            std::io::Error::new(
570                std::io::ErrorKind::InvalidData,
571                "invalid DLQ store filename",
572            )
573        })?;
574        if item.path().extension() == Some(std::ffi::OsStr::new("lock"))
575            || name == "migration-v1.complete"
576        {
577            continue;
578        }
579        if file_type.is_file() && is_scope_store_temp_name(name) {
580            continue;
581        }
582        if file_type.is_symlink() || !file_type.is_file() || !is_scope_store_name(name) {
583            return Err(std::io::Error::new(
584                std::io::ErrorKind::InvalidData,
585                "DLQ root contains an unexpected entry",
586            ));
587        }
588        let store = load_store(&item.path(), None)?;
589        if let Some(first) = store.entries.first() {
590            let scope = DlqScope::new(first.tenant_id.clone(), first.project_id.clone())
591                .map_err(std::io::Error::other)?;
592            if scope_store_path(root, &scope) != item.path()
593                || store.entries.iter().any(|entry| {
594                    entry.tenant_id != scope.tenant_id || entry.project_id != scope.project_id
595                })
596            {
597                return Err(std::io::Error::new(
598                    std::io::ErrorKind::InvalidData,
599                    "DLQ scope store identity does not match its contents",
600                ));
601            }
602        }
603        entries.extend(store.entries);
604    }
605    Ok(entries)
606}
607
608fn is_scope_store_name(name: &str) -> bool {
609    let path = Path::new(name);
610    path.extension() == Some(std::ffi::OsStr::new("json"))
611        && path.file_stem().is_some_and(|stem| {
612            let bytes = stem.as_encoded_bytes();
613            bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
614        })
615}
616
617fn is_scope_store_temp_name(name: &str) -> bool {
618    let Some((store_name, suffix)) = name.split_once(".tmp-") else {
619        return false;
620    };
621    !suffix.is_empty()
622        && suffix
623            .bytes()
624            .all(|byte| byte.is_ascii_hexdigit() || byte == b'-')
625        && store_name.len() == 64
626        && store_name.bytes().all(|byte| byte.is_ascii_hexdigit())
627}
628
629fn probe_store_directory(root: &Path) -> std::io::Result<()> {
630    let path = root.join(format!("writable-{}.lock", uuid::Uuid::new_v4()));
631    let result = (|| {
632        let mut options = OpenOptions::new();
633        options.write(true).create_new(true);
634        #[cfg(unix)]
635        {
636            use std::os::unix::fs::OpenOptionsExt;
637            options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
638        }
639        #[cfg(windows)]
640        {
641            use std::os::windows::fs::OpenOptionsExt;
642            options.custom_flags(0x0020_0000);
643        }
644        options.open(&path)?.sync_all()?;
645        std::fs::remove_file(&path)
646    })();
647    if result.is_err() {
648        let _ = std::fs::remove_file(&path);
649    }
650    result
651}
652
653fn migrate_legacy_store(legacy_path: &Path, root: &Path) -> std::io::Result<()> {
654    if !legacy_path.exists() || root.join("migration-v1.complete").exists() {
655        return Ok(());
656    }
657    let _migration_lock =
658        FileLock::acquire_with_timeout(&root.join("migration-v1.lock"), LOCK_TIMEOUT)
659            .map_err(std::io::Error::other)?;
660    let marker = root.join("migration-v1.complete");
661    if marker.exists() {
662        return Ok(());
663    }
664    let legacy = load_store(legacy_path, None)?;
665    let mut grouped: BTreeMap<(String, String), Vec<DeadLetter>> = BTreeMap::new();
666    for letter in legacy.entries {
667        grouped
668            .entry((letter.tenant_id.clone(), letter.project_id.clone()))
669            .or_default()
670            .push(letter);
671    }
672    for ((tenant_id, project_id), letters) in grouped {
673        let scope = DlqScope::new(tenant_id, project_id).map_err(std::io::Error::other)?;
674        let path = scope_store_path(root, &scope);
675        let _scope_lock =
676            FileLock::acquire_with_timeout(&path.with_extension("lock"), LOCK_TIMEOUT)
677                .map_err(std::io::Error::other)?;
678        let mut current = load_store(&path, Some(&scope))?;
679        for letter in letters {
680            if let Some(existing) = current
681                .entries
682                .iter_mut()
683                .find(|entry| entry.id == letter.id)
684            {
685                if letter.last_failed_at > existing.last_failed_at {
686                    *existing = letter;
687                }
688            } else {
689                current.entries.push(letter);
690            }
691        }
692        if current.entries.len() > MAX_ENTRIES_PER_SCOPE {
693            let excess = current.entries.len() - MAX_ENTRIES_PER_SCOPE;
694            current.entries.drain(..excess);
695        }
696        while serialized_store_size(&current.entries).map_err(std::io::Error::other)?
697            > MAX_STORE_BYTES
698        {
699            if current.entries.is_empty() {
700                break;
701            }
702            current.entries.remove(0);
703        }
704        save_store(&path, &current)?;
705    }
706    write_marker(&marker)?;
707    let backup = legacy_path.with_extension("json.migrated-v1");
708    if !backup.exists() {
709        std::fs::rename(legacy_path, backup)?;
710        sync_parent(legacy_path)?;
711    }
712    Ok(())
713}
714
715fn write_marker(path: &Path) -> std::io::Result<()> {
716    let mut options = OpenOptions::new();
717    options.write(true).create_new(true);
718    #[cfg(unix)]
719    {
720        use std::os::unix::fs::OpenOptionsExt;
721        options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
722    }
723    #[cfg(windows)]
724    {
725        use std::os::windows::fs::OpenOptionsExt;
726        options.custom_flags(0x0020_0000);
727    }
728    let mut file = options.open(path)?;
729    file.write_all(b"v1\n")?;
730    file.sync_all()?;
731    sync_parent(path)
732}
733
734fn load_store(path: &Path, expected_scope: Option<&DlqScope>) -> std::io::Result<DurableDlqV1> {
735    let metadata = match std::fs::symlink_metadata(path) {
736        Ok(metadata) => metadata,
737        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
738            return Ok(DurableDlqV1::default());
739        }
740        Err(error) => return Err(error),
741    };
742    if metadata.file_type().is_symlink() || !metadata.is_file() {
743        return Err(std::io::Error::new(
744            std::io::ErrorKind::InvalidData,
745            "DLQ store must be a regular file",
746        ));
747    }
748    if metadata.len() > MAX_STORE_BYTES {
749        return Err(std::io::Error::new(
750            std::io::ErrorKind::InvalidData,
751            "DLQ store exceeds size limit",
752        ));
753    }
754    let mut content = Vec::new();
755    open_read_nofollow(path)?
756        .take(MAX_STORE_BYTES + 1)
757        .read_to_end(&mut content)?;
758    if content.len() as u64 > MAX_STORE_BYTES {
759        return Err(std::io::Error::new(
760            std::io::ErrorKind::InvalidData,
761            "DLQ store exceeds size limit",
762        ));
763    }
764    let mut store: DurableDlqV1 =
765        serde_json::from_slice(&content).map_err(std::io::Error::other)?;
766    if store.version != STORE_VERSION || store.entries.len() > MAX_ENTRIES_PER_SCOPE {
767        return Err(std::io::Error::new(
768            std::io::ErrorKind::InvalidData,
769            "invalid DLQ store bounds or version",
770        ));
771    }
772    for entry in &mut store.entries {
773        // Older v1 entries did not carry peer-scoped delivery identity. Keep
774        // them readable while making the compatibility scope explicit.
775        if entry.peer_id.is_empty() {
776            entry.peer_id = "legacy".to_string();
777        }
778        if entry.delivery_id.is_empty() {
779            entry.delivery_id = entry.id.clone();
780        }
781        entry
782            .validate()
783            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
784        if expected_scope.is_some_and(|scope| {
785            entry.tenant_id != scope.tenant_id || entry.project_id != scope.project_id
786        }) {
787            return Err(std::io::Error::new(
788                std::io::ErrorKind::InvalidData,
789                "DLQ scope store contains an entry from another scope",
790            ));
791        }
792    }
793    Ok(store)
794}
795
796fn save_store(path: &Path, store: &DurableDlqV1) -> std::io::Result<()> {
797    if let Ok(metadata) = std::fs::symlink_metadata(path)
798        && (metadata.file_type().is_symlink() || !metadata.is_file())
799    {
800        return Err(std::io::Error::new(
801            std::io::ErrorKind::InvalidData,
802            "DLQ store must be a regular file",
803        ));
804    }
805    let json = serde_json::to_vec_pretty(store).map_err(std::io::Error::other)?;
806    if json.len() as u64 > MAX_STORE_BYTES {
807        return Err(std::io::Error::new(
808            std::io::ErrorKind::InvalidData,
809            "DLQ store exceeds size limit",
810        ));
811    }
812    let tmp = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
813    let result = (|| {
814        let mut options = OpenOptions::new();
815        options.write(true).create_new(true);
816        #[cfg(unix)]
817        {
818            use std::os::unix::fs::OpenOptionsExt;
819            options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
820        }
821        #[cfg(windows)]
822        {
823            use std::os::windows::fs::OpenOptionsExt;
824            options.custom_flags(0x0020_0000);
825        }
826        let mut file = options.open(&tmp)?;
827        file.write_all(&json)?;
828        file.sync_all()?;
829        drop(file);
830        atomic_replace(&tmp, path)?;
831        sync_parent(path)
832    })();
833    if result.is_err() {
834        let _ = std::fs::remove_file(&tmp);
835    }
836    result
837}
838
839fn open_read_nofollow(path: &Path) -> std::io::Result<File> {
840    let mut options = OpenOptions::new();
841    options.read(true);
842    #[cfg(unix)]
843    {
844        use std::os::unix::fs::OpenOptionsExt;
845        options.custom_flags(libc::O_NOFOLLOW);
846    }
847    #[cfg(windows)]
848    {
849        use std::os::windows::fs::OpenOptionsExt;
850        options.custom_flags(0x0020_0000);
851    }
852    options.open(path)
853}
854
855#[cfg(not(windows))]
856fn atomic_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
857    std::fs::rename(source, destination)
858}
859
860#[cfg(windows)]
861fn atomic_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
862    use std::os::windows::ffi::OsStrExt;
863    use windows_sys::Win32::Storage::FileSystem::{
864        MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
865    };
866
867    let source: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
868    let destination: Vec<u16> = destination
869        .as_os_str()
870        .encode_wide()
871        .chain(Some(0))
872        .collect();
873    // SAFETY: both vectors own the UTF-16 buffers, include a terminating NUL,
874    // and remain alive for the duration of the call.
875    let result = unsafe {
876        MoveFileExW(
877            source.as_ptr(),
878            destination.as_ptr(),
879            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
880        )
881    };
882    if result == 0 {
883        Err(std::io::Error::last_os_error())
884    } else {
885        Ok(())
886    }
887}
888
889fn sync_parent(path: &Path) -> std::io::Result<()> {
890    #[cfg(unix)]
891    if let Some(parent) = path.parent() {
892        File::open(parent)?.sync_all()?;
893    }
894    // `MoveFileExW(MOVEFILE_WRITE_THROUGH)` above waits for the replacement to
895    // reach disk. Windows does not support `FlushFileBuffers` on directory
896    // handles, so attempting the Unix parent-fsync pattern returns ACCESS_DENIED
897    // after an otherwise successful atomic replacement.
898    #[cfg(windows)]
899    let _ = path;
900    Ok(())
901}
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906
907    fn scope(tenant: &str, project: &str) -> DlqScope {
908        DlqScope::new(tenant, project).unwrap()
909    }
910    fn letter(id: &str, tenant: &str, project: &str) -> DeadLetter {
911        DeadLetter {
912            id: id.into(),
913            peer_id: "legacy".into(),
914            delivery_id: id.into(),
915            tenant_id: tenant.into(),
916            project_id: project.into(),
917            delivery: DeadLetterDelivery::LocalAgentBus,
918            original_message: format!("message-{id}"),
919            target_agent: "agent-a".into(),
920            error: "delivery failed".into(),
921            attempts: 1,
922            first_failed_at: "2026-01-01T00:00:00Z".into(),
923            last_failed_at: "2026-01-01T00:00:00Z".into(),
924        }
925    }
926
927    #[test]
928    fn scoped_operations_do_not_cross_tenant_or_project() {
929        let queue = DeadLetterQueue::new();
930        queue
931            .enqueue(letter("one", "tenant-a", "project-a"))
932            .unwrap();
933        queue
934            .enqueue(letter("one", "tenant-b", "project-a"))
935            .unwrap();
936        assert_eq!(
937            queue.peek(&scope("tenant-a", "project-a")).unwrap().len(),
938            1
939        );
940        assert!(
941            queue
942                .dequeue(&scope("tenant-a", "project-b"), "one")
943                .unwrap()
944                .is_none()
945        );
946        assert_eq!(queue.stats(None).unwrap().total, 2);
947    }
948
949    #[test]
950    fn persistent_queue_survives_reopen_and_deduplicates() {
951        let dir = tempfile::tempdir().unwrap();
952        let path = dir.path().join("a2a").join("dead-letters-v1.json");
953        let queue = DeadLetterQueue::persistent(path.clone()).unwrap();
954        queue
955            .enqueue(letter("one", "tenant-a", "project-a"))
956            .unwrap();
957        queue
958            .enqueue(letter("one", "tenant-a", "project-a"))
959            .unwrap();
960        drop(queue);
961        assert_eq!(
962            DeadLetterQueue::persistent(path)
963                .unwrap()
964                .peek(&scope("tenant-a", "project-a"))
965                .unwrap()
966                .len(),
967            1
968        );
969    }
970
971    #[test]
972    fn concurrent_persistent_enqueues_do_not_lose_entries() {
973        let dir = tempfile::tempdir().unwrap();
974        let path = dir.path().join("a2a").join("dead-letters-v1.json");
975        let queue = DeadLetterQueue::persistent(path.clone()).unwrap();
976        let workers: Vec<_> = (0..8)
977            .map(|worker| {
978                let queue = queue.clone();
979                std::thread::spawn(move || {
980                    for item in 0..25 {
981                        queue
982                            .enqueue(letter(
983                                &format!("worker-{worker}-item-{item}"),
984                                "tenant-a",
985                                "project-a",
986                            ))
987                            .unwrap();
988                    }
989                })
990            })
991            .collect();
992        for worker in workers {
993            worker.join().unwrap();
994        }
995        drop(queue);
996        assert_eq!(
997            DeadLetterQueue::persistent(path)
998                .unwrap()
999                .peek(&scope("tenant-a", "project-a"))
1000                .unwrap()
1001                .len(),
1002            200
1003        );
1004    }
1005
1006    #[test]
1007    fn corrupt_oversized_and_symlink_stores_fail_closed() {
1008        let dir = tempfile::tempdir().unwrap();
1009        let corrupt = dir.path().join("corrupt.json");
1010        std::fs::write(&corrupt, b"not json").unwrap();
1011        assert!(DeadLetterQueue::persistent(corrupt).is_err());
1012        let oversized = dir.path().join("oversized.json");
1013        std::fs::write(&oversized, vec![b'x'; (MAX_STORE_BYTES + 1) as usize]).unwrap();
1014        assert!(DeadLetterQueue::persistent(oversized).is_err());
1015        #[cfg(unix)]
1016        {
1017            use std::os::unix::fs::symlink;
1018            let target = dir.path().join("target.json");
1019            std::fs::write(&target, b"{}").unwrap();
1020            let link = dir.path().join("link.json");
1021            symlink(&target, &link).unwrap();
1022            assert!(DeadLetterQueue::persistent(link).is_err());
1023        }
1024    }
1025
1026    #[test]
1027    fn remote_entries_cannot_be_retried_as_local_messages() {
1028        let queue = DeadLetterQueue::new();
1029        let mut item = letter("one", "tenant-a", "project-a");
1030        item.delivery = DeadLetterDelivery::RemoteHttp {
1031            endpoint_url: "https://agent.example".into(),
1032        };
1033        queue.enqueue(item).unwrap();
1034        assert!(queue.retry(&scope("tenant-a", "project-a"), "one").is_err());
1035        assert_eq!(queue.stats(None).unwrap().total, 1);
1036    }
1037
1038    #[test]
1039    fn invalid_attempts_time_order_and_endpoint_credentials_are_rejected() {
1040        let queue = DeadLetterQueue::new();
1041        let mut zero_attempts = letter("zero", "tenant-a", "project-a");
1042        zero_attempts.attempts = 0;
1043        assert!(queue.enqueue(zero_attempts).is_err());
1044
1045        let mut reversed = letter("reversed", "tenant-a", "project-a");
1046        reversed.last_failed_at = "2025-01-01T00:00:00Z".into();
1047        assert!(queue.enqueue(reversed).is_err());
1048
1049        let mut credential_url = letter("credentials", "tenant-a", "project-a");
1050        credential_url.delivery = DeadLetterDelivery::RemoteHttp {
1051            endpoint_url: "https://user:secret@agent.example".into(),
1052        };
1053        assert!(queue.enqueue(credential_url).is_err());
1054        assert_eq!(queue.stats(None).unwrap().total, 0);
1055    }
1056
1057    #[test]
1058    fn per_scope_capacity_never_evicts_another_scope() {
1059        let queue = DeadLetterQueue::new();
1060        queue
1061            .enqueue(letter("protected", "tenant-b", "project-b"))
1062            .unwrap();
1063        for index in 0..=MAX_ENTRIES_PER_SCOPE {
1064            queue
1065                .enqueue(letter(&format!("a-{index}"), "tenant-a", "project-a"))
1066                .unwrap();
1067        }
1068        let protected = queue.peek(&scope("tenant-b", "project-b")).unwrap();
1069        assert_eq!(protected.len(), 1);
1070        assert_eq!(protected[0].id, "protected");
1071        let tenant_a = queue.peek(&scope("tenant-a", "project-a")).unwrap();
1072        assert_eq!(tenant_a.len(), MAX_ENTRIES_PER_SCOPE);
1073        assert_eq!(tenant_a.first().unwrap().id, "a-1");
1074    }
1075
1076    #[test]
1077    fn durable_scopes_use_distinct_files_and_byte_eviction_is_local() {
1078        let dir = tempfile::tempdir().unwrap();
1079        let legacy = dir.path().join("dead-letters-v1.json");
1080        let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1081        let mut protected = letter("protected", "tenant-b", "project-b");
1082        protected.original_message = "b".repeat(MAX_MESSAGE_BYTES);
1083        queue.enqueue(protected).unwrap();
1084        for index in 0..5 {
1085            let mut item = letter(&format!("large-{index}"), "tenant-a", "project-a");
1086            item.original_message = "a".repeat(MAX_MESSAGE_BYTES);
1087            queue.enqueue(item).unwrap();
1088        }
1089
1090        let root = legacy.with_extension("d");
1091        let scope_a = scope("tenant-a", "project-a");
1092        let scope_b = scope("tenant-b", "project-b");
1093        assert_ne!(
1094            scope_store_path(&root, &scope_a),
1095            scope_store_path(&root, &scope_b)
1096        );
1097        assert_eq!(queue.peek(&scope_b).unwrap().len(), 1);
1098        let tenant_a = queue.peek(&scope_a).unwrap();
1099        assert!(!tenant_a.is_empty(), "`tenant_a` is empty");
1100        assert!(tenant_a.len() < 5);
1101        assert!(
1102            std::fs::metadata(scope_store_path(&root, &scope_a))
1103                .unwrap()
1104                .len()
1105                <= MAX_STORE_BYTES
1106        );
1107    }
1108
1109    #[test]
1110    fn compact_legacy_boundary_is_readable_and_next_enqueue_uses_writer_size() {
1111        let dir = tempfile::tempdir().unwrap();
1112        let legacy = dir.path().join("dead-letters-v1.json");
1113        let root = legacy.with_extension("d");
1114        let scope = scope("tenant-a", "project-a");
1115        let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1116        prepare_store_directory(&root).unwrap();
1117
1118        let mut entries = (0..MAX_ENTRIES_PER_SCOPE)
1119            .map(|index| {
1120                let mut item = letter(&format!("boundary-{index}"), "tenant-a", "project-a");
1121                item.original_message = "x".repeat(8_200);
1122                item
1123            })
1124            .collect::<Vec<_>>();
1125        while serde_json::to_vec(&DurableDlqV1 {
1126            version: STORE_VERSION,
1127            entries: entries.clone(),
1128        })
1129        .unwrap()
1130        .len() as u64
1131            > MAX_STORE_BYTES
1132        {
1133            entries.remove(0);
1134        }
1135        let compact = serde_json::to_vec(&DurableDlqV1 {
1136            version: STORE_VERSION,
1137            entries: entries.clone(),
1138        })
1139        .unwrap();
1140        assert!(compact.len() as u64 <= MAX_STORE_BYTES);
1141        assert!(
1142            serde_json::to_vec_pretty(&DurableDlqV1 {
1143                version: STORE_VERSION,
1144                entries,
1145            })
1146            .unwrap()
1147            .len() as u64
1148                > MAX_STORE_BYTES
1149        );
1150        std::fs::write(scope_store_path(&root, &scope), compact).unwrap();
1151
1152        queue.ensure_writable(&scope).unwrap();
1153        queue
1154            .enqueue(letter("after-boundary", "tenant-a", "project-a"))
1155            .unwrap();
1156        let stored = std::fs::metadata(scope_store_path(&root, &scope)).unwrap();
1157        assert!(stored.len() <= MAX_STORE_BYTES);
1158        assert!(queue.get(&scope, "after-boundary").unwrap().is_some());
1159    }
1160
1161    #[test]
1162    fn aggregate_stats_ignore_regular_interrupted_write_artifacts() {
1163        let dir = tempfile::tempdir().unwrap();
1164        let legacy = dir.path().join("dead-letters-v1.json");
1165        let scope = scope("tenant-a", "project-a");
1166        let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1167        queue
1168            .enqueue(letter("good", "tenant-a", "project-a"))
1169            .unwrap();
1170        let store = scope_store_path(&legacy.with_extension("d"), &scope);
1171        let tmp = store.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
1172        std::fs::write(&tmp, b"partial").unwrap();
1173
1174        assert_eq!(queue.stats(None).unwrap().total, 1);
1175        std::fs::remove_file(tmp).unwrap();
1176    }
1177
1178    #[cfg(unix)]
1179    #[test]
1180    fn persistent_store_repairs_preexisting_directory_permissions() {
1181        use std::os::unix::fs::PermissionsExt;
1182
1183        let dir = tempfile::tempdir().unwrap();
1184        let legacy = dir.path().join("dead-letters-v1.json");
1185        let root = legacy.with_extension("d");
1186        std::fs::create_dir(&root).unwrap();
1187        std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap();
1188
1189        let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1190        queue
1191            .enqueue(letter("private", "tenant-a", "project-a"))
1192            .unwrap();
1193        assert_eq!(
1194            std::fs::metadata(root).unwrap().permissions().mode() & 0o777,
1195            0o700
1196        );
1197    }
1198
1199    #[test]
1200    fn legacy_global_store_migrates_without_cross_scope_cohabitation() {
1201        let dir = tempfile::tempdir().unwrap();
1202        let legacy = dir.path().join("dead-letters-v1.json");
1203        save_store(
1204            &legacy,
1205            &DurableDlqV1 {
1206                version: STORE_VERSION,
1207                entries: vec![
1208                    letter("a", "tenant-a", "project-a"),
1209                    letter("b", "tenant-b", "project-b"),
1210                ],
1211            },
1212        )
1213        .unwrap();
1214
1215        let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1216        assert!(!legacy.exists());
1217        assert_eq!(
1218            queue.peek(&scope("tenant-a", "project-a")).unwrap().len(),
1219            1
1220        );
1221        assert_eq!(
1222            queue.peek(&scope("tenant-b", "project-b")).unwrap().len(),
1223            1
1224        );
1225        let stores = std::fs::read_dir(legacy.with_extension("d"))
1226            .unwrap()
1227            .filter_map(Result::ok)
1228            .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
1229            .count();
1230        assert_eq!(stores, 2);
1231    }
1232
1233    #[test]
1234    fn corrupt_scope_does_not_block_another_scope_or_reopen() {
1235        let dir = tempfile::tempdir().unwrap();
1236        let legacy = dir.path().join("dead-letters-v1.json");
1237        let scope_a = scope("tenant-a", "project-a");
1238        let scope_b = scope("tenant-b", "project-b");
1239        let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1240        queue
1241            .enqueue(letter("good", "tenant-a", "project-a"))
1242            .unwrap();
1243        queue
1244            .enqueue(letter("bad", "tenant-b", "project-b"))
1245            .unwrap();
1246        std::fs::write(
1247            scope_store_path(&legacy.with_extension("d"), &scope_b),
1248            b"not json",
1249        )
1250        .unwrap();
1251
1252        let reopened = DeadLetterQueue::persistent(legacy).unwrap();
1253        assert_eq!(reopened.peek(&scope_a).unwrap()[0].id, "good");
1254        assert!(reopened.peek(&scope_b).is_err());
1255    }
1256
1257    #[test]
1258    fn read_only_stats_does_not_create_store_directory() {
1259        let dir = tempfile::tempdir().unwrap();
1260        let legacy = dir.path().join("missing").join("dead-letters-v1.json");
1261        let root = legacy.with_extension("d");
1262        let queue = DeadLetterQueue::persistent(legacy).unwrap();
1263
1264        assert_eq!(queue.stats(None).unwrap().total, 0);
1265        assert!(!root.exists());
1266    }
1267
1268    #[test]
1269    fn aggregate_stats_report_worst_scope_depth() {
1270        let queue = DeadLetterQueue::new();
1271        queue
1272            .enqueue(letter("a1", "tenant-a", "project-a"))
1273            .unwrap();
1274        queue
1275            .enqueue(letter("a2", "tenant-a", "project-a"))
1276            .unwrap();
1277        queue
1278            .enqueue(letter("b1", "tenant-b", "project-b"))
1279            .unwrap();
1280
1281        let stats = queue.stats(None).unwrap();
1282        assert_eq!(stats.total, 3);
1283        assert_eq!(stats.max_scope_depth, 2);
1284        assert_eq!(
1285            queue
1286                .stats(Some(&scope("tenant-b", "project-b")))
1287                .unwrap()
1288                .max_scope_depth,
1289            1
1290        );
1291    }
1292
1293    #[test]
1294    fn successful_local_retry_atomically_persists_removal() {
1295        // A retry persists the agent registry below the data directory: an own
1296        // one, or the run lands in whatever a parallel test set and removes.
1297        let _data = crate::core::data_dir::isolated_data_dir();
1298        let dir = tempfile::tempdir().unwrap();
1299        let legacy = dir.path().join("dead-letters-v1.json");
1300        let scope = scope("tenant-a", "project-a");
1301        let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1302        let mut item = letter("retry", "tenant-a", "project-a");
1303        item.target_agent = "retry-target".into();
1304        queue.enqueue(item).unwrap();
1305
1306        queue.retry(&scope, "retry").unwrap();
1307        drop(queue);
1308        let stored = DeadLetterQueue::persistent(legacy)
1309            .unwrap()
1310            .peek(&scope)
1311            .unwrap();
1312        assert!(stored.is_empty(), "{stored:?}");
1313    }
1314
1315    #[test]
1316    fn retry_lock_artifacts_are_bounded_per_scope() {
1317        let dir = tempfile::tempdir().unwrap();
1318        let legacy = dir.path().join("dead-letters-v1.json");
1319        let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1320        let scope_a = scope("tenant-a", "project-a");
1321        let scope_b = scope("tenant-b", "project-b");
1322        queue
1323            .enqueue(letter("first", "tenant-a", "project-a"))
1324            .unwrap();
1325        queue
1326            .enqueue(letter("third", "tenant-b", "project-b"))
1327            .unwrap();
1328
1329        drop(queue.lock_retry(&scope_a, "first").unwrap());
1330        drop(queue.lock_retry(&scope_a, "second").unwrap());
1331        drop(queue.lock_retry(&scope_b, "third").unwrap());
1332
1333        let retry_locks = std::fs::read_dir(legacy.with_extension("d"))
1334            .unwrap()
1335            .filter_map(Result::ok)
1336            .filter(|entry| entry.file_name().to_string_lossy().ends_with(".retry.lock"))
1337            .count();
1338        assert_eq!(retry_locks, 2);
1339    }
1340}