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        use std::os::unix::fs::PermissionsExt;
533        let metadata = std::fs::metadata(root)?;
534        // SAFETY: `geteuid` has no arguments and only reads process credentials.
535        if metadata.uid() != unsafe { libc::geteuid() } {
536            return Err(std::io::Error::new(
537                std::io::ErrorKind::PermissionDenied,
538                "DLQ store directory must be owned by the current user",
539            ));
540        }
541        std::fs::set_permissions(root, std::fs::Permissions::from_mode(0o700))?;
542    }
543    Ok(())
544}
545
546fn scope_store_path(root: &Path, scope: &DlqScope) -> PathBuf {
547    let mut material = Vec::with_capacity(scope.tenant_id.len() + scope.project_id.len() + 1);
548    material.extend_from_slice(scope.tenant_id.as_bytes());
549    material.push(0);
550    material.extend_from_slice(scope.project_id.as_bytes());
551    root.join(format!("{}.json", blake3::hash(&material).to_hex()))
552}
553
554fn serialized_store_size(entries: &[DeadLetter]) -> OclaResult<u64> {
555    serde_json::to_vec_pretty(&DurableDlqV1 {
556        version: STORE_VERSION,
557        entries: entries.to_vec(),
558    })
559    .map(|json| json.len() as u64)
560    .map_err(|error| OclaError::InvalidRequest(format!("failed to size dead letters: {error}")))
561}
562
563fn load_all_scope_entries(root: &Path) -> std::io::Result<Vec<DeadLetter>> {
564    let mut entries = Vec::new();
565    for item in std::fs::read_dir(root)? {
566        let item = item?;
567        let file_type = item.file_type()?;
568        let name = item.file_name();
569        let name = name.to_str().ok_or_else(|| {
570            std::io::Error::new(
571                std::io::ErrorKind::InvalidData,
572                "invalid DLQ store filename",
573            )
574        })?;
575        if item.path().extension() == Some(std::ffi::OsStr::new("lock"))
576            || name == "migration-v1.complete"
577        {
578            continue;
579        }
580        if file_type.is_file() && is_scope_store_temp_name(name) {
581            continue;
582        }
583        if file_type.is_symlink() || !file_type.is_file() || !is_scope_store_name(name) {
584            return Err(std::io::Error::new(
585                std::io::ErrorKind::InvalidData,
586                "DLQ root contains an unexpected entry",
587            ));
588        }
589        let store = load_store(&item.path(), None)?;
590        if let Some(first) = store.entries.first() {
591            let scope = DlqScope::new(first.tenant_id.clone(), first.project_id.clone())
592                .map_err(std::io::Error::other)?;
593            if scope_store_path(root, &scope) != item.path()
594                || store.entries.iter().any(|entry| {
595                    entry.tenant_id != scope.tenant_id || entry.project_id != scope.project_id
596                })
597            {
598                return Err(std::io::Error::new(
599                    std::io::ErrorKind::InvalidData,
600                    "DLQ scope store identity does not match its contents",
601                ));
602            }
603        }
604        entries.extend(store.entries);
605    }
606    Ok(entries)
607}
608
609fn is_scope_store_name(name: &str) -> bool {
610    let path = Path::new(name);
611    path.extension() == Some(std::ffi::OsStr::new("json"))
612        && path.file_stem().is_some_and(|stem| {
613            let bytes = stem.as_encoded_bytes();
614            bytes.len() == 64 && bytes.iter().all(u8::is_ascii_hexdigit)
615        })
616}
617
618fn is_scope_store_temp_name(name: &str) -> bool {
619    let Some((store_name, suffix)) = name.split_once(".tmp-") else {
620        return false;
621    };
622    !suffix.is_empty()
623        && suffix
624            .bytes()
625            .all(|byte| byte.is_ascii_hexdigit() || byte == b'-')
626        && store_name.len() == 64
627        && store_name.bytes().all(|byte| byte.is_ascii_hexdigit())
628}
629
630fn probe_store_directory(root: &Path) -> std::io::Result<()> {
631    let path = root.join(format!("writable-{}.lock", uuid::Uuid::new_v4()));
632    let result = (|| {
633        let mut options = OpenOptions::new();
634        options.write(true).create_new(true);
635        #[cfg(unix)]
636        {
637            use std::os::unix::fs::OpenOptionsExt;
638            options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
639        }
640        #[cfg(windows)]
641        {
642            use std::os::windows::fs::OpenOptionsExt;
643            options.custom_flags(0x0020_0000);
644        }
645        options.open(&path)?.sync_all()?;
646        std::fs::remove_file(&path)
647    })();
648    if result.is_err() {
649        let _ = std::fs::remove_file(&path);
650    }
651    result
652}
653
654fn migrate_legacy_store(legacy_path: &Path, root: &Path) -> std::io::Result<()> {
655    if !legacy_path.exists() || root.join("migration-v1.complete").exists() {
656        return Ok(());
657    }
658    let _migration_lock =
659        FileLock::acquire_with_timeout(&root.join("migration-v1.lock"), LOCK_TIMEOUT)
660            .map_err(std::io::Error::other)?;
661    let marker = root.join("migration-v1.complete");
662    if marker.exists() {
663        return Ok(());
664    }
665    let legacy = load_store(legacy_path, None)?;
666    let mut grouped: BTreeMap<(String, String), Vec<DeadLetter>> = BTreeMap::new();
667    for letter in legacy.entries {
668        grouped
669            .entry((letter.tenant_id.clone(), letter.project_id.clone()))
670            .or_default()
671            .push(letter);
672    }
673    for ((tenant_id, project_id), letters) in grouped {
674        let scope = DlqScope::new(tenant_id, project_id).map_err(std::io::Error::other)?;
675        let path = scope_store_path(root, &scope);
676        let _scope_lock =
677            FileLock::acquire_with_timeout(&path.with_extension("lock"), LOCK_TIMEOUT)
678                .map_err(std::io::Error::other)?;
679        let mut current = load_store(&path, Some(&scope))?;
680        for letter in letters {
681            if let Some(existing) = current
682                .entries
683                .iter_mut()
684                .find(|entry| entry.id == letter.id)
685            {
686                if letter.last_failed_at > existing.last_failed_at {
687                    *existing = letter;
688                }
689            } else {
690                current.entries.push(letter);
691            }
692        }
693        if current.entries.len() > MAX_ENTRIES_PER_SCOPE {
694            let excess = current.entries.len() - MAX_ENTRIES_PER_SCOPE;
695            current.entries.drain(..excess);
696        }
697        while serialized_store_size(&current.entries).map_err(std::io::Error::other)?
698            > MAX_STORE_BYTES
699        {
700            if current.entries.is_empty() {
701                break;
702            }
703            current.entries.remove(0);
704        }
705        save_store(&path, &current)?;
706    }
707    write_marker(&marker)?;
708    let backup = legacy_path.with_extension("json.migrated-v1");
709    if !backup.exists() {
710        std::fs::rename(legacy_path, backup)?;
711        sync_parent(legacy_path)?;
712    }
713    Ok(())
714}
715
716fn write_marker(path: &Path) -> std::io::Result<()> {
717    let mut options = OpenOptions::new();
718    options.write(true).create_new(true);
719    #[cfg(unix)]
720    {
721        use std::os::unix::fs::OpenOptionsExt;
722        options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
723    }
724    #[cfg(windows)]
725    {
726        use std::os::windows::fs::OpenOptionsExt;
727        options.custom_flags(0x0020_0000);
728    }
729    let mut file = options.open(path)?;
730    file.write_all(b"v1\n")?;
731    file.sync_all()?;
732    sync_parent(path)
733}
734
735fn load_store(path: &Path, expected_scope: Option<&DlqScope>) -> std::io::Result<DurableDlqV1> {
736    let metadata = match std::fs::symlink_metadata(path) {
737        Ok(metadata) => metadata,
738        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
739            return Ok(DurableDlqV1::default());
740        }
741        Err(error) => return Err(error),
742    };
743    if metadata.file_type().is_symlink() || !metadata.is_file() {
744        return Err(std::io::Error::new(
745            std::io::ErrorKind::InvalidData,
746            "DLQ store must be a regular file",
747        ));
748    }
749    if metadata.len() > MAX_STORE_BYTES {
750        return Err(std::io::Error::new(
751            std::io::ErrorKind::InvalidData,
752            "DLQ store exceeds size limit",
753        ));
754    }
755    let mut content = Vec::new();
756    open_read_nofollow(path)?
757        .take(MAX_STORE_BYTES + 1)
758        .read_to_end(&mut content)?;
759    if content.len() as u64 > MAX_STORE_BYTES {
760        return Err(std::io::Error::new(
761            std::io::ErrorKind::InvalidData,
762            "DLQ store exceeds size limit",
763        ));
764    }
765    let mut store: DurableDlqV1 =
766        serde_json::from_slice(&content).map_err(std::io::Error::other)?;
767    if store.version != STORE_VERSION || store.entries.len() > MAX_ENTRIES_PER_SCOPE {
768        return Err(std::io::Error::new(
769            std::io::ErrorKind::InvalidData,
770            "invalid DLQ store bounds or version",
771        ));
772    }
773    for entry in &mut store.entries {
774        // Older v1 entries did not carry peer-scoped delivery identity. Keep
775        // them readable while making the compatibility scope explicit.
776        if entry.peer_id.is_empty() {
777            entry.peer_id = "legacy".to_string();
778        }
779        if entry.delivery_id.is_empty() {
780            entry.delivery_id = entry.id.clone();
781        }
782        entry
783            .validate()
784            .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
785        if expected_scope.is_some_and(|scope| {
786            entry.tenant_id != scope.tenant_id || entry.project_id != scope.project_id
787        }) {
788            return Err(std::io::Error::new(
789                std::io::ErrorKind::InvalidData,
790                "DLQ scope store contains an entry from another scope",
791            ));
792        }
793    }
794    Ok(store)
795}
796
797fn save_store(path: &Path, store: &DurableDlqV1) -> std::io::Result<()> {
798    if let Ok(metadata) = std::fs::symlink_metadata(path)
799        && (metadata.file_type().is_symlink() || !metadata.is_file())
800    {
801        return Err(std::io::Error::new(
802            std::io::ErrorKind::InvalidData,
803            "DLQ store must be a regular file",
804        ));
805    }
806    let json = serde_json::to_vec_pretty(store).map_err(std::io::Error::other)?;
807    if json.len() as u64 > MAX_STORE_BYTES {
808        return Err(std::io::Error::new(
809            std::io::ErrorKind::InvalidData,
810            "DLQ store exceeds size limit",
811        ));
812    }
813    let tmp = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
814    let result = (|| {
815        let mut options = OpenOptions::new();
816        options.write(true).create_new(true);
817        #[cfg(unix)]
818        {
819            use std::os::unix::fs::OpenOptionsExt;
820            options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
821        }
822        #[cfg(windows)]
823        {
824            use std::os::windows::fs::OpenOptionsExt;
825            options.custom_flags(0x0020_0000);
826        }
827        let mut file = options.open(&tmp)?;
828        file.write_all(&json)?;
829        file.sync_all()?;
830        drop(file);
831        atomic_replace(&tmp, path)?;
832        sync_parent(path)
833    })();
834    if result.is_err() {
835        let _ = std::fs::remove_file(&tmp);
836    }
837    result
838}
839
840fn open_read_nofollow(path: &Path) -> std::io::Result<File> {
841    let mut options = OpenOptions::new();
842    options.read(true);
843    #[cfg(unix)]
844    {
845        use std::os::unix::fs::OpenOptionsExt;
846        options.custom_flags(libc::O_NOFOLLOW);
847    }
848    #[cfg(windows)]
849    {
850        use std::os::windows::fs::OpenOptionsExt;
851        options.custom_flags(0x0020_0000);
852    }
853    options.open(path)
854}
855
856#[cfg(not(windows))]
857fn atomic_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
858    std::fs::rename(source, destination)
859}
860
861#[cfg(windows)]
862fn atomic_replace(source: &Path, destination: &Path) -> std::io::Result<()> {
863    use std::os::windows::ffi::OsStrExt;
864    use windows_sys::Win32::Storage::FileSystem::{
865        MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW,
866    };
867
868    let source: Vec<u16> = source.as_os_str().encode_wide().chain(Some(0)).collect();
869    let destination: Vec<u16> = destination
870        .as_os_str()
871        .encode_wide()
872        .chain(Some(0))
873        .collect();
874    // SAFETY: both vectors own the UTF-16 buffers, include a terminating NUL,
875    // and remain alive for the duration of the call.
876    let result = unsafe {
877        MoveFileExW(
878            source.as_ptr(),
879            destination.as_ptr(),
880            MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
881        )
882    };
883    if result == 0 {
884        Err(std::io::Error::last_os_error())
885    } else {
886        Ok(())
887    }
888}
889
890fn sync_parent(path: &Path) -> std::io::Result<()> {
891    #[cfg(unix)]
892    if let Some(parent) = path.parent() {
893        File::open(parent)?.sync_all()?;
894    }
895    // `MoveFileExW(MOVEFILE_WRITE_THROUGH)` above waits for the replacement to
896    // reach disk. Windows does not support `FlushFileBuffers` on directory
897    // handles, so attempting the Unix parent-fsync pattern returns ACCESS_DENIED
898    // after an otherwise successful atomic replacement.
899    #[cfg(windows)]
900    let _ = path;
901    Ok(())
902}
903
904#[cfg(test)]
905mod tests {
906    use super::*;
907
908    fn scope(tenant: &str, project: &str) -> DlqScope {
909        DlqScope::new(tenant, project).unwrap()
910    }
911    fn letter(id: &str, tenant: &str, project: &str) -> DeadLetter {
912        DeadLetter {
913            id: id.into(),
914            peer_id: "legacy".into(),
915            delivery_id: id.into(),
916            tenant_id: tenant.into(),
917            project_id: project.into(),
918            delivery: DeadLetterDelivery::LocalAgentBus,
919            original_message: format!("message-{id}"),
920            target_agent: "agent-a".into(),
921            error: "delivery failed".into(),
922            attempts: 1,
923            first_failed_at: "2026-01-01T00:00:00Z".into(),
924            last_failed_at: "2026-01-01T00:00:00Z".into(),
925        }
926    }
927
928    #[test]
929    fn scoped_operations_do_not_cross_tenant_or_project() {
930        let queue = DeadLetterQueue::new();
931        queue
932            .enqueue(letter("one", "tenant-a", "project-a"))
933            .unwrap();
934        queue
935            .enqueue(letter("one", "tenant-b", "project-a"))
936            .unwrap();
937        assert_eq!(
938            queue.peek(&scope("tenant-a", "project-a")).unwrap().len(),
939            1
940        );
941        assert!(
942            queue
943                .dequeue(&scope("tenant-a", "project-b"), "one")
944                .unwrap()
945                .is_none()
946        );
947        assert_eq!(queue.stats(None).unwrap().total, 2);
948    }
949
950    #[test]
951    fn persistent_queue_survives_reopen_and_deduplicates() {
952        let dir = tempfile::tempdir().unwrap();
953        let path = dir.path().join("a2a").join("dead-letters-v1.json");
954        let queue = DeadLetterQueue::persistent(path.clone()).unwrap();
955        queue
956            .enqueue(letter("one", "tenant-a", "project-a"))
957            .unwrap();
958        queue
959            .enqueue(letter("one", "tenant-a", "project-a"))
960            .unwrap();
961        drop(queue);
962        assert_eq!(
963            DeadLetterQueue::persistent(path)
964                .unwrap()
965                .peek(&scope("tenant-a", "project-a"))
966                .unwrap()
967                .len(),
968            1
969        );
970    }
971
972    #[test]
973    fn concurrent_persistent_enqueues_do_not_lose_entries() {
974        let dir = tempfile::tempdir().unwrap();
975        let path = dir.path().join("a2a").join("dead-letters-v1.json");
976        let queue = DeadLetterQueue::persistent(path.clone()).unwrap();
977        let workers: Vec<_> = (0..8)
978            .map(|worker| {
979                let queue = queue.clone();
980                std::thread::spawn(move || {
981                    for item in 0..25 {
982                        queue
983                            .enqueue(letter(
984                                &format!("worker-{worker}-item-{item}"),
985                                "tenant-a",
986                                "project-a",
987                            ))
988                            .unwrap();
989                    }
990                })
991            })
992            .collect();
993        for worker in workers {
994            worker.join().unwrap();
995        }
996        drop(queue);
997        assert_eq!(
998            DeadLetterQueue::persistent(path)
999                .unwrap()
1000                .peek(&scope("tenant-a", "project-a"))
1001                .unwrap()
1002                .len(),
1003            200
1004        );
1005    }
1006
1007    #[test]
1008    fn corrupt_oversized_and_symlink_stores_fail_closed() {
1009        let dir = tempfile::tempdir().unwrap();
1010        let corrupt = dir.path().join("corrupt.json");
1011        std::fs::write(&corrupt, b"not json").unwrap();
1012        assert!(DeadLetterQueue::persistent(corrupt).is_err());
1013        let oversized = dir.path().join("oversized.json");
1014        std::fs::write(&oversized, vec![b'x'; (MAX_STORE_BYTES + 1) as usize]).unwrap();
1015        assert!(DeadLetterQueue::persistent(oversized).is_err());
1016        #[cfg(unix)]
1017        {
1018            use std::os::unix::fs::symlink;
1019            let target = dir.path().join("target.json");
1020            std::fs::write(&target, b"{}").unwrap();
1021            let link = dir.path().join("link.json");
1022            symlink(&target, &link).unwrap();
1023            assert!(DeadLetterQueue::persistent(link).is_err());
1024        }
1025    }
1026
1027    #[test]
1028    fn remote_entries_cannot_be_retried_as_local_messages() {
1029        let queue = DeadLetterQueue::new();
1030        let mut item = letter("one", "tenant-a", "project-a");
1031        item.delivery = DeadLetterDelivery::RemoteHttp {
1032            endpoint_url: "https://agent.example".into(),
1033        };
1034        queue.enqueue(item).unwrap();
1035        assert!(queue.retry(&scope("tenant-a", "project-a"), "one").is_err());
1036        assert_eq!(queue.stats(None).unwrap().total, 1);
1037    }
1038
1039    #[test]
1040    fn invalid_attempts_time_order_and_endpoint_credentials_are_rejected() {
1041        let queue = DeadLetterQueue::new();
1042        let mut zero_attempts = letter("zero", "tenant-a", "project-a");
1043        zero_attempts.attempts = 0;
1044        assert!(queue.enqueue(zero_attempts).is_err());
1045
1046        let mut reversed = letter("reversed", "tenant-a", "project-a");
1047        reversed.last_failed_at = "2025-01-01T00:00:00Z".into();
1048        assert!(queue.enqueue(reversed).is_err());
1049
1050        let mut credential_url = letter("credentials", "tenant-a", "project-a");
1051        credential_url.delivery = DeadLetterDelivery::RemoteHttp {
1052            endpoint_url: "https://user:secret@agent.example".into(),
1053        };
1054        assert!(queue.enqueue(credential_url).is_err());
1055        assert_eq!(queue.stats(None).unwrap().total, 0);
1056    }
1057
1058    #[test]
1059    fn per_scope_capacity_never_evicts_another_scope() {
1060        let queue = DeadLetterQueue::new();
1061        queue
1062            .enqueue(letter("protected", "tenant-b", "project-b"))
1063            .unwrap();
1064        for index in 0..=MAX_ENTRIES_PER_SCOPE {
1065            queue
1066                .enqueue(letter(&format!("a-{index}"), "tenant-a", "project-a"))
1067                .unwrap();
1068        }
1069        let protected = queue.peek(&scope("tenant-b", "project-b")).unwrap();
1070        assert_eq!(protected.len(), 1);
1071        assert_eq!(protected[0].id, "protected");
1072        let tenant_a = queue.peek(&scope("tenant-a", "project-a")).unwrap();
1073        assert_eq!(tenant_a.len(), MAX_ENTRIES_PER_SCOPE);
1074        assert_eq!(tenant_a.first().unwrap().id, "a-1");
1075    }
1076
1077    #[test]
1078    fn durable_scopes_use_distinct_files_and_byte_eviction_is_local() {
1079        let dir = tempfile::tempdir().unwrap();
1080        let legacy = dir.path().join("dead-letters-v1.json");
1081        let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1082        let mut protected = letter("protected", "tenant-b", "project-b");
1083        protected.original_message = "b".repeat(MAX_MESSAGE_BYTES);
1084        queue.enqueue(protected).unwrap();
1085        for index in 0..5 {
1086            let mut item = letter(&format!("large-{index}"), "tenant-a", "project-a");
1087            item.original_message = "a".repeat(MAX_MESSAGE_BYTES);
1088            queue.enqueue(item).unwrap();
1089        }
1090
1091        let root = legacy.with_extension("d");
1092        let scope_a = scope("tenant-a", "project-a");
1093        let scope_b = scope("tenant-b", "project-b");
1094        assert_ne!(
1095            scope_store_path(&root, &scope_a),
1096            scope_store_path(&root, &scope_b)
1097        );
1098        assert_eq!(queue.peek(&scope_b).unwrap().len(), 1);
1099        let tenant_a = queue.peek(&scope_a).unwrap();
1100        assert!(!tenant_a.is_empty(), "`tenant_a` is empty");
1101        assert!(tenant_a.len() < 5);
1102        assert!(
1103            std::fs::metadata(scope_store_path(&root, &scope_a))
1104                .unwrap()
1105                .len()
1106                <= MAX_STORE_BYTES
1107        );
1108    }
1109
1110    #[test]
1111    fn compact_legacy_boundary_is_readable_and_next_enqueue_uses_writer_size() {
1112        let dir = tempfile::tempdir().unwrap();
1113        let legacy = dir.path().join("dead-letters-v1.json");
1114        let root = legacy.with_extension("d");
1115        let scope = scope("tenant-a", "project-a");
1116        let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1117        prepare_store_directory(&root).unwrap();
1118
1119        let mut entries = (0..MAX_ENTRIES_PER_SCOPE)
1120            .map(|index| {
1121                let mut item = letter(&format!("boundary-{index}"), "tenant-a", "project-a");
1122                item.original_message = "x".repeat(8_200);
1123                item
1124            })
1125            .collect::<Vec<_>>();
1126        while serde_json::to_vec(&DurableDlqV1 {
1127            version: STORE_VERSION,
1128            entries: entries.clone(),
1129        })
1130        .unwrap()
1131        .len() as u64
1132            > MAX_STORE_BYTES
1133        {
1134            entries.remove(0);
1135        }
1136        let compact = serde_json::to_vec(&DurableDlqV1 {
1137            version: STORE_VERSION,
1138            entries: entries.clone(),
1139        })
1140        .unwrap();
1141        assert!(compact.len() as u64 <= MAX_STORE_BYTES);
1142        assert!(
1143            serde_json::to_vec_pretty(&DurableDlqV1 {
1144                version: STORE_VERSION,
1145                entries,
1146            })
1147            .unwrap()
1148            .len() as u64
1149                > MAX_STORE_BYTES
1150        );
1151        std::fs::write(scope_store_path(&root, &scope), compact).unwrap();
1152
1153        queue.ensure_writable(&scope).unwrap();
1154        queue
1155            .enqueue(letter("after-boundary", "tenant-a", "project-a"))
1156            .unwrap();
1157        let stored = std::fs::metadata(scope_store_path(&root, &scope)).unwrap();
1158        assert!(stored.len() <= MAX_STORE_BYTES);
1159        assert!(queue.get(&scope, "after-boundary").unwrap().is_some());
1160    }
1161
1162    #[test]
1163    fn aggregate_stats_ignore_regular_interrupted_write_artifacts() {
1164        let dir = tempfile::tempdir().unwrap();
1165        let legacy = dir.path().join("dead-letters-v1.json");
1166        let scope = scope("tenant-a", "project-a");
1167        let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1168        queue
1169            .enqueue(letter("good", "tenant-a", "project-a"))
1170            .unwrap();
1171        let store = scope_store_path(&legacy.with_extension("d"), &scope);
1172        let tmp = store.with_extension(format!("tmp-{}", uuid::Uuid::new_v4()));
1173        std::fs::write(&tmp, b"partial").unwrap();
1174
1175        assert_eq!(queue.stats(None).unwrap().total, 1);
1176        std::fs::remove_file(tmp).unwrap();
1177    }
1178
1179    #[cfg(unix)]
1180    #[test]
1181    fn persistent_store_repairs_preexisting_directory_permissions() {
1182        use std::os::unix::fs::PermissionsExt;
1183
1184        let dir = tempfile::tempdir().unwrap();
1185        let legacy = dir.path().join("dead-letters-v1.json");
1186        let root = legacy.with_extension("d");
1187        std::fs::create_dir(&root).unwrap();
1188        std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap();
1189
1190        let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1191        queue
1192            .enqueue(letter("private", "tenant-a", "project-a"))
1193            .unwrap();
1194        assert_eq!(
1195            std::fs::metadata(root).unwrap().permissions().mode() & 0o777,
1196            0o700
1197        );
1198    }
1199
1200    #[test]
1201    fn legacy_global_store_migrates_without_cross_scope_cohabitation() {
1202        let dir = tempfile::tempdir().unwrap();
1203        let legacy = dir.path().join("dead-letters-v1.json");
1204        save_store(
1205            &legacy,
1206            &DurableDlqV1 {
1207                version: STORE_VERSION,
1208                entries: vec![
1209                    letter("a", "tenant-a", "project-a"),
1210                    letter("b", "tenant-b", "project-b"),
1211                ],
1212            },
1213        )
1214        .unwrap();
1215
1216        let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1217        assert!(!legacy.exists());
1218        assert_eq!(
1219            queue.peek(&scope("tenant-a", "project-a")).unwrap().len(),
1220            1
1221        );
1222        assert_eq!(
1223            queue.peek(&scope("tenant-b", "project-b")).unwrap().len(),
1224            1
1225        );
1226        let stores = std::fs::read_dir(legacy.with_extension("d"))
1227            .unwrap()
1228            .filter_map(Result::ok)
1229            .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "json"))
1230            .count();
1231        assert_eq!(stores, 2);
1232    }
1233
1234    #[test]
1235    fn corrupt_scope_does_not_block_another_scope_or_reopen() {
1236        let dir = tempfile::tempdir().unwrap();
1237        let legacy = dir.path().join("dead-letters-v1.json");
1238        let scope_a = scope("tenant-a", "project-a");
1239        let scope_b = scope("tenant-b", "project-b");
1240        let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1241        queue
1242            .enqueue(letter("good", "tenant-a", "project-a"))
1243            .unwrap();
1244        queue
1245            .enqueue(letter("bad", "tenant-b", "project-b"))
1246            .unwrap();
1247        std::fs::write(
1248            scope_store_path(&legacy.with_extension("d"), &scope_b),
1249            b"not json",
1250        )
1251        .unwrap();
1252
1253        let reopened = DeadLetterQueue::persistent(legacy).unwrap();
1254        assert_eq!(reopened.peek(&scope_a).unwrap()[0].id, "good");
1255        assert!(reopened.peek(&scope_b).is_err());
1256    }
1257
1258    #[test]
1259    fn read_only_stats_does_not_create_store_directory() {
1260        let dir = tempfile::tempdir().unwrap();
1261        let legacy = dir.path().join("missing").join("dead-letters-v1.json");
1262        let root = legacy.with_extension("d");
1263        let queue = DeadLetterQueue::persistent(legacy).unwrap();
1264
1265        assert_eq!(queue.stats(None).unwrap().total, 0);
1266        assert!(!root.exists());
1267    }
1268
1269    #[test]
1270    fn aggregate_stats_report_worst_scope_depth() {
1271        let queue = DeadLetterQueue::new();
1272        queue
1273            .enqueue(letter("a1", "tenant-a", "project-a"))
1274            .unwrap();
1275        queue
1276            .enqueue(letter("a2", "tenant-a", "project-a"))
1277            .unwrap();
1278        queue
1279            .enqueue(letter("b1", "tenant-b", "project-b"))
1280            .unwrap();
1281
1282        let stats = queue.stats(None).unwrap();
1283        assert_eq!(stats.total, 3);
1284        assert_eq!(stats.max_scope_depth, 2);
1285        assert_eq!(
1286            queue
1287                .stats(Some(&scope("tenant-b", "project-b")))
1288                .unwrap()
1289                .max_scope_depth,
1290            1
1291        );
1292    }
1293
1294    #[test]
1295    fn successful_local_retry_atomically_persists_removal() {
1296        // A retry persists the agent registry below the data directory: an own
1297        // one, or the run lands in whatever a parallel test set and removes.
1298        let _data = crate::core::data_dir::isolated_data_dir();
1299        let dir = tempfile::tempdir().unwrap();
1300        let legacy = dir.path().join("dead-letters-v1.json");
1301        let scope = scope("tenant-a", "project-a");
1302        let queue = DeadLetterQueue::persistent(legacy.clone()).unwrap();
1303        let mut item = letter("retry", "tenant-a", "project-a");
1304        item.target_agent = "retry-target".into();
1305        queue.enqueue(item).unwrap();
1306
1307        queue.retry(&scope, "retry").unwrap();
1308        drop(queue);
1309        let stored = DeadLetterQueue::persistent(legacy)
1310            .unwrap()
1311            .peek(&scope)
1312            .unwrap();
1313        assert!(stored.is_empty(), "{stored:?}");
1314    }
1315
1316    #[test]
1317    fn retry_lock_artifacts_are_bounded_per_scope() {
1318        let dir = tempfile::tempdir().unwrap();
1319        let legacy = dir.path().join("dead-letters-v1.json");
1320        let queue = DeadLetterQueue::persistent(&legacy).unwrap();
1321        let scope_a = scope("tenant-a", "project-a");
1322        let scope_b = scope("tenant-b", "project-b");
1323        queue
1324            .enqueue(letter("first", "tenant-a", "project-a"))
1325            .unwrap();
1326        queue
1327            .enqueue(letter("third", "tenant-b", "project-b"))
1328            .unwrap();
1329
1330        drop(queue.lock_retry(&scope_a, "first").unwrap());
1331        drop(queue.lock_retry(&scope_a, "second").unwrap());
1332        drop(queue.lock_retry(&scope_b, "third").unwrap());
1333
1334        let retry_locks = std::fs::read_dir(legacy.with_extension("d"))
1335            .unwrap()
1336            .filter_map(Result::ok)
1337            .filter(|entry| entry.file_name().to_string_lossy().ends_with(".retry.lock"))
1338            .count();
1339        assert_eq!(retry_locks, 2);
1340    }
1341}