1use av_events::{AgentIdentity, StopReason};
5use av_receipts::EventChain;
6use parking_lot::{Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard};
7use std::path::PathBuf;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::Arc;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Workflow {
14 Signed,
16 Unsigned,
18}
19
20impl Workflow {
21 pub const fn as_str(self) -> &'static str {
23 match self {
24 Self::Signed => "signed",
25 Self::Unsigned => "unsigned",
26 }
27 }
28
29 pub fn parse(value: &str) -> Option<Self> {
31 match value {
32 "signed" => Some(Self::Signed),
33 "unsigned" => Some(Self::Unsigned),
34 _ => None,
35 }
36 }
37}
38
39pub struct Session {
41 pub id: String,
43 pub workflow: Workflow,
45 pub identity: AgentIdentity,
47 latest_identity: Mutex<AgentIdentity>,
49 seq: AtomicU64,
51 journal_index: AtomicU64,
53 pub loop_state: av_loopdetect::SessionLoopState,
55 pub chain: Mutex<EventChain>,
57 pub atif: Mutex<av_atif::TrajectoryBuilder>,
59 pub totals: Totals,
61 pub last_activity_ms: AtomicU64,
63 last_stop_reason_id: AtomicU64,
65 pub closed: AtomicU64,
67 artifact_committed: AtomicU64,
69 close_complete: AtomicU64,
75 admission: RwLock<()>,
77 active_streams: AtomicU64,
79 streams_drained: tokio::sync::Notify,
81 pub receipt: Mutex<Option<av_receipts::Receipt>>,
83 pub atif_path: Mutex<Option<PathBuf>>,
85 promoted: AtomicU64,
87 pending_jobs: AtomicU64,
89 jobs_drained: tokio::sync::Notify,
91 capture_failed: AtomicU64,
93}
94
95#[derive(Debug, Default)]
97pub struct Totals {
98 pub tool_calls: AtomicU64,
100 pub tool_allowed: AtomicU64,
102 pub tool_blocked: AtomicU64,
104 pub prompt_tokens: AtomicU64,
106 pub completion_tokens: AtomicU64,
108 pub cached_tokens: AtomicU64,
110 pub cost_usd_micros: AtomicU64,
112}
113
114impl Session {
115 pub fn new(
117 id: String,
118 workflow: Workflow,
119 identity: AgentIdentity,
120 breaker: av_loopdetect::BreakerConfig,
121 ) -> Self {
122 let agent = av_atif::Agent {
123 name: "agentvisor-ai-harness".into(),
124 version: identity.version.clone(),
125 model_name: None,
126 tool_definitions: None,
127 extra: Some(serde_json::json!({
128 "charter": identity.charter,
129 "instance_uid": identity.instance_uid,
130 "ttl_remaining_s": identity.ttl_remaining_s,
131 })),
132 };
133 Self {
134 chain: Mutex::new(EventChain::new(&id)),
135 atif: Mutex::new(av_atif::TrajectoryBuilder::new(agent, Some(id.clone()))),
136 id,
137 workflow,
138 identity: identity.clone(),
139 latest_identity: Mutex::new(identity.clone()),
140 seq: AtomicU64::new(0),
141 journal_index: AtomicU64::new(0),
142 loop_state: av_loopdetect::SessionLoopState::new(breaker),
143 totals: Totals::default(),
144 last_activity_ms: AtomicU64::new(av_core::time::now_ms()),
145 last_stop_reason_id: AtomicU64::new(0),
146 closed: AtomicU64::new(0),
147 artifact_committed: AtomicU64::new(0),
148 close_complete: AtomicU64::new(0),
149 admission: RwLock::new(()),
150 active_streams: AtomicU64::new(0),
151 streams_drained: tokio::sync::Notify::new(),
152 receipt: Mutex::new(None),
153 atif_path: Mutex::new(None),
154 promoted: AtomicU64::new(0),
155 pending_jobs: AtomicU64::new(0),
156 jobs_drained: tokio::sync::Notify::new(),
157 capture_failed: AtomicU64::new(0),
158 }
159 }
160
161 pub fn next_seq(&self) -> u64 {
163 self.seq.fetch_add(1, Ordering::AcqRel)
164 }
165
166 pub(crate) fn peek_seq(&self) -> u64 {
171 self.seq.load(Ordering::Acquire)
172 }
173
174 pub(crate) fn advance_seq_past(&self, seq: u64) {
177 self.seq.store(seq.saturating_add(1), Ordering::Release);
178 }
179
180 pub(crate) fn restore_next_seq(&self, next: u64) {
181 self.seq.store(next, Ordering::Release);
182 }
183
184 pub(crate) fn journal_index(&self) -> u64 {
185 self.journal_index.load(Ordering::Acquire)
186 }
187
188 pub(crate) fn commit_journal_index(&self, index: u64) -> Result<(), String> {
189 self.journal_index
190 .compare_exchange(
191 index,
192 index.saturating_add(1),
193 Ordering::AcqRel,
194 Ordering::Acquire,
195 )
196 .map(|_| ())
197 .map_err(|actual| format!("journal index changed from {index} to {actual}"))
198 }
199
200 pub(crate) fn restore_journal_index(&self, next: u64) {
201 self.journal_index.store(next, Ordering::Release);
202 }
203
204 pub fn touch(&self) {
206 self.last_activity_ms
207 .store(av_core::time::now_ms(), Ordering::Release);
208 }
209
210 pub fn try_close(&self) -> bool {
214 self.closed
215 .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
216 .is_ok()
217 }
218
219 pub fn is_closed(&self) -> bool {
221 self.closed.load(Ordering::Acquire) != 0 || self.artifact_committed.load(Ordering::Acquire) != 0
222 }
223
224 pub(crate) fn admission_guard(&self) -> RwLockReadGuard<'_, ()> {
225 self.admission.read()
226 }
227
228 pub(crate) fn try_lease(self: &Arc<Self>) -> Option<SessionLease> {
229 let admission = self.admission.read();
230 if self.is_closed() {
231 return None;
232 }
233 let lease = SessionLease::new(Arc::clone(self));
234 drop(admission);
235 Some(lease)
236 }
237
238 pub(crate) fn close_guard(&self) -> RwLockWriteGuard<'_, ()> {
239 self.admission.write()
240 }
241
242 pub(crate) fn reset_close(&self) {
243 self.closed.store(0, Ordering::Release);
244 }
245
246 pub fn artifact_committed_flag(&self) -> bool {
248 self.artifact_committed.load(Ordering::Acquire) != 0
249 }
250
251 pub fn close_complete_flag(&self) -> bool {
253 self.close_complete.load(Ordering::Acquire) != 0
254 }
255
256 pub(crate) fn mark_artifact_committed(&self) {
257 self.artifact_committed.store(1, Ordering::Release);
258 }
259
260 pub(crate) fn mark_close_complete(&self) {
263 self.close_complete.store(1, Ordering::Release);
264 }
265
266 pub(crate) async fn wait_for_streams(&self) {
267 loop {
268 let notified = self.streams_drained.notified();
272 let mut notified = std::pin::pin!(notified);
273 notified.as_mut().enable();
274 if self.active_streams.load(Ordering::Acquire) == 0 {
275 return;
276 }
277 notified.await;
278 }
279 }
280
281 pub fn try_promote(&self) -> bool {
285 self.promoted
286 .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
287 .is_ok()
288 }
289
290 pub fn is_promoted(&self) -> bool {
293 self.promoted.load(Ordering::Acquire) == 2
294 }
295
296 pub(crate) fn finish_promotion(&self) {
297 self.promoted.store(2, Ordering::Release);
298 }
299
300 pub(crate) fn reset_promotion(&self) {
301 let _ = self
302 .promoted
303 .compare_exchange(1, 0, Ordering::AcqRel, Ordering::Acquire);
304 }
305
306 pub(crate) fn restore_receipt(&self, receipt: av_receipts::Receipt) {
307 *self.receipt.lock() = Some(receipt);
308 self.finish_promotion();
309 }
310
311 pub(crate) fn restore_pending_receipt(&self, receipt: av_receipts::Receipt) {
312 *self.receipt.lock() = Some(receipt);
313 }
314
315 pub fn recover_unsigned(
317 id: String,
318 identity: AgentIdentity,
319 breaker: av_loopdetect::BreakerConfig,
320 path: PathBuf,
321 metrics: Option<&av_atif::FinalMetrics>,
322 ) -> Result<Self, String> {
323 let session = Self::new(id, Workflow::Unsigned, identity, breaker);
324 session.closed.store(1, Ordering::Release);
325 session.mark_artifact_committed();
326 *session.atif_path.lock() = Some(path);
327 if let Some(metrics) = metrics {
328 let prompt_tokens = recovered_counter(metrics.total_prompt_tokens, "prompt tokens")?;
329 let completion_tokens = recovered_counter(metrics.total_completion_tokens, "completion tokens")?;
330 let cached_tokens = recovered_counter(metrics.total_cached_tokens, "cached tokens")?;
331 session
332 .totals
333 .prompt_tokens
334 .store(prompt_tokens, Ordering::Release);
335 session
336 .totals
337 .completion_tokens
338 .store(completion_tokens, Ordering::Release);
339 session
340 .totals
341 .cached_tokens
342 .store(cached_tokens, Ordering::Release);
343 if let Some(extra) = metrics.extra.as_ref() {
344 let cost_usd_micros = recovered_counter(
345 extra.get("cost_usd_micros").and_then(serde_json::Value::as_u64),
346 "cost",
347 )?;
348 session
349 .totals
350 .cost_usd_micros
351 .store(cost_usd_micros, Ordering::Release);
352 let tool_calls = recovered_counter(
353 extra.get("tool_calls").and_then(serde_json::Value::as_u64),
354 "tool calls",
355 )?;
356 let tool_allowed = recovered_counter(
357 extra.get("tool_allowed").and_then(serde_json::Value::as_u64),
358 "allowed tools",
359 )?;
360 let tool_blocked = recovered_counter(
361 extra.get("tool_blocked").and_then(serde_json::Value::as_u64),
362 "blocked tools",
363 )?;
364 if tool_allowed
365 .checked_add(tool_blocked)
366 .is_none_or(|classified| classified > tool_calls)
367 {
368 return Err("recovered tool accounting is inconsistent".to_owned());
369 }
370 session.totals.tool_calls.store(tool_calls, Ordering::Release);
371 session.totals.tool_allowed.store(tool_allowed, Ordering::Release);
372 session.totals.tool_blocked.store(tool_blocked, Ordering::Release);
373 if let Some(id) = extra.get("stop_reason_id").and_then(serde_json::Value::as_u64) {
374 if id > u64::from(u8::MAX) {
375 return Err("recovered stop reason exceeds u8".to_owned());
376 }
377 session.last_stop_reason_id.store(id, Ordering::Release);
378 }
379 } else if let Some(cost) = metrics.total_cost_usd {
380 if !cost.is_finite() || cost < 0.0 {
381 return Err("recovered cost is not finite and nonnegative".to_owned());
382 }
383 let micros = (cost * av_core::units::USD_MICROS_PER_DOLLAR as f64).round();
384 if micros > av_core::error::JCS_SAFE_MAX as f64 {
385 return Err("recovered cost exceeds JCS-safe bounds".to_owned());
386 }
387 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
388 session
389 .totals
390 .cost_usd_micros
391 .store(micros as u64, Ordering::Release);
392 }
393 }
394 Ok(session)
395 }
396
397 pub fn take_trajectory(&self) -> av_atif::Trajectory {
399 let identity = self.current_identity();
400 let agent = av_atif::Agent {
401 name: "agentvisor-ai-harness".into(),
402 version: identity.version.clone(),
403 model_name: None,
404 tool_definitions: None,
405 extra: Some(serde_json::json!({
406 "charter": identity.charter,
407 "instance_uid": identity.instance_uid,
408 "ttl_remaining_s": identity.ttl_remaining_s,
409 })),
410 };
411 let replacement = av_atif::TrajectoryBuilder::new(agent, Some(self.id.clone()));
412 let builder = std::mem::replace(&mut *self.atif.lock(), replacement);
413 builder.finish()
414 }
415
416 pub(crate) fn snapshot_trajectory(&self) -> av_atif::Trajectory {
417 self.atif.lock().clone().finish()
418 }
419
420 pub(crate) fn worker_job_started(&self) {
421 self.pending_jobs.fetch_add(1, Ordering::AcqRel);
422 }
423
424 pub(crate) fn worker_job_finished(&self) {
425 if self.pending_jobs.fetch_sub(1, Ordering::AcqRel) == 1 {
426 self.jobs_drained.notify_waiters();
427 }
428 }
429
430 pub(crate) async fn wait_for_worker_jobs(&self) {
431 loop {
432 let notified = self.jobs_drained.notified();
433 let mut notified = std::pin::pin!(notified);
434 notified.as_mut().enable();
435 if self.pending_jobs.load(Ordering::Acquire) == 0 {
436 return;
437 }
438 notified.await;
439 }
440 }
441
442 pub fn active_streams_count(&self) -> u64 {
444 self.active_streams.load(Ordering::Acquire)
445 }
446
447 pub fn pending_jobs_count(&self) -> u64 {
449 self.pending_jobs.load(Ordering::Acquire)
450 }
451
452 pub(crate) fn is_empty_unsigned_quarantine(&self) -> bool {
468 self.workflow == Workflow::Unsigned
469 && self.artifact_committed.load(Ordering::Acquire) != 0
470 && self.atif_path.lock().is_none()
471 }
472
473 pub(crate) fn mark_capture_failed(&self) {
474 self.capture_failed.store(1, Ordering::Release);
475 }
476
477 pub(crate) fn capture_failed(&self) -> bool {
478 self.capture_failed.load(Ordering::Acquire) != 0
479 }
480
481 pub fn refresh_identity(&self, identity: &AgentIdentity) {
483 *self.latest_identity.lock() = identity.clone();
484 }
485
486 pub fn current_identity(&self) -> AgentIdentity {
488 self.latest_identity.lock().clone()
489 }
490
491 pub(crate) fn record_stop_reason(&self, reason: StopReason) {
492 self.last_stop_reason_id
493 .store(u64::from(reason.id()), Ordering::Release);
494 }
495
496 pub(crate) fn recorded_stop_reason_id(&self) -> u64 {
497 self.last_stop_reason_id.load(Ordering::Acquire)
498 }
499
500 pub fn receipt_body(
502 &self,
503 subject: av_receipts::ReceiptSubject,
504 stop: StopReason,
505 ) -> av_receipts::ReceiptBody {
506 let recorded =
507 StopReason::from_id(u8::try_from(self.last_stop_reason_id.load(Ordering::Acquire)).unwrap_or(0));
508 let stop = if recorded == StopReason::Unknown {
509 stop
510 } else {
511 recorded
512 };
513 av_receipts::receipt::new_body(
514 self.id.clone(),
515 self.current_identity(),
516 subject,
517 av_receipts::ToolCallSummary {
518 total: self.totals.tool_calls.load(Ordering::Acquire),
519 allowed: self.totals.tool_allowed.load(Ordering::Acquire),
520 blocked: self.totals.tool_blocked.load(Ordering::Acquire),
521 },
522 av_receipts::CostSummary {
523 prompt_tokens: self.totals.prompt_tokens.load(Ordering::Acquire),
524 completion_tokens: self.totals.completion_tokens.load(Ordering::Acquire),
525 cached_tokens: self.totals.cached_tokens.load(Ordering::Acquire),
526 cost_usd_micros: self.totals.cost_usd_micros.load(Ordering::Acquire),
527 },
528 stop,
529 )
530 }
531}
532
533fn recovered_counter(value: Option<u64>, field: &str) -> Result<u64, String> {
534 let value = value.unwrap_or(0);
535 if value > av_core::error::JCS_SAFE_MAX {
536 return Err(format!("recovered {field} exceeds JCS-safe bounds"));
537 }
538 Ok(value)
539}
540
541pub struct SessionLease {
543 session: Arc<Session>,
544}
545
546impl SessionLease {
547 pub(crate) fn new(session: Arc<Session>) -> Self {
548 session.active_streams.fetch_add(1, Ordering::AcqRel);
549 Self { session }
550 }
551}
552
553impl Drop for SessionLease {
554 fn drop(&mut self) {
555 if self.session.active_streams.fetch_sub(1, Ordering::AcqRel) == 1 {
556 self.session.streams_drained.notify_waiters();
557 }
558 }
559}
560
561#[derive(Default)]
563pub struct SessionRegistry {
564 sessions: dashmap::DashMap<String, Arc<Session>>,
565}
566
567impl SessionRegistry {
568 pub fn new() -> Self {
570 Self::default()
571 }
572
573 pub fn get_or_open(
593 &self,
594 id: &str,
595 workflow: Workflow,
596 identity: &AgentIdentity,
597 breaker: &av_loopdetect::BreakerConfig,
598 ) -> Arc<Session> {
599 self.get_or_open_inner(
600 id, workflow, identity, breaker, true,
601 )
602 }
603
604 pub fn get_or_open_no_reopen(
610 &self,
611 id: &str,
612 workflow: Workflow,
613 identity: &AgentIdentity,
614 breaker: &av_loopdetect::BreakerConfig,
615 ) -> Arc<Session> {
616 self.get_or_open_inner(
617 id, workflow, identity, breaker, false,
618 )
619 }
620
621 fn get_or_open_inner(
622 &self,
623 id: &str,
624 workflow: Workflow,
625 identity: &AgentIdentity,
626 breaker: &av_loopdetect::BreakerConfig,
627 reopen_after_close: bool,
628 ) -> Arc<Session> {
629 use dashmap::mapref::entry::Entry;
630 match self.sessions.entry(id.to_owned()) {
631 Entry::Occupied(mut occupied) => {
632 if reopen_after_close && occupied.get().close_complete_flag() {
633 let fresh = Arc::new(Session::new(
634 id.to_owned(),
635 workflow,
636 identity.clone(),
637 breaker.clone(),
638 ));
639 occupied.insert(Arc::clone(&fresh));
640 fresh
641 } else {
642 occupied.get().clone()
643 }
644 }
645 Entry::Vacant(vacant) => {
646 let fresh = Arc::new(Session::new(
647 id.to_owned(),
648 workflow,
649 identity.clone(),
650 breaker.clone(),
651 ));
652 vacant.insert(Arc::clone(&fresh));
653 fresh
654 }
655 }
656 }
657
658 pub fn get(&self, id: &str) -> Option<Arc<Session>> {
660 self.sessions.get(id).map(|s| s.clone())
661 }
662
663 pub fn insert_recovered(&self, session: Session) -> Arc<Session> {
665 let id = session.id.clone();
666 self.sessions
667 .entry(id)
668 .or_insert_with(|| Arc::new(session))
669 .clone()
670 }
671
672 pub fn try_insert_recovered(&self, session: Session) -> Result<Arc<Session>, Arc<Session>> {
677 use dashmap::mapref::entry::Entry;
678 match self.sessions.entry(session.id.clone()) {
679 Entry::Occupied(existing) => Err(existing.get().clone()),
680 Entry::Vacant(slot) => {
681 let arc = Arc::new(session);
682 slot.insert(Arc::clone(&arc));
683 Ok(arc)
684 }
685 }
686 }
687
688 pub fn remove(&self, id: &str) {
690 self.sessions.remove(id);
691 }
692
693 pub fn evict_finalized(&self, idle_s: u64) -> Vec<Arc<Session>> {
713 let cutoff =
714 av_core::time::now_ms().saturating_sub(idle_s.saturating_mul(av_core::units::MS_PER_SEC));
715 let mut evicted = Vec::new();
716 self.sessions.retain(|_, session| {
717 let evict = session.workflow == Workflow::Signed
718 && session.close_complete.load(Ordering::Acquire) != 0
719 && !session.capture_failed()
720 && session.active_streams.load(Ordering::Acquire) == 0
721 && session.pending_jobs.load(Ordering::Acquire) == 0
722 && session.last_activity_ms.load(Ordering::Acquire) < cutoff;
723 if evict {
724 evicted.push(Arc::clone(session));
725 }
726 !evict
727 });
728 evicted
729 }
730
731 pub fn pending_close_sessions(&self) -> Vec<Arc<Session>> {
751 self.sessions
752 .iter()
753 .filter(|entry| {
754 entry.artifact_committed.load(Ordering::Acquire) != 0
755 && entry.close_complete.load(Ordering::Acquire) == 0
756 && !entry.capture_failed()
757 && !entry.is_empty_unsigned_quarantine()
758 })
759 .map(|entry| entry.clone())
760 .collect()
761 }
762
763 pub fn idle_sessions(&self, idle_s: u64) -> Vec<Arc<Session>> {
765 let cutoff =
766 av_core::time::now_ms().saturating_sub(idle_s.saturating_mul(av_core::units::MS_PER_SEC));
767 self.sessions
768 .iter()
769 .filter(|e| {
770 e.last_activity_ms.load(Ordering::Acquire) < cutoff
771 && !e.is_closed()
772 && e.active_streams.load(Ordering::Acquire) == 0
780 })
781 .map(|e| e.clone())
782 .collect()
783 }
784
785 pub fn open_sessions_including_closed(&self) -> Vec<Arc<Session>> {
789 self.sessions.iter().map(|entry| entry.clone()).collect()
790 }
791
792 pub fn open_sessions(&self) -> Vec<Arc<Session>> {
794 self.sessions
795 .iter()
796 .filter(|entry| !entry.is_closed())
797 .map(|entry| entry.clone())
798 .collect()
799 }
800
801 pub fn len(&self) -> usize {
803 self.sessions.len()
804 }
805
806 pub fn is_empty(&self) -> bool {
808 self.sessions.is_empty()
809 }
810}
811
812#[cfg(test)]
813mod tests {
814 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
815
816 use super::*;
817
818 fn identity() -> AgentIdentity {
819 AgentIdentity {
820 version: "1".into(),
821 charter: "c".into(),
822 instance_uid: "i".into(),
823 ttl_remaining_s: None,
824 }
825 }
826
827 #[test]
828 fn seq_is_monotonic_under_concurrency() {
829 let s = Arc::new(Session::new(
830 "s".into(),
831 Workflow::Unsigned,
832 identity(),
833 av_loopdetect::BreakerConfig::default(),
834 ));
835 let mut handles = Vec::new();
836 for _ in 0..8 {
837 let s = Arc::clone(&s);
838 handles.push(std::thread::spawn(move || {
839 (0..1000).map(|_| s.next_seq()).collect::<Vec<_>>()
840 }));
841 }
842 let mut all: Vec<u64> = handles.into_iter().flat_map(|h| h.join().unwrap()).collect();
843 all.sort_unstable();
844 all.dedup();
845 assert_eq!(all.len(), 8000, "sequence numbers must be unique");
846 }
847
848 #[test]
849 fn close_is_idempotent() {
850 let s = Session::new("s".into(), Workflow::Signed, identity(), Default::default());
851 assert!(s.try_close());
852 assert!(!s.try_close(), "second close must be refused");
853 assert!(s.is_closed());
854 }
855
856 #[test]
857 fn registry_reuses_sessions() {
858 let r = SessionRegistry::new();
859 let a = r.get_or_open("x", Workflow::Unsigned, &identity(), &Default::default());
860 let b = r.get_or_open("x", Workflow::Unsigned, &identity(), &Default::default());
861 assert!(Arc::ptr_eq(&a, &b));
862 assert_eq!(r.len(), 1);
863 }
864
865 #[test]
874 fn get_or_open_recycles_id_after_close_complete() {
875 let registry = SessionRegistry::new();
876 let breaker = av_loopdetect::BreakerConfig::default();
877
878 let first = registry.get_or_open("reused", Workflow::Signed, &identity(), &breaker);
882 assert!(first.try_close(), "first close must land");
883 first.mark_artifact_committed();
884 first.mark_close_complete();
885
886 let second = registry.get_or_open("reused", Workflow::Signed, &identity(), &breaker);
887 assert!(
888 !Arc::ptr_eq(&first, &second),
889 "closed session must be replaced by a fresh one"
890 );
891 assert!(!second.is_closed(), "reopened session must accept new work");
892 assert_eq!(registry.len(), 1, "id remains a single registry entry");
893 }
894
895 #[test]
899 fn get_or_open_preserves_session_mid_close() {
900 let registry = SessionRegistry::new();
901 let breaker = av_loopdetect::BreakerConfig::default();
902 let first = registry.get_or_open("closing", Workflow::Signed, &identity(), &breaker);
903 assert!(first.try_close());
904 let second = registry.get_or_open("closing", Workflow::Signed, &identity(), &breaker);
906 assert!(
907 Arc::ptr_eq(&first, &second),
908 "mid-close session must be handed back unchanged"
909 );
910 }
911
912 #[test]
919 fn try_insert_recovered_returns_err_on_collision_and_leaves_active_untouched() {
920 let r = SessionRegistry::new();
921 let active = r.get_or_open("race", Workflow::Signed, &identity(), &Default::default());
922 assert!(!active.is_closed(), "precondition: active session is open");
923 let recovered = Session::new("race".into(), Workflow::Signed, identity(), Default::default());
924 let existing = match r.try_insert_recovered(recovered) {
925 Ok(_) => panic!("collision must be reported as Err, not Ok"),
926 Err(existing) => existing,
927 };
928 assert!(
929 Arc::ptr_eq(&active, &existing),
930 "Err must carry the pre-existing active Arc, not a fresh one",
931 );
932 assert!(
933 !active.is_closed(),
934 "the active session must remain open after a discarded recovery insert",
935 );
936 assert_eq!(r.len(), 1, "no duplicate entry must be added");
937 }
938
939 #[test]
940 fn try_insert_recovered_returns_ok_when_registry_is_vacant() {
941 let r = SessionRegistry::new();
942 let recovered = Session::new("fresh".into(), Workflow::Signed, identity(), Default::default());
943 let inserted = match r.try_insert_recovered(recovered) {
944 Ok(inserted) => inserted,
945 Err(_) => panic!("vacant slot must accept the recovered session"),
946 };
947 assert_eq!(inserted.id, "fresh");
948 assert_eq!(r.len(), 1);
949 }
950
951 #[test]
952 fn idle_detection() {
953 let r = SessionRegistry::new();
954 let s = r.get_or_open("idle", Workflow::Unsigned, &identity(), &Default::default());
955 s.last_activity_ms
956 .store(av_core::time::now_ms() - 10_000, Ordering::Release);
957 assert_eq!(r.idle_sessions(5).len(), 1);
958 assert!(r.idle_sessions(60).is_empty());
959 s.try_close();
960 assert!(
961 r.idle_sessions(5).is_empty(),
962 "closed sessions are not idle candidates"
963 );
964 }
965
966 #[test]
974 fn idle_sweep_skips_sessions_with_active_streams() {
975 let r = SessionRegistry::new();
976 let s = r.get_or_open("streaming", Workflow::Unsigned, &identity(), &Default::default());
977 s.last_activity_ms
978 .store(av_core::time::now_ms() - 10_000, Ordering::Release);
979 let lease = SessionLease::new(Arc::clone(&s));
980 assert!(
981 r.idle_sessions(5).is_empty(),
982 "a session with an active response stream must not be reaped as idle",
983 );
984 drop(lease);
985 assert_eq!(
986 r.idle_sessions(5).len(),
987 1,
988 "once the stream lease drops, the stale session becomes an idle candidate again",
989 );
990 }
991
992 #[test]
999 fn idle_reap_is_safe_when_clock_runs_backward() {
1000 let r = SessionRegistry::new();
1001 let s = r.get_or_open("backward", Workflow::Unsigned, &identity(), &Default::default());
1002 s.last_activity_ms.store(
1004 av_core::time::now_ms() + av_core::units::MS_PER_HOUR,
1005 Ordering::Release,
1006 );
1007 for idle_s in [0u64, 1, 60, 3_600, av_core::units::SECS_PER_DAY] {
1008 assert!(
1009 r.idle_sessions(idle_s).is_empty(),
1010 "session with future last_activity must not be reaped at idle_s={idle_s}",
1011 );
1012 }
1013 }
1014
1015 #[test]
1019 fn idle_reap_saturates_on_pathological_idle_secs() {
1020 let r = SessionRegistry::new();
1021 let _s = r.get_or_open(
1022 "pathological",
1023 Workflow::Unsigned,
1024 &identity(),
1025 &Default::default(),
1026 );
1027 assert!(
1028 r.idle_sessions(u64::MAX).is_empty(),
1029 "idle_s = u64::MAX must saturate rather than reap everything",
1030 );
1031 assert!(r.idle_sessions(u64::MAX / 500).is_empty());
1034 }
1035
1036 #[test]
1040 fn evict_finalized_removes_only_quiescent_committed_signed_sessions() {
1041 let r = SessionRegistry::new();
1042 let stale = av_core::time::now_ms() - 10_000;
1043
1044 let eligible = r.get_or_open("evict-me", Workflow::Signed, &identity(), &Default::default());
1045 eligible.try_close();
1046 eligible.mark_artifact_committed();
1047 eligible.mark_close_complete();
1048 eligible.last_activity_ms.store(stale, Ordering::Release);
1049
1050 let incomplete = r.get_or_open(
1054 "keep-incomplete",
1055 Workflow::Signed,
1056 &identity(),
1057 &Default::default(),
1058 );
1059 incomplete.try_close();
1060 incomplete.mark_artifact_committed();
1061 incomplete.last_activity_ms.store(stale, Ordering::Release);
1062
1063 let unsigned = r.get_or_open(
1064 "keep-unsigned",
1065 Workflow::Unsigned,
1066 &identity(),
1067 &Default::default(),
1068 );
1069 unsigned.try_close();
1070 unsigned.mark_artifact_committed();
1071 unsigned.mark_close_complete();
1072 unsigned.last_activity_ms.store(stale, Ordering::Release);
1073
1074 let failed = r.get_or_open("keep-failed", Workflow::Signed, &identity(), &Default::default());
1075 failed.try_close();
1076 failed.mark_artifact_committed();
1077 failed.mark_close_complete();
1078 failed.mark_capture_failed();
1079 failed.last_activity_ms.store(stale, Ordering::Release);
1080
1081 let streaming = r.get_or_open(
1082 "keep-streaming",
1083 Workflow::Signed,
1084 &identity(),
1085 &Default::default(),
1086 );
1087 streaming.try_close();
1088 streaming.mark_artifact_committed();
1089 streaming.mark_close_complete();
1090 streaming.last_activity_ms.store(stale, Ordering::Release);
1091 let lease = SessionLease::new(Arc::clone(&streaming));
1092
1093 let fresh = r.get_or_open("keep-fresh", Workflow::Signed, &identity(), &Default::default());
1094 fresh.try_close();
1095 fresh.mark_artifact_committed();
1096 fresh.mark_close_complete();
1097
1098 let open = r.get_or_open("keep-open", Workflow::Signed, &identity(), &Default::default());
1099 open.last_activity_ms.store(stale, Ordering::Release);
1100
1101 let evicted = r.evict_finalized(5);
1102 assert_eq!(
1103 evicted.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
1104 vec!["evict-me"],
1105 "only the committed, quiescent, idle signed session may be evicted",
1106 );
1107 assert!(r.get("evict-me").is_none());
1108 for id in [
1109 "keep-incomplete",
1110 "keep-unsigned",
1111 "keep-failed",
1112 "keep-streaming",
1113 "keep-fresh",
1114 "keep-open",
1115 ] {
1116 assert!(r.get(id).is_some(), "{id} must stay resident");
1117 }
1118 drop(lease);
1119 }
1120
1121 #[test]
1127 fn touch_never_regresses_last_activity() {
1128 let r = SessionRegistry::new();
1129 let s = r.get_or_open("touched", Workflow::Unsigned, &identity(), &Default::default());
1130 let mut previous = s.last_activity_ms.load(Ordering::Acquire);
1131 for _ in 0..10_000 {
1132 s.touch();
1133 let current = s.last_activity_ms.load(Ordering::Acquire);
1134 assert!(
1135 current >= previous,
1136 "touch() moved last_activity backward: {previous} -> {current}",
1137 );
1138 previous = current;
1139 }
1140 }
1141}