Skip to main content

aidens_queue_kit/
lib.rs

1//! Append-only queue substrate for P11 daemon execution.
2
3use aidens_contracts::{
4    ArtifactId, CanonicalToolSideEffectClass, DaemonNamespaceV1, DuplicateSuppressionReportV1,
5    JobStateV1, JobV1, QueueHopKindV1, QueueHopReportV1, QueueLeaseV1, SafeModeOperationV1,
6    SafeModeReportV1, ScheduleOccurrenceV1, WakeSignalV1,
7};
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11use std::fs::{File, OpenOptions};
12use std::io::{BufRead, BufReader, Write};
13use std::path::{Path, PathBuf};
14use std::thread;
15use std::time::{Duration as StdDuration, Instant};
16use thiserror::Error;
17
18#[derive(Debug, Error)]
19pub enum QueueError {
20    #[error("queue io error at {path}: {source}")]
21    Io {
22        path: PathBuf,
23        #[source]
24        source: std::io::Error,
25    },
26    #[error("queue json error at {path}: {source}")]
27    Json {
28        path: PathBuf,
29        #[source]
30        source: serde_json::Error,
31    },
32    #[error("queue writer owner mismatch: expected {expected}, got {actual}")]
33    WriterOwnerMismatch { expected: String, actual: String },
34    #[error("queue opened read-only")]
35    ReadOnly,
36    #[error("job not found: {0}")]
37    JobNotFound(String),
38    #[error("job is terminal and cannot transition: {0}")]
39    TerminalJob(String),
40    #[error("job completion requires an active lease: {0}")]
41    LeaseRequired(String),
42    #[error("job lease mismatch for {job_id}: expected {expected}, got {actual}")]
43    LeaseMismatch {
44        job_id: String,
45        expected: String,
46        actual: String,
47    },
48    #[error("job lease expired for {job_id}: {lease_id}")]
49    LeaseExpired { job_id: String, lease_id: String },
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct QueueLogEntryV1 {
54    pub sequence: u64,
55    pub namespace_id: ArtifactId,
56    pub job: Option<JobV1>,
57    pub lease: Option<QueueLeaseV1>,
58    pub safe_mode_enabled: Option<bool>,
59    pub queue_hop_receipt: Option<QueueHopReportV1>,
60    pub duplicate_suppression_receipt: Option<DuplicateSuppressionReportV1>,
61    pub safe_mode_receipt: Option<SafeModeReportV1>,
62    pub recorded_at: DateTime<Utc>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct QueueSnapshotV1 {
67    pub namespace: DaemonNamespaceV1,
68    pub jobs: Vec<JobV1>,
69    pub leases: Vec<QueueLeaseV1>,
70    pub safe_mode_enabled: bool,
71    pub records_seen: usize,
72}
73
74impl QueueSnapshotV1 {
75    pub fn job(&self, job_id: &ArtifactId) -> Option<&JobV1> {
76        self.jobs.iter().find(|job| &job.job_id == job_id)
77    }
78
79    pub fn logical_job_count_for(&self, idempotency_key: &str) -> usize {
80        self.jobs
81            .iter()
82            .filter(|job| job.idempotency_key == idempotency_key)
83            .count()
84    }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct QueueEnqueueOutcomeV1 {
89    pub enqueued: bool,
90    pub job: Option<JobV1>,
91    pub existing_job: Option<JobV1>,
92    pub queue_hop_receipt: QueueHopReportV1,
93    pub duplicate_suppression_receipt: Option<DuplicateSuppressionReportV1>,
94    pub safe_mode_receipt: Option<SafeModeReportV1>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct QueueLeaseOutcomeV1 {
99    pub job: JobV1,
100    pub lease: QueueLeaseV1,
101    pub queue_hop_receipt: QueueHopReportV1,
102}
103
104#[derive(Debug, Clone)]
105pub struct DurableQueueLogV1 {
106    namespace: DaemonNamespaceV1,
107    root_path: PathBuf,
108    log_path: PathBuf,
109    writer_owner: Option<String>,
110}
111
112impl DurableQueueLogV1 {
113    pub fn open(
114        root_path: impl AsRef<Path>,
115        namespace: DaemonNamespaceV1,
116        writer_owner: impl Into<String>,
117    ) -> Result<Self, QueueError> {
118        let writer_owner = writer_owner.into();
119        if writer_owner != namespace.daemon_owner {
120            return Err(QueueError::WriterOwnerMismatch {
121                expected: namespace.daemon_owner.clone(),
122                actual: writer_owner,
123            });
124        }
125        Self::open_inner(root_path, namespace, Some(writer_owner))
126    }
127
128    pub fn open_read_only(
129        root_path: impl AsRef<Path>,
130        namespace: DaemonNamespaceV1,
131    ) -> Result<Self, QueueError> {
132        Self::open_inner(root_path, namespace, None)
133    }
134
135    fn open_inner(
136        root_path: impl AsRef<Path>,
137        namespace: DaemonNamespaceV1,
138        writer_owner: Option<String>,
139    ) -> Result<Self, QueueError> {
140        let root_path = root_path.as_ref().to_path_buf();
141        std::fs::create_dir_all(&root_path).map_err(|source| QueueError::Io {
142            path: root_path.clone(),
143            source,
144        })?;
145        let log_path = root_path.join("queue.ndjson");
146        ensure_file(&log_path)?;
147        Ok(Self {
148            namespace,
149            root_path,
150            log_path,
151            writer_owner,
152        })
153    }
154
155    pub fn namespace(&self) -> &DaemonNamespaceV1 {
156        &self.namespace
157    }
158
159    pub fn root_path(&self) -> &Path {
160        &self.root_path
161    }
162
163    pub fn enqueue_from_schedule(
164        &self,
165        occurrence: ScheduleOccurrenceV1,
166    ) -> Result<QueueEnqueueOutcomeV1, QueueError> {
167        let job = JobV1::new(
168            occurrence.namespace_id.clone(),
169            occurrence.idempotency_key.clone(),
170            "schedule",
171            occurrence.payload,
172            occurrence.risk,
173            Some(occurrence.occurrence_id),
174            None,
175        );
176        self.enqueue_job(job)
177    }
178
179    pub fn enqueue_from_wake(
180        &self,
181        signal: WakeSignalV1,
182    ) -> Result<QueueEnqueueOutcomeV1, QueueError> {
183        let job = JobV1::new(
184            signal.namespace_id.clone(),
185            signal.idempotency_key.clone(),
186            "wake",
187            signal.payload,
188            signal.risk,
189            None,
190            Some(signal.signal_id),
191        );
192        self.enqueue_job(job)
193    }
194
195    pub fn enqueue_job(&self, job: JobV1) -> Result<QueueEnqueueOutcomeV1, QueueError> {
196        self.ensure_writer()?;
197        let _lock = acquire_exclusive_lock(&self.log_path)?;
198        let records = self.read_records()?;
199        let snapshot = self.snapshot_from_records(&records);
200        if snapshot.safe_mode_enabled && job.risk != CanonicalToolSideEffectClass::ReadOnly {
201            let safe = SafeModeReportV1::new(
202                self.namespace.namespace_id.clone(),
203                SafeModeOperationV1::BlockedRiskyJob,
204                true,
205                Some(job.job_id.clone()),
206                "safe-mode-blocked-new-risky-job",
207            );
208            let hop = QueueHopReportV1::new(
209                self.namespace.namespace_id.clone(),
210                job.job_id.clone(),
211                None,
212                QueueHopKindV1::SafeModeBlocked,
213                None,
214                JobStateV1::Queued,
215                "safe-mode-blocked-new-risky-job",
216            );
217            self.append_record_locked(QueueLogEntryV1 {
218                sequence: 0,
219                namespace_id: self.namespace.namespace_id.clone(),
220                job: None,
221                lease: None,
222                safe_mode_enabled: Some(true),
223                queue_hop_receipt: Some(hop.clone()),
224                duplicate_suppression_receipt: None,
225                safe_mode_receipt: Some(safe.clone()),
226                recorded_at: Utc::now(),
227            })?;
228            return Ok(QueueEnqueueOutcomeV1 {
229                enqueued: false,
230                job: Some(job),
231                existing_job: None,
232                queue_hop_receipt: hop,
233                duplicate_suppression_receipt: None,
234                safe_mode_receipt: Some(safe),
235            });
236        }
237
238        if let Some(existing) = snapshot
239            .jobs
240            .iter()
241            .find(|existing| existing.idempotency_key == job.idempotency_key)
242            .cloned()
243        {
244            let duplicate = DuplicateSuppressionReportV1::new(
245                self.namespace.namespace_id.clone(),
246                job.idempotency_key.clone(),
247                existing.job_id.clone(),
248                job.source_kind,
249            );
250            let hop = QueueHopReportV1::new(
251                self.namespace.namespace_id.clone(),
252                existing.job_id.clone(),
253                existing.lease_id.clone(),
254                QueueHopKindV1::DuplicateSuppressed,
255                Some(existing.state.clone()),
256                existing.state.clone(),
257                "duplicate-logical-job-suppressed",
258            );
259            self.append_record_locked(QueueLogEntryV1 {
260                sequence: 0,
261                namespace_id: self.namespace.namespace_id.clone(),
262                job: None,
263                lease: None,
264                safe_mode_enabled: None,
265                queue_hop_receipt: Some(hop.clone()),
266                duplicate_suppression_receipt: Some(duplicate.clone()),
267                safe_mode_receipt: None,
268                recorded_at: Utc::now(),
269            })?;
270            return Ok(QueueEnqueueOutcomeV1 {
271                enqueued: false,
272                job: None,
273                existing_job: Some(existing),
274                queue_hop_receipt: hop,
275                duplicate_suppression_receipt: Some(duplicate),
276                safe_mode_receipt: None,
277            });
278        }
279
280        let hop = QueueHopReportV1::new(
281            self.namespace.namespace_id.clone(),
282            job.job_id.clone(),
283            None,
284            QueueHopKindV1::Enqueued,
285            None,
286            JobStateV1::Queued,
287            "job-enqueued",
288        );
289        self.append_record_locked(QueueLogEntryV1 {
290            sequence: 0,
291            namespace_id: self.namespace.namespace_id.clone(),
292            job: Some(job.clone()),
293            lease: None,
294            safe_mode_enabled: None,
295            queue_hop_receipt: Some(hop.clone()),
296            duplicate_suppression_receipt: None,
297            safe_mode_receipt: None,
298            recorded_at: Utc::now(),
299        })?;
300        Ok(QueueEnqueueOutcomeV1 {
301            enqueued: true,
302            job: Some(job),
303            existing_job: None,
304            queue_hop_receipt: hop,
305            duplicate_suppression_receipt: None,
306            safe_mode_receipt: None,
307        })
308    }
309
310    pub fn acquire_next_lease(
311        &self,
312        owner: impl Into<String>,
313        ttl_seconds: i64,
314    ) -> Result<Option<QueueLeaseOutcomeV1>, QueueError> {
315        self.acquire_next_lease_at(owner, ttl_seconds, Utc::now())
316    }
317
318    pub fn acquire_next_lease_at(
319        &self,
320        owner: impl Into<String>,
321        ttl_seconds: i64,
322        now: DateTime<Utc>,
323    ) -> Result<Option<QueueLeaseOutcomeV1>, QueueError> {
324        self.ensure_writer()?;
325        let owner = owner.into();
326        let _lock = acquire_exclusive_lock(&self.log_path)?;
327        let records = self.read_records()?;
328        let snapshot = self.snapshot_from_records(&records);
329        let leases_by_id = snapshot
330            .leases
331            .iter()
332            .map(|lease| (lease.lease_id.clone(), lease.clone()))
333            .collect::<BTreeMap<_, _>>();
334
335        let mut jobs = snapshot.jobs;
336        jobs.sort_by_key(|job| (job.created_at, job.job_id.0.clone()));
337        for job in jobs {
338            let stale_lease = job
339                .lease_id
340                .as_ref()
341                .and_then(|lease_id| leases_by_id.get(lease_id))
342                .filter(|lease| lease.is_expired_at(now))
343                .cloned();
344            let can_lease = matches!(job.state, JobStateV1::Queued | JobStateV1::Retrying)
345                || stale_lease.is_some();
346            if !can_lease || job.state.is_terminal() {
347                continue;
348            }
349            let from_state = job.state.clone();
350            let lease = QueueLeaseV1::new(
351                &job,
352                owner.clone(),
353                ttl_seconds,
354                stale_lease.as_ref().map(|lease| lease.lease_id.clone()),
355            );
356            let mut leased_job = job.with_state(
357                JobStateV1::Leased,
358                Some(lease.lease_id.clone()),
359                if stale_lease.is_some() {
360                    "lease-expired-stolen"
361                } else {
362                    "lease-acquired"
363                },
364            );
365            leased_job.updated_at = now;
366            let hop = QueueHopReportV1::new(
367                self.namespace.namespace_id.clone(),
368                leased_job.job_id.clone(),
369                Some(lease.lease_id.clone()),
370                if stale_lease.is_some() {
371                    QueueHopKindV1::LeaseStolen
372                } else {
373                    QueueHopKindV1::LeaseAcquired
374                },
375                Some(from_state),
376                JobStateV1::Leased,
377                if stale_lease.is_some() {
378                    "lease-expired-stolen"
379                } else {
380                    "lease-acquired"
381                },
382            );
383            self.append_record_locked(QueueLogEntryV1 {
384                sequence: 0,
385                namespace_id: self.namespace.namespace_id.clone(),
386                job: Some(leased_job.clone()),
387                lease: Some(lease.clone()),
388                safe_mode_enabled: None,
389                queue_hop_receipt: Some(hop.clone()),
390                duplicate_suppression_receipt: None,
391                safe_mode_receipt: None,
392                recorded_at: Utc::now(),
393            })?;
394            return Ok(Some(QueueLeaseOutcomeV1 {
395                job: leased_job,
396                lease,
397                queue_hop_receipt: hop,
398            }));
399        }
400        Ok(None)
401    }
402
403    pub fn cancel_job(
404        &self,
405        job_id: &ArtifactId,
406        reason: impl Into<String>,
407    ) -> Result<QueueHopReportV1, QueueError> {
408        self.transition_job(
409            job_id,
410            JobStateV1::Cancelled,
411            QueueHopKindV1::Cancelled,
412            None,
413            reason,
414            Utc::now(),
415        )
416    }
417
418    pub fn complete_job(
419        &self,
420        job_id: &ArtifactId,
421        lease_id: Option<ArtifactId>,
422    ) -> Result<QueueHopReportV1, QueueError> {
423        self.complete_job_at(job_id, lease_id, Utc::now())
424    }
425
426    pub fn complete_job_at(
427        &self,
428        job_id: &ArtifactId,
429        lease_id: Option<ArtifactId>,
430        now: DateTime<Utc>,
431    ) -> Result<QueueHopReportV1, QueueError> {
432        self.transition_job(
433            job_id,
434            JobStateV1::Completed,
435            QueueHopKindV1::Executed,
436            lease_id,
437            "job-executed",
438            now,
439        )
440    }
441
442    pub fn poison_job(
443        &self,
444        job_id: &ArtifactId,
445        reason: impl Into<String>,
446    ) -> Result<QueueHopReportV1, QueueError> {
447        self.transition_job(
448            job_id,
449            JobStateV1::Poisoned,
450            QueueHopKindV1::Poisoned,
451            None,
452            reason,
453            Utc::now(),
454        )
455    }
456
457    pub fn set_safe_mode(
458        &self,
459        enabled: bool,
460        reason: impl Into<String>,
461    ) -> Result<SafeModeReportV1, QueueError> {
462        self.ensure_writer()?;
463        let _lock = acquire_exclusive_lock(&self.log_path)?;
464        let records = self.read_records()?;
465        let snapshot = self.snapshot_from_records(&records);
466        let receipt = SafeModeReportV1::new(
467            self.namespace.namespace_id.clone(),
468            if enabled {
469                SafeModeOperationV1::Entered
470            } else {
471                SafeModeOperationV1::Exited
472            },
473            enabled,
474            None,
475            reason,
476        );
477        self.append_record_locked(QueueLogEntryV1 {
478            sequence: 0,
479            namespace_id: self.namespace.namespace_id.clone(),
480            job: None,
481            lease: None,
482            safe_mode_enabled: Some(enabled),
483            queue_hop_receipt: None,
484            duplicate_suppression_receipt: None,
485            safe_mode_receipt: Some(receipt.clone()),
486            recorded_at: Utc::now(),
487        })?;
488        if enabled {
489            for job in snapshot.jobs {
490                if !job.state.is_terminal() && job.risk != CanonicalToolSideEffectClass::ReadOnly {
491                    let from_state = job.state.clone();
492                    let lease_id = job.lease_id.clone();
493                    let next_job = job.with_state(
494                        JobStateV1::Cancelled,
495                        lease_id,
496                        "safe-mode-quarantined-existing-risky-job",
497                    );
498                    let hop = QueueHopReportV1::new(
499                        self.namespace.namespace_id.clone(),
500                        next_job.job_id.clone(),
501                        next_job.lease_id.clone(),
502                        QueueHopKindV1::Drained,
503                        Some(from_state),
504                        JobStateV1::Cancelled,
505                        "safe-mode-quarantined-existing-risky-job",
506                    );
507                    self.append_record_locked(QueueLogEntryV1 {
508                        sequence: 0,
509                        namespace_id: self.namespace.namespace_id.clone(),
510                        job: Some(next_job),
511                        lease: None,
512                        safe_mode_enabled: None,
513                        queue_hop_receipt: Some(hop),
514                        duplicate_suppression_receipt: None,
515                        safe_mode_receipt: None,
516                        recorded_at: Utc::now(),
517                    })?;
518                }
519            }
520        }
521        Ok(receipt)
522    }
523
524    pub fn drain_non_terminal(
525        &self,
526        reason: impl Into<String>,
527    ) -> Result<Vec<QueueHopReportV1>, QueueError> {
528        self.ensure_writer()?;
529        let reason = reason.into();
530        let jobs = self.snapshot()?.jobs;
531        let mut receipts = Vec::new();
532        for job in jobs {
533            if !job.state.is_terminal() {
534                receipts.push(self.transition_job(
535                    &job.job_id,
536                    JobStateV1::Cancelled,
537                    QueueHopKindV1::Drained,
538                    job.lease_id.clone(),
539                    reason.clone(),
540                    Utc::now(),
541                )?);
542            }
543        }
544        Ok(receipts)
545    }
546
547    pub fn snapshot(&self) -> Result<QueueSnapshotV1, QueueError> {
548        let records = self.read_records()?;
549        Ok(self.snapshot_from_records(&records))
550    }
551
552    fn snapshot_from_records(&self, records: &[QueueLogEntryV1]) -> QueueSnapshotV1 {
553        let mut jobs_by_id = BTreeMap::<ArtifactId, JobV1>::new();
554        let mut leases_by_id = BTreeMap::<ArtifactId, QueueLeaseV1>::new();
555        let mut safe_mode_enabled = self.namespace.safe_mode_enabled;
556        for record in records {
557            if record.namespace_id != self.namespace.namespace_id {
558                continue;
559            }
560            if let Some(job) = &record.job {
561                jobs_by_id.insert(job.job_id.clone(), job.clone());
562            }
563            if let Some(lease) = &record.lease {
564                leases_by_id.insert(lease.lease_id.clone(), lease.clone());
565            }
566            if let Some(enabled) = record.safe_mode_enabled {
567                safe_mode_enabled = enabled;
568            }
569        }
570        QueueSnapshotV1 {
571            namespace: self.namespace.clone().with_safe_mode(safe_mode_enabled),
572            jobs: jobs_by_id.into_values().collect(),
573            leases: leases_by_id.into_values().collect(),
574            safe_mode_enabled,
575            records_seen: records.len(),
576        }
577    }
578
579    fn transition_job(
580        &self,
581        job_id: &ArtifactId,
582        to_state: JobStateV1,
583        hop_kind: QueueHopKindV1,
584        lease_id: Option<ArtifactId>,
585        reason: impl Into<String>,
586        now: DateTime<Utc>,
587    ) -> Result<QueueHopReportV1, QueueError> {
588        self.ensure_writer()?;
589        let reason = reason.into();
590        let _lock = acquire_exclusive_lock(&self.log_path)?;
591        let records = self.read_records()?;
592        let snapshot = self.snapshot_from_records(&records);
593        let job = snapshot
594            .job(job_id)
595            .cloned()
596            .ok_or_else(|| QueueError::JobNotFound(job_id.0.clone()))?;
597        if job.state.is_terminal() {
598            return Err(QueueError::TerminalJob(job_id.0.clone()));
599        }
600        if to_state == JobStateV1::Completed {
601            let requested_lease_id = lease_id
602                .clone()
603                .ok_or_else(|| QueueError::LeaseRequired(job_id.0.clone()))?;
604            let current_lease_id = job
605                .lease_id
606                .clone()
607                .ok_or_else(|| QueueError::LeaseRequired(job_id.0.clone()))?;
608            if requested_lease_id != current_lease_id {
609                return Err(QueueError::LeaseMismatch {
610                    job_id: job_id.0.clone(),
611                    expected: current_lease_id.0,
612                    actual: requested_lease_id.0,
613                });
614            }
615            let lease = snapshot
616                .leases
617                .iter()
618                .find(|lease| lease.lease_id == requested_lease_id && lease.active)
619                .ok_or_else(|| QueueError::LeaseRequired(job_id.0.clone()))?;
620            if lease.is_expired_at(now) {
621                return Err(QueueError::LeaseExpired {
622                    job_id: job_id.0.clone(),
623                    lease_id: lease.lease_id.0.clone(),
624                });
625            }
626        }
627        let from_state = job.state.clone();
628        let next_job = job.with_state(to_state.clone(), lease_id.clone(), reason.clone());
629        let hop = QueueHopReportV1::new(
630            self.namespace.namespace_id.clone(),
631            next_job.job_id.clone(),
632            lease_id,
633            hop_kind,
634            Some(from_state),
635            to_state,
636            reason,
637        );
638        self.append_record_locked(QueueLogEntryV1 {
639            sequence: 0,
640            namespace_id: self.namespace.namespace_id.clone(),
641            job: Some(next_job),
642            lease: None,
643            safe_mode_enabled: None,
644            queue_hop_receipt: Some(hop.clone()),
645            duplicate_suppression_receipt: None,
646            safe_mode_receipt: None,
647            recorded_at: Utc::now(),
648        })?;
649        Ok(hop)
650    }
651
652    fn ensure_writer(&self) -> Result<(), QueueError> {
653        match &self.writer_owner {
654            Some(owner) if owner == &self.namespace.daemon_owner => Ok(()),
655            Some(owner) => Err(QueueError::WriterOwnerMismatch {
656                expected: self.namespace.daemon_owner.clone(),
657                actual: owner.clone(),
658            }),
659            None => Err(QueueError::ReadOnly),
660        }
661    }
662
663    fn append_record_locked(&self, mut record: QueueLogEntryV1) -> Result<(), QueueError> {
664        record.sequence = self.read_records()?.len() as u64 + 1;
665        let line = serde_json::to_string(&record).map_err(|source| QueueError::Json {
666            path: self.log_path.clone(),
667            source,
668        })?;
669        let mut file = OpenOptions::new()
670            .create(true)
671            .append(true)
672            .open(&self.log_path)
673            .map_err(|source| QueueError::Io {
674                path: self.log_path.clone(),
675                source,
676            })?;
677        writeln!(file, "{line}").map_err(|source| QueueError::Io {
678            path: self.log_path.clone(),
679            source,
680        })?;
681        file.sync_data().map_err(|source| QueueError::Io {
682            path: self.log_path.clone(),
683            source,
684        })?;
685        Ok(())
686    }
687
688    fn read_records(&self) -> Result<Vec<QueueLogEntryV1>, QueueError> {
689        let file = File::open(&self.log_path).map_err(|source| QueueError::Io {
690            path: self.log_path.clone(),
691            source,
692        })?;
693        let reader = BufReader::new(file);
694        let mut records = Vec::new();
695        for line in reader.lines() {
696            let line = line.map_err(|source| QueueError::Io {
697                path: self.log_path.clone(),
698                source,
699            })?;
700            if line.trim().is_empty() {
701                continue;
702            }
703            records.push(
704                serde_json::from_str::<QueueLogEntryV1>(&line).map_err(|source| {
705                    QueueError::Json {
706                        path: self.log_path.clone(),
707                        source,
708                    }
709                })?,
710            );
711        }
712        Ok(records)
713    }
714}
715
716fn ensure_file(path: &Path) -> Result<(), QueueError> {
717    if let Some(parent) = path.parent() {
718        std::fs::create_dir_all(parent).map_err(|source| QueueError::Io {
719            path: parent.to_path_buf(),
720            source,
721        })?;
722    }
723    OpenOptions::new()
724        .create(true)
725        .append(true)
726        .open(path)
727        .map_err(|source| QueueError::Io {
728            path: path.to_path_buf(),
729            source,
730        })?;
731    Ok(())
732}
733
734fn acquire_exclusive_lock(path: &Path) -> Result<ExclusiveQueueLock, QueueError> {
735    let lock_path = path.with_file_name(format!(
736        "{}.lock",
737        path.file_name()
738            .and_then(|name| name.to_str())
739            .unwrap_or("queue")
740    ));
741    if let Some(parent) = lock_path.parent() {
742        std::fs::create_dir_all(parent).map_err(|source| QueueError::Io {
743            path: parent.to_path_buf(),
744            source,
745        })?;
746    }
747    let started = Instant::now();
748    loop {
749        match OpenOptions::new()
750            .create_new(true)
751            .write(true)
752            .open(&lock_path)
753        {
754            Ok(mut file) => {
755                writeln!(
756                    file,
757                    "pid={} acquired_at={}",
758                    std::process::id(),
759                    Utc::now().to_rfc3339()
760                )
761                .map_err(|source| QueueError::Io {
762                    path: lock_path.clone(),
763                    source,
764                })?;
765                file.sync_all().map_err(|source| QueueError::Io {
766                    path: lock_path.clone(),
767                    source,
768                })?;
769                return Ok(ExclusiveQueueLock { lock_path });
770            }
771            Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
772                if started.elapsed() > StdDuration::from_secs(10) {
773                    return Err(QueueError::Io {
774                        path: lock_path,
775                        source: std::io::Error::new(
776                            std::io::ErrorKind::TimedOut,
777                            "queue lock still held",
778                        ),
779                    });
780                }
781                thread::sleep(StdDuration::from_millis(5));
782            }
783            Err(source) => {
784                return Err(QueueError::Io {
785                    path: lock_path,
786                    source,
787                });
788            }
789        }
790    }
791}
792
793struct ExclusiveQueueLock {
794    lock_path: PathBuf,
795}
796
797impl Drop for ExclusiveQueueLock {
798    fn drop(&mut self) {
799        let _ = std::fs::remove_file(&self.lock_path);
800    }
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use chrono::Duration;
807    use std::sync::{Arc, Barrier};
808
809    fn temp_root(name: &str) -> PathBuf {
810        let root = std::env::temp_dir().join(format!("aidens-p11-{name}-{}", std::process::id()));
811        let _ = std::fs::remove_dir_all(&root);
812        root
813    }
814
815    fn namespace(root: &Path) -> DaemonNamespaceV1 {
816        DaemonNamespaceV1::new("test-namespace", root.display().to_string(), "daemon-a")
817    }
818
819    #[test]
820    fn duplicate_schedule_occurrence_is_suppressed_by_idempotency_key() {
821        let root = temp_root("duplicate");
822        let ns = namespace(&root);
823        let queue = DurableQueueLogV1::open(&root, ns.clone(), "daemon-a").unwrap();
824        let occurrence = ScheduleOccurrenceV1::new(
825            ns.namespace_id.clone(),
826            "daily",
827            "2026-04-27T00:00:00Z",
828            Utc::now(),
829            serde_json::json!({"task":"refresh"}),
830            CanonicalToolSideEffectClass::ReadOnly,
831        );
832        assert!(occurrence.identity_is_not_timestamp_only());
833
834        let first = queue.enqueue_from_schedule(occurrence.clone()).unwrap();
835        let second = queue.enqueue_from_schedule(occurrence.clone()).unwrap();
836
837        assert!(first.enqueued);
838        assert!(!second.enqueued);
839        assert_eq!(
840            second
841                .duplicate_suppression_receipt
842                .as_ref()
843                .unwrap()
844                .idempotency_key,
845            occurrence.idempotency_key
846        );
847        assert_eq!(
848            queue
849                .snapshot()
850                .unwrap()
851                .logical_job_count_for(&occurrence.idempotency_key),
852            1
853        );
854        let _ = std::fs::remove_dir_all(&root);
855    }
856
857    #[test]
858    fn restart_preserves_queued_and_cancelled_jobs_without_resurrection() {
859        let root = temp_root("restart");
860        let ns = namespace(&root);
861        let queue = DurableQueueLogV1::open(&root, ns.clone(), "daemon-a").unwrap();
862        let job = JobV1::new(
863            ns.namespace_id.clone(),
864            "manual:job-1",
865            "manual",
866            serde_json::json!({"task":"one"}),
867            CanonicalToolSideEffectClass::ReadOnly,
868            None,
869            None,
870        );
871        let enqueued = queue.enqueue_job(job).unwrap().job.unwrap();
872        queue
873            .cancel_job(&enqueued.job_id, "operator-cancelled")
874            .unwrap();
875
876        let reopened = DurableQueueLogV1::open(&root, ns, "daemon-a").unwrap();
877        let snapshot = reopened.snapshot().unwrap();
878        let restored = snapshot.job(&enqueued.job_id).unwrap();
879        assert_eq!(restored.state, JobStateV1::Cancelled);
880        assert!(reopened
881            .acquire_next_lease("daemon-a", 30)
882            .unwrap()
883            .is_none());
884        let _ = std::fs::remove_dir_all(&root);
885    }
886
887    #[test]
888    fn safe_mode_blocks_risky_jobs_and_allows_inspection_and_drain() {
889        let root = temp_root("safe-mode");
890        let ns = namespace(&root);
891        let queue = DurableQueueLogV1::open(&root, ns.clone(), "daemon-a").unwrap();
892        let safe = queue.set_safe_mode(true, "operator-safe-mode").unwrap();
893        assert!(safe.blocks_new_risky_jobs_but_allows_drain());
894
895        let blocked = queue
896            .enqueue_job(JobV1::new(
897                ns.namespace_id.clone(),
898                "shell:1",
899                "manual",
900                serde_json::json!({"cmd":"cargo check"}),
901                CanonicalToolSideEffectClass::Admin,
902                None,
903                None,
904            ))
905            .unwrap();
906        assert!(!blocked.enqueued);
907        assert!(blocked.safe_mode_receipt.is_some());
908
909        let read_only = queue
910            .enqueue_job(JobV1::new(
911                ns.namespace_id,
912                "inspect:1",
913                "manual",
914                serde_json::json!({"inspect":true}),
915                CanonicalToolSideEffectClass::ReadOnly,
916                None,
917                None,
918            ))
919            .unwrap();
920        assert!(read_only.enqueued);
921        assert_eq!(queue.snapshot().unwrap().jobs.len(), 1);
922        assert_eq!(
923            queue.drain_non_terminal("safe-mode-drain").unwrap().len(),
924            1
925        );
926        assert_eq!(
927            queue.snapshot().unwrap().jobs[0].state,
928            JobStateV1::Cancelled
929        );
930        let _ = std::fs::remove_dir_all(&root);
931    }
932
933    #[test]
934    fn expired_lease_can_be_stolen_once_without_duplicate_job() {
935        let root = temp_root("lease");
936        let ns = namespace(&root);
937        let queue = DurableQueueLogV1::open(&root, ns.clone(), "daemon-a").unwrap();
938        let job = JobV1::new(
939            ns.namespace_id,
940            "lease:job",
941            "manual",
942            serde_json::json!({"work":true}),
943            CanonicalToolSideEffectClass::ReadOnly,
944            None,
945            None,
946        );
947        let enqueued = queue.enqueue_job(job).unwrap().job.unwrap();
948        let first = queue.acquire_next_lease("daemon-a", 1).unwrap().unwrap();
949        let stolen = queue
950            .acquire_next_lease_at("daemon-b", 30, Utc::now() + Duration::seconds(5))
951            .unwrap()
952            .unwrap();
953        assert_eq!(stolen.job.job_id, enqueued.job_id);
954        assert_eq!(stolen.lease.stolen_from, Some(first.lease.lease_id));
955        assert_eq!(queue.snapshot().unwrap().jobs.len(), 1);
956        let _ = std::fs::remove_dir_all(&root);
957    }
958
959    #[test]
960    fn concurrent_enqueue_is_single_writer_and_idempotent() {
961        let root = temp_root("concurrent-enqueue");
962        let ns = namespace(&root);
963        let queue = Arc::new(DurableQueueLogV1::open(&root, ns.clone(), "daemon-a").unwrap());
964        let barrier = Arc::new(Barrier::new(16));
965        let mut handles = Vec::new();
966
967        for _ in 0..16 {
968            let queue = Arc::clone(&queue);
969            let barrier = Arc::clone(&barrier);
970            let namespace_id = ns.namespace_id.clone();
971            handles.push(std::thread::spawn(move || {
972                barrier.wait();
973                let job = JobV1::new(
974                    namespace_id,
975                    "concurrent:one-logical-job",
976                    "manual",
977                    serde_json::json!({"work":true}),
978                    CanonicalToolSideEffectClass::ReadOnly,
979                    None,
980                    None,
981                );
982                queue.enqueue_job(job).unwrap().enqueued
983            }));
984        }
985
986        let enqueued_count = handles
987            .into_iter()
988            .map(|handle| handle.join().unwrap())
989            .filter(|enqueued| *enqueued)
990            .count();
991        let snapshot = queue.snapshot().unwrap();
992        let records = queue.read_records().unwrap();
993
994        assert_eq!(enqueued_count, 1);
995        assert_eq!(
996            snapshot.logical_job_count_for("concurrent:one-logical-job"),
997            1
998        );
999        assert_eq!(snapshot.jobs.len(), 1);
1000        assert_eq!(records.len(), 16);
1001        assert_eq!(
1002            records
1003                .iter()
1004                .map(|record| record.sequence)
1005                .collect::<Vec<_>>(),
1006            (1..=records.len() as u64).collect::<Vec<_>>()
1007        );
1008        let _ = std::fs::remove_dir_all(&root);
1009    }
1010
1011    #[test]
1012    fn concurrent_lease_acquisition_grants_one_active_lease() {
1013        let root = temp_root("concurrent-lease");
1014        let ns = namespace(&root);
1015        let queue = Arc::new(DurableQueueLogV1::open(&root, ns.clone(), "daemon-a").unwrap());
1016        queue
1017            .enqueue_job(JobV1::new(
1018                ns.namespace_id,
1019                "lease:single-active",
1020                "manual",
1021                serde_json::json!({"work":true}),
1022                CanonicalToolSideEffectClass::ReadOnly,
1023                None,
1024                None,
1025            ))
1026            .unwrap();
1027
1028        let barrier = Arc::new(Barrier::new(12));
1029        let mut handles = Vec::new();
1030        for worker in 0..12 {
1031            let queue = Arc::clone(&queue);
1032            let barrier = Arc::clone(&barrier);
1033            handles.push(std::thread::spawn(move || {
1034                barrier.wait();
1035                queue
1036                    .acquire_next_lease(format!("daemon-worker-{worker}"), 30)
1037                    .unwrap()
1038                    .is_some()
1039            }));
1040        }
1041
1042        let lease_count = handles
1043            .into_iter()
1044            .map(|handle| handle.join().unwrap())
1045            .filter(|leased| *leased)
1046            .count();
1047        let snapshot = queue.snapshot().unwrap();
1048
1049        assert_eq!(lease_count, 1);
1050        assert_eq!(snapshot.jobs.len(), 1);
1051        assert_eq!(snapshot.jobs[0].state, JobStateV1::Leased);
1052        assert_eq!(
1053            snapshot.leases.iter().filter(|lease| lease.active).count(),
1054            1
1055        );
1056        let _ = std::fs::remove_dir_all(&root);
1057    }
1058
1059    #[test]
1060    fn late_completion_after_ttl_is_rejected_and_job_stays_leased() {
1061        let root = temp_root("late-completion");
1062        let ns = namespace(&root);
1063        let queue = DurableQueueLogV1::open(&root, ns.clone(), "daemon-a").unwrap();
1064        queue
1065            .enqueue_job(JobV1::new(
1066                ns.namespace_id,
1067                "lease:late-completion",
1068                "manual",
1069                serde_json::json!({"work":true}),
1070                CanonicalToolSideEffectClass::ReadOnly,
1071                None,
1072                None,
1073            ))
1074            .unwrap();
1075
1076        let now = Utc::now();
1077        let leased = queue
1078            .acquire_next_lease_at("daemon-a", 1, now)
1079            .unwrap()
1080            .unwrap();
1081        let err = queue
1082            .complete_job_at(
1083                &leased.job.job_id,
1084                Some(leased.lease.lease_id.clone()),
1085                now + Duration::seconds(2),
1086            )
1087            .unwrap_err();
1088        assert!(matches!(err, QueueError::LeaseExpired { .. }));
1089
1090        let snapshot = queue.snapshot().unwrap();
1091        let job = snapshot.job(&leased.job.job_id).unwrap();
1092        assert_eq!(job.state, JobStateV1::Leased);
1093        assert_eq!(job.lease_id, Some(leased.lease.lease_id));
1094        let _ = std::fs::remove_dir_all(&root);
1095    }
1096
1097    #[test]
1098    fn stale_lease_cannot_complete_after_lease_is_stolen() {
1099        let root = temp_root("stale-completion");
1100        let ns = namespace(&root);
1101        let queue = DurableQueueLogV1::open(&root, ns.clone(), "daemon-a").unwrap();
1102        queue
1103            .enqueue_job(JobV1::new(
1104                ns.namespace_id,
1105                "lease:stale-completion",
1106                "manual",
1107                serde_json::json!({"work":true}),
1108                CanonicalToolSideEffectClass::ReadOnly,
1109                None,
1110                None,
1111            ))
1112            .unwrap();
1113        let first = queue.acquire_next_lease("daemon-a", 1).unwrap().unwrap();
1114        let second = queue
1115            .acquire_next_lease_at("daemon-b", 30, Utc::now() + Duration::seconds(5))
1116            .unwrap()
1117            .unwrap();
1118
1119        let err = queue
1120            .complete_job_at(
1121                &first.job.job_id,
1122                Some(first.lease.lease_id.clone()),
1123                Utc::now() + Duration::seconds(6),
1124            )
1125            .unwrap_err();
1126        assert!(matches!(err, QueueError::LeaseMismatch { .. }));
1127        assert_eq!(
1128            queue
1129                .snapshot()
1130                .unwrap()
1131                .job(&first.job.job_id)
1132                .unwrap()
1133                .lease_id,
1134            Some(second.lease.lease_id)
1135        );
1136        let _ = std::fs::remove_dir_all(&root);
1137    }
1138
1139    #[test]
1140    fn entering_safe_mode_quarantines_existing_risky_jobs() {
1141        let root = temp_root("safe-mode-existing");
1142        let ns = namespace(&root);
1143        let queue = DurableQueueLogV1::open(&root, ns.clone(), "daemon-a").unwrap();
1144        let risky = queue
1145            .enqueue_job(JobV1::new(
1146                ns.namespace_id.clone(),
1147                "admin:queued-before-safe-mode",
1148                "manual",
1149                serde_json::json!({"cmd":"write"}),
1150                CanonicalToolSideEffectClass::Admin,
1151                None,
1152                None,
1153            ))
1154            .unwrap()
1155            .job
1156            .unwrap();
1157        let read_only = queue
1158            .enqueue_job(JobV1::new(
1159                ns.namespace_id,
1160                "read:queued-before-safe-mode",
1161                "manual",
1162                serde_json::json!({"inspect":true}),
1163                CanonicalToolSideEffectClass::ReadOnly,
1164                None,
1165                None,
1166            ))
1167            .unwrap()
1168            .job
1169            .unwrap();
1170
1171        queue.set_safe_mode(true, "operator-safe-mode").unwrap();
1172
1173        let snapshot = queue.snapshot().unwrap();
1174        let risky_after = snapshot.job(&risky.job_id).unwrap();
1175        let read_after = snapshot.job(&read_only.job_id).unwrap();
1176        assert_eq!(risky_after.state, JobStateV1::Cancelled);
1177        assert!(risky_after
1178            .reason_codes
1179            .contains(&"safe-mode-quarantined-existing-risky-job".to_string()));
1180        assert_eq!(read_after.state, JobStateV1::Queued);
1181        let _ = std::fs::remove_dir_all(&root);
1182    }
1183}