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;
10use std::time::{Duration, Instant};
11
12/// Workflow kind (brief Modules G/H).
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum Workflow {
16    /// Consequential actions: event chain + receipt at close.
17    Signed,
18    /// Exploratory: ATIF capture, receipt only on promotion.
19    Unsigned,
20}
21
22impl Workflow {
23    /// Canonical wire name (`x-av-workflow` header, config, journal metadata).
24    pub const fn as_str(self) -> &'static str {
25        match self {
26            Self::Signed => "signed",
27            Self::Unsigned => "unsigned",
28        }
29    }
30
31    /// Parse a wire name back into a `Workflow` (`None` on unknown input).
32    pub fn parse(value: &str) -> Option<Self> {
33        match value {
34            "signed" => Some(Self::Signed),
35            "unsigned" => Some(Self::Unsigned),
36            _ => None,
37        }
38    }
39}
40
41/// A live session.
42pub struct Session {
43    /// Session id.
44    pub id: String,
45    /// A per-open UUID that disambiguates
46    /// recycled session ids in the off-path vector store. Client
47    /// callers may reuse the same `id` after a finalize; the
48    /// `SessionRegistry::get_or_open` path historically returned a
49    /// fresh `Session` under the same key. A Qdrant vector search
50    /// filtered ONLY on `session_id` would then return the prior
51    /// incarnation's vectors, causing false semantic-loop signals
52    /// from an unrelated past session. Every vector record and
53    /// query is now scoped by
54    /// `session_scope = "{id}#{generation_uid}"` — the UUID is
55    /// fresh for every `Session::new`, so a recycled id lands in
56    /// a distinct scope. The bare `id` remains the primary key for
57    /// on-disk artifacts (spool paths, journal filenames), which
58    /// use crash-safe generation logic of their own.
59    pub session_scope: String,
60    /// Workflow kind.
61    pub workflow: Workflow,
62    /// Agent identity bound at open (from NHI validation).
63    pub identity: AgentIdentity,
64    /// Most recently validated identity, including current TTL.
65    latest_identity: Mutex<AgentIdentity>,
66    principal_binding: Mutex<Option<Option<String>>>,
67    /// Monotonic per-session event sequence (authoritative order).
68    seq: AtomicU64,
69    /// Monotonic authenticated-journal record index.
70    journal_index: AtomicU64,
71    /// Loop-breaker state.
72    pub loop_state: av_loopdetect::SessionLoopState,
73    /// Signed workflow: incremental event chain.
74    pub chain: Mutex<EventChain>,
75    /// Unsigned workflow: ATIF steps.
76    pub atif: Mutex<av_atif::TrajectoryBuilder>,
77    /// Aggregates for the receipt.
78    pub totals: Totals,
79    /// Last-activity timestamp (dashboard/display only), epoch ms.
80    /// Wall-clock time subject to VM/NTP jumps; not safe to use for
81    /// idle-eviction decisions — the private `last_activity_instant`
82    /// field carries a monotonic anchor the idle sweeper uses instead.
83    pub last_activity_ms: AtomicU64,
84    /// Monotonic last-activity instant. Used by the idle sweeper so a
85    /// forward wall-clock jump (VM resume, NTP correction) cannot make
86    /// active sessions look premature-idle: `Instant` is bounded by
87    /// real elapsed time on the system's monotonic clock.
88    last_activity_instant: Mutex<Instant>,
89    /// Last normalized provider or enforcement stop reason id.
90    last_stop_reason_id: AtomicU64,
91    /// Sticky enforcement latch (0 = never tripped, else the enforcement
92    /// stop-reason id). Set ONLY at genuine enforcement-refusal sites
93    /// (chat token-budget refusal, mid-stream budget refusal, loop-
94    /// breaker open refusal) — never by the last-write-wins
95    /// `last_stop_reason_id`, which later successful responses
96    /// overwrite and which quota-BACKEND failures and blocked tool
97    /// calls also write, making it an unreliable proxy for "enforcement
98    /// caused this session's end" (false lockouts and losable refusals,
99    /// both verified). Gates same-id recycling after close.
100    enforcement_tripped: AtomicU64,
101    /// Set once closed (idempotent close).
102    pub closed: AtomicU64,
103    /// Serializes close against request admission.
104    admission: RwLock<()>,
105    /// Forwarded chat responses that have not completed or aborted.
106    active_streams: AtomicU64,
107    /// Notification used by finalization to await active response completion.
108    streams_drained: tokio::sync::Notify,
109    /// Issued signed or retroactive receipt.
110    pub receipt: Mutex<Option<av_receipts::Receipt>>,
111    /// Persisted unsigned ATIF artifact.
112    pub atif_path: Mutex<Option<PathBuf>>,
113    /// Set once an unsigned artifact is promoted.
114    promoted: AtomicU64,
115    /// Worker jobs accepted but not yet fully captured.
116    pending_jobs: AtomicU64,
117    /// Notification used by finalization to await a drained worker queue.
118    jobs_drained: tokio::sync::Notify,
119    /// Set when an upstream action could not be captured completely.
120    capture_failed: AtomicU64,
121    /// RAM-cliff guard: count of unsigned ATIF steps
122    /// journaled by the worker. The steps themselves live ONLY in the
123    /// events journal; close rebuilds the trajectory from disk.
124    atif_steps: AtomicU64,
125    /// The lifecycle chain state — the single
126    /// holder of Open/Draining/Sealed/Complete, advanced only via
127    /// CAS `transition` calls that name their legal source states.
128    /// The `closed` claim flag above and the orthogonal properties
129    /// (`capture_failed`, `promoted`, admission) deliberately stay
130    /// outside the chain (see `SessionState` docs).
131    lifecycle: std::sync::atomic::AtomicU8,
132}
133
134/// The session lifecycle chain, held in
135/// one `AtomicU8` and advanced only through CAS transitions that
136/// name their expected source state — illegal transitions become
137/// impossible rather than untested.
138///
139/// Two flags deliberately stay OUTSIDE the chain (mirroring the S2
140/// plan's `admission_open` exception): `capture_failed` and
141/// `promoted` are orthogonal *properties*, not chain positions — a
142/// capture-failed session still seals and completes (the
143/// capture-failed seal path), and promotion happens to already-
144/// complete sessions. Folding them in would multiply states for
145/// combinations that all remain reachable.
146#[repr(u8)]
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148#[non_exhaustive]
149pub enum SessionState {
150    /// Admitting requests.
151    Open = 0,
152    /// `try_close` claimed the close; streams may still be draining.
153    /// A failed finalize returns to `Open` via `reset_close`.
154    Draining = 1,
155    /// The receipt/ATIF artifact is durably committed; the fallible
156    /// close tail (bridge emit + journal removal) is still pending.
157    Sealed = 2,
158    /// The close ran to full completion; the registry may evict.
159    Complete = 3,
160}
161
162impl SessionState {
163    fn decode(raw: u8) -> Self {
164        match raw {
165            1 => Self::Draining,
166            2 => Self::Sealed,
167            3 => Self::Complete,
168            _ => Self::Open,
169        }
170    }
171}
172
173/// Aggregate counters for receipts (atomics: workers update concurrently).
174#[derive(Debug, Default)]
175pub struct Totals {
176    /// Tool calls observed.
177    pub tool_calls: AtomicU64,
178    /// Tool calls allowed.
179    pub tool_allowed: AtomicU64,
180    /// Tool calls blocked.
181    pub tool_blocked: AtomicU64,
182    /// Prompt tokens.
183    pub prompt_tokens: AtomicU64,
184    /// Completion tokens.
185    pub completion_tokens: AtomicU64,
186    /// Cached tokens.
187    pub cached_tokens: AtomicU64,
188    /// Cost in micro-USD.
189    pub cost_usd_micros: AtomicU64,
190}
191
192impl Session {
193    /// Digest of the authenticated issuer and human principal, if any.
194    pub(crate) fn principal_binding(&self) -> Option<String> {
195        self.principal_binding.lock().clone().flatten()
196    }
197
198    /// Bind a fresh session once, and refuse a different principal thereafter.
199    pub(crate) fn bind_principal(&self, principal: Option<&str>) -> bool {
200        let mut binding = self.principal_binding.lock();
201        match binding.as_ref() {
202            Some(current) => current.as_deref() == principal,
203            None => {
204                *binding = Some(principal.map(str::to_owned));
205                true
206            }
207        }
208    }
209
210    /// Open a session.
211    pub fn new(
212        id: String,
213        workflow: Workflow,
214        identity: AgentIdentity,
215        breaker: av_loopdetect::BreakerConfig,
216    ) -> Self {
217        let agent = av_atif::Agent {
218            name: "agentvisor-ai-harness".into(),
219            version: identity.version.clone(),
220            model_name: None,
221            tool_definitions: None,
222            extra: Some(serde_json::json!({
223                "charter": identity.charter,
224                "instance_uid": identity.instance_uid,
225                "ttl_remaining_s": identity.ttl_remaining_s,
226            })),
227        };
228        Self {
229            chain: Mutex::new(EventChain::new(&id)),
230            atif: Mutex::new(av_atif::TrajectoryBuilder::new(agent, Some(id.clone()))),
231            // Fresh generation UUID per Session::new so
232            // recycled ids get a distinct vector-sink scope.
233            session_scope: format!("{id}#{}", av_core::new_event_uid()),
234            id,
235            workflow,
236            identity: identity.clone(),
237            latest_identity: Mutex::new(identity.clone()),
238            principal_binding: Mutex::new(None),
239            seq: AtomicU64::new(0),
240            journal_index: AtomicU64::new(0),
241            loop_state: av_loopdetect::SessionLoopState::new(breaker),
242            totals: Totals::default(),
243            last_activity_ms: AtomicU64::new(av_core::time::now_ms()),
244            last_activity_instant: Mutex::new(Instant::now()),
245            last_stop_reason_id: AtomicU64::new(0),
246            enforcement_tripped: AtomicU64::new(0),
247            closed: AtomicU64::new(0),
248            admission: RwLock::new(()),
249            active_streams: AtomicU64::new(0),
250            streams_drained: tokio::sync::Notify::new(),
251            receipt: Mutex::new(None),
252            atif_path: Mutex::new(None),
253            promoted: AtomicU64::new(0),
254            pending_jobs: AtomicU64::new(0),
255            jobs_drained: tokio::sync::Notify::new(),
256            capture_failed: AtomicU64::new(0),
257            atif_steps: AtomicU64::new(0),
258            lifecycle: std::sync::atomic::AtomicU8::new(SessionState::Open as u8),
259        }
260    }
261
262    /// Next event sequence number.
263    pub fn next_seq(&self) -> u64 {
264        self.seq.fetch_add(1, Ordering::AcqRel)
265    }
266
267    /// Peek at the sequence number that `next_seq` would return without
268    /// consuming it. Used by lifecycle emitters that may fail to persist —
269    /// a burned seq would misalign the journal on retry, since recovery
270    /// checks `event.metadata.sequence == journal position`.
271    pub(crate) fn peek_seq(&self) -> u64 {
272        self.seq.load(Ordering::Acquire)
273    }
274
275    /// Commit a previously peeked seq. Callers hold the lifecycle_lock and
276    /// have drained worker jobs, so no concurrent updater can race the store.
277    pub(crate) fn advance_seq_past(&self, seq: u64) {
278        self.seq.store(seq.saturating_add(1), Ordering::Release);
279    }
280
281    pub(crate) fn restore_next_seq(&self, next: u64) {
282        self.seq.store(next, Ordering::Release);
283    }
284
285    pub(crate) fn journal_index(&self) -> u64 {
286        self.journal_index.load(Ordering::Acquire)
287    }
288
289    pub(crate) fn commit_journal_index(&self, index: u64) -> Result<(), String> {
290        self.journal_index
291            .compare_exchange(
292                index,
293                index.saturating_add(1),
294                Ordering::AcqRel,
295                Ordering::Acquire,
296            )
297            .map(|_| ())
298            .map_err(|actual| format!("journal index changed from {index} to {actual}"))
299    }
300
301    pub(crate) fn restore_journal_index(&self, next: u64) {
302        self.journal_index.store(next, Ordering::Release);
303    }
304
305    /// Touch the activity clock. Both the wall-clock and the monotonic
306    /// anchors are updated: the wall-clock feeds dashboard display, the
307    /// monotonic feeds the idle sweeper.
308    pub fn touch(&self) {
309        self.last_activity_ms
310            .store(av_core::time::now_ms(), Ordering::Release);
311        *self.last_activity_instant.lock() = Instant::now();
312    }
313
314    /// Duration elapsed since this session's last touch, measured on
315    /// the monotonic clock. Safe against wall-clock jumps.
316    pub(crate) fn idle_duration(&self) -> Duration {
317        self.last_activity_instant.lock().elapsed()
318    }
319
320    /// Test-only: age a session so the idle sweeper (which uses the
321    /// monotonic clock) treats it as idle by `ago_seconds`. Production
322    /// code refreshes both anchors via `touch()`.
323    #[cfg(test)]
324    pub(crate) fn set_idle_for_testing(&self, ago_seconds: u64) {
325        let ago = Duration::from_secs(ago_seconds);
326        self.last_activity_ms.store(
327            av_core::time::now_ms().saturating_sub(ago_seconds.saturating_mul(av_core::units::MS_PER_SEC)),
328            Ordering::Release,
329        );
330        // `Instant::checked_sub` fails if the resulting Instant would
331        // predate the platform monotonic zero — in that case, fall back
332        // to the earliest available Instant so the test still expresses
333        // "as idle as possible".
334        let now = Instant::now();
335        *self.last_activity_instant.lock() = now.checked_sub(ago).unwrap_or(now);
336    }
337
338    /// Attempt to claim the close transition. Only one caller can hold the
339    /// claim at a time; a failed finalize resets it (`reset_close`) so the
340    /// close can be retried.
341    pub fn try_close(&self) -> bool {
342        let claimed = self
343            .closed
344            .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
345            .is_ok();
346        if claimed {
347            self.transition(&[SessionState::Open], SessionState::Draining);
348        }
349        claimed
350    }
351
352    /// The lifecycle chain position.
353    pub fn lifecycle_state(&self) -> SessionState {
354        SessionState::decode(self.lifecycle.load(Ordering::Acquire))
355    }
356
357    /// One CAS transition of the lifecycle state machine (S2 step 3:
358    /// the enum IS the state; the mirror flags are deleted), tolerant
359    /// of two documented behaviors of the close machinery:
360    ///
361    /// * **Idempotent/late re-marks** — a current state at or past
362    ///   `to` in the chain is a no-op. This covers idempotent flag
363    ///   stores AND the `closed` claim flag's post-seal semantics:
364    ///   `reset_close`/`try_close` toggle the claim after an
365    ///   artifact is already committed (failed close-tail retries),
366    ///   where `is_closed()` stays true via `artifact_committed`
367    ///   and the chain must stay Sealed.
368    /// * **Multiple legal sources** — signed recovery seals adopted
369    ///   sessions without a prior claim (Open→Sealed); a live close
370    ///   seals under its claim (Draining→Sealed).
371    ///
372    /// Any other source state is a model violation: the transition
373    /// is REFUSED (the CAS never fires — illegal transitions are
374    /// impossible, not merely untested), and debug builds (the
375    /// entire test suite) additionally panic naming the transition.
376    fn transition(&self, from: &[SessionState], to: SessionState) {
377        let mut current = self.lifecycle.load(Ordering::Acquire);
378        loop {
379            let state = SessionState::decode(current);
380            if from.contains(&state) {
381                match self.lifecycle.compare_exchange_weak(
382                    current,
383                    to as u8,
384                    Ordering::AcqRel,
385                    Ordering::Acquire,
386                ) {
387                    Ok(_) => break,
388                    Err(actual) => current = actual,
389                }
390            } else if state == to || (state as u8) > (to as u8) {
391                break;
392            } else {
393                debug_assert!(
394                    false,
395                    "illegal lifecycle transition {state:?}->{to:?} (expected from {from:?})"
396                );
397                break;
398            }
399        }
400    }
401
402    /// True when closed: the close claim is held (`closed` — a claim
403    /// flag, not a chain position; see `transition`) or the
404    /// lifecycle chain is at/past Sealed.
405    pub fn is_closed(&self) -> bool {
406        self.closed.load(Ordering::Acquire) != 0
407            || matches!(
408                self.lifecycle_state(),
409                SessionState::Sealed | SessionState::Complete
410            )
411    }
412
413    pub(crate) fn admission_guard(&self) -> RwLockReadGuard<'_, ()> {
414        self.admission.read()
415    }
416
417    pub(crate) fn try_lease(self: &Arc<Self>) -> Option<SessionLease> {
418        let admission = self.admission.read();
419        if self.is_closed() {
420            return None;
421        }
422        let lease = SessionLease::new(Arc::clone(self));
423        drop(admission);
424        Some(lease)
425    }
426
427    pub(crate) fn close_guard(&self) -> RwLockWriteGuard<'_, ()> {
428        self.admission.write()
429    }
430
431    pub(crate) fn reset_close(&self) {
432        self.closed.store(0, Ordering::Release);
433        self.transition(&[SessionState::Draining], SessionState::Open);
434    }
435
436    /// True once the receipt/ATIF artifact is durably persisted.
437    pub fn artifact_committed_flag(&self) -> bool {
438        matches!(
439            self.lifecycle_state(),
440            SessionState::Sealed | SessionState::Complete
441        )
442    }
443
444    /// True once the close ran to full completion (journal removed).
445    pub fn close_complete_flag(&self) -> bool {
446        self.lifecycle_state() == SessionState::Complete
447    }
448
449    /// Mark the receipt/ATIF artifact durably committed (S2 step 3:
450    /// the lifecycle enum is the only holder of this state; note the
451    /// Sealed→Complete split exists because the artifact commits
452    /// BEFORE the fallible close tail — bridge emits + journal
453    /// removal — so a failed or in-flight close must not look
454    /// evictable).
455    pub(crate) fn mark_artifact_committed(&self) {
456        self.transition(
457            &[SessionState::Open, SessionState::Draining],
458            SessionState::Sealed,
459        );
460    }
461
462    /// Record that a close ran to full completion (journal and outboxes
463    /// removed). Only such sessions may be evicted from the registry.
464    pub(crate) fn mark_close_complete(&self) {
465        self.transition(&[SessionState::Sealed], SessionState::Complete);
466    }
467
468    pub(crate) async fn wait_for_streams(&self) {
469        loop {
470            // Subscribe first (via enable), then check the guard; the same Notified
471            // must remain pinned through the await, or notify_waiters() firing in
472            // the interval between drop and re-subscribe is lost forever.
473            let notified = self.streams_drained.notified();
474            let mut notified = std::pin::pin!(notified);
475            notified.as_mut().enable();
476            if self.active_streams.load(Ordering::Acquire) == 0 {
477                return;
478            }
479            notified.await;
480        }
481    }
482
483    /// Atomically claim promotion. Only one caller can hold the claim at a
484    /// time; failed receipt persistence resets it (`reset_promotion`) so
485    /// promotion can be retried.
486    pub fn try_promote(&self) -> bool {
487        self.promoted
488            .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
489            .is_ok()
490    }
491
492    /// True after promotion has completed (receipt persisted), not merely
493    /// been claimed.
494    pub fn is_promoted(&self) -> bool {
495        self.promoted.load(Ordering::Acquire) == 2
496    }
497
498    pub(crate) fn finish_promotion(&self) {
499        self.promoted.store(2, Ordering::Release);
500    }
501
502    pub(crate) fn reset_promotion(&self) {
503        let _ = self
504            .promoted
505            .compare_exchange(1, 0, Ordering::AcqRel, Ordering::Acquire);
506    }
507
508    pub(crate) fn restore_receipt(&self, receipt: av_receipts::Receipt) {
509        *self.receipt.lock() = Some(receipt);
510        self.finish_promotion();
511    }
512
513    pub(crate) fn restore_pending_receipt(&self, receipt: av_receipts::Receipt) {
514        *self.receipt.lock() = Some(receipt);
515    }
516
517    /// Recreate a closed unsigned session from a persisted ATIF trajectory.
518    ///
519    /// `next_seq` is the number of per-session events already published to
520    /// the bridge for this session (steps ↔ journal records are 1:1, so the
521    /// step count is exact). Without restoring it, an adopted session
522    /// reports `peek_seq() == 0` and a post-crash SESSION_CLOSE or
523    /// retroactive-receipt event is minted with `metadata.sequence = 0`,
524    /// colliding with the session's first step event already on the bridge
525    /// (both sibling recovery paths call `restore_next_seq`; this one used
526    /// to be the exception).
527    pub fn recover_unsigned(
528        id: String,
529        identity: AgentIdentity,
530        breaker: av_loopdetect::BreakerConfig,
531        path: PathBuf,
532        metrics: Option<&av_atif::FinalMetrics>,
533        next_seq: u64,
534    ) -> Result<Self, String> {
535        Self::recover_unsigned_with_principal(id, identity, breaker, path, metrics, next_seq, None)
536    }
537
538    /// Recover an authenticated ATIF artifact with its persisted owner. Legacy
539    /// artifacts without an owner bind anonymously, so a later authenticated
540    /// caller cannot claim them merely by sharing an agent instance id.
541    pub(crate) fn recover_unsigned_with_principal(
542        id: String,
543        identity: AgentIdentity,
544        breaker: av_loopdetect::BreakerConfig,
545        path: PathBuf,
546        metrics: Option<&av_atif::FinalMetrics>,
547        next_seq: u64,
548        principal: Option<&str>,
549    ) -> Result<Self, String> {
550        let session = Self::new(id, Workflow::Unsigned, identity, breaker);
551        session.bind_principal(principal);
552        session.restore_next_seq(next_seq);
553        // A recovered artifact is by definition past close: claim the
554        // close (fresh session — the claim cannot fail) and seal, so
555        // the lifecycle chain walks the same Open→Draining→Sealed
556        // path a live close does.
557        let claimed = session.try_close();
558        debug_assert!(claimed, "recover_unsigned starts from a fresh session");
559        session.mark_artifact_committed();
560        *session.atif_path.lock() = Some(path);
561        if let Some(metrics) = metrics {
562            let prompt_tokens = recovered_counter(metrics.total_prompt_tokens, "prompt tokens")?;
563            let completion_tokens = recovered_counter(metrics.total_completion_tokens, "completion tokens")?;
564            let cached_tokens = recovered_counter(metrics.total_cached_tokens, "cached tokens")?;
565            session
566                .totals
567                .prompt_tokens
568                .store(prompt_tokens, Ordering::Release);
569            session
570                .totals
571                .completion_tokens
572                .store(completion_tokens, Ordering::Release);
573            session
574                .totals
575                .cached_tokens
576                .store(cached_tokens, Ordering::Release);
577            if let Some(extra) = metrics.extra.as_ref() {
578                // Cost has two possible sources on the persisted extra:
579                //   1. `cost_usd_micros` — the current writer's exact
580                //      u64 field.
581                //   2. missing / null — an older writer's format,
582                //      pre-dating the cost_usd_micros key.
583                // Falling straight to `recovered_counter(...)?` on the
584                // missing key returned 0, silently zeroing the session's
585                // recovered cost even when `metrics.total_cost_usd` (the
586                // ATIF-level fallback consumed by the non-extra branch
587                // below) was present. Prefer the exact u64 when it's
588                // there, otherwise use the ATIF-level float — same logic
589                // as the pre-extra branch.
590                let cost_usd_micros = match extra.get("cost_usd_micros").and_then(serde_json::Value::as_u64) {
591                    Some(v) => recovered_counter(Some(v), "cost")?,
592                    None => cost_from_atif_total(metrics.total_cost_usd)?,
593                };
594                session
595                    .totals
596                    .cost_usd_micros
597                    .store(cost_usd_micros, Ordering::Release);
598                let tool_calls = recovered_counter(
599                    extra.get("tool_calls").and_then(serde_json::Value::as_u64),
600                    "tool calls",
601                )?;
602                let tool_allowed = recovered_counter(
603                    extra.get("tool_allowed").and_then(serde_json::Value::as_u64),
604                    "allowed tools",
605                )?;
606                let tool_blocked = recovered_counter(
607                    extra.get("tool_blocked").and_then(serde_json::Value::as_u64),
608                    "blocked tools",
609                )?;
610                if tool_allowed
611                    .checked_add(tool_blocked)
612                    .is_none_or(|classified| classified > tool_calls)
613                {
614                    return Err("recovered tool accounting is inconsistent".to_owned());
615                }
616                session.totals.tool_calls.store(tool_calls, Ordering::Release);
617                session.totals.tool_allowed.store(tool_allowed, Ordering::Release);
618                session.totals.tool_blocked.store(tool_blocked, Ordering::Release);
619                if let Some(id) = extra.get("stop_reason_id").and_then(serde_json::Value::as_u64) {
620                    if id > u64::from(u8::MAX) {
621                        return Err("recovered stop reason exceeds u8".to_owned());
622                    }
623                    session.last_stop_reason_id.store(id, Ordering::Release);
624                }
625                // Latch restoration across restart, from the EXPLICITLY
626                // persisted latch value — never inferred from the stop
627                // reason, which Inject-action breaker trips also record
628                // without latching live (a restart used to upgrade such
629                // deliberately non-terminal trips into permanent id
630                // lockouts). Absent key (pre-latch artifacts,
631                // consolidation-rebuilt artifacts) means no latch.
632                if let Some(latched) = extra
633                    .get("enforcement_latched")
634                    .and_then(serde_json::Value::as_u64)
635                {
636                    if latched > u64::from(u8::MAX) {
637                        return Err("recovered enforcement latch exceeds u8".to_owned());
638                    }
639                    if latched != 0 {
640                        session.enforcement_tripped.store(latched, Ordering::Release);
641                    }
642                }
643            } else if let Some(cost) = metrics.total_cost_usd {
644                session
645                    .totals
646                    .cost_usd_micros
647                    .store(cost_from_atif_total(Some(cost))?, Ordering::Release);
648            }
649        }
650        Ok(session)
651    }
652
653    /// Consume the current ATIF builder and replace it with an empty one.
654    pub fn take_trajectory(&self) -> av_atif::Trajectory {
655        let identity = self.current_identity();
656        let agent = av_atif::Agent {
657            name: "agentvisor-ai-harness".into(),
658            version: identity.version.clone(),
659            model_name: None,
660            tool_definitions: None,
661            extra: Some(serde_json::json!({
662                "charter": identity.charter,
663                "instance_uid": identity.instance_uid,
664                "ttl_remaining_s": identity.ttl_remaining_s,
665            })),
666        };
667        let replacement = av_atif::TrajectoryBuilder::new(agent, Some(self.id.clone()));
668        let builder = std::mem::replace(&mut *self.atif.lock(), replacement);
669        let mut trajectory = builder.finish();
670        self.stamp_trajectory_principal(&mut trajectory);
671        trajectory
672    }
673
674    pub(crate) fn snapshot_trajectory(&self) -> av_atif::Trajectory {
675        let mut trajectory = self.atif.lock().clone().finish();
676        self.stamp_trajectory_principal(&mut trajectory);
677        trajectory
678    }
679
680    fn stamp_trajectory_principal(&self, trajectory: &mut av_atif::Trajectory) {
681        let extra = trajectory
682            .agent
683            .extra
684            .get_or_insert_with(|| serde_json::json!({}));
685        if let Some(extra) = extra.as_object_mut() {
686            extra.insert(
687                "principal_binding".into(),
688                serde_json::json!(self.principal_binding()),
689            );
690        }
691    }
692
693    /// Release the trajectory builder's memory after a durable close.
694    /// SUCCESSFUL unsigned closes previously kept
695    /// the full trajectory (every captured Step, up to 8 MiB each) in
696    /// RAM for the process lifetime because `evict_finalized` only
697    /// evicts Signed sessions. Drain the builder AFTER the artifact +
698    /// sidecar are durable, so a failed write can still be retried (it
699    /// re-uses the still-populated builder) but a succeeded close
700    /// promptly reclaims the memory.
701    pub(crate) fn drain_trajectory_builder(&self) {
702        let identity = self.current_identity();
703        let agent = av_atif::Agent {
704            name: "agentvisor-ai-harness".into(),
705            version: identity.version.clone(),
706            model_name: None,
707            tool_definitions: None,
708            extra: Some(serde_json::json!({
709                "charter": identity.charter,
710                "instance_uid": identity.instance_uid,
711                "ttl_remaining_s": identity.ttl_remaining_s,
712            })),
713        };
714        *self.atif.lock() = av_atif::TrajectoryBuilder::new(agent, Some(self.id.clone()));
715    }
716
717    pub(crate) fn worker_job_started(&self) {
718        self.pending_jobs.fetch_add(1, Ordering::AcqRel);
719    }
720
721    /// RAM-cliff guard: count a journaled unsigned ATIF
722    /// step. The step content lives in the events journal only;
723    /// close rebuilds the trajectory from there.
724    pub(crate) fn note_atif_step(&self) {
725        self.atif_steps.fetch_add(1, Ordering::AcqRel);
726    }
727
728    /// Number of unsigned ATIF steps journaled for this session.
729    pub fn atif_steps_count(&self) -> u64 {
730        self.atif_steps.load(Ordering::Acquire)
731    }
732
733    pub(crate) fn worker_job_finished(&self) {
734        if self.pending_jobs.fetch_sub(1, Ordering::AcqRel) == 1 {
735            self.jobs_drained.notify_waiters();
736        }
737    }
738
739    /// Wait until every accepted worker job for this session has been
740    /// durably captured (journal fsynced, bridge acked, marker
741    /// cleared). Public so external harnesses — the criterion bench,
742    /// embedders sequencing a close after a burst — can drain the
743    /// audit queue deterministically instead of sleeping.
744    pub async fn wait_for_worker_jobs(&self) {
745        loop {
746            let notified = self.jobs_drained.notified();
747            let mut notified = std::pin::pin!(notified);
748            notified.as_mut().enable();
749            if self.pending_jobs.load(Ordering::Acquire) == 0 {
750                return;
751            }
752            notified.await;
753        }
754    }
755
756    /// Number of forwarded chat responses currently streaming.
757    pub fn active_streams_count(&self) -> u64 {
758        self.active_streams.load(Ordering::Acquire)
759    }
760
761    /// Number of worker jobs accepted but not yet fully captured.
762    pub fn pending_jobs_count(&self) -> u64 {
763        self.pending_jobs.load(Ordering::Acquire)
764    }
765
766    /// Distinguish the "empty unsigned session close
767    /// was rejected" quarantine (the reconciler's "no captured steps"
768    /// refusal) from a
769    /// successfully-persisted unsigned session. The reject path sets
770    /// `artifact_committed = 1` (so `is_closed()` stays true) but
771    /// never writes an ATIF file, never sets `atif_path`, and never
772    /// calls `mark_capture_failed`. Without this predicate the
773    /// `pending_close_sessions()` filter picks it up and
774    /// drives the finalization tail — which emits a spurious
775    /// SESSION_CLOSE bridge event for a session that has no receipt
776    /// and no ATIF, breaking downstream OCSF consumers' invariant
777    /// that a close event follows an artifact event. It also marks
778    /// `close_complete = 1`, which lets `get_or_open` (reopen=true)
779    /// silently replace the quarantined Session with a fresh one on
780    /// the next chat request, losing the incident evidence.
781    pub(crate) fn is_empty_unsigned_quarantine(&self) -> bool {
782        self.workflow == Workflow::Unsigned
783            && self.artifact_committed_flag()
784            && self.atif_path.lock().is_none()
785    }
786
787    pub(crate) fn mark_capture_failed(&self) {
788        self.capture_failed.store(1, Ordering::Release);
789    }
790
791    pub(crate) fn capture_failed(&self) -> bool {
792        self.capture_failed.load(Ordering::Acquire) != 0
793    }
794
795    /// Refresh token-derived fields after successful validation.
796    pub fn refresh_identity(&self, identity: &AgentIdentity) {
797        *self.latest_identity.lock() = identity.clone();
798    }
799
800    /// Current validated identity snapshot.
801    pub fn current_identity(&self) -> AgentIdentity {
802        self.latest_identity.lock().clone()
803    }
804
805    pub(crate) fn record_stop_reason(&self, reason: StopReason) {
806        self.last_stop_reason_id
807            .store(u64::from(reason.id()), Ordering::Release);
808    }
809
810    pub(crate) fn recorded_stop_reason_id(&self) -> u64 {
811        self.last_stop_reason_id.load(Ordering::Acquire)
812    }
813
814    /// Latch an enforcement refusal (sticky; see the field doc).
815    pub(crate) fn latch_enforcement(&self, reason: StopReason) {
816        self.enforcement_tripped
817            .store(u64::from(reason.id()), Ordering::Release);
818    }
819
820    /// True when an enforcement refusal was latched on this session.
821    pub(crate) fn enforcement_tripped(&self) -> bool {
822        self.enforcement_tripped.load(Ordering::Acquire) != 0
823    }
824
825    /// Raw latched enforcement id (0 = never latched) — persisted into
826    /// the unsigned artifact's final-metrics extra at close so the
827    /// refusal survives restarts without inferring it from the
828    /// overwritable, action-ambiguous stop reason.
829    pub(crate) fn enforcement_latched_id(&self) -> u64 {
830        self.enforcement_tripped.load(Ordering::Acquire)
831    }
832
833    /// Build the receipt body for this session (signed close or promotion).
834    pub fn receipt_body(
835        &self,
836        subject: av_receipts::ReceiptSubject,
837        stop: StopReason,
838    ) -> av_receipts::ReceiptBody {
839        let recorded_id = self.last_stop_reason_id.load(Ordering::Acquire);
840        let recorded = StopReason::from_id(u8::try_from(recorded_id).unwrap_or(0));
841        // Distinguish "never recorded" (raw id 0) from "recorded an id
842        // this build cannot map" (nonzero → `from_id` says Unknown —
843        // a NEWER node's stop-reason id recovered from its artifact).
844        // Only the former may fall back to the caller's stop: rewriting
845        // a foreign id into e.g. SessionClosed would forge a specific
846        // reason the session never had into the SIGNED receipt. The
847        // honest representation for a foreign id is Unknown.
848        let stop = if recorded_id == 0 { stop } else { recorded };
849        av_receipts::receipt::new_body(
850            self.id.clone(),
851            self.current_identity(),
852            subject,
853            av_receipts::ToolCallSummary {
854                total: self.totals.tool_calls.load(Ordering::Acquire),
855                allowed: self.totals.tool_allowed.load(Ordering::Acquire),
856                blocked: self.totals.tool_blocked.load(Ordering::Acquire),
857            },
858            av_receipts::CostSummary {
859                prompt_tokens: self.totals.prompt_tokens.load(Ordering::Acquire),
860                completion_tokens: self.totals.completion_tokens.load(Ordering::Acquire),
861                cached_tokens: self.totals.cached_tokens.load(Ordering::Acquire),
862                cost_usd_micros: self.totals.cost_usd_micros.load(Ordering::Acquire),
863            },
864            stop,
865        )
866    }
867}
868
869fn recovered_counter(value: Option<u64>, field: &str) -> Result<u64, String> {
870    let value = value.unwrap_or(0);
871    if value > av_core::error::JCS_SAFE_MAX {
872        return Err(format!("recovered {field} exceeds JCS-safe bounds"));
873    }
874    Ok(value)
875}
876
877/// Convert an ATIF-level `total_cost_usd` float (dollars) into u64
878/// micros with the same finite/nonnegative/JCS-safe guards the
879/// non-extra branch enforces inline. Returns 0 for a missing value,
880/// mirroring `recovered_counter`. Shared between the two recovery
881/// branches so a cross-version artifact — extra present but without
882/// `cost_usd_micros`, with `total_cost_usd` at the ATIF level — no
883/// longer silently zeroes the recovered cost.
884fn cost_from_atif_total(cost: Option<f64>) -> Result<u64, String> {
885    let Some(cost) = cost else {
886        return Ok(0);
887    };
888    if !cost.is_finite() || cost < 0.0 {
889        return Err("recovered cost is not finite and nonnegative".to_owned());
890    }
891    let micros = (cost * av_core::units::USD_MICROS_PER_DOLLAR as f64).round();
892    if micros > av_core::error::JCS_SAFE_MAX as f64 {
893        return Err("recovered cost exceeds JCS-safe bounds".to_owned());
894    }
895    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
896    Ok(micros as u64)
897}
898
899/// RAII claim keeping a forwarded response active until completion or abort.
900pub struct SessionLease {
901    session: Arc<Session>,
902}
903
904impl SessionLease {
905    pub(crate) fn new(session: Arc<Session>) -> Self {
906        session.active_streams.fetch_add(1, Ordering::AcqRel);
907        Self { session }
908    }
909}
910
911impl Drop for SessionLease {
912    fn drop(&mut self) {
913        if self.session.active_streams.fetch_sub(1, Ordering::AcqRel) == 1 {
914            self.session.streams_drained.notify_waiters();
915        }
916    }
917}
918
919/// The session registry.
920#[derive(Default)]
921pub struct SessionRegistry {
922    sessions: dashmap::DashMap<String, Arc<Session>>,
923}
924
925impl SessionRegistry {
926    /// Create an empty registry.
927    pub fn new() -> Self {
928        Self::default()
929    }
930
931    /// Get or open a session.
932    ///
933    /// If an entry exists but has completed close (receipt/ATIF durably
934    /// committed, journal removed) and has not yet been reaped by the
935    /// idle sweeper, treat the id as free and open a fresh session —
936    /// otherwise a well-behaved client that reuses a session id after a
937    /// server-side close (retry after a 5xx, network partition, TTL
938    /// refresh) would see `400 session is already closed` for the entire
939    /// eviction window. A session that started close but has not yet
940    /// finished it (`close_complete = 0`) is *not* replaced: reopening
941    /// would race the in-flight close and split the audit trail.
942    ///
943    /// This is the right shape for **chat requests**: the client is
944    /// starting a new turn and it's fine to give them a fresh state
945    /// under the same id. For **tool interception**, use
946    /// [`Self::get_or_open_no_reopen`] — a tool call references an
947    /// in-progress conversation, and silently resurrecting a closed
948    /// session would let the client extend the audit trail past its
949    /// signed receipt.
950    pub fn get_or_open(
951        &self,
952        id: &str,
953        workflow: Workflow,
954        identity: &AgentIdentity,
955        breaker: &av_loopdetect::BreakerConfig,
956    ) -> Arc<Session> {
957        self.get_or_open_inner(
958            id, workflow, identity, breaker, /* reopen_after_close */ true,
959        )
960    }
961
962    /// Like [`Self::get_or_open`] but hand back the existing session
963    /// **without** recycling completed-close entries — the caller then
964    /// sees `is_closed() == true` and can refuse with `BadRequest`.
965    /// Use this on paths where the caller is trying to extend an
966    /// existing session (tool interception, session-scoped mutations).
967    pub fn get_or_open_no_reopen(
968        &self,
969        id: &str,
970        workflow: Workflow,
971        identity: &AgentIdentity,
972        breaker: &av_loopdetect::BreakerConfig,
973    ) -> Arc<Session> {
974        self.get_or_open_inner(
975            id, workflow, identity, breaker, /* reopen_after_close */ false,
976        )
977    }
978
979    fn get_or_open_inner(
980        &self,
981        id: &str,
982        workflow: Workflow,
983        identity: &AgentIdentity,
984        breaker: &av_loopdetect::BreakerConfig,
985        reopen_after_close: bool,
986    ) -> Arc<Session> {
987        use dashmap::mapref::entry::Entry;
988        match self.sessions.entry(id.to_owned()) {
989            Entry::Occupied(mut occupied) => {
990                if reopen_after_close && occupied.get().close_complete_flag() {
991                    // Enforcement-triggered closes must NOT silently
992                    // recycle: a session closed because its token budget
993                    // was exhausted (or its loop breaker aborted) would
994                    // otherwise reopen under the SAME id with a fresh
995                    // budget and fresh breaker — verified live as a
996                    // strict 200/403/200/403 alternation that rides the
997                    // per-session cap at ~50% duty forever. Per-session
998                    // budgets cannot stop a client spreading across NEW
999                    // ids (inherent to per-session semantics), but the
1000                    // same id must stay terminally refused so each
1001                    // incarnation is an explicit, distinct audit entity.
1002                    // Keyed on the sticky enforcement latch, not the
1003                    // last-write-wins stop reason — later responses
1004                    // overwrite that record, and non-enforcement
1005                    // failures (quota-backend errors, blocked tool
1006                    // calls) also write it, making it an unreliable
1007                    // proxy in both directions.
1008                    if occupied.get().enforcement_tripped() {
1009                        return occupied.get().clone();
1010                    }
1011                    let fresh = Arc::new(Session::new(
1012                        id.to_owned(),
1013                        workflow,
1014                        identity.clone(),
1015                        breaker.clone(),
1016                    ));
1017                    occupied.insert(Arc::clone(&fresh));
1018                    fresh
1019                } else {
1020                    occupied.get().clone()
1021                }
1022            }
1023            Entry::Vacant(vacant) => {
1024                let fresh = Arc::new(Session::new(
1025                    id.to_owned(),
1026                    workflow,
1027                    identity.clone(),
1028                    breaker.clone(),
1029                ));
1030                vacant.insert(Arc::clone(&fresh));
1031                fresh
1032            }
1033        }
1034    }
1035
1036    /// Look up a session.
1037    pub fn get(&self, id: &str) -> Option<Arc<Session>> {
1038        self.sessions.get(id).map(|s| s.clone())
1039    }
1040
1041    /// Collect the SHA-256[..32] hex stem of every currently-registered
1042    /// session id — the same encoding the ATIF/journal spool uses. The
1043    /// reconciler's orphan sweep uses this to skip files whose stem
1044    /// belongs to a live session, closing the race window where an
1045    /// in-progress close's atif+atif-auth pair is quarantined mid-write.
1046    pub fn live_atif_stems(&self) -> std::collections::HashSet<String> {
1047        self.sessions
1048            .iter()
1049            .map(|entry| {
1050                let digest = av_core::digest::sha256_hex(entry.key().as_bytes());
1051                digest.get(..32).unwrap_or(&digest).to_owned()
1052            })
1053            .collect()
1054    }
1055
1056    /// Insert a session reconstructed from durable spool state.
1057    pub fn insert_recovered(&self, session: Session) -> Arc<Session> {
1058        let id = session.id.clone();
1059        self.sessions
1060            .entry(id)
1061            .or_insert_with(|| Arc::new(session))
1062            .clone()
1063    }
1064
1065    /// Insert a recovered session only if the id is not already registered.
1066    /// Returns `Err(existing)` on collision so recovery does not clobber a
1067    /// concurrently-opened active session — the recovery loop must not run
1068    /// finalize on the returned Arc when it happens to be the live one.
1069    pub fn try_insert_recovered(&self, session: Session) -> Result<Arc<Session>, Arc<Session>> {
1070        use dashmap::mapref::entry::Entry;
1071        match self.sessions.entry(session.id.clone()) {
1072            Entry::Occupied(existing) => Err(existing.get().clone()),
1073            Entry::Vacant(slot) => {
1074                let arc = Arc::new(session);
1075                slot.insert(Arc::clone(&arc));
1076                Ok(arc)
1077            }
1078        }
1079    }
1080
1081    /// Remove a session (after finalization).
1082    pub fn remove(&self, id: &str) {
1083        self.sessions.remove(id);
1084    }
1085
1086    /// Evict signed sessions whose close ran to full completion and that
1087    /// have been idle longer than `idle_s`, returning the evicted sessions.
1088    ///
1089    /// Only signed sessions whose close *fully completed* are eligible: a
1090    /// completed close removed the on-disk journal, so nothing re-inserts
1091    /// them, and a later request or lifecycle call for the id behaves
1092    /// exactly as it would after a process restart. `close_complete` (not
1093    /// `artifact_committed`, which is set before the fallible bridge emits
1094    /// and journal removal) is the gate — a failed or in-flight close must
1095    /// stay resident, or a client reusing the id could open a fresh session
1096    /// whose journal appends collide with the still-on-disk records.
1097    /// Unsigned sessions must stay resident — the recovery scan re-inserts
1098    /// them from their spool artifact on the next tick anyway, and evicting
1099    /// one lets a client reuse its id against the still-present artifact and
1100    /// provenance files, poisoning the new incarnation's close. Capture-failed
1101    /// (quarantined) sessions also stay: they are bounded by real crash
1102    /// events and their in-registry seal is what keeps the fail-closed
1103    /// refusal cheap. Without eviction the registry grows by one entry per
1104    /// client-chosen session id for the process lifetime. The three
1105    /// client-mintable retained classes (enforcement-latched,
1106    /// capture-failed, empty-unsigned quarantine) are each bounded by an
1107    /// overflow cap below.
1108    pub fn evict_finalized(&self, idle_s: u64) -> Vec<Arc<Session>> {
1109        // Idle comparison uses the monotonic clock so a
1110        // forward wall-clock jump (VM resume, NTP correction) cannot
1111        // make finalized sessions look premature-idle. Wall-clock
1112        // `last_activity_ms` stays available for dashboard display.
1113        let idle_duration = Duration::from_secs(idle_s);
1114        let mut evicted = Vec::new();
1115        self.sessions.retain(|_, session| {
1116            let evict = session.workflow == Workflow::Signed
1117                && session.close_complete_flag()
1118                && !session.capture_failed()
1119                // Enforcement-latched sessions are retained like
1120                // capture-failed ones: evicting them vacated the id, and
1121                // the next get_or_open built a fresh session with a
1122                // fresh budget/breaker — the same-id refusal silently
1123                // expired after idle_s for signed workflows. Bounded by
1124                // the overflow pass below (unlike capture_failed, the
1125                // latch is client-mintable at line rate — one over-cap
1126                // request per fresh id — so retention needs a cap).
1127                && !session.enforcement_tripped()
1128                && session.active_streams.load(Ordering::Acquire) == 0
1129                && session.pending_jobs.load(Ordering::Acquire) == 0
1130                && session.idle_duration() >= idle_duration;
1131            if evict {
1132                evicted.push(Arc::clone(session));
1133            }
1134            !evict
1135        });
1136        // Overflow pass: cap latched-retained signed sessions. Beyond the
1137        // cap, evict oldest-first — the same-id refusal expires for the
1138        // evicted ids (degraded to the pre-latch idle-bounded behavior),
1139        // which is the accepted cost of bounding client-mintable registry
1140        // growth. Unsigned latched sessions are governed by the existing
1141        // unsigned retention posture and are not evicted here.
1142        const MAX_LATCHED_RETAINED: usize = 4096;
1143        let mut latched: Vec<Arc<Session>> = self
1144            .sessions
1145            .iter()
1146            .filter(|entry| {
1147                entry.workflow == Workflow::Signed
1148                    && entry.close_complete_flag()
1149                    && entry.enforcement_tripped()
1150                    && !entry.capture_failed()
1151                    && entry.active_streams.load(Ordering::Acquire) == 0
1152                    && entry.pending_jobs.load(Ordering::Acquire) == 0
1153            })
1154            .map(|entry| Arc::clone(&entry))
1155            .collect();
1156        if latched.len() > MAX_LATCHED_RETAINED {
1157            let mut with_idle: Vec<_> = latched
1158                .into_iter()
1159                .map(|s| {
1160                    let d = s.idle_duration();
1161                    (s, d)
1162                })
1163                .collect();
1164            with_idle.sort_by_key(|(_, d)| std::cmp::Reverse(*d));
1165            latched = with_idle.into_iter().map(|(s, _)| s).collect();
1166            let excess = latched.len() - MAX_LATCHED_RETAINED;
1167            tracing::warn!(
1168                excess,
1169                cap = MAX_LATCHED_RETAINED,
1170                "latched-retained signed sessions exceed the cap; evicting oldest \
1171                 (their same-id enforcement refusal expires)"
1172            );
1173            for session in latched.into_iter().take(excess) {
1174                self.sessions.remove(&session.id);
1175                evicted.push(session);
1176            }
1177        }
1178        // Overflow pass: cap capture-failed (quarantined) session
1179        // retention. Same shape as the latched pass above but for a
1180        // distinct class: capture-failed sessions are supposed to be
1181        // bounded by real crash events, but the paired
1182        // `Finalizer::quarantined_sessions` set is now FIFO-capped at
1183        // MAX_QUARANTINED_SESSIONS. Their in-registry retention needed
1184        // a matching cap to prevent the Session Arcs from growing
1185        // indefinitely across a long-lived process's lifetime. Beyond
1186        // the cap, evict oldest-first — the quarantine artifact on
1187        // disk is what fundamentally gates resurrection, so evicting
1188        // the in-memory Arc is safe (a subsequent recovery pass
1189        // re-inserts if the artifact still lives on disk).
1190        const MAX_CAPTURE_FAILED_RETAINED: usize = 4096;
1191        let mut capture_failed: Vec<Arc<Session>> = self
1192            .sessions
1193            .iter()
1194            .filter(|entry| {
1195                // artifact_committed is the terminal flag the
1196                // capture-failed close path sets (close_session_locked
1197                // seals the session and returns CaptureIncomplete
1198                // WITHOUT ever reaching mark_close_complete — that
1199                // marker belongs to successful closes). Requiring
1200                // close_complete here meant NO capture-failed session
1201                // ever qualified: the cap existed but matched nothing,
1202                // and sustained capture failures grew the registry
1203                // without bound.
1204                entry.artifact_committed_flag()
1205                    && entry.capture_failed()
1206                    && entry.active_streams.load(Ordering::Acquire) == 0
1207                    && entry.pending_jobs.load(Ordering::Acquire) == 0
1208            })
1209            .map(|entry| Arc::clone(&entry))
1210            .collect();
1211        if capture_failed.len() > MAX_CAPTURE_FAILED_RETAINED {
1212            let mut with_idle: Vec<_> = capture_failed
1213                .into_iter()
1214                .map(|s| {
1215                    let d = s.idle_duration();
1216                    (s, d)
1217                })
1218                .collect();
1219            with_idle.sort_by_key(|(_, d)| std::cmp::Reverse(*d));
1220            capture_failed = with_idle.into_iter().map(|(s, _)| s).collect();
1221            let excess = capture_failed.len() - MAX_CAPTURE_FAILED_RETAINED;
1222            tracing::warn!(
1223                excess,
1224                cap = MAX_CAPTURE_FAILED_RETAINED,
1225                "capture-failed sessions exceed the retention cap; evicting oldest \
1226                 (their same-id fail-closed refusal expires until on-disk quarantine \
1227                 artifact re-inserts on the next recovery tick)"
1228            );
1229            for session in capture_failed.into_iter().take(excess) {
1230                self.sessions.remove(&session.id);
1231                evicted.push(session);
1232            }
1233        }
1234        // Overflow pass: cap empty-unsigned quarantine retention. These
1235        // are the reconciler's "no captured steps" refusals — retained
1236        // so the same-id refusal stays cheap and the incident evidence
1237        // survives, but they are client-mintable at line rate (open an
1238        // unsigned session with a fresh id, submit no work, let the
1239        // idle sweep close it) and, having captured nothing, they leave
1240        // NO spool artifact behind: nothing on disk re-inserts or
1241        // collides after eviction. Without a cap the registry grows by
1242        // one permanent `Arc<Session>` per aborted admission for the
1243        // process lifetime. Same accepted tradeoff as the two sibling
1244        // caps: beyond the cap, evict oldest-first and let the evicted
1245        // ids' refusal expire.
1246        const MAX_EMPTY_UNSIGNED_RETAINED: usize = 4096;
1247        let mut empty_quarantined: Vec<Arc<Session>> = self
1248            .sessions
1249            .iter()
1250            .filter(|entry| {
1251                entry.is_empty_unsigned_quarantine()
1252                    && entry.active_streams.load(Ordering::Acquire) == 0
1253                    && entry.pending_jobs.load(Ordering::Acquire) == 0
1254            })
1255            .map(|entry| Arc::clone(&entry))
1256            .collect();
1257        if empty_quarantined.len() > MAX_EMPTY_UNSIGNED_RETAINED {
1258            let mut with_idle: Vec<_> = empty_quarantined
1259                .into_iter()
1260                .map(|s| {
1261                    let d = s.idle_duration();
1262                    (s, d)
1263                })
1264                .collect();
1265            with_idle.sort_by_key(|(_, d)| std::cmp::Reverse(*d));
1266            empty_quarantined = with_idle.into_iter().map(|(s, _)| s).collect();
1267            let excess = empty_quarantined.len() - MAX_EMPTY_UNSIGNED_RETAINED;
1268            tracing::warn!(
1269                excess,
1270                cap = MAX_EMPTY_UNSIGNED_RETAINED,
1271                "empty-unsigned quarantined sessions exceed the retention cap; evicting \
1272                 oldest (their same-id refusal expires; no on-disk artifact is affected)"
1273            );
1274            for session in empty_quarantined.into_iter().take(excess) {
1275                self.sessions.remove(&session.id);
1276                evicted.push(session);
1277            }
1278        }
1279        evicted
1280    }
1281
1282    /// Sessions where `close_session_locked` marked
1283    /// `artifact_committed = 1` but crashed / failed before running
1284    /// the finalization tail (`emit_bridge_event(SESSION_CLOSE)` +
1285    /// `remove_step_journal` + `remove_lifecycle_outbox` +
1286    /// `mark_close_complete`). Without this recovery hook such
1287    /// sessions accumulate in the registry forever: `is_closed()` is
1288    /// true so the idle sweeper skips them; `close_complete = 0` so
1289    /// `evict_finalized` refuses them; recovery scans skip them via
1290    /// the "already in registry" short-circuit. Capture-failed and
1291    /// empty-unsigned quarantines are excluded — they intentionally
1292    /// stay in the registry as evidence of the incident.
1293    ///
1294    /// The empty-unsigned quarantine (the reconciler's
1295    /// "no captured steps" refusal) does NOT set `capture_failed = 1` (it
1296    /// is a distinct semantic — "no work was captured" rather than
1297    /// "capture was lost mid-flight"), so `!capture_failed()` alone
1298    /// let the sweep pick it up and emit a spurious SESSION_CLOSE
1299    /// bridge event for a session that had no other events on the
1300    /// wire. `is_empty_unsigned_quarantine()` closes that gap.
1301    pub fn pending_close_sessions(&self) -> Vec<Arc<Session>> {
1302        self.sessions
1303            .iter()
1304            .filter(|entry| {
1305                entry.artifact_committed_flag()
1306                    && !entry.close_complete_flag()
1307                    && !entry.capture_failed()
1308                    && !entry.is_empty_unsigned_quarantine()
1309            })
1310            .map(|entry| entry.clone())
1311            .collect()
1312    }
1313
1314    /// Sessions idle longer than `idle_s` (for the sweeper).
1315    pub fn idle_sessions(&self, idle_s: u64) -> Vec<Arc<Session>> {
1316        // Monotonic idle math — see `evict_finalized`.
1317        let idle_duration = Duration::from_secs(idle_s);
1318        self.sessions
1319            .iter()
1320            .filter(|e| {
1321                e.idle_duration() >= idle_duration
1322                    && !e.is_closed()
1323                    // A session with a live forwarded response is not idle even
1324                    // when its admission clock is stale: the last-activity
1325                    // clock is refreshed only at request admission, so a
1326                    // stream that outlives the idle window would otherwise
1327                    // let the sweeper claim the close (sealing the session
1328                    // mid-conversation) and then park inside `wait_for_streams`
1329                    // while holding the shared lifecycle lock until the
1330                    // client's stream ends.
1331                    && e.active_streams.load(Ordering::Acquire) == 0
1332            })
1333            .map(|e| e.clone())
1334            .collect()
1335    }
1336
1337    /// Snapshot every session in the registry — open, closed, or
1338    /// capture-failed. Used by the dashboard to show recent activity
1339    /// including sessions that have just been sealed but not yet evicted.
1340    pub fn open_sessions_including_closed(&self) -> Vec<Arc<Session>> {
1341        self.sessions.iter().map(|entry| entry.clone()).collect()
1342    }
1343
1344    /// Snapshot every session still accepting work.
1345    pub fn open_sessions(&self) -> Vec<Arc<Session>> {
1346        self.sessions
1347            .iter()
1348            .filter(|entry| !entry.is_closed())
1349            .map(|entry| entry.clone())
1350            .collect()
1351    }
1352
1353    /// Number of registered sessions (includes closed sessions not yet removed).
1354    pub fn len(&self) -> usize {
1355        self.sessions.len()
1356    }
1357
1358    /// True when no sessions are registered (closed or open).
1359    pub fn is_empty(&self) -> bool {
1360        self.sessions.is_empty()
1361    }
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1367
1368    use super::*;
1369
1370    fn identity() -> AgentIdentity {
1371        AgentIdentity {
1372            version: "1".into(),
1373            charter: "c".into(),
1374            instance_uid: "i".into(),
1375            ttl_remaining_s: None,
1376        }
1377    }
1378
1379    #[test]
1380    fn trajectory_snapshots_capture_a_principal_bound_after_construction() {
1381        let session = Session::new(
1382            "snapshot-owner".into(),
1383            Workflow::Unsigned,
1384            identity(),
1385            Default::default(),
1386        );
1387        assert!(session.bind_principal(Some("principal-a")));
1388        assert_eq!(
1389            session
1390                .snapshot_trajectory()
1391                .agent
1392                .extra
1393                .unwrap()
1394                .get("principal_binding")
1395                .unwrap(),
1396            "principal-a"
1397        );
1398        assert_eq!(
1399            session
1400                .take_trajectory()
1401                .agent
1402                .extra
1403                .unwrap()
1404                .get("principal_binding")
1405                .unwrap(),
1406            "principal-a"
1407        );
1408        assert!(!session.bind_principal(Some("principal-b")));
1409    }
1410
1411    /// Mutation-run hardening (round 10): `restore_journal_index` had a
1412    /// surviving no-op mutant — recovery restores the MAC'd journal
1413    /// cursor through it, and a silently-unrestored cursor would make
1414    /// every post-recovery append claim index 0 again (MAC index
1415    /// mismatch → journal writes refused for the session's lifetime).
1416    #[test]
1417    fn restore_journal_index_actually_moves_the_cursor() {
1418        let session = Session::new(
1419            "journal-cursor".to_owned(),
1420            Workflow::Unsigned,
1421            identity(),
1422            Default::default(),
1423        );
1424        assert_eq!(session.journal_index(), 0);
1425        session.restore_journal_index(7);
1426        assert_eq!(session.journal_index(), 7);
1427        // The restored cursor is what the next append claims.
1428        session.commit_journal_index(7).unwrap();
1429        assert_eq!(session.journal_index(), 8);
1430        assert!(
1431            session.commit_journal_index(7).is_err(),
1432            "a stale index must be refused after the commit advanced it"
1433        );
1434    }
1435
1436    /// Mutation-run hardening (round 10): `wait_for_streams` had a
1437    /// surviving no-op mutant — a close that stops waiting for active
1438    /// streams races the in-flight response capture (the exact lost-
1439    /// capture class the wait exists to prevent). Pin both directions:
1440    /// blocked while a lease is live, released when it drops.
1441    #[tokio::test]
1442    async fn wait_for_streams_blocks_until_the_last_lease_drops() {
1443        let session = Arc::new(Session::new(
1444            "stream-wait".to_owned(),
1445            Workflow::Unsigned,
1446            identity(),
1447            Default::default(),
1448        ));
1449        let lease = SessionLease::new(Arc::clone(&session));
1450        assert_eq!(
1451            session.active_streams_count(),
1452            1,
1453            "the live lease must be visible in the stream gauge"
1454        );
1455        let mut waiter = tokio::spawn({
1456            let session = Arc::clone(&session);
1457            async move { session.wait_for_streams().await }
1458        });
1459        // The waiter must still be pending while the lease is alive.
1460        assert!(
1461            tokio::time::timeout(std::time::Duration::from_millis(100), &mut waiter)
1462                .await
1463                .is_err(),
1464            "wait_for_streams returned while a stream lease was still held"
1465        );
1466        drop(lease);
1467        // After the drop the wait must resolve promptly.
1468        tokio::time::timeout(std::time::Duration::from_secs(2), waiter)
1469            .await
1470            .expect("wait_for_streams must resolve once the last lease dropped")
1471            .unwrap();
1472    }
1473
1474    /// Mutation-run hardening (round 10): the JCS bound on recovered
1475    /// counters is STRICT-greater — the bound itself is a legal value,
1476    /// one past it is refused. Both mutation directions survived.
1477    #[test]
1478    fn recovered_counter_bound_is_exact() {
1479        assert_eq!(
1480            recovered_counter(Some(av_core::error::JCS_SAFE_MAX), "tokens").unwrap(),
1481            av_core::error::JCS_SAFE_MAX
1482        );
1483        assert!(recovered_counter(Some(av_core::error::JCS_SAFE_MAX + 1), "tokens").is_err());
1484        assert_eq!(recovered_counter(None, "tokens").unwrap(), 0);
1485    }
1486
1487    /// Cross-version recovery of cost totals: a session artifact
1488    /// written by an older writer may carry `metrics.extra` (with
1489    /// e.g. `tool_calls`) but NOT the newer `cost_usd_micros` key.
1490    /// Pre-fix this path silently reset cost to 0 by taking the
1491    /// missing-key → `recovered_counter(None,...) → Ok(0)` branch,
1492    /// never reaching the ATIF-level `metrics.total_cost_usd`
1493    /// fallback (which the non-extra branch already consumed). The
1494    /// helper below now bridges the two: an extra without a cost
1495    /// key falls through to the total_cost_usd value.
1496    #[test]
1497    fn cost_recovery_bridges_extra_and_total_cost_usd() {
1498        // Direct helper tests: complement the end-to-end
1499        // integration test below so both the helper AND the branch
1500        // selection at recover_unsigned are pinned.
1501        // (a) Some(dollars) → micros; the exact fallback the extra-
1502        //     missing branch now takes.
1503        assert_eq!(
1504            cost_from_atif_total(Some(1.0)).unwrap(),
1505            av_core::units::USD_MICROS_PER_DOLLAR
1506        );
1507        // (b) None → 0 (no cost recorded anywhere).
1508        assert_eq!(cost_from_atif_total(None).unwrap(), 0);
1509        // (c) Fractional dollar rounds to nearest micro-USD.
1510        assert_eq!(cost_from_atif_total(Some(0.000001)).unwrap(), 1);
1511        // (d) NaN and negative refused.
1512        assert!(cost_from_atif_total(Some(f64::NAN)).is_err());
1513        assert!(cost_from_atif_total(Some(-1.0)).is_err());
1514        // (e) Overflow past JCS-safe bound refused. Use 2× the bound
1515        //     so the roundtrip through f64 (which loses precision at
1516        //     values > 2^53) still lands well above the check.
1517        let over_dollars =
1518            (av_core::error::JCS_SAFE_MAX as f64 * 2.0) / av_core::units::USD_MICROS_PER_DOLLAR as f64;
1519        assert!(cost_from_atif_total(Some(over_dollars)).is_err());
1520    }
1521
1522    /// End-to-end integration test that pins the actual branch
1523    /// selection in `Session::recover_unsigned`. The isolated helper
1524    /// tests above pass regardless of whether the caller consults
1525    /// them — the R11 review agent showed the standalone
1526    /// helper-only test still passes when the fix is reverted to the
1527    /// inline `recovered_counter(extra.get("cost_usd_micros")...)`
1528    /// pattern. This test drives the actual call path with the exact
1529    /// artifact shape the older writer produced: extra present, no
1530    /// `cost_usd_micros` key, non-None `metrics.total_cost_usd` at
1531    /// the ATIF level.
1532    #[test]
1533    fn recover_unsigned_bridges_missing_extra_cost_to_atif_fallback() {
1534        let identity = identity();
1535        let breaker = av_loopdetect::BreakerConfig::default();
1536        // The shape an older writer produced: extra carries
1537        // tool_calls but NOT cost_usd_micros; the ATIF-level
1538        // total_cost_usd carries the actual cost.
1539        let metrics = av_atif::FinalMetrics {
1540            total_prompt_tokens: Some(0),
1541            total_completion_tokens: Some(0),
1542            total_cached_tokens: Some(0),
1543            total_cost_usd: Some(1.23),
1544            extra: Some(serde_json::json!({"tool_calls": 5})),
1545            ..av_atif::FinalMetrics::default()
1546        };
1547        let session = Session::recover_unsigned(
1548            "recover-cost".to_owned(),
1549            identity,
1550            breaker,
1551            std::path::PathBuf::from("/tmp/atif.json"),
1552            Some(&metrics),
1553            0,
1554        )
1555        .unwrap();
1556        assert_eq!(
1557            session.totals.cost_usd_micros.load(Ordering::Acquire),
1558            1_230_000,
1559            "extra present without cost_usd_micros must fall back to \
1560             metrics.total_cost_usd; pre-fix this silently zeroed cost"
1561        );
1562        // Also confirm extra's tool_calls was still consumed (the
1563        // extra-branch didn't fall through wholesale).
1564        assert_eq!(session.totals.tool_calls.load(Ordering::Acquire), 5);
1565    }
1566
1567    /// Receipt stop-reason precedence: a recorded KNOWN reason wins over
1568    /// the caller's stop; an UNRECORDED session (raw id 0) takes the
1569    /// caller's stop; and a FOREIGN id (a newer node's stop-reason id
1570    /// recovered from its artifact — maps to Unknown in this build)
1571    /// must stay Unknown. Pre-fix the foreign id was silently rewritten
1572    /// to the caller's stop (typically SessionClosed), forging a
1573    /// specific reason the session never had into the SIGNED receipt.
1574    #[test]
1575    fn receipt_body_never_rewrites_a_foreign_stop_reason_id() {
1576        let subject = || av_receipts::ReceiptSubject::EventChain {
1577            chain_head: "aa".repeat(32),
1578            event_count: 0,
1579        };
1580        let session = Session::new(
1581            "stop-precedence".to_owned(),
1582            Workflow::Signed,
1583            identity(),
1584            Default::default(),
1585        );
1586
1587        // Unrecorded (id 0): caller's stop is authoritative.
1588        let body = session.receipt_body(subject(), StopReason::SessionClosed);
1589        assert_eq!(body.stop_reason_id, StopReason::SessionClosed.id());
1590
1591        // Recorded known reason wins over the caller's stop.
1592        session.record_stop_reason(StopReason::BudgetExceeded);
1593        let body = session.receipt_body(subject(), StopReason::SessionClosed);
1594        assert_eq!(body.stop_reason_id, StopReason::BudgetExceeded.id());
1595
1596        // Foreign id (unknown to this build): honest Unknown, never the
1597        // caller's stop.
1598        session.last_stop_reason_id.store(95, Ordering::Release);
1599        let body = session.receipt_body(subject(), StopReason::SessionClosed);
1600        assert_eq!(
1601            body.stop_reason_id,
1602            StopReason::Unknown.id(),
1603            "a foreign stop-reason id must not be rewritten to the caller's stop"
1604        );
1605    }
1606
1607    /// The lifecycle state machine walks the
1608    /// close chain, and a failed finalize returns to Open. The debug
1609    /// asserts inside `transition` extend this check to every test
1610    /// in the suite; this test pins the happy chain explicitly.
1611    #[test]
1612    fn lifecycle_walks_the_close_chain() {
1613        let session = Session::new(
1614            "s2".into(),
1615            Workflow::Unsigned,
1616            identity(),
1617            av_loopdetect::BreakerConfig::default(),
1618        );
1619        assert_eq!(session.lifecycle_state(), SessionState::Open);
1620        assert!(session.try_close());
1621        assert_eq!(session.lifecycle_state(), SessionState::Draining);
1622        // Failed finalize: the claim drops unarmed and the session
1623        // reopens.
1624        session.reset_close();
1625        assert_eq!(session.lifecycle_state(), SessionState::Open);
1626        // Retry to completion.
1627        assert!(session.try_close());
1628        session.mark_artifact_committed();
1629        assert_eq!(session.lifecycle_state(), SessionState::Sealed);
1630        session.mark_close_complete();
1631        assert_eq!(session.lifecycle_state(), SessionState::Complete);
1632        // Idempotent re-marks are tolerated (the flag stores are).
1633        session.mark_artifact_committed();
1634        session.mark_close_complete();
1635        assert_eq!(session.lifecycle_state(), SessionState::Complete);
1636    }
1637
1638    /// A second `try_close` must not double-claim, and must leave the
1639    /// state untouched.
1640    #[test]
1641    fn lifecycle_refuses_double_close_claim() {
1642        let session = Session::new(
1643            "s2b".into(),
1644            Workflow::Unsigned,
1645            identity(),
1646            av_loopdetect::BreakerConfig::default(),
1647        );
1648        assert!(session.try_close());
1649        assert!(!session.try_close());
1650        assert_eq!(session.lifecycle_state(), SessionState::Draining);
1651    }
1652
1653    /// Recovery-constructed sessions walk the same chain as a live
1654    /// close: `recover_unsigned` lands Sealed, never Complete (the
1655    /// close tail still owes bridge emits + journal removal).
1656    #[test]
1657    fn recovered_unsigned_session_is_sealed() {
1658        let session = Session::recover_unsigned(
1659            "s2c".into(),
1660            identity(),
1661            av_loopdetect::BreakerConfig::default(),
1662            std::path::PathBuf::from("x.json"),
1663            None,
1664            0,
1665        )
1666        .unwrap();
1667        assert_eq!(session.lifecycle_state(), SessionState::Sealed);
1668    }
1669
1670    #[test]
1671    fn seq_is_monotonic_under_concurrency() {
1672        let s = Arc::new(Session::new(
1673            "s".into(),
1674            Workflow::Unsigned,
1675            identity(),
1676            av_loopdetect::BreakerConfig::default(),
1677        ));
1678        let mut handles = Vec::new();
1679        for _ in 0..8 {
1680            let s = Arc::clone(&s);
1681            handles.push(std::thread::spawn(move || {
1682                (0..1000).map(|_| s.next_seq()).collect::<Vec<_>>()
1683            }));
1684        }
1685        let mut all: Vec<u64> = handles.into_iter().flat_map(|h| h.join().unwrap()).collect();
1686        all.sort_unstable();
1687        // The invariant the journal MAC path depends on is dense
1688        // unit-stride monotonicity — `metadata.sequence` must equal
1689        // `journal_position` (see peek_seq's doc + restore_next_seq).
1690        // The previous `dedup().len() == 8000` check only pinned
1691        // uniqueness, so a regression from `fetch_add(1)` to
1692        // `fetch_add(2)` (or any hash-seeded unique-nonce scheme) still
1693        // passed while every restored session's first append misaligned.
1694        assert_eq!(
1695            all,
1696            (0..8000).collect::<Vec<_>>(),
1697            "next_seq must be dense unit-stride monotone: the journal MAC \
1698             re-verifies metadata.sequence against journal_position at \
1699             recovery, so any gap or stride > 1 breaks the invariant that \
1700             makes crash recovery sound"
1701        );
1702    }
1703
1704    #[test]
1705    fn close_is_idempotent() {
1706        let s = Session::new("s".into(), Workflow::Signed, identity(), Default::default());
1707        assert!(s.try_close());
1708        assert!(!s.try_close(), "second close must be refused");
1709        assert!(s.is_closed());
1710    }
1711
1712    #[test]
1713    fn registry_reuses_sessions() {
1714        let r = SessionRegistry::new();
1715        let a = r.get_or_open("x", Workflow::Unsigned, &identity(), &Default::default());
1716        let b = r.get_or_open("x", Workflow::Unsigned, &identity(), &Default::default());
1717        assert!(Arc::ptr_eq(&a, &b));
1718        assert_eq!(r.len(), 1);
1719    }
1720
1721    /// A client that reuses a session id after the server-side close
1722    /// completes (retry after a 5xx, TTL refresh, network partition
1723    /// recovery) must get a fresh session instead of the closed one —
1724    /// otherwise every request until the idle sweep would return
1725    /// `400 session is already closed`. Only sessions with
1726    /// `close_complete = true` are recycled; a session mid-close must
1727    /// still be treated as the same one so the ongoing close is not
1728    /// raced.
1729    #[test]
1730    fn get_or_open_recycles_id_after_close_complete() {
1731        let registry = SessionRegistry::new();
1732        let breaker = av_loopdetect::BreakerConfig::default();
1733
1734        // First open. Simulate a full close (artifact committed +
1735        // close_complete flipped) so the next get_or_open sees a
1736        // fully-sealed session.
1737        let first = registry.get_or_open("reused", Workflow::Signed, &identity(), &breaker);
1738        assert!(first.try_close(), "first close must land");
1739        first.mark_artifact_committed();
1740        first.mark_close_complete();
1741
1742        let second = registry.get_or_open("reused", Workflow::Signed, &identity(), &breaker);
1743        assert!(
1744            !Arc::ptr_eq(&first, &second),
1745            "closed session must be replaced by a fresh one"
1746        );
1747        assert!(!second.is_closed(), "reopened session must accept new work");
1748        assert_eq!(registry.len(), 1, "id remains a single registry entry");
1749    }
1750
1751    /// The latch is restored across restarts ONLY from the explicitly
1752    /// persisted `enforcement_latched` field — never inferred from
1753    /// `stop_reason_id`, which Inject-action breaker trips also record
1754    /// without latching live.
1755    #[test]
1756    fn recover_unsigned_restores_only_the_persisted_latch() {
1757        let breaker = av_loopdetect::BreakerConfig::default();
1758        let metrics = |extra: serde_json::Value| av_atif::FinalMetrics {
1759            total_prompt_tokens: Some(1),
1760            total_completion_tokens: Some(1),
1761            total_cached_tokens: Some(0),
1762            total_cost_usd: Some(0.0),
1763            total_steps: Some(1),
1764            extra: Some(extra),
1765        };
1766        // Persisted latch restores the refusal.
1767        let latched = Session::recover_unsigned(
1768            "latched".into(),
1769            identity(),
1770            breaker.clone(),
1771            PathBuf::from("latched.json"),
1772            Some(&metrics(serde_json::json!({
1773                "stop_reason_id": 92u64,
1774                "enforcement_latched": 92u64,
1775            }))),
1776            1,
1777        )
1778        .unwrap();
1779        assert!(latched.enforcement_tripped(), "persisted latch must restore");
1780        // A LoopDetected stop reason WITHOUT the latch key (Inject trip,
1781        // or a legacy pre-latch artifact) must NOT lock the id.
1782        let inject = Session::recover_unsigned(
1783            "inject".into(),
1784            identity(),
1785            breaker.clone(),
1786            PathBuf::from("inject.json"),
1787            Some(&metrics(serde_json::json!({"stop_reason_id": 91u64}))),
1788            1,
1789        )
1790        .unwrap();
1791        assert!(
1792            !inject.enforcement_tripped(),
1793            "stop reason alone must not restore the latch"
1794        );
1795        // Explicit zero means unlatched.
1796        let natural = Session::recover_unsigned(
1797            "natural".into(),
1798            identity(),
1799            breaker,
1800            PathBuf::from("natural.json"),
1801            Some(&metrics(serde_json::json!({
1802                "stop_reason_id": 1u64,
1803                "enforcement_latched": 0u64,
1804            }))),
1805            1,
1806        )
1807        .unwrap();
1808        assert!(!natural.enforcement_tripped());
1809        // Mutation-run hardening (round 10): the u8 bound on the
1810        // recovered latch value had a surviving `>`→`==` mutant —
1811        // 257+ was admitted, persisted, and would fail every FUTURE
1812        // recovery of the same artifact. Any out-of-range value is a
1813        // typed error, not a partial restore.
1814        let oversized = Session::recover_unsigned(
1815            "oversized-latch".into(),
1816            identity(),
1817            av_loopdetect::BreakerConfig::default(),
1818            PathBuf::from("oversized.json"),
1819            Some(&metrics(serde_json::json!({
1820                "stop_reason_id": 92u64,
1821                "enforcement_latched": 300u64,
1822            }))),
1823            1,
1824        );
1825        assert!(
1826            oversized.is_err(),
1827            "an enforcement latch beyond u8 must refuse recovery, got {:?}",
1828            oversized.err()
1829        );
1830    }
1831
1832    /// Mutation-run hardening (round 10): `drain_trajectory_builder`
1833    /// had a surviving no-op mutant — the post-close RAM reclamation
1834    /// for never-evicted unsigned sessions. Pin the observable: after
1835    /// the drain, the builder holds zero steps.
1836    #[test]
1837    fn drain_trajectory_builder_empties_the_builder() {
1838        let session = Session::new(
1839            "drain-builder".to_owned(),
1840            Workflow::Unsigned,
1841            identity(),
1842            Default::default(),
1843        );
1844        session
1845            .atif
1846            .lock()
1847            .push_step(av_atif::Step {
1848                step_id: 0,
1849                timestamp: None,
1850                source: av_atif::Source::User,
1851                message: serde_json::json!("hello"),
1852                reasoning_effort: None,
1853                reasoning_content: None,
1854                model_name: None,
1855                tool_calls: None,
1856                observation: None,
1857                metrics: None,
1858                is_copied_context: None,
1859                llm_call_count: None,
1860                extra: None,
1861            })
1862            .unwrap();
1863        assert_eq!(session.snapshot_trajectory().steps.len(), 1);
1864        // Getter pins (constant-return mutants survived): the step
1865        // counter and stream gauge feed the dashboard.
1866        session.note_atif_step();
1867        session.note_atif_step();
1868        assert_eq!(session.atif_steps_count(), 2);
1869        session.drain_trajectory_builder();
1870        assert_eq!(
1871            session.snapshot_trajectory().steps.len(),
1872            0,
1873            "the drained builder must hold no steps (RAM reclamation)"
1874        );
1875    }
1876
1877    /// Enforcement-latched sessions are terminal for the session id:
1878    /// recycling would hand the SAME id a fresh budget and fresh
1879    /// breaker — verified live as a 200/403 alternation riding the
1880    /// per-session token cap at ~50% duty forever. Keyed on the sticky
1881    /// latch: a LATER recorded stop reason (post-refusal success, a
1882    /// concurrent stream's provider finish) must not reclassify the
1883    /// close as natural, and a recorded-but-unlatched enforcement
1884    /// reason (blocked tool call, quota-backend outage) must not lock
1885    /// out a naturally-closed id.
1886    #[test]
1887    fn get_or_open_refuses_to_recycle_enforcement_latched_sessions() {
1888        let registry = SessionRegistry::new();
1889        let breaker = av_loopdetect::BreakerConfig::default();
1890        for (reason, workflow) in [
1891            (StopReason::BudgetExceeded, Workflow::Unsigned),
1892            (StopReason::LoopDetected, Workflow::Unsigned),
1893            // Signed sessions must refuse identically (they also must
1894            // not be evicted out of the refusal — covered below).
1895            (StopReason::BudgetExceeded, Workflow::Signed),
1896        ] {
1897            let id = format!("enforced-{}-{}", reason.id(), workflow.as_str());
1898            let first = registry.get_or_open(&id, workflow, &identity(), &breaker);
1899            first.latch_enforcement(reason);
1900            // A later provider stop reason must NOT un-latch.
1901            first.record_stop_reason(StopReason::Stop);
1902            assert!(first.try_close());
1903            first.mark_artifact_committed();
1904            first.mark_close_complete();
1905            let second = registry.get_or_open(&id, workflow, &identity(), &breaker);
1906            assert!(
1907                Arc::ptr_eq(&first, &second),
1908                "enforcement-latched session must NOT be recycled ({reason:?}/{workflow:?})"
1909            );
1910            assert!(second.is_closed(), "the id must stay refused ({reason:?})");
1911        }
1912        // Latched signed sessions are excluded from eviction — evicting
1913        // vacated the id and the refusal silently expired after idle_s.
1914        assert!(
1915            registry.evict_finalized(0).is_empty(),
1916            "latched sessions must not be evicted"
1917        );
1918        // A RECORDED enforcement reason without the latch (blocked tool
1919        // call, quota-backend outage) must not lock out a naturally
1920        // closed id.
1921        let first = registry.get_or_open("natural", Workflow::Unsigned, &identity(), &breaker);
1922        first.record_stop_reason(StopReason::BudgetExceeded);
1923        assert!(first.try_close());
1924        first.mark_artifact_committed();
1925        first.mark_close_complete();
1926        let second = registry.get_or_open("natural", Workflow::Unsigned, &identity(), &breaker);
1927        assert!(
1928            !Arc::ptr_eq(&first, &second),
1929            "recorded-but-unlatched reasons must keep recycling"
1930        );
1931    }
1932
1933    /// A session that has *started* close but has not completed it
1934    /// must not be replaced — reopening would race the finaliser and
1935    /// split the audit trail across two artifacts for the same id.
1936    #[test]
1937    fn get_or_open_preserves_session_mid_close() {
1938        let registry = SessionRegistry::new();
1939        let breaker = av_loopdetect::BreakerConfig::default();
1940        let first = registry.get_or_open("closing", Workflow::Signed, &identity(), &breaker);
1941        assert!(first.try_close());
1942        // close_complete NOT set — this is the in-flight close state.
1943        let second = registry.get_or_open("closing", Workflow::Signed, &identity(), &breaker);
1944        assert!(
1945            Arc::ptr_eq(&first, &second),
1946            "mid-close session must be handed back unchanged"
1947        );
1948    }
1949
1950    /// A signed-recovery loop that discovers a stale journal for session X
1951    /// must not clobber a live session X that raced its way into the
1952    /// registry (client retries with the same id after a crash are a real
1953    /// production case). `try_insert_recovered` returns `Err(existing)` so
1954    /// the recovery loop can `continue` instead of running
1955    /// `close_session_locked` on the active Arc and force-closing it.
1956    #[test]
1957    fn try_insert_recovered_returns_err_on_collision_and_leaves_active_untouched() {
1958        let r = SessionRegistry::new();
1959        let active = r.get_or_open("race", Workflow::Signed, &identity(), &Default::default());
1960        assert!(!active.is_closed(), "precondition: active session is open");
1961        let recovered = Session::new("race".into(), Workflow::Signed, identity(), Default::default());
1962        let existing = match r.try_insert_recovered(recovered) {
1963            Ok(_) => panic!("collision must be reported as Err, not Ok"),
1964            Err(existing) => existing,
1965        };
1966        assert!(
1967            Arc::ptr_eq(&active, &existing),
1968            "Err must carry the pre-existing active Arc, not a fresh one",
1969        );
1970        assert!(
1971            !active.is_closed(),
1972            "the active session must remain open after a discarded recovery insert",
1973        );
1974        assert_eq!(r.len(), 1, "no duplicate entry must be added");
1975    }
1976
1977    #[test]
1978    fn try_insert_recovered_returns_ok_when_registry_is_vacant() {
1979        let r = SessionRegistry::new();
1980        let recovered = Session::new("fresh".into(), Workflow::Signed, identity(), Default::default());
1981        let inserted = match r.try_insert_recovered(recovered) {
1982            Ok(inserted) => inserted,
1983            Err(_) => panic!("vacant slot must accept the recovered session"),
1984        };
1985        assert_eq!(inserted.id, "fresh");
1986        assert_eq!(r.len(), 1);
1987    }
1988
1989    #[test]
1990    fn idle_detection() {
1991        let r = SessionRegistry::new();
1992        let s = r.get_or_open("idle", Workflow::Unsigned, &identity(), &Default::default());
1993        s.set_idle_for_testing(10);
1994        assert_eq!(r.idle_sessions(5).len(), 1);
1995        assert!(r.idle_sessions(60).is_empty());
1996        s.try_close();
1997        assert!(
1998            r.idle_sessions(5).is_empty(),
1999            "closed sessions are not idle candidates"
2000        );
2001    }
2002
2003    /// `last_activity_ms` is refreshed only at request admission, so a chat
2004    /// stream that outlives the idle window makes its session *look* idle
2005    /// while a response is actively relaying. The sweeper must skip it:
2006    /// otherwise `try_close` seals the session mid-conversation (every new
2007    /// request gets "session is already closed") and `close_session_locked`
2008    /// then parks inside `wait_for_streams` while holding the shared
2009    /// lifecycle lock until the client's stream ends.
2010    #[test]
2011    fn idle_sweep_skips_sessions_with_active_streams() {
2012        let r = SessionRegistry::new();
2013        let s = r.get_or_open("streaming", Workflow::Unsigned, &identity(), &Default::default());
2014        s.set_idle_for_testing(10);
2015        let lease = SessionLease::new(Arc::clone(&s));
2016        assert!(
2017            r.idle_sessions(5).is_empty(),
2018            "a session with an active response stream must not be reaped as idle",
2019        );
2020        drop(lease);
2021        assert_eq!(
2022            r.idle_sessions(5).len(),
2023            1,
2024            "once the stream lease drops, the stale session becomes an idle candidate again",
2025        );
2026    }
2027
2028    /// Idle sweeping now uses the monotonic anchor
2029    /// (`monotonic_ns_since_start`), so wall-clock jumps in EITHER
2030    /// direction — backward (NTP correction, VM pause/resume) or
2031    /// forward (VM resume after a long pause) — cannot flip a fresh
2032    /// session into the idle set. The monotonic elapsed time is
2033    /// bounded by real wall time since process start and is immune
2034    /// to `SystemTime` jumps.
2035    #[test]
2036    fn idle_reap_is_safe_across_wall_clock_jumps() {
2037        let r = SessionRegistry::new();
2038        let s = r.get_or_open("clock-jump", Workflow::Unsigned, &identity(), &Default::default());
2039        // Simulate wall-clock chaos in BOTH directions on the display
2040        // clock — the monotonic anchor is untouched and the sweeper
2041        // reads only the monotonic side.
2042        s.last_activity_ms.store(
2043            av_core::time::now_ms() + av_core::units::MS_PER_HOUR,
2044            Ordering::Release,
2045        );
2046        for idle_s in [1u64, 60, 3_600, av_core::units::SECS_PER_DAY] {
2047            assert!(
2048                r.idle_sessions(idle_s).is_empty(),
2049                "fresh session must not be reaped after a wall-clock jump at idle_s={idle_s}",
2050            );
2051        }
2052    }
2053
2054    /// A pathologically large `idle_s` (e.g., attacker-controlled config that
2055    /// gets past validation, or `u64::MAX`) must saturate the cutoff at 0
2056    /// instead of wrapping, causing no session to be reaped.
2057    #[test]
2058    fn idle_reap_saturates_on_pathological_idle_secs() {
2059        let r = SessionRegistry::new();
2060        let _s = r.get_or_open(
2061            "pathological",
2062            Workflow::Unsigned,
2063            &identity(),
2064            &Default::default(),
2065        );
2066        assert!(
2067            r.idle_sessions(u64::MAX).is_empty(),
2068            "idle_s = u64::MAX must saturate rather than reap everything",
2069        );
2070        // Something halfway through the multiplication path still triggers
2071        // saturation because `idle_s * 1000` overflows.
2072        assert!(r.idle_sessions(u64::MAX / 500).is_empty());
2073    }
2074
2075    /// `evict_finalized` removes only signed, fully committed, quiescent,
2076    /// idle sessions — and leaves unsigned, capture-failed, active-stream,
2077    /// and recently-active sessions resident.
2078    #[test]
2079    fn evict_finalized_removes_only_quiescent_committed_signed_sessions() {
2080        let r = SessionRegistry::new();
2081
2082        let eligible = r.get_or_open("evict-me", Workflow::Signed, &identity(), &Default::default());
2083        eligible.try_close();
2084        eligible.mark_artifact_committed();
2085        eligible.mark_close_complete();
2086        eligible.set_idle_for_testing(10);
2087
2088        // Artifact committed but the close never completed (bridge emit or
2089        // journal removal failed): the journal may still be on disk, so a
2090        // reused id must find this sealed session, not a fresh one.
2091        let incomplete = r.get_or_open(
2092            "keep-incomplete",
2093            Workflow::Signed,
2094            &identity(),
2095            &Default::default(),
2096        );
2097        incomplete.try_close();
2098        incomplete.mark_artifact_committed();
2099        incomplete.set_idle_for_testing(10);
2100
2101        let unsigned = r.get_or_open(
2102            "keep-unsigned",
2103            Workflow::Unsigned,
2104            &identity(),
2105            &Default::default(),
2106        );
2107        unsigned.try_close();
2108        unsigned.mark_artifact_committed();
2109        unsigned.mark_close_complete();
2110        unsigned.set_idle_for_testing(10);
2111
2112        let failed = r.get_or_open("keep-failed", Workflow::Signed, &identity(), &Default::default());
2113        failed.try_close();
2114        failed.mark_artifact_committed();
2115        failed.mark_close_complete();
2116        failed.mark_capture_failed();
2117        failed.set_idle_for_testing(10);
2118
2119        let streaming = r.get_or_open(
2120            "keep-streaming",
2121            Workflow::Signed,
2122            &identity(),
2123            &Default::default(),
2124        );
2125        streaming.try_close();
2126        streaming.mark_artifact_committed();
2127        streaming.mark_close_complete();
2128        streaming.set_idle_for_testing(10);
2129        let lease = SessionLease::new(Arc::clone(&streaming));
2130
2131        let fresh = r.get_or_open("keep-fresh", Workflow::Signed, &identity(), &Default::default());
2132        fresh.try_close();
2133        fresh.mark_artifact_committed();
2134        fresh.mark_close_complete();
2135
2136        let open = r.get_or_open("keep-open", Workflow::Signed, &identity(), &Default::default());
2137        open.set_idle_for_testing(10);
2138
2139        let evicted = r.evict_finalized(5);
2140        assert_eq!(
2141            evicted.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
2142            vec!["evict-me"],
2143            "only the committed, quiescent, idle signed session may be evicted",
2144        );
2145        assert!(r.get("evict-me").is_none());
2146        for id in [
2147            "keep-incomplete",
2148            "keep-unsigned",
2149            "keep-failed",
2150            "keep-streaming",
2151            "keep-fresh",
2152            "keep-open",
2153        ] {
2154            assert!(r.get(id).is_some(), "{id} must stay resident");
2155        }
2156        drop(lease);
2157    }
2158
2159    /// Empty-unsigned quarantines (unsigned close refused for zero
2160    /// captured steps) are retained for evidence and cheap same-id
2161    /// refusal, but they are client-mintable at line rate and leave no
2162    /// spool artifact — the overflow cap must bound them, evicting
2163    /// oldest-first, while every sibling class stays untouched.
2164    #[test]
2165    fn evict_finalized_caps_empty_unsigned_quarantines() {
2166        let r = SessionRegistry::new();
2167        const CAP: usize = 4096;
2168        for n in 0..CAP + 2 {
2169            let s = r.get_or_open(
2170                &format!("empty-{n}"),
2171                Workflow::Unsigned,
2172                &identity(),
2173                &Default::default(),
2174            );
2175            s.try_close();
2176            // The reconciler's zero-step refusal seals the session
2177            // without writing an ATIF artifact: artifact_committed set,
2178            // atif_path never set, close_complete never set.
2179            s.mark_artifact_committed();
2180            assert!(s.is_empty_unsigned_quarantine());
2181            s.set_idle_for_testing(10);
2182        }
2183        // The two oldest entries must go; make idle age observable.
2184        r.get("empty-0").unwrap().set_idle_for_testing(100);
2185        r.get("empty-1").unwrap().set_idle_for_testing(50);
2186
2187        let evicted = r.evict_finalized(u64::MAX);
2188        let mut evicted_ids: Vec<String> = evicted.iter().map(|s| s.id.clone()).collect();
2189        evicted_ids.sort();
2190        assert_eq!(
2191            evicted_ids,
2192            vec!["empty-0".to_owned(), "empty-1".to_owned()],
2193            "exactly the two oldest over-cap quarantines are evicted",
2194        );
2195        assert!(r.get("empty-0").is_none());
2196        assert!(r.get("empty-1").is_none());
2197        assert!(r.get("empty-2").is_some(), "under-cap quarantines stay resident");
2198        assert!(r.get(&format!("empty-{}", CAP + 1)).is_some());
2199    }
2200
2201    /// The capture-failed overflow cap must MATCH the state the
2202    /// capture-failed close path actually leaves behind:
2203    /// `close_session_locked` seals with `mark_artifact_committed` and
2204    /// returns `CaptureIncomplete` WITHOUT ever calling
2205    /// `mark_close_complete` (that marker belongs to successful
2206    /// closes). The old predicate required `close_complete_flag`, so
2207    /// the cap matched NOTHING — sustained capture failures (a flaky
2208    /// disk, a crashing bridge) grew the registry without bound for
2209    /// the life of the process.
2210    #[test]
2211    fn evict_finalized_caps_capture_failed_sessions() {
2212        let r = SessionRegistry::new();
2213        const CAP: usize = 4096;
2214        for n in 0..CAP + 2 {
2215            let s = r.get_or_open(
2216                &format!("cf-{n}"),
2217                Workflow::Signed,
2218                &identity(),
2219                &Default::default(),
2220            );
2221            s.mark_capture_failed();
2222            s.try_close();
2223            // Mirror close_session_locked's capture-failed arm:
2224            // artifact_committed set, close_complete NEVER set.
2225            s.mark_artifact_committed();
2226            s.set_idle_for_testing(10);
2227        }
2228        r.get("cf-0").unwrap().set_idle_for_testing(100);
2229        r.get("cf-1").unwrap().set_idle_for_testing(50);
2230
2231        let evicted = r.evict_finalized(u64::MAX);
2232        let mut evicted_ids: Vec<String> = evicted.iter().map(|s| s.id.clone()).collect();
2233        evicted_ids.sort();
2234        assert_eq!(
2235            evicted_ids,
2236            vec!["cf-0".to_owned(), "cf-1".to_owned()],
2237            "exactly the two oldest over-cap capture-failed sessions are evicted",
2238        );
2239        assert!(
2240            r.get("cf-2").is_some(),
2241            "under-cap capture-failed sessions stay resident"
2242        );
2243        assert!(r.get(&format!("cf-{}", CAP + 1)).is_some());
2244    }
2245
2246    /// A session touched millions of times a second under normal operation
2247    /// must never observe `last_activity_ms` moving backward — the wall
2248    /// clock underpinning `touch()` is monotone under a healthy kernel and
2249    /// the store uses Release ordering so a later reader always sees a
2250    /// value ≥ every prior stamp.
2251    #[test]
2252    fn touch_never_regresses_last_activity() {
2253        let r = SessionRegistry::new();
2254        let s = r.get_or_open("touched", Workflow::Unsigned, &identity(), &Default::default());
2255        let mut previous = s.last_activity_ms.load(Ordering::Acquire);
2256        for _ in 0..10_000 {
2257            s.touch();
2258            let current = s.last_activity_ms.load(Ordering::Acquire);
2259            assert!(
2260                current >= previous,
2261                "touch() moved last_activity backward: {previous} -> {current}",
2262            );
2263            previous = current;
2264        }
2265    }
2266}