Skip to main content

av_harness/
session.rs

1//! Per-session state: sequence numbers, event chain, ATIF builder, loop state,
2//! lifecycle (open → active → closed/promoted), and finalization products.
3
4use 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/// Workflow kind (brief Modules G/H).
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Workflow {
14    /// Consequential actions: event chain + receipt at close.
15    Signed,
16    /// Exploratory: ATIF capture, receipt only on promotion.
17    Unsigned,
18}
19
20impl Workflow {
21    /// Canonical wire name (`x-av-workflow` header, config, journal metadata).
22    pub const fn as_str(self) -> &'static str {
23        match self {
24            Self::Signed => "signed",
25            Self::Unsigned => "unsigned",
26        }
27    }
28
29    /// Parse a wire name back into a `Workflow` (`None` on unknown input).
30    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
39/// A live session.
40pub struct Session {
41    /// Session id.
42    pub id: String,
43    /// Workflow kind.
44    pub workflow: Workflow,
45    /// Agent identity bound at open (from NHI validation).
46    pub identity: AgentIdentity,
47    /// Most recently validated identity, including current TTL.
48    latest_identity: Mutex<AgentIdentity>,
49    /// Monotonic per-session event sequence (authoritative order).
50    seq: AtomicU64,
51    /// Monotonic authenticated-journal record index.
52    journal_index: AtomicU64,
53    /// Loop-breaker state.
54    pub loop_state: av_loopdetect::SessionLoopState,
55    /// Signed workflow: incremental event chain.
56    pub chain: Mutex<EventChain>,
57    /// Unsigned workflow: ATIF steps.
58    pub atif: Mutex<av_atif::TrajectoryBuilder>,
59    /// Aggregates for the receipt.
60    pub totals: Totals,
61    /// Last-activity timestamp (idle sweeping), epoch ms.
62    pub last_activity_ms: AtomicU64,
63    /// Last normalized provider or enforcement stop reason id.
64    last_stop_reason_id: AtomicU64,
65    /// Set once closed (idempotent close).
66    pub closed: AtomicU64,
67    /// Set once a receipt or ATIF artifact is durably committed.
68    artifact_committed: AtomicU64,
69    /// Set only when a close ran to full completion: artifact committed,
70    /// lifecycle events published, and the on-disk journal removed. Gates
71    /// registry eviction — `artifact_committed` alone is set *before* the
72    /// fallible bridge emits and journal removal, so a failed or in-flight
73    /// close must not look evictable.
74    close_complete: AtomicU64,
75    /// Serializes close against request admission.
76    admission: RwLock<()>,
77    /// Forwarded chat responses that have not completed or aborted.
78    active_streams: AtomicU64,
79    /// Notification used by finalization to await active response completion.
80    streams_drained: tokio::sync::Notify,
81    /// Issued signed or retroactive receipt.
82    pub receipt: Mutex<Option<av_receipts::Receipt>>,
83    /// Persisted unsigned ATIF artifact.
84    pub atif_path: Mutex<Option<PathBuf>>,
85    /// Set once an unsigned artifact is promoted.
86    promoted: AtomicU64,
87    /// Worker jobs accepted but not yet fully captured.
88    pending_jobs: AtomicU64,
89    /// Notification used by finalization to await a drained worker queue.
90    jobs_drained: tokio::sync::Notify,
91    /// Set when an upstream action could not be captured completely.
92    capture_failed: AtomicU64,
93}
94
95/// Aggregate counters for receipts (atomics: workers update concurrently).
96#[derive(Debug, Default)]
97pub struct Totals {
98    /// Tool calls observed.
99    pub tool_calls: AtomicU64,
100    /// Tool calls allowed.
101    pub tool_allowed: AtomicU64,
102    /// Tool calls blocked.
103    pub tool_blocked: AtomicU64,
104    /// Prompt tokens.
105    pub prompt_tokens: AtomicU64,
106    /// Completion tokens.
107    pub completion_tokens: AtomicU64,
108    /// Cached tokens.
109    pub cached_tokens: AtomicU64,
110    /// Cost in micro-USD.
111    pub cost_usd_micros: AtomicU64,
112}
113
114impl Session {
115    /// Open a session.
116    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    /// Next event sequence number.
162    pub fn next_seq(&self) -> u64 {
163        self.seq.fetch_add(1, Ordering::AcqRel)
164    }
165
166    /// Peek at the sequence number that `next_seq` would return without
167    /// consuming it. Used by lifecycle emitters that may fail to persist —
168    /// a burned seq would misalign the journal on retry, since recovery
169    /// checks `event.metadata.sequence == journal position`.
170    pub(crate) fn peek_seq(&self) -> u64 {
171        self.seq.load(Ordering::Acquire)
172    }
173
174    /// Commit a previously peeked seq. Callers hold the lifecycle_lock and
175    /// have drained worker jobs, so no concurrent updater can race the store.
176    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    /// Touch the activity clock.
205    pub fn touch(&self) {
206        self.last_activity_ms
207            .store(av_core::time::now_ms(), Ordering::Release);
208    }
209
210    /// Attempt to claim the close transition. Only one caller can hold the
211    /// claim at a time; a failed finalize resets it (`reset_close`) so the
212    /// close can be retried.
213    pub fn try_close(&self) -> bool {
214        self.closed
215            .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
216            .is_ok()
217    }
218
219    /// True when closed.
220    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    /// True once the receipt/ATIF artifact is durably persisted.
247    pub fn artifact_committed_flag(&self) -> bool {
248        self.artifact_committed.load(Ordering::Acquire) != 0
249    }
250
251    /// True once the close ran to full completion (journal removed).
252    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    /// Record that a close ran to full completion (journal and outboxes
261    /// removed). Only such sessions may be evicted from the registry.
262    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            // Subscribe first (via enable), then check the guard; the same Notified
269            // must remain pinned through the await, or notify_waiters() firing in
270            // the interval between drop and re-subscribe is lost forever.
271            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    /// Atomically claim promotion. Only one caller can hold the claim at a
282    /// time; failed receipt persistence resets it (`reset_promotion`) so
283    /// promotion can be retried.
284    pub fn try_promote(&self) -> bool {
285        self.promoted
286            .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
287            .is_ok()
288    }
289
290    /// True after promotion has completed (receipt persisted), not merely
291    /// been claimed.
292    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    /// Recreate a closed unsigned session from a persisted ATIF trajectory.
316    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    /// Consume the current ATIF builder and replace it with an empty one.
398    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    /// Number of forwarded chat responses currently streaming.
443    pub fn active_streams_count(&self) -> u64 {
444        self.active_streams.load(Ordering::Acquire)
445    }
446
447    /// Number of worker jobs accepted but not yet fully captured.
448    pub fn pending_jobs_count(&self) -> u64 {
449        self.pending_jobs.load(Ordering::Acquire)
450    }
451
452    /// Round-44 F2: distinguish the "empty unsigned session close
453    /// was rejected" quarantine (the reconciler's "no captured steps"
454    /// refusal) from a
455    /// successfully-persisted unsigned session. The reject path sets
456    /// `artifact_committed = 1` (so `is_closed()` stays true) but
457    /// never writes an ATIF file, never sets `atif_path`, and never
458    /// calls `mark_capture_failed`. Without this predicate the
459    /// round-43 F1 `pending_close_sessions()` filter picks it up and
460    /// drives the finalization tail — which emits a spurious
461    /// SESSION_CLOSE bridge event for a session that has no receipt
462    /// and no ATIF, breaking downstream OCSF consumers' invariant
463    /// that a close event follows an artifact event. It also marks
464    /// `close_complete = 1`, which lets `get_or_open` (reopen=true)
465    /// silently replace the quarantined Session with a fresh one on
466    /// the next chat request, losing the incident evidence.
467    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    /// Refresh token-derived fields after successful validation.
482    pub fn refresh_identity(&self, identity: &AgentIdentity) {
483        *self.latest_identity.lock() = identity.clone();
484    }
485
486    /// Current validated identity snapshot.
487    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    /// Build the receipt body for this session (signed close or promotion).
501    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
541/// RAII claim keeping a forwarded response active until completion or abort.
542pub 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/// The session registry.
562#[derive(Default)]
563pub struct SessionRegistry {
564    sessions: dashmap::DashMap<String, Arc<Session>>,
565}
566
567impl SessionRegistry {
568    /// Create an empty registry.
569    pub fn new() -> Self {
570        Self::default()
571    }
572
573    /// Get or open a session.
574    ///
575    /// If an entry exists but has completed close (receipt/ATIF durably
576    /// committed, journal removed) and has not yet been reaped by the
577    /// idle sweeper, treat the id as free and open a fresh session —
578    /// otherwise a well-behaved client that reuses a session id after a
579    /// server-side close (retry after a 5xx, network partition, TTL
580    /// refresh) would see `400 session is already closed` for the entire
581    /// eviction window. A session that started close but has not yet
582    /// finished it (`close_complete = 0`) is *not* replaced: reopening
583    /// would race the in-flight close and split the audit trail.
584    ///
585    /// This is the right shape for **chat requests**: the client is
586    /// starting a new turn and it's fine to give them a fresh state
587    /// under the same id. For **tool interception**, use
588    /// [`Self::get_or_open_no_reopen`] — a tool call references an
589    /// in-progress conversation, and silently resurrecting a closed
590    /// session would let the client extend the audit trail past its
591    /// signed receipt.
592    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, /* reopen_after_close */ true,
601        )
602    }
603
604    /// Like [`Self::get_or_open`] but hand back the existing session
605    /// **without** recycling completed-close entries — the caller then
606    /// sees `is_closed() == true` and can refuse with `BadRequest`.
607    /// Use this on paths where the caller is trying to extend an
608    /// existing session (tool interception, session-scoped mutations).
609    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, /* reopen_after_close */ 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    /// Look up a session.
659    pub fn get(&self, id: &str) -> Option<Arc<Session>> {
660        self.sessions.get(id).map(|s| s.clone())
661    }
662
663    /// Insert a session reconstructed from durable spool state.
664    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    /// Insert a recovered session only if the id is not already registered.
673    /// Returns `Err(existing)` on collision so recovery does not clobber a
674    /// concurrently-opened active session — the recovery loop must not run
675    /// finalize on the returned Arc when it happens to be the live one.
676    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    /// Remove a session (after finalization).
689    pub fn remove(&self, id: &str) {
690        self.sessions.remove(id);
691    }
692
693    /// Evict signed sessions whose close ran to full completion and that
694    /// have been idle longer than `idle_s`, returning the evicted sessions.
695    ///
696    /// Only signed sessions whose close *fully completed* are eligible: a
697    /// completed close removed the on-disk journal, so nothing re-inserts
698    /// them, and a later request or lifecycle call for the id behaves
699    /// exactly as it would after a process restart. `close_complete` (not
700    /// `artifact_committed`, which is set before the fallible bridge emits
701    /// and journal removal) is the gate — a failed or in-flight close must
702    /// stay resident, or a client reusing the id could open a fresh session
703    /// whose journal appends collide with the still-on-disk records.
704    /// Unsigned sessions must stay resident — the recovery scan re-inserts
705    /// them from their spool artifact on the next tick anyway, and evicting
706    /// one lets a client reuse its id against the still-present artifact and
707    /// provenance files, poisoning the new incarnation's close. Capture-failed
708    /// (quarantined) sessions also stay: they are bounded by real crash
709    /// events and their in-registry seal is what keeps the fail-closed
710    /// refusal cheap. Without eviction the registry grows by one entry per
711    /// client-chosen session id for the process lifetime.
712    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    /// Round-43 F1: sessions where `close_session_locked` marked
732    /// `artifact_committed = 1` but crashed / failed before running
733    /// the finalization tail (`emit_bridge_event(SESSION_CLOSE)` +
734    /// `remove_step_journal` + `remove_lifecycle_outbox` +
735    /// `mark_close_complete`). Without this recovery hook such
736    /// sessions accumulate in the registry forever: `is_closed()` is
737    /// true so the idle sweeper skips them; `close_complete = 0` so
738    /// `evict_finalized` refuses them; recovery scans skip them via
739    /// the "already in registry" short-circuit. Capture-failed and
740    /// empty-unsigned quarantines are excluded — they intentionally
741    /// stay in the registry as evidence of the incident.
742    ///
743    /// Round-44 F2: the empty-unsigned quarantine (the reconciler's
744    /// "no captured steps" refusal) does NOT set `capture_failed = 1` (it
745    /// is a distinct semantic — "no work was captured" rather than
746    /// "capture was lost mid-flight"), so `!capture_failed()` alone
747    /// let the sweep pick it up and emit a spurious SESSION_CLOSE
748    /// bridge event for a session that had no other events on the
749    /// wire. `is_empty_unsigned_quarantine()` closes that gap.
750    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    /// Sessions idle longer than `idle_s` (for the sweeper).
764    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                    // A session with a live forwarded response is not idle even
773                    // when its admission clock is stale: `last_activity_ms` is
774                    // refreshed only at request admission, so a stream that
775                    // outlives the idle window would otherwise let the sweeper
776                    // claim the close (sealing the session mid-conversation)
777                    // and then park inside `wait_for_streams` while holding
778                    // the shared lifecycle lock until the client's stream ends.
779                    && e.active_streams.load(Ordering::Acquire) == 0
780            })
781            .map(|e| e.clone())
782            .collect()
783    }
784
785    /// Snapshot every session in the registry — open, closed, or
786    /// capture-failed. Used by the dashboard to show recent activity
787    /// including sessions that have just been sealed but not yet evicted.
788    pub fn open_sessions_including_closed(&self) -> Vec<Arc<Session>> {
789        self.sessions.iter().map(|entry| entry.clone()).collect()
790    }
791
792    /// Snapshot every session still accepting work.
793    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    /// Number of registered sessions (includes closed sessions not yet removed).
802    pub fn len(&self) -> usize {
803        self.sessions.len()
804    }
805
806    /// True when no sessions are registered (closed or open).
807    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    /// A client that reuses a session id after the server-side close
866    /// completes (retry after a 5xx, TTL refresh, network partition
867    /// recovery) must get a fresh session instead of the closed one —
868    /// otherwise every request until the idle sweep would return
869    /// `400 session is already closed`. Only sessions with
870    /// `close_complete = true` are recycled; a session mid-close must
871    /// still be treated as the same one so the ongoing close is not
872    /// raced.
873    #[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        // First open. Simulate a full close (artifact committed +
879        // close_complete flipped) so the next get_or_open sees a
880        // fully-sealed session.
881        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    /// A session that has *started* close but has not completed it
896    /// must not be replaced — reopening would race the finaliser and
897    /// split the audit trail across two artifacts for the same id.
898    #[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        // close_complete NOT set — this is the in-flight close state.
905        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    /// A signed-recovery loop that discovers a stale journal for session X
913    /// must not clobber a live session X that raced its way into the
914    /// registry (client retries with the same id after a crash are a real
915    /// production case). `try_insert_recovered` returns `Err(existing)` so
916    /// the recovery loop can `continue` instead of running
917    /// `close_session_locked` on the active Arc and force-closing it.
918    #[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    /// `last_activity_ms` is refreshed only at request admission, so a chat
967    /// stream that outlives the idle window makes its session *look* idle
968    /// while a response is actively relaying. The sweeper must skip it:
969    /// otherwise `try_close` seals the session mid-conversation (every new
970    /// request gets "session is already closed") and `close_session_locked`
971    /// then parks inside `wait_for_streams` while holding the shared
972    /// lifecycle lock until the client's stream ends.
973    #[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    /// If the wall clock jumps backward (NTP correction, VM pause/resume,
993    /// unsynchronized replicas), `now_ms()` may return a value below a
994    /// session's stored `last_activity_ms`. `idle_sessions` must never
995    /// flag the session as idle in that case — a saturating_sub in the
996    /// cutoff calculation keeps the comparison well-defined and the
997    /// session survives until the clock catches back up.
998    #[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        // Simulate: session's activity stamp is FUTURE relative to `now_ms()`.
1003        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    /// A pathologically large `idle_s` (e.g., attacker-controlled config that
1016    /// gets past validation, or `u64::MAX`) must saturate the cutoff at 0
1017    /// instead of wrapping, causing no session to be reaped.
1018    #[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        // Something halfway through the multiplication path still triggers
1032        // saturation because `idle_s * 1000` overflows.
1033        assert!(r.idle_sessions(u64::MAX / 500).is_empty());
1034    }
1035
1036    /// `evict_finalized` removes only signed, fully committed, quiescent,
1037    /// idle sessions — and leaves unsigned, capture-failed, active-stream,
1038    /// and recently-active sessions resident.
1039    #[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        // Artifact committed but the close never completed (bridge emit or
1051        // journal removal failed): the journal may still be on disk, so a
1052        // reused id must find this sealed session, not a fresh one.
1053        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    /// A session touched millions of times a second under normal operation
1122    /// must never observe `last_activity_ms` moving backward — the wall
1123    /// clock underpinning `touch()` is monotone under a healthy kernel and
1124    /// the store uses Release ordering so a later reader always sees a
1125    /// value ≥ every prior stamp.
1126    #[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}