1use crate::agents::OperatorAnnotation;
8use serde::Serialize;
9use std::collections::VecDeque;
10use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
11use std::time::{Duration, Instant};
12use tokio::sync::RwLock;
13use utoipa::ToSchema;
14
15#[async_trait::async_trait]
25pub trait AckHandle: Send + Sync {
26 async fn ack(&self) -> anyhow::Result<()>;
28}
29
30pub struct NatsAckHandle(pub async_nats::jetstream::Message);
32
33#[async_trait::async_trait]
34impl AckHandle for NatsAckHandle {
35 async fn ack(&self) -> anyhow::Result<()> {
36 self.0
37 .ack()
38 .await
39 .map_err(|e| anyhow::anyhow!("ack failed: {}", e))
40 }
41}
42
43pub struct PreAckedHandle;
52
53#[async_trait::async_trait]
54impl AckHandle for PreAckedHandle {
55 async fn ack(&self) -> anyhow::Result<()> {
56 Ok(()) }
58}
59
60pub struct BufferedResponse {
62 pub id: String,
64 pub action: String,
66 pub job_id: String,
68 pub round: u32,
70 pub reply_subject: String,
72 pub payload: Vec<u8>,
74 pub created_at: Instant,
76 pub release_at: Instant,
78 pub ack_handle: Box<dyn AckHandle>,
80 pub msg_id: String,
82 pub annotations: Vec<OperatorAnnotation>,
84 pub edited: bool,
86 pub stopped: bool,
93}
94
95#[derive(Debug, Clone, Serialize, ToSchema)]
101pub struct BufferEntrySummary {
102 pub id: String,
104 pub action: String,
106 pub job_id: String,
108 pub round: u32,
110 pub age_ms: u64,
112 pub release_in_ms: i64,
114 pub stopped: bool,
116}
117
118#[derive(Debug, Clone, Serialize, ToSchema)]
123pub struct BufferEntryDetail {
124 #[serde(flatten)]
125 #[schema(inline)]
126 pub summary: BufferEntrySummary,
127 pub content: serde_json::Value,
130}
131
132pub struct ResponseBuffer {
142 pending: RwLock<VecDeque<BufferedResponse>>,
143 base_hold_duration_ms: u64,
145 hold_duration_ms: AtomicU64,
148 paused: AtomicBool,
149 response_sla_ms: AtomicU64,
154 auto_approve: AtomicBool,
159 auto_approve_threshold_milli: AtomicU64,
182}
183
184impl ResponseBuffer {
185 pub fn new(hold_duration: Duration) -> Self {
194 let ms = hold_duration.as_millis() as u64;
195 Self {
196 pending: RwLock::new(VecDeque::new()),
197 base_hold_duration_ms: ms,
198 hold_duration_ms: AtomicU64::new(ms),
199 paused: AtomicBool::new(false),
200 response_sla_ms: AtomicU64::new(ms),
201 auto_approve: AtomicBool::new(true),
202 auto_approve_threshold_milli: AtomicU64::new(1000), }
204 }
205
206 pub fn hold_duration(&self) -> Duration {
208 Duration::from_millis(self.hold_duration_ms.load(Ordering::Relaxed))
209 }
210
211 pub fn base_hold_duration(&self) -> Duration {
213 Duration::from_millis(self.base_hold_duration_ms)
214 }
215
216 pub fn set_hold_duration(&self, duration: Duration) {
221 self.hold_duration_ms
222 .store(duration.as_millis() as u64, Ordering::Relaxed);
223 }
224
225 pub fn set_response_sla(&self, sla: Duration) {
230 self.response_sla_ms
231 .store(sla.as_millis() as u64, Ordering::Relaxed);
232 }
233
234 pub fn response_sla(&self) -> Option<Duration> {
236 let ms = self.response_sla_ms.load(Ordering::Relaxed);
237 if ms == 0 {
238 None
239 } else {
240 Some(Duration::from_millis(ms))
241 }
242 }
243
244 pub async fn push(&self, entry: BufferedResponse) {
246 self.pending.write().await.push_back(entry);
247 }
248
249 pub async fn push_with_deadline(&self, mut entry: BufferedResponse, task_received: Instant) {
262 if self.auto_approve.load(Ordering::Relaxed) {
265 entry.release_at = Instant::now();
266 self.pending.write().await.push_back(entry);
267 return;
268 }
269 let sla_ms = self.response_sla_ms.load(Ordering::Relaxed);
270 if sla_ms > 0 {
271 let sla = Duration::from_millis(sla_ms);
272 let deadline = task_received + sla;
273 let now = Instant::now();
274 entry.release_at = if deadline > now { deadline } else { now };
275 } else {
276 entry.release_at = Instant::now();
277 }
278 self.pending.write().await.push_back(entry);
279 }
280
281 pub async fn drain_ready(&self) -> Vec<BufferedResponse> {
286 if self.paused.load(Ordering::Relaxed) {
287 return Vec::new();
288 }
289 let now = Instant::now();
290 let mut pending = self.pending.write().await;
291 let mut ready = Vec::new();
292 let mut remaining = VecDeque::with_capacity(pending.len());
293 for entry in pending.drain(..) {
294 if now >= entry.release_at && !entry.stopped {
295 ready.push(entry);
296 } else {
297 remaining.push_back(entry);
298 }
299 }
300 *pending = remaining;
301 ready
302 }
303
304 pub async fn list(&self) -> Vec<BufferEntrySummary> {
306 let now = Instant::now();
307 let pending = self.pending.read().await;
308 pending
309 .iter()
310 .map(|entry| {
311 let age = now.duration_since(entry.created_at);
312 let release_in = if now >= entry.release_at {
313 -(now.duration_since(entry.release_at).as_millis() as i64)
314 } else {
315 entry.release_at.duration_since(now).as_millis() as i64
316 };
317 BufferEntrySummary {
318 id: entry.id.clone(),
319 action: entry.action.clone(),
320 job_id: entry.job_id.clone(),
321 round: entry.round,
322 age_ms: age.as_millis() as u64,
323 release_in_ms: release_in,
324 stopped: entry.stopped,
325 }
326 })
327 .collect()
328 }
329
330 pub async fn release(&self, id: &str) -> Option<BufferedResponse> {
334 let mut pending = self.pending.write().await;
335 if let Some(pos) = pending.iter().position(|e| e.id == id) {
336 pending.remove(pos)
337 } else {
338 None
339 }
340 }
341
342 pub async fn reject(&self, id: &str) -> Option<BufferedResponse> {
347 self.release(id).await
349 }
350
351 pub async fn drain_stale(&self, current_job_id: &str) -> Vec<BufferedResponse> {
357 let mut pending = self.pending.write().await;
358 let mut stale = Vec::new();
359 let mut remaining = VecDeque::with_capacity(pending.len());
360 for entry in pending.drain(..) {
361 if entry.job_id != current_job_id {
362 stale.push(entry);
363 } else {
364 remaining.push_back(entry);
365 }
366 }
367 *pending = remaining;
368 stale
369 }
370
371 pub fn pause(&self) {
374 self.paused.store(true, Ordering::Relaxed);
375 }
376
377 pub fn resume(&self) {
379 self.paused.store(false, Ordering::Relaxed);
380 }
381
382 pub fn is_paused(&self) -> bool {
384 self.paused.load(Ordering::Relaxed)
385 }
386
387 pub fn set_auto_approve(&self, enabled: bool) {
395 self.auto_approve.store(enabled, Ordering::Relaxed);
396 }
397
398 pub fn is_auto_approve(&self) -> bool {
400 self.auto_approve.load(Ordering::Relaxed)
401 }
402
403 pub fn set_auto_approve_threshold(&self, threshold: f32) {
407 let clamped = threshold.clamp(0.0, 1.0);
408 self.auto_approve_threshold_milli
409 .store((clamped * 1000.0) as u64, Ordering::Relaxed);
410 }
411
412 pub fn auto_approve_threshold(&self) -> f32 {
414 self.auto_approve_threshold_milli.load(Ordering::Relaxed) as f32 / 1000.0
415 }
416
417 pub async fn auto_release_if_eligible(&self, divergence: Option<f32>) -> usize {
430 if !self.auto_approve.load(Ordering::Relaxed) {
431 return 0;
432 }
433 if let Some(div) = divergence {
436 if div > self.auto_approve_threshold() {
437 return 0; }
439 }
440
441 let now = Instant::now();
442 let mut pending = self.pending.write().await;
443 let mut count = 0;
444 for entry in pending.iter_mut() {
445 if !entry.stopped && entry.release_at > now {
446 entry.release_at = now;
447 count += 1;
448 }
449 }
450 count
451 }
452
453 pub async fn len(&self) -> usize {
455 self.pending.read().await.len()
456 }
457
458 pub async fn is_empty(&self) -> bool {
460 self.pending.read().await.is_empty()
461 }
462
463 pub async fn get_detail(&self, id: &str) -> Option<BufferEntryDetail> {
466 let now = Instant::now();
467 let pending = self.pending.read().await;
468 pending.iter().find(|e| e.id == id).map(|entry| {
469 let age = now.duration_since(entry.created_at);
470 let release_in = if now >= entry.release_at {
471 -(now.duration_since(entry.release_at).as_millis() as i64)
472 } else {
473 entry.release_at.duration_since(now).as_millis() as i64
474 };
475 let content = serde_json::from_slice(&entry.payload).unwrap_or(serde_json::Value::Null);
476 BufferEntryDetail {
477 summary: BufferEntrySummary {
478 id: entry.id.clone(),
479 action: entry.action.clone(),
480 job_id: entry.job_id.clone(),
481 round: entry.round,
482 age_ms: age.as_millis() as u64,
483 release_in_ms: release_in,
484 stopped: entry.stopped,
485 },
486 content,
487 }
488 })
489 }
490
491 pub async fn update_payload(&self, id: &str, new_payload: Vec<u8>) -> bool {
495 let mut pending = self.pending.write().await;
496 if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
497 entry.payload = new_payload;
498 true
499 } else {
500 false
501 }
502 }
503
504 pub async fn add_comment(&self, id: &str, annotation: OperatorAnnotation) -> bool {
508 let mut pending = self.pending.write().await;
509 if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
510 entry.annotations.push(annotation);
511 true
512 } else {
513 false
514 }
515 }
516
517 pub async fn mark_for_release(&self, id: &str) -> bool {
529 let mut pending = self.pending.write().await;
530 if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
531 entry.release_at = Instant::now();
532 true
533 } else {
534 false
535 }
536 }
537
538 pub async fn force_release(&self, id: &str) -> bool {
546 let mut pending = self.pending.write().await;
547 if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
548 entry.stopped = false;
549 entry.release_at = Instant::now();
550 true
551 } else {
552 false
553 }
554 }
555
556 pub async fn stop(&self, id: &str) -> bool {
564 let mut pending = self.pending.write().await;
565 if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
566 entry.stopped = true;
567 true
568 } else {
569 false
570 }
571 }
572
573 pub async fn unstop(&self, id: &str) -> bool {
580 let mut pending = self.pending.write().await;
581 if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
582 entry.stopped = false;
583 true
584 } else {
585 false
586 }
587 }
588
589 pub async fn update_payload_with_annotation(
594 &self,
595 id: &str,
596 new_payload: Vec<u8>,
597 annotation: OperatorAnnotation,
598 ) -> bool {
599 let mut pending = self.pending.write().await;
600 if let Some(entry) = pending.iter_mut().find(|e| e.id == id) {
601 entry.payload = new_payload;
602 entry.edited = true;
603 entry.annotations.push(annotation);
604 true
605 } else {
606 false
607 }
608 }
609}
610
611pub fn compute_adaptive_hold(
643 base: Duration,
644 mean_score: Option<f32>,
645 amplification: f32,
646) -> Duration {
647 let Some(score) = mean_score else {
648 return base;
649 };
650 let positive = soft_normalize_positive(score);
651 let multiplier = 1.0 + (1.0 - positive) * amplification;
652 Duration::from_secs_f64(base.as_secs_f64() * multiplier as f64)
653}
654
655fn soft_normalize_positive(score: f32) -> f32 {
678 let soft = score / (1.0 + score.abs());
679 ((soft + 1.0) / 2.0).clamp(0.0, 1.0)
680}
681
682pub fn compute_divergence(mean_score: Option<f32>, score_std_dev: Option<f32>) -> Option<f32> {
683 let score_div = mean_score.map(|s| 1.0 - soft_normalize_positive(s));
684 let std_div = score_std_dev.map(|sd| sd.clamp(0.0, 1.0));
685 match (score_div, std_div) {
686 (Some(a), Some(b)) => Some(a.max(b)),
687 (Some(a), None) => Some(a),
688 (None, Some(b)) => Some(b),
689 (None, None) => None,
690 }
691}
692
693#[cfg(test)]
698mod tests {
699 use super::*;
700
701 struct NoopAckHandle;
703
704 #[async_trait::async_trait]
705 impl AckHandle for NoopAckHandle {
706 async fn ack(&self) -> anyhow::Result<()> {
707 Ok(())
708 }
709 }
710
711 fn make_entry(id: &str, action: &str, job_id: &str, hold: Duration) -> BufferedResponse {
713 let now = Instant::now();
714 BufferedResponse {
715 id: id.to_string(),
716 action: action.to_string(),
717 job_id: job_id.to_string(),
718 round: 1,
719 reply_subject: format!("nsed.{}.result.1.agent.{}", job_id, action),
720 payload: b"{}".to_vec(),
721 created_at: now,
722 release_at: now + hold,
723 ack_handle: Box::new(NoopAckHandle),
724 msg_id: format!("msg-{}", id),
725 annotations: Vec::new(),
726 edited: false,
727 stopped: false,
728 }
729 }
730
731 #[tokio::test]
732 async fn test_buffer_push_and_len() {
733 let buf = ResponseBuffer::new(Duration::from_secs(10));
734 assert_eq!(buf.len().await, 0);
735 assert!(buf.is_empty().await);
736
737 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(10)))
738 .await;
739 buf.push(make_entry(
740 "b",
741 "evaluate",
742 "job-2",
743 Duration::from_secs(10),
744 ))
745 .await;
746 assert_eq!(buf.len().await, 2);
747 assert!(!buf.is_empty().await);
748 }
749
750 #[tokio::test]
751 async fn test_buffer_drain_respects_hold_duration() {
752 let buf = ResponseBuffer::new(Duration::from_secs(60));
753 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
754 .await;
755
756 let drained = buf.drain_ready().await;
758 assert!(drained.is_empty());
759 assert_eq!(buf.len().await, 1);
760 }
761
762 #[tokio::test]
763 async fn test_buffer_drain_releases_ready() {
764 let buf = ResponseBuffer::new(Duration::ZERO);
765 buf.push(make_entry("a", "propose", "job-1", Duration::ZERO))
767 .await;
768 buf.push(make_entry("b", "evaluate", "job-2", Duration::ZERO))
769 .await;
770
771 let drained = buf.drain_ready().await;
772 assert_eq!(drained.len(), 2);
773 assert!(buf.is_empty().await);
774 }
775
776 #[tokio::test]
777 async fn test_buffer_pause_stops_drain() {
778 let buf = ResponseBuffer::new(Duration::ZERO);
779 buf.push(make_entry("a", "propose", "job-1", Duration::ZERO))
780 .await;
781
782 buf.pause();
783 assert!(buf.is_paused());
784
785 let drained = buf.drain_ready().await;
786 assert!(drained.is_empty(), "paused buffer should not drain");
787 assert_eq!(buf.len().await, 1, "entry should still be in buffer");
788 }
789
790 #[tokio::test]
791 async fn test_buffer_resume_releases_overdue() {
792 let buf = ResponseBuffer::new(Duration::ZERO);
793 buf.push(make_entry("a", "propose", "job-1", Duration::ZERO))
794 .await;
795
796 buf.pause();
797 let drained = buf.drain_ready().await;
798 assert!(drained.is_empty());
799
800 buf.resume();
801 assert!(!buf.is_paused());
802 let drained = buf.drain_ready().await;
803 assert_eq!(drained.len(), 1);
804 }
805
806 #[tokio::test]
807 async fn test_buffer_release_by_id() {
808 let buf = ResponseBuffer::new(Duration::from_secs(60));
809 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
810 .await;
811 buf.push(make_entry(
812 "b",
813 "evaluate",
814 "job-2",
815 Duration::from_secs(60),
816 ))
817 .await;
818
819 let released = buf.release("a").await;
820 assert!(released.is_some());
821 assert_eq!(released.unwrap().id, "a");
822 assert_eq!(buf.len().await, 1);
823 }
824
825 #[tokio::test]
826 async fn test_buffer_reject_by_id() {
827 let buf = ResponseBuffer::new(Duration::from_secs(60));
828 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
829 .await;
830
831 let rejected = buf.reject("a").await;
832 assert!(rejected.is_some());
833 assert_eq!(rejected.unwrap().id, "a");
834 assert!(buf.is_empty().await);
835 }
836
837 #[tokio::test]
838 async fn test_buffer_release_unknown_id() {
839 let buf = ResponseBuffer::new(Duration::from_secs(60));
840 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
841 .await;
842
843 let released = buf.release("nonexistent").await;
844 assert!(released.is_none());
845 assert_eq!(buf.len().await, 1, "existing entry should remain");
846 }
847
848 #[tokio::test]
849 async fn test_buffer_list_returns_summaries() {
850 let buf = ResponseBuffer::new(Duration::from_secs(30));
851 buf.push(make_entry(
852 "entry-1",
853 "propose",
854 "job-abcd1234",
855 Duration::from_secs(30),
856 ))
857 .await;
858
859 let list = buf.list().await;
860 assert_eq!(list.len(), 1);
861 assert_eq!(list[0].id, "entry-1");
862 assert_eq!(list[0].action, "propose");
863 assert_eq!(list[0].job_id, "job-abcd1234");
864 assert_eq!(list[0].round, 1);
865 assert!(list[0].release_in_ms > 0, "should still be holding");
866 }
867
868 #[tokio::test]
869 async fn test_buffer_zero_hold_drains_immediately() {
870 let buf = ResponseBuffer::new(Duration::ZERO);
871 for i in 0..5 {
872 buf.push(make_entry(
873 &format!("e{}", i),
874 "propose",
875 &format!("job-{}", i),
876 Duration::ZERO,
877 ))
878 .await;
879 }
880 let drained = buf.drain_ready().await;
881 assert_eq!(drained.len(), 5);
882 assert!(buf.is_empty().await);
883 }
884
885 #[tokio::test]
886 async fn test_get_detail_returns_content() {
887 let buf = ResponseBuffer::new(Duration::from_secs(30));
888 let payload = serde_json::json!({"title": "My proposal", "content": "Hello world"});
889 let now = Instant::now();
890 buf.push(BufferedResponse {
891 id: "detail-1".to_string(),
892 action: "propose".to_string(),
893 job_id: "job-xyz".to_string(),
894 round: 3,
895 reply_subject: "nsed.job-xyz.result.3.agent.propose".to_string(),
896 payload: serde_json::to_vec(&payload).unwrap(),
897 created_at: now,
898 release_at: now + Duration::from_secs(30),
899 ack_handle: Box::new(NoopAckHandle),
900 msg_id: "msg-detail-1".to_string(),
901 annotations: Vec::new(),
902 edited: false,
903 stopped: false,
904 })
905 .await;
906
907 let detail = buf.get_detail("detail-1").await;
908 assert!(detail.is_some());
909 let detail = detail.unwrap();
910 assert_eq!(detail.summary.id, "detail-1");
911 assert_eq!(detail.summary.action, "propose");
912 assert_eq!(detail.summary.job_id, "job-xyz");
913 assert_eq!(detail.summary.round, 3);
914 assert!(detail.summary.release_in_ms > 0);
915 assert_eq!(detail.content["title"], "My proposal");
916 assert_eq!(detail.content["content"], "Hello world");
917 }
918
919 #[tokio::test]
920 async fn test_get_detail_returns_none_for_unknown_id() {
921 let buf = ResponseBuffer::new(Duration::from_secs(30));
922 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(30)))
923 .await;
924 assert!(buf.get_detail("nonexistent").await.is_none());
925 }
926
927 #[tokio::test]
928 async fn test_get_detail_invalid_payload_returns_null_content() {
929 let buf = ResponseBuffer::new(Duration::from_secs(30));
930 let now = Instant::now();
931 buf.push(BufferedResponse {
932 id: "bad-json".to_string(),
933 action: "propose".to_string(),
934 job_id: "job-1".to_string(),
935 round: 1,
936 reply_subject: "nsed.job-1.result.1.agent.propose".to_string(),
937 payload: b"not valid json!".to_vec(),
938 created_at: now,
939 release_at: now + Duration::from_secs(30),
940 ack_handle: Box::new(NoopAckHandle),
941 msg_id: "msg-bad".to_string(),
942 annotations: Vec::new(),
943 edited: false,
944 stopped: false,
945 })
946 .await;
947
948 let detail = buf.get_detail("bad-json").await.unwrap();
949 assert_eq!(detail.content, serde_json::Value::Null);
950 }
951
952 #[tokio::test]
953 async fn test_update_payload_replaces_content() {
954 let buf = ResponseBuffer::new(Duration::from_secs(30));
955 buf.push(make_entry(
956 "upd-1",
957 "evaluate",
958 "job-1",
959 Duration::from_secs(30),
960 ))
961 .await;
962
963 let new_payload = serde_json::json!({"scores": [8, 9, 7]});
964 let updated = buf
965 .update_payload("upd-1", serde_json::to_vec(&new_payload).unwrap())
966 .await;
967 assert!(updated);
968
969 let detail = buf.get_detail("upd-1").await.unwrap();
971 assert_eq!(detail.content["scores"], serde_json::json!([8, 9, 7]));
972 }
973
974 #[tokio::test]
975 async fn test_update_payload_unknown_id_returns_false() {
976 let buf = ResponseBuffer::new(Duration::from_secs(30));
977 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(30)))
978 .await;
979
980 let result = buf.update_payload("nonexistent", b"{}".to_vec()).await;
981 assert!(!result);
982 assert_eq!(buf.len().await, 1);
984 }
985
986 #[tokio::test]
987 async fn test_add_comment_records_annotation() {
988 use crate::agents::{AnnotationType, OperatorAnnotation};
989
990 let buf = ResponseBuffer::new(Duration::from_secs(30));
991 buf.push(make_entry(
992 "ann-1",
993 "propose",
994 "job-1",
995 Duration::from_secs(30),
996 ))
997 .await;
998
999 let annotation = OperatorAnnotation {
1000 annotation_type: AnnotationType::Comment,
1001 comment: "Looks good".to_string(),
1002 timestamp: "2026-03-02T12:00:00Z".to_string(),
1003 original_content_hash: None,
1004 };
1005
1006 assert!(buf.add_comment("ann-1", annotation).await);
1007
1008 assert_eq!(buf.len().await, 1);
1011 }
1012
1013 #[tokio::test]
1014 async fn test_add_comment_unknown_id_returns_false() {
1015 use crate::agents::{AnnotationType, OperatorAnnotation};
1016
1017 let buf = ResponseBuffer::new(Duration::from_secs(30));
1018 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(30)))
1019 .await;
1020
1021 let annotation = OperatorAnnotation {
1022 annotation_type: AnnotationType::Comment,
1023 comment: "test".to_string(),
1024 timestamp: "2026-03-02T12:00:00Z".to_string(),
1025 original_content_hash: None,
1026 };
1027
1028 assert!(!buf.add_comment("nonexistent", annotation).await);
1029 }
1030
1031 #[tokio::test]
1032 async fn test_update_payload_with_annotation_marks_edited() {
1033 use crate::agents::{AnnotationType, OperatorAnnotation};
1034
1035 let buf = ResponseBuffer::new(Duration::ZERO);
1036 buf.push(make_entry("edit-1", "propose", "job-1", Duration::ZERO))
1037 .await;
1038
1039 let annotation = OperatorAnnotation {
1040 annotation_type: AnnotationType::Edit,
1041 comment: "Fixed wording".to_string(),
1042 timestamp: "2026-03-02T12:00:00Z".to_string(),
1043 original_content_hash: Some("abc123".to_string()),
1044 };
1045
1046 let new_payload = serde_json::json!({"content": "edited"});
1047 assert!(
1048 buf.update_payload_with_annotation(
1049 "edit-1",
1050 serde_json::to_vec(&new_payload).unwrap(),
1051 annotation
1052 )
1053 .await
1054 );
1055
1056 let drained = buf.drain_ready().await;
1058 assert_eq!(drained.len(), 1);
1059 let entry = &drained[0];
1060 assert!(entry.edited);
1061 assert_eq!(entry.annotations.len(), 1);
1062 assert_eq!(entry.annotations[0].annotation_type, AnnotationType::Edit);
1063 assert_eq!(entry.annotations[0].comment, "Fixed wording");
1064 }
1065
1066 #[tokio::test]
1067 async fn test_buffer_concurrent_push_drain() {
1068 use std::sync::Arc;
1069
1070 let buf = Arc::new(ResponseBuffer::new(Duration::ZERO));
1071 let mut handles = Vec::new();
1072
1073 for i in 0..10 {
1075 let buf = buf.clone();
1076 handles.push(tokio::spawn(async move {
1077 buf.push(make_entry(
1078 &format!("c{}", i),
1079 "propose",
1080 &format!("job-{}", i),
1081 Duration::ZERO,
1082 ))
1083 .await;
1084 }));
1085 }
1086
1087 for h in handles {
1088 h.await.unwrap();
1089 }
1090
1091 let drained = buf.drain_ready().await;
1093 assert_eq!(drained.len(), 10);
1094 assert!(buf.is_empty().await);
1095 }
1096
1097 #[test]
1102 fn test_compute_adaptive_hold_high_score() {
1103 let base = Duration::from_secs(10);
1104 let hold = super::compute_adaptive_hold(base, Some(0.8), 3.0);
1107 let expected_secs = 18.33;
1108 assert!(
1109 (hold.as_secs_f64() - expected_secs).abs() < 0.5,
1110 "hold={:?}",
1111 hold
1112 );
1113 }
1114
1115 #[test]
1116 fn test_compute_adaptive_hold_low_score() {
1117 let base = Duration::from_secs(10);
1118 let hold = super::compute_adaptive_hold(base, Some(-0.8), 3.0);
1121 let expected_secs = 31.67;
1122 assert!(
1123 (hold.as_secs_f64() - expected_secs).abs() < 0.5,
1124 "hold={:?}",
1125 hold
1126 );
1127 }
1128
1129 #[test]
1130 fn test_compute_adaptive_hold_no_score() {
1131 let base = Duration::from_secs(10);
1132 let hold = super::compute_adaptive_hold(base, None, 3.0);
1133 assert_eq!(hold, base);
1134 }
1135
1136 #[test]
1137 fn test_compute_adaptive_hold_perfect_score() {
1138 let base = Duration::from_secs(10);
1139 let hold = super::compute_adaptive_hold(base, Some(1.0), 3.0);
1142 let expected_secs = 17.5;
1143 assert!(
1144 (hold.as_secs_f64() - expected_secs).abs() < 0.5,
1145 "hold={:?}",
1146 hold
1147 );
1148 }
1149
1150 #[test]
1151 fn test_compute_adaptive_hold_zero_score() {
1152 let base = Duration::from_secs(10);
1153 let hold = super::compute_adaptive_hold(base, Some(0.0), 3.0);
1156 let expected_secs = 25.0;
1157 assert!(
1158 (hold.as_secs_f64() - expected_secs).abs() < 0.5,
1159 "hold={:?}",
1160 hold
1161 );
1162 }
1163
1164 #[test]
1165 fn test_set_hold_duration_atomic() {
1166 let buf = ResponseBuffer::new(Duration::from_secs(10));
1167 assert_eq!(buf.hold_duration(), Duration::from_secs(10));
1168 assert_eq!(buf.base_hold_duration(), Duration::from_secs(10));
1169
1170 buf.set_hold_duration(Duration::from_secs(25));
1171 assert_eq!(buf.hold_duration(), Duration::from_secs(25));
1172 assert_eq!(buf.base_hold_duration(), Duration::from_secs(10));
1174 }
1175
1176 #[test]
1181 fn test_response_sla_default_matches_hold_duration() {
1182 let buf = ResponseBuffer::new(Duration::from_secs(10));
1184 assert_eq!(buf.response_sla(), Some(Duration::from_secs(10)));
1185
1186 let buf_long = ResponseBuffer::new(Duration::from_secs(600));
1187 assert_eq!(buf_long.response_sla(), Some(Duration::from_secs(600)));
1188
1189 let buf_fast = ResponseBuffer::new(Duration::from_millis(500));
1191 assert_eq!(buf_fast.response_sla(), Some(Duration::from_millis(500)));
1192 }
1193
1194 #[test]
1195 fn test_response_sla_zero_hold_is_none() {
1196 let buf = ResponseBuffer::new(Duration::ZERO);
1197 assert!(buf.response_sla().is_none());
1198 }
1199
1200 #[test]
1201 fn test_set_response_sla() {
1202 let buf = ResponseBuffer::new(Duration::from_secs(10));
1203 buf.set_response_sla(Duration::from_secs(600));
1204 assert_eq!(buf.response_sla(), Some(Duration::from_secs(600)));
1205 }
1206
1207 #[test]
1208 fn test_set_response_sla_uses_exact_value() {
1209 let buf = ResponseBuffer::new(Duration::from_secs(10));
1210 buf.set_response_sla(Duration::from_secs(30));
1212 assert_eq!(buf.response_sla(), Some(Duration::from_secs(30)));
1213
1214 buf.set_response_sla(Duration::from_millis(1));
1215 assert_eq!(buf.response_sla(), Some(Duration::from_millis(1)));
1216
1217 buf.set_response_sla(Duration::ZERO);
1219 assert_eq!(buf.response_sla(), None, "zero means passthrough");
1220 }
1221
1222 #[tokio::test]
1223 async fn test_push_with_deadline_sla() {
1224 let buf = ResponseBuffer::new(Duration::from_secs(10));
1227 buf.set_auto_approve(false);
1228 buf.set_response_sla(Duration::from_secs(60));
1229
1230 let task_received = Instant::now();
1231 let entry = make_entry("sla-1", "propose", "job-1", Duration::from_secs(10));
1232 buf.push_with_deadline(entry, task_received).await;
1233
1234 let drained = buf.drain_ready().await;
1236 assert!(
1237 drained.is_empty(),
1238 "should hold for full SLA (~60s), not drain immediately"
1239 );
1240 assert_eq!(buf.len().await, 1);
1241 }
1242
1243 #[tokio::test]
1244 async fn test_push_with_deadline_no_sla_fallback() {
1245 let buf = ResponseBuffer::new(Duration::ZERO);
1247 let task_received = Instant::now();
1250 let entry = make_entry("nosla-1", "propose", "job-1", Duration::ZERO);
1251 buf.push_with_deadline(entry, task_received).await;
1252
1253 let drained = buf.drain_ready().await;
1255 assert_eq!(drained.len(), 1, "should drain immediately when no SLA set");
1256 }
1257
1258 #[tokio::test]
1259 async fn test_push_with_deadline_past_deadline_clamps() {
1260 let buf = ResponseBuffer::new(Duration::from_secs(60));
1264 buf.set_response_sla(Duration::from_secs(600));
1265
1266 let task_received = Instant::now() - Duration::from_secs(620);
1267 let entry = make_entry("late-1", "evaluate", "job-1", Duration::from_secs(60));
1268 buf.push_with_deadline(entry, task_received).await;
1269
1270 let drained = buf.drain_ready().await;
1272 assert_eq!(
1273 drained.len(),
1274 1,
1275 "past-deadline entry should drain immediately"
1276 );
1277 }
1278
1279 #[tokio::test]
1288 async fn test_short_hold_duration_uses_exact_sla() {
1289 let buf = ResponseBuffer::new(Duration::from_secs(10));
1291 buf.set_auto_approve(false);
1292
1293 let task_received = Instant::now();
1294 let entry = make_entry("p1", "propose", "job-1", Duration::from_secs(10));
1295 buf.push_with_deadline(entry, task_received).await;
1296
1297 let list = buf.list().await;
1299 assert_eq!(list.len(), 1);
1300 assert!(
1301 list[0].release_in_ms > 0 && list[0].release_in_ms <= 10_000,
1302 "release_in_ms should be in (0, 10_000], got {}",
1303 list[0].release_in_ms
1304 );
1305 }
1306
1307 #[tokio::test]
1308 async fn test_response_sla_matches_hold_on_construction() {
1309 let buf = ResponseBuffer::new(Duration::from_secs(10));
1310 let sla = buf.response_sla();
1311 assert_eq!(sla, Some(Duration::from_secs(10)));
1312 }
1313
1314 #[tokio::test]
1317 async fn test_zero_hold_is_passthrough_no_sla_floor() {
1318 let buf = ResponseBuffer::new(Duration::ZERO);
1319 let sla = buf.response_sla();
1321 assert_eq!(sla, None, "pass-through mode should have no SLA");
1322 }
1323
1324 #[tokio::test]
1325 async fn test_drain_stale_removes_entries_from_other_jobs() {
1326 let buf = ResponseBuffer::new(Duration::from_secs(300));
1327 buf.push(make_entry(
1328 "a",
1329 "propose",
1330 "old-job",
1331 Duration::from_secs(300),
1332 ))
1333 .await;
1334 buf.push(make_entry(
1335 "b",
1336 "evaluate",
1337 "old-job",
1338 Duration::from_secs(300),
1339 ))
1340 .await;
1341 buf.push(make_entry(
1342 "c",
1343 "propose",
1344 "current-job",
1345 Duration::from_secs(300),
1346 ))
1347 .await;
1348 assert_eq!(buf.len().await, 3);
1349
1350 let stale = buf.drain_stale("current-job").await;
1351 assert_eq!(stale.len(), 2, "should drain 2 old-job entries");
1352 assert_eq!(buf.len().await, 1, "should keep 1 current-job entry");
1353
1354 let list = buf.list().await;
1356 assert_eq!(list[0].id, "c");
1357 assert_eq!(list[0].job_id, "current-job");
1358 }
1359
1360 #[tokio::test]
1361 async fn test_drain_stale_no_op_when_all_current() {
1362 let buf = ResponseBuffer::new(Duration::from_secs(300));
1363 buf.push(make_entry(
1364 "a",
1365 "propose",
1366 "job-1",
1367 Duration::from_secs(300),
1368 ))
1369 .await;
1370 buf.push(make_entry(
1371 "b",
1372 "evaluate",
1373 "job-1",
1374 Duration::from_secs(300),
1375 ))
1376 .await;
1377
1378 let stale = buf.drain_stale("job-1").await;
1379 assert!(stale.is_empty());
1380 assert_eq!(buf.len().await, 2);
1381 }
1382
1383 #[tokio::test]
1384 async fn test_drain_stale_empty_buffer() {
1385 let buf = ResponseBuffer::new(Duration::from_secs(300));
1386 let stale = buf.drain_stale("any-job").await;
1387 assert!(stale.is_empty());
1388 }
1389
1390 #[tokio::test]
1397 async fn test_pre_acked_handle_is_noop() {
1398 let handle = PreAckedHandle;
1399 assert!(
1400 handle.ack().await.is_ok(),
1401 "PreAckedHandle.ack() should always succeed"
1402 );
1403 assert!(handle.ack().await.is_ok());
1405 }
1406
1407 #[tokio::test]
1410 async fn test_buffer_entry_with_pre_acked_handle_drains_correctly() {
1411 let buf = ResponseBuffer::new(Duration::ZERO);
1412 let now = Instant::now();
1413 buf.push(BufferedResponse {
1414 id: "pre-acked-1".to_string(),
1415 action: "propose".to_string(),
1416 job_id: "job-1".to_string(),
1417 round: 1,
1418 reply_subject: "nsed.job-1.result.1.agent.propose".to_string(),
1419 payload: b"{\"content\":\"test\"}".to_vec(),
1420 created_at: now,
1421 release_at: now, ack_handle: Box::new(PreAckedHandle), msg_id: "msg-pre-acked-1".to_string(),
1424 annotations: Vec::new(),
1425 edited: false,
1426 stopped: false,
1427 })
1428 .await;
1429
1430 assert_eq!(buf.len().await, 1);
1431
1432 let drained = buf.drain_ready().await;
1434 assert_eq!(drained.len(), 1);
1435 assert!(drained[0].ack_handle.ack().await.is_ok());
1437 assert!(buf.is_empty().await);
1438 }
1439
1440 #[tokio::test]
1445 async fn test_mark_for_release_sets_release_at_to_now() {
1446 let buf = ResponseBuffer::new(Duration::from_secs(600));
1448 let now = Instant::now();
1449 let far_future = now + Duration::from_secs(3600);
1450 buf.push(BufferedResponse {
1451 id: "mark-1".to_string(),
1452 action: "propose".to_string(),
1453 job_id: "job-1".to_string(),
1454 round: 1,
1455 reply_subject: "nsed.job-1.result.1.agent.propose".to_string(),
1456 payload: b"{}".to_vec(),
1457 created_at: now,
1458 release_at: far_future,
1459 ack_handle: Box::new(NoopAckHandle),
1460 msg_id: "msg-mark-1".to_string(),
1461 annotations: Vec::new(),
1462 edited: false,
1463 stopped: false,
1464 })
1465 .await;
1466
1467 assert!(buf.drain_ready().await.is_empty());
1469 assert_eq!(buf.len().await, 1);
1470
1471 let found = buf.mark_for_release("mark-1").await;
1473 assert!(found, "mark_for_release should find the entry");
1474
1475 let drained = buf.drain_ready().await;
1477 assert_eq!(drained.len(), 1);
1478 assert_eq!(drained[0].id, "mark-1");
1479 assert!(buf.is_empty().await);
1480 }
1481
1482 #[tokio::test]
1483 async fn test_mark_for_release_unknown_id_returns_false() {
1484 let buf = ResponseBuffer::new(Duration::from_secs(60));
1485 let found = buf.mark_for_release("nonexistent").await;
1486 assert!(
1487 !found,
1488 "mark_for_release should return false for unknown ID"
1489 );
1490 }
1491
1492 #[tokio::test]
1493 async fn test_mark_for_release_only_affects_target_entry() {
1494 let buf = ResponseBuffer::new(Duration::from_secs(600));
1495 let now = Instant::now();
1496 let far_future = now + Duration::from_secs(3600);
1497
1498 for i in 0..2 {
1500 buf.push(BufferedResponse {
1501 id: format!("entry-{}", i),
1502 action: "propose".to_string(),
1503 job_id: "job-1".to_string(),
1504 round: 1,
1505 reply_subject: format!("nsed.job-1.result.1.agent{}.propose", i),
1506 payload: b"{}".to_vec(),
1507 created_at: now,
1508 release_at: far_future,
1509 ack_handle: Box::new(NoopAckHandle),
1510 msg_id: format!("msg-{}", i),
1511 annotations: Vec::new(),
1512 edited: false,
1513 stopped: false,
1514 })
1515 .await;
1516 }
1517 assert_eq!(buf.len().await, 2);
1518
1519 buf.mark_for_release("entry-0").await;
1521
1522 let drained = buf.drain_ready().await;
1524 assert_eq!(drained.len(), 1);
1525 assert_eq!(drained[0].id, "entry-0");
1526 assert_eq!(buf.len().await, 1); }
1528
1529 #[tokio::test]
1530 async fn test_mark_for_release_while_paused_still_marks() {
1531 let buf = ResponseBuffer::new(Duration::from_secs(600));
1532 let now = Instant::now();
1533 buf.push(BufferedResponse {
1534 id: "paused-mark-1".to_string(),
1535 action: "evaluate".to_string(),
1536 job_id: "job-2".to_string(),
1537 round: 1,
1538 reply_subject: "nsed.job-2.result.1.agent.evaluate".to_string(),
1539 payload: b"{}".to_vec(),
1540 created_at: now,
1541 release_at: now + Duration::from_secs(3600),
1542 ack_handle: Box::new(NoopAckHandle),
1543 msg_id: "msg-paused-1".to_string(),
1544 annotations: Vec::new(),
1545 edited: false,
1546 stopped: false,
1547 })
1548 .await;
1549
1550 buf.pause();
1551
1552 assert!(buf.mark_for_release("paused-mark-1").await);
1554
1555 assert!(buf.drain_ready().await.is_empty());
1557 assert_eq!(buf.len().await, 1);
1558
1559 buf.resume();
1561 let drained = buf.drain_ready().await;
1562 assert_eq!(drained.len(), 1);
1563 assert_eq!(drained[0].id, "paused-mark-1");
1564 }
1565
1566 #[tokio::test]
1567 async fn test_mark_for_release_preserves_stopped_flag() {
1568 let buf = ResponseBuffer::new(Duration::from_secs(600));
1569 buf.push(make_entry(
1570 "stopped-release",
1571 "propose",
1572 "job-1",
1573 Duration::from_secs(3600),
1574 ))
1575 .await;
1576
1577 assert!(buf.stop("stopped-release").await);
1579
1580 let drained = buf.drain_ready().await;
1582 assert!(drained.is_empty(), "stopped entry should not drain");
1583
1584 assert!(buf.mark_for_release("stopped-release").await);
1586
1587 let drained = buf.drain_ready().await;
1589 assert!(
1590 drained.is_empty(),
1591 "stopped entry should not drain even after mark_for_release"
1592 );
1593
1594 assert!(buf.unstop("stopped-release").await);
1596 let drained = buf.drain_ready().await;
1597 assert_eq!(drained.len(), 1);
1598 assert_eq!(drained[0].id, "stopped-release");
1599 }
1600
1601 #[tokio::test]
1602 async fn test_force_release_atomically_unstops_and_releases() {
1603 let buf = ResponseBuffer::new(Duration::from_secs(600));
1604 buf.push(make_entry(
1605 "atomic-rel",
1606 "propose",
1607 "job-1",
1608 Duration::from_secs(3600),
1609 ))
1610 .await;
1611
1612 assert!(buf.stop("atomic-rel").await);
1614 let drained = buf.drain_ready().await;
1615 assert!(drained.is_empty(), "stopped entry should not drain");
1616
1617 assert!(buf.force_release("atomic-rel").await);
1619
1620 let drained = buf.drain_ready().await;
1622 assert_eq!(drained.len(), 1);
1623 assert_eq!(drained[0].id, "atomic-rel");
1624 }
1625
1626 #[tokio::test]
1627 async fn test_force_release_nonexistent_returns_false() {
1628 let buf = ResponseBuffer::new(Duration::from_secs(600));
1629 assert!(!buf.force_release("no-such-entry").await);
1630 }
1631
1632 #[tokio::test]
1637 async fn test_stopped_entry_not_drained() {
1638 let buf = ResponseBuffer::new(Duration::ZERO);
1639 buf.push(make_entry("stop-1", "propose", "job-1", Duration::ZERO))
1641 .await;
1642
1643 assert!(buf.stop("stop-1").await);
1645
1646 let drained = buf.drain_ready().await;
1648 assert!(drained.is_empty(), "stopped entry should not drain");
1649 assert_eq!(buf.len().await, 1, "entry should still be in buffer");
1650 }
1651
1652 #[tokio::test]
1653 async fn test_unstop_makes_entry_drainable() {
1654 let buf = ResponseBuffer::new(Duration::ZERO);
1655 buf.push(make_entry("unstop-1", "evaluate", "job-1", Duration::ZERO))
1656 .await;
1657
1658 assert!(buf.stop("unstop-1").await);
1660 assert!(buf.unstop("unstop-1").await);
1661
1662 let drained = buf.drain_ready().await;
1664 assert_eq!(drained.len(), 1);
1665 assert_eq!(drained[0].id, "unstop-1");
1666 assert!(buf.is_empty().await);
1667 }
1668
1669 #[tokio::test]
1670 async fn test_stop_unknown_id_returns_false() {
1671 let buf = ResponseBuffer::new(Duration::from_secs(60));
1672 assert!(!buf.stop("nonexistent").await);
1673 }
1674
1675 #[tokio::test]
1676 async fn test_unstop_unknown_id_returns_false() {
1677 let buf = ResponseBuffer::new(Duration::from_secs(60));
1678 assert!(!buf.unstop("nonexistent").await);
1679 }
1680
1681 #[tokio::test]
1682 async fn test_stop_only_affects_target_entry() {
1683 let buf = ResponseBuffer::new(Duration::ZERO);
1684 buf.push(make_entry("s-1", "propose", "job-1", Duration::ZERO))
1685 .await;
1686 buf.push(make_entry("s-2", "evaluate", "job-1", Duration::ZERO))
1687 .await;
1688
1689 assert!(buf.stop("s-1").await);
1691
1692 let drained = buf.drain_ready().await;
1694 assert_eq!(drained.len(), 1);
1695 assert_eq!(drained[0].id, "s-2");
1696 assert_eq!(buf.len().await, 1);
1698 }
1699
1700 #[tokio::test]
1701 async fn test_stopped_entry_visible_in_list() {
1702 let buf = ResponseBuffer::new(Duration::from_secs(60));
1703 buf.push(make_entry(
1704 "vis-1",
1705 "propose",
1706 "job-1",
1707 Duration::from_secs(60),
1708 ))
1709 .await;
1710
1711 buf.stop("vis-1").await;
1712
1713 let entries = buf.list().await;
1714 assert_eq!(entries.len(), 1);
1715 assert!(entries[0].stopped, "stopped flag should be true in list");
1716 }
1717
1718 #[tokio::test]
1719 async fn test_stopped_entry_visible_in_detail() {
1720 let buf = ResponseBuffer::new(Duration::from_secs(60));
1721 buf.push(make_entry(
1722 "vis-d-1",
1723 "propose",
1724 "job-1",
1725 Duration::from_secs(60),
1726 ))
1727 .await;
1728
1729 buf.stop("vis-d-1").await;
1730
1731 let detail = buf.get_detail("vis-d-1").await;
1732 assert!(detail.is_some());
1733 assert!(
1734 detail.unwrap().summary.stopped,
1735 "stopped flag should be true in detail"
1736 );
1737 }
1738
1739 #[tokio::test]
1740 async fn test_stop_while_paused_still_stops() {
1741 let buf = ResponseBuffer::new(Duration::ZERO);
1742 buf.push(make_entry("sp-1", "propose", "job-1", Duration::ZERO))
1743 .await;
1744
1745 buf.pause();
1746 assert!(buf.stop("sp-1").await);
1747 buf.resume();
1748
1749 let drained = buf.drain_ready().await;
1751 assert!(
1752 drained.is_empty(),
1753 "stopped entry should not drain even after resume"
1754 );
1755 assert_eq!(buf.len().await, 1);
1756
1757 buf.unstop("sp-1").await;
1759 let drained = buf.drain_ready().await;
1760 assert_eq!(drained.len(), 1);
1761 }
1762
1763 #[tokio::test]
1768 async fn test_reply_subject_preserved_after_edit() {
1769 use crate::agents::{AnnotationType, OperatorAnnotation};
1770
1771 let buf = ResponseBuffer::new(Duration::ZERO);
1772 let entry = make_entry("rs-1", "propose", "job-A", Duration::ZERO);
1773 let original_subject = entry.reply_subject.clone();
1774 buf.push(entry).await;
1775
1776 let new_payload = br#"{"content":"edited by operator"}"#.to_vec();
1778 let annotation = OperatorAnnotation {
1779 annotation_type: AnnotationType::Edit,
1780 comment: "Improved wording".into(),
1781 timestamp: "2026-01-01T00:00:00Z".into(),
1782 original_content_hash: None,
1783 };
1784 assert!(
1785 buf.update_payload_with_annotation("rs-1", new_payload.clone(), annotation)
1786 .await
1787 );
1788
1789 let drained = buf.drain_ready().await;
1791 assert_eq!(drained.len(), 1);
1792 assert_eq!(
1793 drained[0].reply_subject, original_subject,
1794 "reply_subject must survive edits"
1795 );
1796 assert_eq!(drained[0].payload, new_payload, "payload should be updated");
1797 assert!(drained[0].edited, "edited flag should be set");
1798 assert_eq!(drained[0].annotations.len(), 1);
1799 }
1800
1801 #[tokio::test]
1802 async fn test_reply_subject_preserved_after_multiple_edits() {
1803 use crate::agents::{AnnotationType, OperatorAnnotation};
1804
1805 let buf = ResponseBuffer::new(Duration::ZERO);
1806 let entry = make_entry("rs-2", "evaluate", "job-B", Duration::ZERO);
1807 let original_subject = entry.reply_subject.clone();
1808 buf.push(entry).await;
1809
1810 buf.update_payload_with_annotation(
1812 "rs-2",
1813 b"v2".to_vec(),
1814 OperatorAnnotation {
1815 annotation_type: AnnotationType::Edit,
1816 comment: "First edit".into(),
1817 timestamp: "t1".into(),
1818 original_content_hash: None,
1819 },
1820 )
1821 .await;
1822
1823 buf.update_payload_with_annotation(
1825 "rs-2",
1826 b"v3".to_vec(),
1827 OperatorAnnotation {
1828 annotation_type: AnnotationType::Edit,
1829 comment: "Second edit".into(),
1830 timestamp: "t2".into(),
1831 original_content_hash: None,
1832 },
1833 )
1834 .await;
1835
1836 buf.add_comment(
1838 "rs-2",
1839 OperatorAnnotation {
1840 annotation_type: AnnotationType::Comment,
1841 comment: "LGTM".into(),
1842 timestamp: "t3".into(),
1843 original_content_hash: None,
1844 },
1845 )
1846 .await;
1847
1848 let drained = buf.drain_ready().await;
1849 assert_eq!(drained.len(), 1);
1850 assert_eq!(
1851 drained[0].reply_subject, original_subject,
1852 "reply_subject must survive multiple edits"
1853 );
1854 assert_eq!(
1855 drained[0].payload, b"v3",
1856 "payload should reflect last edit"
1857 );
1858 assert_eq!(drained[0].annotations.len(), 3, "all annotations preserved");
1859 }
1860
1861 #[tokio::test]
1862 async fn test_reply_subject_preserved_after_stop_edit_unstop() {
1863 use crate::agents::{AnnotationType, OperatorAnnotation};
1864
1865 let buf = ResponseBuffer::new(Duration::ZERO);
1866 let entry = make_entry("rs-3", "propose", "job-C", Duration::ZERO);
1867 let original_subject = entry.reply_subject.clone();
1868 buf.push(entry).await;
1869
1870 assert!(buf.stop("rs-3").await);
1872
1873 buf.update_payload_with_annotation(
1875 "rs-3",
1876 br#"{"content":"regenerated proposal"}"#.to_vec(),
1877 OperatorAnnotation {
1878 annotation_type: AnnotationType::Edit,
1879 comment: "Regenerated by operator".into(),
1880 timestamp: "t1".into(),
1881 original_content_hash: None,
1882 },
1883 )
1884 .await;
1885
1886 let drained = buf.drain_ready().await;
1888 assert!(
1889 drained.is_empty(),
1890 "stopped entry should not drain even after edit"
1891 );
1892
1893 assert!(buf.unstop("rs-3").await);
1895
1896 let drained = buf.drain_ready().await;
1898 assert_eq!(drained.len(), 1);
1899 assert_eq!(
1900 drained[0].reply_subject, original_subject,
1901 "reply_subject must survive stop→edit→unstop cycle"
1902 );
1903 assert_eq!(
1904 std::str::from_utf8(&drained[0].payload).unwrap(),
1905 r#"{"content":"regenerated proposal"}"#
1906 );
1907 }
1908
1909 #[tokio::test]
1910 async fn test_double_stop_is_idempotent() {
1911 let buf = ResponseBuffer::new(Duration::ZERO);
1912 buf.push(make_entry("ds-1", "propose", "j", Duration::ZERO))
1913 .await;
1914
1915 assert!(buf.stop("ds-1").await);
1916 assert!(buf.stop("ds-1").await); assert!(buf.drain_ready().await.is_empty());
1918
1919 assert!(buf.unstop("ds-1").await);
1920 assert_eq!(buf.drain_ready().await.len(), 1);
1921 }
1922
1923 #[tokio::test]
1924 async fn test_double_unstop_is_idempotent() {
1925 let buf = ResponseBuffer::new(Duration::ZERO);
1926 buf.push(make_entry("du-1", "propose", "j", Duration::ZERO))
1927 .await;
1928 buf.stop("du-1").await;
1929
1930 assert!(buf.unstop("du-1").await);
1931 assert!(buf.unstop("du-1").await); assert_eq!(buf.drain_ready().await.len(), 1);
1933 }
1934
1935 #[tokio::test]
1940 async fn test_edit_nonexistent_returns_false() {
1941 use crate::agents::{AnnotationType, OperatorAnnotation};
1942
1943 let buf = ResponseBuffer::new(Duration::ZERO);
1944 let result = buf
1945 .update_payload_with_annotation(
1946 "ghost",
1947 b"new".to_vec(),
1948 OperatorAnnotation {
1949 annotation_type: AnnotationType::Edit,
1950 comment: "".into(),
1951 timestamp: "t".into(),
1952 original_content_hash: None,
1953 },
1954 )
1955 .await;
1956 assert!(!result);
1957 }
1958
1959 #[tokio::test]
1960 async fn test_edit_after_drain_returns_false() {
1961 use crate::agents::{AnnotationType, OperatorAnnotation};
1962
1963 let buf = ResponseBuffer::new(Duration::ZERO);
1964 buf.push(make_entry("ed-1", "propose", "j", Duration::ZERO))
1965 .await;
1966 buf.drain_ready().await; let result = buf
1969 .update_payload_with_annotation(
1970 "ed-1",
1971 b"too late".to_vec(),
1972 OperatorAnnotation {
1973 annotation_type: AnnotationType::Edit,
1974 comment: "".into(),
1975 timestamp: "t".into(),
1976 original_content_hash: None,
1977 },
1978 )
1979 .await;
1980 assert!(!result, "cannot edit an already-drained entry");
1981 }
1982
1983 #[tokio::test]
1988 async fn test_selective_stop_only_blocks_target() {
1989 let buf = ResponseBuffer::new(Duration::ZERO);
1990 buf.push(make_entry("m-1", "propose", "j", Duration::ZERO))
1991 .await;
1992 buf.push(make_entry("m-2", "evaluate", "j", Duration::ZERO))
1993 .await;
1994 buf.push(make_entry("m-3", "propose", "j", Duration::ZERO))
1995 .await;
1996
1997 buf.stop("m-2").await;
1998
1999 let drained = buf.drain_ready().await;
2000 assert_eq!(drained.len(), 2, "only non-stopped entries should drain");
2001 let ids: Vec<&str> = drained.iter().map(|e| e.id.as_str()).collect();
2002 assert!(ids.contains(&"m-1"));
2003 assert!(ids.contains(&"m-3"));
2004 assert!(!ids.contains(&"m-2"));
2005
2006 assert_eq!(buf.len().await, 1);
2008 assert!(buf.get_detail("m-2").await.is_some());
2009 }
2010
2011 #[tokio::test]
2012 async fn test_job_id_and_action_preserved_through_full_lifecycle() {
2013 use crate::agents::{AnnotationType, OperatorAnnotation};
2014
2015 let buf = ResponseBuffer::new(Duration::ZERO);
2016 let mut entry = make_entry("lc-1", "evaluate", "job-XYZ", Duration::ZERO);
2017 entry.round = 3;
2018 entry.reply_subject = "nsed.job-XYZ.result.3.agent.evaluate".into();
2019 buf.push(entry).await;
2020
2021 buf.stop("lc-1").await;
2023
2024 buf.update_payload_with_annotation(
2026 "lc-1",
2027 b"edited".to_vec(),
2028 OperatorAnnotation {
2029 annotation_type: AnnotationType::Edit,
2030 comment: "regen".into(),
2031 timestamp: "t".into(),
2032 original_content_hash: None,
2033 },
2034 )
2035 .await;
2036
2037 buf.unstop("lc-1").await;
2039
2040 let drained = buf.drain_ready().await;
2042 assert_eq!(drained.len(), 1);
2043 let e = &drained[0];
2044 assert_eq!(e.job_id, "job-XYZ");
2045 assert_eq!(e.action, "evaluate");
2046 assert_eq!(e.round, 3);
2047 assert_eq!(e.reply_subject, "nsed.job-XYZ.result.3.agent.evaluate");
2048 assert!(e.edited);
2049 }
2050
2051 #[test]
2056 fn test_compute_divergence_both_signals() {
2057 let div = super::compute_divergence(Some(0.6), Some(0.25));
2061 assert!((div.unwrap() - 0.3125).abs() < 0.01);
2062 }
2063
2064 #[test]
2065 fn test_compute_divergence_score_only() {
2066 let div = super::compute_divergence(Some(1.0), None);
2068 assert!((div.unwrap() - 0.25).abs() < 0.01);
2069 }
2070
2071 #[test]
2072 fn test_compute_divergence_score_low() {
2073 let div = super::compute_divergence(Some(-0.8), None);
2075 assert!((div.unwrap() - 0.722).abs() < 0.02);
2076 }
2077
2078 #[test]
2079 fn test_compute_divergence_std_dev_only() {
2080 let div = super::compute_divergence(None, Some(0.25));
2082 assert!((div.unwrap() - 0.25).abs() < 0.01);
2083 }
2084
2085 #[test]
2086 fn test_compute_divergence_none() {
2087 let div = super::compute_divergence(None, None);
2088 assert!(div.is_none());
2089 }
2090
2091 #[test]
2092 fn test_compute_divergence_perfect_score() {
2093 let div = super::compute_divergence(Some(3.0), Some(0.0));
2097 assert!((div.unwrap() - 0.125).abs() < 0.01);
2098 }
2099
2100 #[test]
2101 fn test_compute_divergence_worst_score() {
2102 let div = super::compute_divergence(Some(-3.0), Some(1.2));
2106 assert!((div.unwrap() - 1.0).abs() < 0.01);
2107 }
2108
2109 #[test]
2110 fn test_compute_divergence_large_positive_score() {
2111 let div = super::compute_divergence(Some(10.0), None);
2113 assert!(
2115 div.unwrap() < 0.1,
2116 "large positive should give low divergence"
2117 );
2118 }
2119
2120 #[test]
2121 fn test_compute_divergence_large_negative_score() {
2122 let div = super::compute_divergence(Some(-10.0), None);
2124 assert!(
2126 div.unwrap() > 0.9,
2127 "large negative should give high divergence"
2128 );
2129 }
2130
2131 #[test]
2136 fn test_auto_approve_default_on() {
2137 let buf = ResponseBuffer::new(Duration::from_secs(10));
2138 assert!(
2139 buf.is_auto_approve(),
2140 "auto-approve should be ON by default"
2141 );
2142 }
2143
2144 #[test]
2145 fn test_auto_approve_toggle() {
2146 let buf = ResponseBuffer::new(Duration::from_secs(10));
2147 assert!(buf.is_auto_approve());
2148 buf.set_auto_approve(false);
2149 assert!(!buf.is_auto_approve());
2150 buf.set_auto_approve(true);
2151 assert!(buf.is_auto_approve());
2152 }
2153
2154 #[test]
2155 fn test_auto_approve_threshold_default() {
2156 let buf = ResponseBuffer::new(Duration::from_secs(10));
2161 assert!(
2162 (buf.auto_approve_threshold() - 1.0).abs() < 0.01,
2163 "auto_approve_threshold default should be 1.0 (release everything)"
2164 );
2165 }
2166
2167 #[tokio::test]
2168 async fn test_default_config_releases_every_entry_regardless_of_divergence() {
2169 let buf = ResponseBuffer::new(Duration::from_secs(60));
2174 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2177 .await;
2178 buf.push(make_entry("b", "propose", "job-2", Duration::from_secs(60)))
2179 .await;
2180 buf.push(make_entry("c", "propose", "job-3", Duration::from_secs(60)))
2181 .await;
2182
2183 let count = buf.auto_release_if_eligible(Some(1.0)).await;
2188 assert_eq!(
2189 count, 3,
2190 "all three entries should auto-release under the default 100% threshold"
2191 );
2192 }
2193
2194 #[test]
2195 fn test_auto_approve_threshold_set_and_get() {
2196 let buf = ResponseBuffer::new(Duration::from_secs(10));
2197 buf.set_auto_approve_threshold(0.75);
2198 assert!((buf.auto_approve_threshold() - 0.75).abs() < 0.01);
2199 buf.set_auto_approve_threshold(0.1);
2200 assert!((buf.auto_approve_threshold() - 0.1).abs() < 0.01);
2201 }
2202
2203 #[test]
2204 fn test_auto_approve_threshold_clamped() {
2205 let buf = ResponseBuffer::new(Duration::from_secs(10));
2206 buf.set_auto_approve_threshold(-0.5);
2207 assert!((buf.auto_approve_threshold() - 0.0).abs() < 0.01);
2208 buf.set_auto_approve_threshold(2.0);
2209 assert!((buf.auto_approve_threshold() - 1.0).abs() < 0.01);
2210 }
2211
2212 #[tokio::test]
2213 async fn test_auto_release_when_eligible() {
2214 let buf = ResponseBuffer::new(Duration::from_secs(60));
2215 buf.set_auto_approve(true);
2216 buf.set_auto_approve_threshold(0.5);
2217 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2218 .await;
2219
2220 let count = buf.auto_release_if_eligible(Some(0.2)).await;
2222 assert_eq!(count, 1);
2223 let drained = buf.drain_ready().await;
2225 assert_eq!(drained.len(), 1);
2226 }
2227
2228 #[tokio::test]
2229 async fn test_auto_release_skipped_when_disabled() {
2230 let buf = ResponseBuffer::new(Duration::from_secs(60));
2231 buf.set_auto_approve(false);
2232 buf.set_auto_approve_threshold(0.5);
2233 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2234 .await;
2235
2236 let count = buf.auto_release_if_eligible(Some(0.2)).await;
2237 assert_eq!(count, 0);
2238 let drained = buf.drain_ready().await;
2239 assert!(drained.is_empty());
2240 }
2241
2242 #[tokio::test]
2243 async fn test_auto_release_skipped_when_divergence_above_threshold() {
2244 let buf = ResponseBuffer::new(Duration::from_secs(60));
2245 buf.set_auto_approve(true);
2246 buf.set_auto_approve_threshold(0.3);
2247 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2248 .await;
2249
2250 let count = buf.auto_release_if_eligible(Some(0.5)).await;
2252 assert_eq!(count, 0);
2253 let drained = buf.drain_ready().await;
2254 assert!(drained.is_empty());
2255 }
2256
2257 #[tokio::test]
2258 async fn test_auto_release_with_no_divergence_data_trusts_operator() {
2259 let buf = ResponseBuffer::new(Duration::from_secs(60));
2260 buf.set_auto_approve(true);
2261 buf.set_auto_approve_threshold(0.5);
2262 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2263 .await;
2264
2265 let count = buf.auto_release_if_eligible(None).await;
2267 assert_eq!(count, 1);
2268 let drained = buf.drain_ready().await;
2269 assert_eq!(drained.len(), 1);
2270 }
2271
2272 #[tokio::test]
2273 async fn test_auto_release_respects_stopped_flag() {
2274 let buf = ResponseBuffer::new(Duration::from_secs(60));
2275 buf.set_auto_approve(true);
2276 buf.set_auto_approve_threshold(0.5);
2277 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2278 .await;
2279 buf.stop("a").await;
2281
2282 let count = buf.auto_release_if_eligible(Some(0.1)).await;
2284 assert_eq!(count, 0);
2285 let drained = buf.drain_ready().await;
2286 assert!(drained.is_empty());
2287 }
2288
2289 #[tokio::test]
2290 async fn test_auto_release_at_exact_threshold_releases() {
2291 let buf = ResponseBuffer::new(Duration::from_secs(60));
2292 buf.set_auto_approve(true);
2293 buf.set_auto_approve_threshold(0.5);
2294 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2295 .await;
2296
2297 let count = buf.auto_release_if_eligible(Some(0.5)).await;
2300 assert_eq!(count, 1);
2301 }
2302
2303 #[tokio::test]
2304 async fn test_auto_release_above_threshold_does_not_release() {
2305 let buf = ResponseBuffer::new(Duration::from_secs(60));
2306 buf.set_auto_approve(true);
2307 buf.set_auto_approve_threshold(0.5);
2308 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2309 .await;
2310
2311 let count = buf.auto_release_if_eligible(Some(0.51)).await;
2313 assert_eq!(count, 0);
2314 }
2315
2316 #[tokio::test]
2317 async fn test_auto_release_multiple_entries() {
2318 let buf = ResponseBuffer::new(Duration::from_secs(60));
2319 buf.set_auto_approve(true);
2320 buf.set_auto_approve_threshold(0.5);
2321 buf.push(make_entry("a", "propose", "job-1", Duration::from_secs(60)))
2322 .await;
2323 buf.push(make_entry(
2324 "b",
2325 "evaluate",
2326 "job-1",
2327 Duration::from_secs(60),
2328 ))
2329 .await;
2330 buf.push(make_entry("c", "propose", "job-2", Duration::from_secs(60)))
2331 .await;
2332
2333 let count = buf.auto_release_if_eligible(Some(0.2)).await;
2334 assert_eq!(count, 3);
2335 let drained = buf.drain_ready().await;
2336 assert_eq!(drained.len(), 3);
2337 }
2338
2339 #[tokio::test]
2362 async fn test_invariant_release_in_ms_non_negative_after_push() {
2363 let buf = ResponseBuffer::new(Duration::from_secs(60));
2364 buf.set_response_sla(Duration::from_secs(600));
2365
2366 let task_received = Instant::now();
2367 let entry = make_entry("inv-1", "propose", "job-1", Duration::from_secs(60));
2368 buf.push_with_deadline(entry, task_received).await;
2369
2370 let list = buf.list().await;
2371 assert_eq!(list.len(), 1);
2372 assert!(
2373 list[0].release_in_ms >= 0,
2374 "invariant: buffered item must have release_in_ms >= 0, got {}",
2375 list[0].release_in_ms,
2376 );
2377 }
2378
2379 #[tokio::test]
2384 async fn test_invariant_slow_agent_still_non_negative() {
2385 let buf = ResponseBuffer::new(Duration::from_secs(60));
2386 buf.set_response_sla(Duration::from_secs(600));
2387
2388 let task_received = Instant::now() - Duration::from_secs(700);
2390 let entry = make_entry("inv-2", "propose", "job-1", Duration::from_secs(60));
2391 buf.push_with_deadline(entry, task_received).await;
2392
2393 let list = buf.list().await;
2394 assert_eq!(list.len(), 1);
2395 assert!(
2396 list[0].release_in_ms >= 0,
2397 "invariant: even for slow agents, release_in_ms must be >= 0, got {}",
2398 list[0].release_in_ms,
2399 );
2400
2401 let drained = buf.drain_ready().await;
2404 assert_eq!(
2405 drained.len(),
2406 1,
2407 "past-deadline entry should drain immediately"
2408 );
2409 }
2410
2411 #[tokio::test]
2416 async fn test_invariant_paused_entry_stays_in_buffer() {
2417 let buf = ResponseBuffer::new(Duration::ZERO);
2418 buf.push(make_entry("inv-3", "propose", "job-1", Duration::ZERO))
2419 .await;
2420
2421 buf.pause();
2423
2424 let drained = buf.drain_ready().await;
2425 assert!(
2426 drained.is_empty(),
2427 "paused buffer must not drain — entry stays visible in rainfall"
2428 );
2429 assert_eq!(buf.len().await, 1, "entry must remain in buffer");
2430
2431 let list = buf.list().await;
2433 assert_eq!(list.len(), 1);
2434 }
2437
2438 #[tokio::test]
2441 async fn test_invariant_stopped_entry_stays_in_buffer() {
2442 let buf = ResponseBuffer::new(Duration::ZERO);
2443 buf.push(make_entry("inv-4", "propose", "job-1", Duration::ZERO))
2444 .await;
2445
2446 buf.stop("inv-4").await;
2447
2448 let drained = buf.drain_ready().await;
2450 assert!(
2451 drained.is_empty(),
2452 "stopped entry must not drain even though release_at passed"
2453 );
2454 assert_eq!(buf.len().await, 1);
2455 }
2456
2457 #[tokio::test]
2461 async fn test_invariant_full_lifecycle_no_surprise_overdue() {
2462 let buf = ResponseBuffer::new(Duration::from_secs(60));
2463 buf.set_auto_approve(false);
2464 buf.set_response_sla(Duration::from_secs(600));
2465
2466 let task_received = Instant::now();
2467 let entry = make_entry("inv-5", "propose", "job-1", Duration::from_secs(60));
2468 buf.push_with_deadline(entry, task_received).await;
2469
2470 let snap1 = buf.list().await;
2472 assert!(snap1[0].release_in_ms > 0, "snap1: should be positive");
2473
2474 let detail = buf.get_detail("inv-5").await.unwrap();
2477 assert!(
2478 detail.summary.release_in_ms > 0,
2479 "get_detail: should be positive immediately after push"
2480 );
2481
2482 assert_eq!(buf.len().await, 1, "entry still buffered");
2484 }
2485
2486 #[tokio::test]
2494 async fn test_buffer_stop_and_unstop() {
2495 let buf = ResponseBuffer::new(Duration::ZERO);
2496 buf.push(make_entry("e1", "propose", "job_1", Duration::ZERO))
2498 .await;
2499 buf.push(make_entry("e2", "evaluate", "job_2", Duration::ZERO))
2500 .await;
2501
2502 assert!(buf.stop("e1").await);
2504
2505 let drained = buf.drain_ready().await;
2506 assert_eq!(drained.len(), 1, "only e2 should drain");
2507 assert_eq!(drained[0].id, "e2");
2508 assert_eq!(buf.len().await, 1, "e1 should still be in buffer");
2509
2510 assert!(buf.unstop("e1").await);
2512 let drained = buf.drain_ready().await;
2513 assert_eq!(drained.len(), 1, "e1 should drain after unstop");
2514 assert_eq!(drained[0].id, "e1");
2515 assert!(buf.is_empty().await);
2516 }
2517
2518 #[tokio::test]
2521 async fn test_buffer_mark_for_release() {
2522 let buf = ResponseBuffer::new(Duration::from_secs(600));
2523 buf.push(make_entry(
2524 "mr-1",
2525 "propose",
2526 "job-1",
2527 Duration::from_secs(600),
2528 ))
2529 .await;
2530 buf.push(make_entry(
2531 "mr-2",
2532 "evaluate",
2533 "job-1",
2534 Duration::from_secs(600),
2535 ))
2536 .await;
2537
2538 assert!(buf.drain_ready().await.is_empty());
2540
2541 assert!(buf.mark_for_release("mr-1").await);
2543
2544 let drained = buf.drain_ready().await;
2546 assert_eq!(drained.len(), 1);
2547 assert_eq!(drained[0].id, "mr-1");
2548 assert_eq!(buf.len().await, 1, "mr-2 should still be held");
2549
2550 assert!(!buf.mark_for_release("nonexistent").await);
2552 }
2553
2554 #[test]
2559 fn test_buffer_compute_divergence() {
2560 let div = super::compute_divergence(Some(3.0), Some(0.0));
2563 assert!(
2564 div.unwrap() < 0.2,
2565 "strong endorsement should have low divergence, got {}",
2566 div.unwrap()
2567 );
2568
2569 let div_high = super::compute_divergence(Some(-2.0), Some(1.5));
2574 assert!(
2575 (div_high.unwrap() - 1.0).abs() < 0.01,
2576 "rejected + high stddev should saturate divergence, got {}",
2577 div_high.unwrap()
2578 );
2579
2580 let div_empty = super::compute_divergence(None, None);
2582 assert!(
2583 div_empty.is_none(),
2584 "empty recent scores should return None"
2585 );
2586 }
2587
2588 #[tokio::test]
2591 async fn test_buffer_auto_release_if_eligible() {
2592 let buf = ResponseBuffer::new(Duration::from_secs(600));
2593 buf.set_auto_approve(true);
2594 buf.set_auto_approve_threshold(0.4);
2595
2596 buf.push(make_entry(
2598 "ar-1",
2599 "propose",
2600 "job-1",
2601 Duration::from_secs(600),
2602 ))
2603 .await;
2604 buf.push(make_entry(
2605 "ar-2",
2606 "evaluate",
2607 "job-1",
2608 Duration::from_secs(600),
2609 ))
2610 .await;
2611 buf.stop("ar-2").await;
2613 buf.push(make_entry(
2614 "ar-3",
2615 "propose",
2616 "job-2",
2617 Duration::from_secs(600),
2618 ))
2619 .await;
2620
2621 let count = buf.auto_release_if_eligible(Some(0.1)).await;
2624 assert_eq!(count, 2, "only non-stopped entries should be auto-released");
2625
2626 let drained = buf.drain_ready().await;
2628 assert_eq!(drained.len(), 2);
2629 let ids: Vec<&str> = drained.iter().map(|e| e.id.as_str()).collect();
2630 assert!(ids.contains(&"ar-1"));
2631 assert!(ids.contains(&"ar-3"));
2632 assert!(!ids.contains(&"ar-2"));
2633 assert_eq!(buf.len().await, 1, "ar-2 should remain (stopped)");
2634
2635 buf.push(make_entry(
2637 "ar-4",
2638 "propose",
2639 "job-3",
2640 Duration::from_secs(600),
2641 ))
2642 .await;
2643 let count_high = buf.auto_release_if_eligible(Some(0.6)).await;
2644 assert_eq!(
2645 count_high, 0,
2646 "high divergence should not auto-release any entries"
2647 );
2648 assert!(
2649 buf.drain_ready().await.is_empty(),
2650 "no entries should drain when divergence is above threshold"
2651 );
2652 }
2653}