1use 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}