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}