1use crate::session::{Session, Workflow};
4use av_bridge::{EventBus, PublishAck};
5use av_core::metrics::Registry;
6use av_events::{EventClass, EventMetrics, OcsfEventBuilder, StatusId, StopReason};
7use av_loopdetect::{BreakerVerdict, Embedder, NoopVectorSink, VectorSink};
8use serde_json::Value;
9use std::sync::atomic::Ordering;
10use std::sync::Arc;
11use tokio::sync::{mpsc, oneshot};
12use tracing::Instrument as _;
13
14pub struct AtifCapture {
16 pub source: av_atif::Source,
18 pub message: Value,
20 pub reasoning_content: Option<String>,
22 pub model_name: Option<String>,
24 pub tool_calls: Option<Vec<av_atif::ToolCall>>,
26 pub observation: Option<av_atif::Observation>,
28 pub llm_call_count: Option<u64>,
30}
31
32pub struct WorkerJob {
34 pub session: Arc<Session>,
36 pub identity: av_events::AgentIdentity,
38 pub class: EventClass,
40 pub payload: Value,
42 pub text: String,
44 pub analyze_loop: bool,
47 pub status: StatusId,
49 pub stop_reason: Option<StopReason>,
51 pub native_stop_reason: Option<String>,
53 pub metrics: EventMetrics,
55 pub prompt_token_correction: i64,
60 pub cost_usd_micros: u64,
62 pub atif: Option<AtifCapture>,
64 pub response_marker: Option<String>,
66 pub response_attempt: Option<ResponseAttempt>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
72pub struct ResponseAttempt {
73 pub id: String,
75 pub terminal: bool,
77}
78
79#[derive(serde::Serialize, serde::Deserialize)]
80struct InFlightResponse {
81 session_id: String,
82 attempt_id: String,
83 request_digest: String,
84}
85
86#[derive(Debug, serde::Serialize, serde::Deserialize)]
87pub(crate) struct ActiveJournalRecord {
88 pub(crate) event: Value,
89 pub(crate) identity: av_events::AgentIdentity,
90 pub(crate) atif_step: Option<av_atif::Step>,
91 pub(crate) tool_calls: u64,
92 pub(crate) tool_allowed: u64,
93 pub(crate) tool_blocked: u64,
94 pub(crate) prompt_tokens: u64,
95 pub(crate) completion_tokens: u64,
96 pub(crate) cached_tokens: u64,
97 pub(crate) cost_usd_micros: u64,
98 #[serde(default)]
106 pub(crate) prompt_token_correction: i64,
107 pub(crate) stop_reason_id: Option<u8>,
108 pub(crate) response_attempt: Option<ResponseAttempt>,
109}
110
111#[derive(Debug, Default, PartialEq, Eq)]
127pub(crate) struct RecoveredTotals {
128 pub(crate) tool_calls: u64,
129 pub(crate) tool_allowed: u64,
130 pub(crate) tool_blocked: u64,
131 pub(crate) prompt_tokens: u64,
132 pub(crate) completion_tokens: u64,
133 pub(crate) cached_tokens: u64,
134 pub(crate) cost_usd_micros: u64,
135}
136
137impl RecoveredTotals {
138 pub(crate) fn validate_tool_accounting(&self) -> Result<(), String> {
141 if self
142 .tool_allowed
143 .checked_add(self.tool_blocked)
144 .is_none_or(|classified| classified > self.tool_calls)
145 {
146 return Err("journal has inconsistent tool accounting".to_owned());
147 }
148 Ok(())
149 }
150
151 pub(crate) fn store_on(&self, totals: &crate::session::Totals) {
154 use std::sync::atomic::Ordering;
155 totals.tool_calls.store(self.tool_calls, Ordering::Release);
156 totals.tool_allowed.store(self.tool_allowed, Ordering::Release);
157 totals.tool_blocked.store(self.tool_blocked, Ordering::Release);
158 totals.prompt_tokens.store(self.prompt_tokens, Ordering::Release);
159 totals
160 .completion_tokens
161 .store(self.completion_tokens, Ordering::Release);
162 totals.cached_tokens.store(self.cached_tokens, Ordering::Release);
163 totals
164 .cost_usd_micros
165 .store(self.cost_usd_micros, Ordering::Release);
166 }
167}
168
169impl ActiveJournalRecord {
170 pub(crate) fn fold_into(&self, totals: &mut RecoveredTotals) -> Result<(), String> {
174 fn checked(current: u64, value: u64, field: &str) -> Result<u64, String> {
175 current
176 .checked_add(value)
177 .filter(|total| *total <= av_core::error::JCS_SAFE_MAX)
178 .ok_or_else(|| format!("recovered {field} overflow"))
179 }
180 fn checked_cost(current: u64, value: u64) -> Result<u64, String> {
190 current
191 .checked_add(value)
192 .filter(|total| *total <= av_core::units::COST_MICROS_MAX)
193 .ok_or_else(|| "recovered cost exceeds ATIF strict validator ceiling".to_owned())
194 }
195 totals.tool_calls = checked(totals.tool_calls, self.tool_calls, "tool calls")?;
196 totals.tool_allowed = checked(totals.tool_allowed, self.tool_allowed, "allowed tools")?;
197 totals.tool_blocked = checked(totals.tool_blocked, self.tool_blocked, "blocked tools")?;
198 totals.prompt_tokens = checked(totals.prompt_tokens, self.prompt_tokens, "prompt tokens")?;
199 if self.prompt_token_correction != 0 {
200 totals.prompt_tokens = totals
201 .prompt_tokens
202 .checked_add_signed(self.prompt_token_correction)
203 .filter(|total| *total <= av_core::error::JCS_SAFE_MAX)
204 .ok_or_else(|| "recovered prompt token correction underflow/overflow".to_owned())?;
205 }
206 totals.completion_tokens = checked(
207 totals.completion_tokens,
208 self.completion_tokens,
209 "completion tokens",
210 )?;
211 totals.cached_tokens = checked(totals.cached_tokens, self.cached_tokens, "cached tokens")?;
212 totals.cost_usd_micros = checked_cost(totals.cost_usd_micros, self.cost_usd_micros)?;
213 Ok(())
214 }
215
216 pub(crate) fn apply_to_totals(&self, totals: &crate::session::Totals) -> Result<(), String> {
250 use std::sync::atomic::Ordering;
251 let mut snapshot = RecoveredTotals {
252 tool_calls: totals.tool_calls.load(Ordering::Acquire),
253 tool_allowed: totals.tool_allowed.load(Ordering::Acquire),
254 tool_blocked: totals.tool_blocked.load(Ordering::Acquire),
255 prompt_tokens: totals.prompt_tokens.load(Ordering::Acquire),
256 completion_tokens: totals.completion_tokens.load(Ordering::Acquire),
257 cached_tokens: totals.cached_tokens.load(Ordering::Acquire),
258 cost_usd_micros: totals.cost_usd_micros.load(Ordering::Acquire),
259 };
260 self.fold_into(&mut snapshot)?;
261 snapshot.store_on(totals);
262 Ok(())
263 }
264}
265
266#[derive(Debug, serde::Serialize, serde::Deserialize)]
267struct BrokerAckRecord {
268 session_id: String,
269 event_uid: String,
270 ack: PublishAck,
271}
272
273struct Envelope {
274 job: WorkerJob,
275 completion: Option<oneshot::Sender<Result<(), String>>>,
276 span: tracing::Span,
277 _capacity_permit: tokio::sync::OwnedSemaphorePermit,
278}
279
280#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
282#[non_exhaustive]
283pub enum SubmitError {
284 #[error("worker queue is full")]
286 Full,
287 #[error("worker queue is closed")]
289 Closed,
290}
291
292#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296enum DropStage {
297 WorkerQueue,
300 ResponseSlot,
306}
307
308impl DropStage {
309 fn full_counter_key(self) -> &'static str {
310 match self {
311 Self::WorkerQueue => "av_events_dropped_total{stage=\"worker_queue\"}",
312 Self::ResponseSlot => "av_events_dropped_total{stage=\"response_slot\"}",
313 }
314 }
315
316 fn label(self) -> &'static str {
317 match self {
318 Self::WorkerQueue => "worker_queue",
319 Self::ResponseSlot => "response_slot",
320 }
321 }
322}
323
324#[derive(Clone)]
326pub struct WorkerHandle {
327 senders: Arc<Vec<mpsc::Sender<Envelope>>>,
328 metrics: Arc<Registry>,
329 pending: Arc<std::sync::atomic::AtomicU64>,
330 drained: Arc<tokio::sync::Notify>,
331 capacity: Arc<tokio::sync::Semaphore>,
332 response_capacity: Arc<tokio::sync::Semaphore>,
340}
341
342pub struct WorkerPermit {
344 permit: mpsc::OwnedPermit<Envelope>,
345 pending: Arc<std::sync::atomic::AtomicU64>,
346 capacity_permit: tokio::sync::OwnedSemaphorePermit,
347}
348
349pub struct ResponsePermit {
368 _capacity_permit: tokio::sync::OwnedSemaphorePermit,
369}
370
371impl ResponsePermit {
372 pub fn submit(self, worker: &WorkerHandle, job: WorkerJob) -> Result<(), SubmitError> {
386 worker.submit_with_permit(job, self._capacity_permit, DropStage::ResponseSlot)
387 }
388}
389
390pub struct WorkerAndResponsePermit {
395 pub worker: WorkerPermit,
397 pub response: ResponsePermit,
399}
400
401impl WorkerPermit {
402 pub fn submit(self, job: WorkerJob) {
404 job.session.worker_job_started();
405 self.pending.fetch_add(1, Ordering::AcqRel);
406 let span = worker_span(&job);
407 self.permit.send(Envelope {
408 job,
409 completion: None,
410 span,
411 _capacity_permit: self.capacity_permit,
412 });
413 }
414}
415
416impl WorkerHandle {
417 pub fn try_reserve(&self, session_id: &str) -> Result<WorkerPermit, SubmitError> {
419 let capacity_permit = self.try_capacity()?;
420 let Some(sender) = self.sender_for(session_id).cloned() else {
421 return Err(SubmitError::Closed);
422 };
423 match sender.try_reserve_owned() {
424 Ok(permit) => Ok(WorkerPermit {
425 permit,
426 pending: Arc::clone(&self.pending),
427 capacity_permit,
428 }),
429 Err(mpsc::error::TrySendError::Full(_sender)) => {
430 self.metrics
431 .counter(
432 "av_events_dropped_total{stage=\"worker_queue\"}",
433 "Worker jobs or response-slot reservations dropped, labeled by admission stage",
434 )
435 .inc();
436 Err(SubmitError::Full)
437 }
438 Err(mpsc::error::TrySendError::Closed(_sender)) => {
439 self.metrics
440 .counter(
441 "av_events_dropped_total{stage=\"worker_closed\"}",
442 "Worker jobs or response-slot reservations dropped, labeled by admission stage",
443 )
444 .inc();
445 Err(SubmitError::Closed)
446 }
447 }
448 }
449
450 fn try_reserve_response(&self) -> Result<ResponsePermit, SubmitError> {
456 Arc::clone(&self.response_capacity)
457 .try_acquire_owned()
458 .map(|p| ResponsePermit { _capacity_permit: p })
459 .map_err(|_| {
460 self.metrics
461 .counter(
462 "av_events_dropped_total{stage=\"response_slot\"}",
463 "Worker jobs or response-slot reservations dropped, labeled by admission stage",
464 )
465 .inc();
466 SubmitError::Full
467 })
468 }
469
470 pub fn try_reserve_pair(&self, session_id: &str) -> Result<WorkerAndResponsePermit, SubmitError> {
477 let worker = self.try_reserve(session_id)?;
479 let response = self.try_reserve_response()?;
485 Ok(WorkerAndResponsePermit { worker, response })
486 }
487
488 pub fn try_submit(&self, job: WorkerJob) -> Result<(), SubmitError> {
492 self.try_submit_labeled(job, DropStage::WorkerQueue)
493 }
494
495 fn try_submit_labeled(&self, job: WorkerJob, stage: DropStage) -> Result<(), SubmitError> {
500 let capacity_permit = self.try_capacity_labeled(stage)?;
501 self.submit_with_permit(job, capacity_permit, stage)
502 }
503
504 fn submit_with_permit(
511 &self,
512 job: WorkerJob,
513 capacity_permit: tokio::sync::OwnedSemaphorePermit,
514 stage: DropStage,
515 ) -> Result<(), SubmitError> {
516 let Some(sender) = self.sender_for(&job.session.id).cloned() else {
517 return Err(SubmitError::Closed);
518 };
519 job.session.worker_job_started();
520 self.pending.fetch_add(1, Ordering::AcqRel);
521 let span = worker_span(&job);
522 match sender.try_send(Envelope {
523 job,
524 completion: None,
525 span,
526 _capacity_permit: capacity_permit,
527 }) {
528 Ok(()) => Ok(()),
529 Err(mpsc::error::TrySendError::Full(envelope)) => {
530 let session_id = envelope.job.session.id.clone();
531 envelope.job.session.worker_job_finished();
532 self.worker_job_finished();
533 self.metrics
534 .counter(
535 stage.full_counter_key(),
536 "Worker jobs or response-slot reservations dropped, labeled by admission stage",
537 )
538 .inc();
539 tracing::warn!(
540 session = %session_id,
541 stage = %stage.label(),
542 "worker queue full; request must fail closed"
543 );
544 Err(SubmitError::Full)
545 }
546 Err(mpsc::error::TrySendError::Closed(envelope)) => {
547 let session_id = envelope.job.session.id.clone();
548 envelope.job.session.worker_job_finished();
549 self.worker_job_finished();
550 self.metrics
551 .counter(
552 "av_events_dropped_total{stage=\"worker_closed\"}",
553 "Worker jobs or response-slot reservations dropped, labeled by admission stage",
554 )
555 .inc();
556 tracing::warn!(
557 session = %session_id,
558 stage = %stage.label(),
559 "worker queue closed; request must fail closed"
560 );
561 Err(SubmitError::Closed)
562 }
563 }
564 }
565
566 pub async fn submit_and_wait(&self, job: WorkerJob) -> Result<(), String> {
569 let sender_for_session = self
570 .sender_for(&job.session.id)
571 .cloned()
572 .ok_or_else(|| "worker queue is closed".to_owned())?;
573 let (sender, receiver) = oneshot::channel();
574 let capacity_permit = Arc::clone(&self.capacity)
575 .acquire_owned()
576 .await
577 .map_err(|_| "worker capacity semaphore is closed".to_owned())?;
578 job.session.worker_job_started();
579 self.pending.fetch_add(1, Ordering::AcqRel);
580 let span = worker_span(&job);
581 sender_for_session
582 .send(Envelope {
583 job,
584 completion: Some(sender),
585 span,
586 _capacity_permit: capacity_permit,
587 })
588 .await
589 .map_err(|error| {
590 error.0.job.session.worker_job_finished();
591 self.worker_job_finished();
592 "worker queue is closed".to_owned()
593 })?;
594 receiver
595 .await
596 .map_err(|_| "worker completion channel closed".to_owned())?
597 }
598
599 pub async fn wait_idle(&self) {
601 loop {
602 let notified = self.drained.notified();
606 let mut notified = std::pin::pin!(notified);
607 notified.as_mut().enable();
608 if self.pending.load(Ordering::Acquire) == 0 {
609 return;
610 }
611 notified.await;
612 }
613 }
614
615 pub fn queue_depth(&self) -> u64 {
618 self.pending.load(Ordering::Acquire)
619 }
620
621 fn worker_job_finished(&self) {
622 if self.pending.fetch_sub(1, Ordering::AcqRel) == 1 {
623 self.drained.notify_waiters();
624 }
625 }
626
627 fn try_capacity(&self) -> Result<tokio::sync::OwnedSemaphorePermit, SubmitError> {
628 self.try_capacity_labeled(DropStage::WorkerQueue)
629 }
630
631 fn try_capacity_labeled(
637 &self,
638 stage: DropStage,
639 ) -> Result<tokio::sync::OwnedSemaphorePermit, SubmitError> {
640 Arc::clone(&self.capacity).try_acquire_owned().map_err(|_| {
641 self.metrics
642 .counter(
643 stage.full_counter_key(),
644 "Worker jobs or response-slot reservations dropped, labeled by admission stage",
645 )
646 .inc();
647 SubmitError::Full
648 })
649 }
650
651 fn sender_for(&self, session_id: &str) -> Option<&mpsc::Sender<Envelope>> {
652 let partitions = u32::try_from(self.senders.len()).ok()?;
653 let shard = av_bridge::bus::partition_for(session_id, partitions) as usize;
654 self.senders.get(shard)
655 }
656}
657
658pub fn spawn_worker(
661 capacity: usize,
662 bridge: Arc<dyn EventBus>,
663 embedder: Arc<dyn Embedder>,
664 metrics: Arc<Registry>,
665) -> WorkerHandle {
666 spawn_worker_with_sink(capacity, bridge, embedder, Arc::new(NoopVectorSink), metrics)
667}
668
669pub fn spawn_worker_with_sink(
671 capacity: usize,
672 bridge: Arc<dyn EventBus>,
673 embedder: Arc<dyn Embedder>,
674 vector_sink: Arc<dyn VectorSink>,
675 metrics: Arc<Registry>,
676) -> WorkerHandle {
677 spawn_worker_with_spool(capacity, bridge, embedder, vector_sink, None, metrics)
678}
679
680pub fn spawn_worker_with_spool(
682 capacity: usize,
683 bridge: Arc<dyn EventBus>,
684 embedder: Arc<dyn Embedder>,
685 vector_sink: Arc<dyn VectorSink>,
686 spool_dir: Option<std::path::PathBuf>,
687 metrics: Arc<Registry>,
688) -> WorkerHandle {
689 spawn_worker_with_spool_authenticated(
690 capacity,
691 bridge,
692 embedder,
693 vector_sink,
694 spool_dir,
695 [0; 32],
696 metrics,
697 None,
698 )
699}
700
701#[allow(clippy::too_many_arguments)]
703pub fn spawn_worker_with_spool_authenticated(
704 capacity: usize,
705 bridge: Arc<dyn EventBus>,
706 embedder: Arc<dyn Embedder>,
707 vector_sink: Arc<dyn VectorSink>,
708 spool_dir: Option<std::path::PathBuf>,
709 journal_key: [u8; 32],
710 metrics: Arc<Registry>,
711 redaction: Option<Arc<av_redact::RedactionEngine>>,
712) -> WorkerHandle {
713 spawn_worker_with_outputs(
714 capacity,
715 bridge,
716 embedder,
717 vector_sink,
718 spool_dir,
719 journal_key,
720 metrics,
721 crate::content::AuditOutputs::with_redaction(redaction),
722 )
723}
724
725#[allow(clippy::too_many_arguments)]
728pub fn spawn_worker_with_outputs(
729 capacity: usize,
730 bridge: Arc<dyn EventBus>,
731 embedder: Arc<dyn Embedder>,
732 vector_sink: Arc<dyn VectorSink>,
733 spool_dir: Option<std::path::PathBuf>,
734 journal_key: [u8; 32],
735 metrics: Arc<Registry>,
736 outputs: crate::content::AuditOutputs,
737) -> WorkerHandle {
738 const MIN_SHARDS: usize = 16;
754 let capacity = capacity.max(1);
755 let shard_count = std::thread::available_parallelism()
756 .map(usize::from)
757 .unwrap_or(MIN_SHARDS)
758 .max(MIN_SHARDS);
759 let per_shard_capacity = capacity.saturating_mul(2);
773 let global_capacity = Arc::new(tokio::sync::Semaphore::new(capacity));
774 let response_capacity = Arc::new(tokio::sync::Semaphore::new(capacity));
782 let pending = Arc::new(std::sync::atomic::AtomicU64::new(0));
783 let drained = Arc::new(tokio::sync::Notify::new());
784 let mut senders = Vec::with_capacity(shard_count);
785 for _ in 0..shard_count {
786 let (sender, receiver) = mpsc::channel::<Envelope>(per_shard_capacity);
787 senders.push(sender);
788 spawn_worker_shard(
789 receiver,
790 Arc::clone(&bridge),
791 Arc::clone(&embedder),
792 Arc::clone(&vector_sink),
793 spool_dir.clone(),
794 journal_key,
795 Arc::clone(&metrics),
796 Arc::clone(&pending),
797 Arc::clone(&drained),
798 outputs.clone(),
799 );
800 }
801 WorkerHandle {
802 senders: Arc::new(senders),
803 metrics,
804 pending,
805 drained,
806 capacity: global_capacity,
807 response_capacity,
808 }
809}
810
811const MAX_ACTIVE_SESSIONS_PER_SHARD: usize = 8;
818
819const MAX_BATCH_PER_SESSION: usize = 16;
826
827#[allow(clippy::too_many_arguments)]
828fn spawn_worker_shard(
829 mut receiver: mpsc::Receiver<Envelope>,
830 bridge: Arc<dyn EventBus>,
831 embedder: Arc<dyn Embedder>,
832 vector_sink: Arc<dyn VectorSink>,
833 spool_dir: Option<std::path::PathBuf>,
834 journal_key: [u8; 32],
835 worker_metrics: Arc<Registry>,
836 worker_pending: Arc<std::sync::atomic::AtomicU64>,
837 worker_drained: Arc<tokio::sync::Notify>,
838 outputs: crate::content::AuditOutputs,
839) {
840 tokio::spawn(async move {
859 let mut queues: std::collections::HashMap<String, std::collections::VecDeque<Envelope>> =
860 std::collections::HashMap::new();
861 let mut active: std::collections::HashSet<String> = std::collections::HashSet::new();
868 let mut tasks: tokio::task::JoinSet<String> = tokio::task::JoinSet::new();
869 let mut receiver_open = true;
870
871 let spawn_batch = |tasks: &mut tokio::task::JoinSet<String>, batch: Vec<Envelope>| {
872 let session_id = batch
873 .first()
874 .map(|envelope| envelope.job.session.id.clone())
875 .unwrap_or_default();
876 let bridge = Arc::clone(&bridge);
877 let embedder = Arc::clone(&embedder);
878 let vector_sink = Arc::clone(&vector_sink);
879 let spool_dir = spool_dir.clone();
880 let worker_metrics = Arc::clone(&worker_metrics);
881 let worker_pending = Arc::clone(&worker_pending);
882 let worker_drained = Arc::clone(&worker_drained);
883 let outputs = outputs.clone();
884 tasks.spawn(async move {
885 use futures::future::FutureExt as _;
886 let outcome = std::panic::AssertUnwindSafe(async {
893 let metrics = Arc::clone(&worker_metrics);
894 let outcome = std::panic::AssertUnwindSafe(process_envelope_batch(
895 batch,
896 bridge,
897 embedder,
898 vector_sink,
899 spool_dir,
900 journal_key,
901 worker_metrics,
902 worker_pending,
903 worker_drained,
904 outputs,
905 ))
906 .catch_unwind()
907 .await;
908 if let Err(panic) = outcome {
909 let msg = panic
910 .downcast_ref::<&'static str>()
911 .copied()
912 .or_else(|| panic.downcast_ref::<String>().map(String::as_str))
913 .unwrap_or("panic payload was not a string");
914 metrics
915 .counter(
916 "av_worker_shard_panics_total",
917 "Worker shard driver panicked outside a job; supervised via catch_unwind",
918 )
919 .inc();
920 tracing::error!(
921 panic = %msg,
922 "worker envelope routing panicked; continuing"
923 );
924 }
925 })
926 .catch_unwind()
927 .await;
928 drop(outcome);
929 session_id
930 });
931 };
932
933 loop {
934 if !receiver_open && tasks.is_empty() && queues.is_empty() {
935 break;
936 }
937 tokio::select! {
938 maybe_envelope = receiver.recv(), if receiver_open => {
939 match maybe_envelope {
940 Some(envelope) => {
941 let session_id = envelope.job.session.id.clone();
942 if !active.contains(&session_id)
943 && !queues.contains_key(&session_id)
951 && active.len() < MAX_ACTIVE_SESSIONS_PER_SHARD
952 {
953 active.insert(session_id);
955 spawn_batch(&mut tasks, vec![envelope]);
956 } else {
957 queues.entry(session_id).or_default().push_back(envelope);
960 }
961 }
962 None => receiver_open = false,
963 }
964 }
965 Some(joined) = tasks.join_next(), if !tasks.is_empty() => {
966 match joined {
967 Ok(session_id) => {
968 let backlog = queues.get_mut(&session_id).map_or(Vec::new(), |queue| {
969 let take = queue.len().min(MAX_BATCH_PER_SESSION);
970 queue.drain(..take).collect::<Vec<_>>()
971 });
972 if !backlog.is_empty() {
973 if queues.get(&session_id).is_some_and(std::collections::VecDeque::is_empty) {
980 queues.remove(&session_id);
981 }
982 spawn_batch(&mut tasks, backlog);
983 } else {
984 queues.remove(&session_id);
985 active.remove(&session_id);
986 let waiting = queues
990 .keys()
991 .filter(|id| !active.contains(*id))
992 .take(
993 MAX_ACTIVE_SESSIONS_PER_SHARD.saturating_sub(active.len()),
994 )
995 .cloned()
996 .collect::<Vec<_>>();
997 for id in waiting {
998 let batch = queues.get_mut(&id).map_or(Vec::new(), |queue| {
999 let take = queue.len().min(MAX_BATCH_PER_SESSION);
1000 queue.drain(..take).collect::<Vec<_>>()
1001 });
1002 if batch.is_empty() {
1003 continue;
1004 }
1005 if queues.get(&id).is_some_and(std::collections::VecDeque::is_empty) {
1006 queues.remove(&id);
1007 }
1008 active.insert(id);
1009 spawn_batch(&mut tasks, batch);
1010 }
1011 }
1012 }
1013 Err(error) => {
1014 tracing::error!(%error, "worker envelope task failed to join");
1020 }
1021 }
1022 }
1023 else => {
1024 if !queues.is_empty() {
1028 tracing::error!(
1029 sessions = queues.len(),
1030 "worker shard exiting with undispatched envelopes after task join failure"
1031 );
1032 }
1033 break;
1034 }
1035 }
1036 }
1037 });
1038}
1039
1040async fn sync_session_journal(directory: &std::path::Path, session: &Session) -> Result<(), String> {
1043 let digest = av_core::digest::sha256_hex(session.id.as_bytes());
1044 let stem = digest.get(..32).unwrap_or(&digest);
1045 let path = directory.join(format!("{stem}.events.ndjson"));
1046 tokio::task::spawn_blocking(move || {
1047 let mut options = std::fs::OpenOptions::new();
1061 options.read(true);
1062 #[cfg(unix)]
1063 {
1064 use std::os::unix::fs::OpenOptionsExt as _;
1065 options.custom_flags(av_core::fsutil::unix_o_nofollow());
1066 }
1067 match options.open(&path) {
1068 Ok(journal) => journal.sync_data().map_err(|error| error.to_string()),
1069 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1072 Err(error) => Err(error.to_string()),
1073 }
1074 })
1075 .await
1076 .map_err(|error| error.to_string())?
1077}
1078
1079#[allow(clippy::too_many_arguments)]
1097async fn process_envelope_batch(
1098 envelopes: Vec<Envelope>,
1099 bridge: Arc<dyn EventBus>,
1100 embedder: Arc<dyn Embedder>,
1101 vector_sink: Arc<dyn VectorSink>,
1102 spool_dir: Option<std::path::PathBuf>,
1103 journal_key: [u8; 32],
1104 worker_metrics: Arc<Registry>,
1105 worker_pending: Arc<std::sync::atomic::AtomicU64>,
1106 worker_drained: Arc<tokio::sync::Notify>,
1107 outputs: crate::content::AuditOutputs,
1108) {
1109 let mut iterator = envelopes.into_iter();
1110 let Some(first) = iterator.next() else { return };
1111 let rest: Vec<Envelope> = iterator.collect();
1112 if rest.is_empty() {
1113 return process_envelope(
1114 first,
1115 bridge,
1116 embedder,
1117 vector_sink,
1118 spool_dir,
1119 journal_key,
1120 worker_metrics,
1121 worker_pending,
1122 worker_drained,
1123 outputs,
1124 )
1125 .await;
1126 }
1127 let session = Arc::clone(&first.job.session);
1128 let mut guards = Vec::new();
1131 let mut completions = Vec::new();
1132 let mut jobs = Vec::new();
1133 for envelope in std::iter::once(first).chain(rest) {
1134 let Envelope {
1135 job,
1136 completion,
1137 span,
1138 _capacity_permit: capacity_permit,
1139 } = envelope;
1140 guards.push((
1141 PendingGuard::new(Arc::clone(&worker_pending), Arc::clone(&worker_drained)),
1142 SessionPendingGuard {
1143 session: Arc::clone(&job.session),
1144 },
1145 capacity_permit,
1146 ));
1147 completions.push(completion);
1148 jobs.push((job, span));
1149 }
1150 let batch_metrics = Arc::clone(&worker_metrics);
1151 let batch_session = Arc::clone(&session);
1152 let spool = spool_dir.clone();
1153 let result = tokio::spawn(async move {
1154 let mut outcomes: Vec<Result<(), String>> = Vec::new();
1155 let mut persisted: Vec<Option<PersistedJob>> = Vec::new();
1156 let mut failed = false;
1157 for (job, span) in jobs {
1159 if failed {
1160 outcomes.push(Ok(()));
1163 persisted.push(None);
1164 continue;
1165 }
1166 let outcome = persist_job(
1167 job,
1168 Arc::clone(&embedder),
1169 Arc::clone(&vector_sink),
1170 spool.as_deref(),
1171 journal_key,
1172 &batch_metrics,
1173 false,
1174 &outputs,
1175 )
1176 .instrument(span)
1177 .await;
1178 match outcome {
1179 Ok(slot) => {
1180 persisted.push(slot);
1181 outcomes.push(Ok(()));
1182 }
1183 Err(error) => {
1184 batch_session.mark_capture_failed();
1187 persisted.push(None);
1188 outcomes.push(Err(error));
1189 failed = true;
1190 }
1191 }
1192 }
1193 if let Some(directory) = spool.as_deref() {
1196 if persisted.iter().any(Option::is_some) {
1197 if let Err(error) = sync_session_journal(directory, &batch_session).await {
1198 for (index, slot) in persisted.iter_mut().enumerate() {
1202 if slot.take().is_some() {
1203 if let Some(outcome) = outcomes.get_mut(index) {
1204 *outcome = Err(format!("batch journal sync failed: {error}"));
1205 }
1206 }
1207 }
1208 }
1209 }
1210 }
1211 let mut tail_failed = false;
1213 for (index, slot) in persisted.into_iter().enumerate() {
1214 let Some(slot) = slot else { continue };
1215 if tail_failed {
1216 if let Some(outcome) = outcomes.get_mut(index) {
1217 *outcome = Err("skipped after a batch sibling's post-persist failure".to_owned());
1218 }
1219 continue;
1220 }
1221 if let Err(error) = finish_job(slot, Arc::clone(&bridge), spool.as_deref(), journal_key).await {
1222 if let Some(outcome) = outcomes.get_mut(index) {
1223 *outcome = Err(error);
1224 }
1225 tail_failed = true;
1226 }
1227 }
1228 outcomes
1229 })
1230 .await;
1231 let outcomes = match result {
1232 Ok(outcomes) => outcomes,
1233 Err(error) => {
1234 worker_metrics
1235 .counter(
1236 "av_worker_panics_total",
1237 "Worker job panics isolated by supervisor",
1238 )
1239 .inc();
1240 let failure = format!("worker batch panicked: {error}");
1241 vec![Err(failure); completions.len()]
1242 }
1243 };
1244 let mut any_failed = false;
1245 for (completion, outcome) in completions.into_iter().zip(outcomes) {
1246 if let Err(error) = &outcome {
1247 any_failed = true;
1248 tracing::warn!(session = %session.id, %error, "capture job failed; session is fail-closed");
1249 worker_metrics
1250 .counter("av_worker_errors_total", "Worker jobs that failed")
1251 .inc();
1252 }
1253 if let Some(completion) = completion {
1254 let _ = completion.send(outcome);
1255 }
1256 }
1257 if any_failed {
1258 session.mark_capture_failed();
1259 }
1260 drop(guards);
1261}
1262
1263#[allow(clippy::too_many_arguments)]
1266async fn process_envelope(
1267 envelope: Envelope,
1268 bridge: Arc<dyn EventBus>,
1269 embedder: Arc<dyn Embedder>,
1270 vector_sink: Arc<dyn VectorSink>,
1271 spool_dir: Option<std::path::PathBuf>,
1272 journal_key: [u8; 32],
1273 worker_metrics: Arc<Registry>,
1274 worker_pending: Arc<std::sync::atomic::AtomicU64>,
1275 worker_drained: Arc<tokio::sync::Notify>,
1276 outputs: crate::content::AuditOutputs,
1277) {
1278 let _pending_guard = PendingGuard::new(Arc::clone(&worker_pending), Arc::clone(&worker_drained));
1290 let Envelope {
1291 job,
1292 completion,
1293 span,
1294 _capacity_permit: capacity_permit,
1295 } = envelope;
1296 let session = Arc::clone(&job.session);
1297 let _session_pending_guard = SessionPendingGuard {
1309 session: Arc::clone(&session),
1310 };
1311 let result = tokio::spawn(
1312 {
1313 let job_metrics = Arc::clone(&worker_metrics);
1314 async move {
1315 process_job(
1316 job,
1317 bridge,
1318 embedder,
1319 vector_sink,
1320 spool_dir,
1321 journal_key,
1322 job_metrics,
1323 outputs,
1324 )
1325 .await
1326 }
1327 }
1328 .instrument(span),
1329 )
1330 .await;
1331 let outcome = match result {
1332 Ok(result) => result,
1333 Err(error) => {
1334 worker_metrics
1335 .counter(
1336 "av_worker_panics_total",
1337 "Worker job panics isolated by supervisor",
1338 )
1339 .inc();
1340 Err(format!("worker job panicked: {error}"))
1341 }
1342 };
1343 if let Err(error) = &outcome {
1344 tracing::warn!(session = %session.id, %error, "capture job failed; session is fail-closed");
1349 session.mark_capture_failed();
1350 worker_metrics
1351 .counter("av_worker_errors_total", "Worker jobs that failed")
1352 .inc();
1353 }
1354 drop(capacity_permit);
1359 if let Some(completion) = completion {
1365 let _ = completion.send(outcome);
1366 }
1367}
1368
1369struct PendingGuard {
1374 pending: Arc<std::sync::atomic::AtomicU64>,
1375 drained: Arc<tokio::sync::Notify>,
1376}
1377
1378impl PendingGuard {
1379 fn new(pending: Arc<std::sync::atomic::AtomicU64>, drained: Arc<tokio::sync::Notify>) -> Self {
1380 Self { pending, drained }
1381 }
1382}
1383
1384impl Drop for PendingGuard {
1385 fn drop(&mut self) {
1386 if self.pending.fetch_sub(1, Ordering::AcqRel) == 1 {
1387 self.drained.notify_waiters();
1388 }
1389 }
1390}
1391
1392struct SessionPendingGuard {
1403 session: Arc<crate::session::Session>,
1404}
1405
1406impl Drop for SessionPendingGuard {
1407 fn drop(&mut self) {
1408 self.session.worker_job_finished();
1409 }
1410}
1411
1412fn worker_span(job: &WorkerJob) -> tracing::Span {
1413 tracing::info_span!(
1414 parent: &tracing::Span::current(),
1415 "agentvisor.worker",
1416 session.id = %job.session.id,
1417 event.class = ?job.class,
1418 )
1419}
1420
1421#[allow(clippy::too_many_arguments)]
1422struct PersistedJob {
1429 session: Arc<Session>,
1430 identity: av_events::AgentIdentity,
1431 class: EventClass,
1432 value: Value,
1433 event_uid: String,
1434 record: ActiveJournalRecord,
1435 response_marker: Option<String>,
1436 content: Option<(
1439 Arc<crate::content::ContentDispatcher>,
1440 crate::content::ContentRecord,
1441 )>,
1442}
1443
1444#[allow(clippy::too_many_arguments)]
1445async fn process_job(
1446 job: WorkerJob,
1447 bridge: Arc<dyn EventBus>,
1448 embedder: Arc<dyn Embedder>,
1449 vector_sink: Arc<dyn VectorSink>,
1450 spool_dir: Option<std::path::PathBuf>,
1451 journal_key: [u8; 32],
1452 metrics: Arc<Registry>,
1453 outputs: crate::content::AuditOutputs,
1454) -> Result<(), String> {
1455 match persist_job(
1456 job,
1457 embedder,
1458 vector_sink,
1459 spool_dir.as_deref(),
1460 journal_key,
1461 &metrics,
1462 true,
1463 &outputs,
1464 )
1465 .await?
1466 {
1467 None => Ok(()),
1468 Some(persisted) => finish_job(persisted, bridge, spool_dir.as_deref(), journal_key).await,
1469 }
1470}
1471
1472fn redact_audit_capture(
1474 mut value: Value,
1475 mut atif_step: Option<av_atif::Step>,
1476 class: EventClass,
1477 engine: &av_redact::RedactionEngine,
1478) -> (Value, Option<av_atif::Step>) {
1479 let original_payload = (class == EventClass::ToolCall)
1483 .then(|| value.get("payload").map(Value::to_string))
1484 .flatten();
1485 if let Some(payload) = value.get_mut("payload") {
1486 engine.redact_value(payload);
1487 }
1488 if let Some(step) = &mut atif_step {
1489 engine.redact_value(&mut step.message);
1490 if let Some(reasoning) = &mut step.reasoning_content {
1491 let mut text = Value::String(std::mem::take(reasoning));
1492 engine.redact_value(&mut text);
1493 if let Value::String(redacted) = text {
1494 *reasoning = redacted;
1495 }
1496 }
1497 if let Some(calls) = &mut step.tool_calls {
1498 for call in calls {
1499 engine.redact_user_value(&mut call.arguments);
1500 }
1501 }
1502 if let Some(observation) = &mut step.observation {
1503 for result in &mut observation.results {
1504 if let Some(content) = &mut result.content {
1505 if original_payload
1506 .as_deref()
1507 .is_some_and(|original| content.as_str() == Some(original))
1508 {
1509 if let Some(payload) = value.get("payload") {
1510 *content = Value::String(payload.to_string());
1511 }
1512 }
1513 engine.redact_value(content);
1514 }
1515 }
1516 }
1517 }
1518 (value, atif_step)
1519}
1520
1521#[allow(clippy::too_many_arguments)]
1527async fn persist_job(
1528 mut job: WorkerJob,
1529 embedder: Arc<dyn Embedder>,
1530 vector_sink: Arc<dyn VectorSink>,
1531 spool_dir: Option<&std::path::Path>,
1532 journal_key: [u8; 32],
1533 metrics: &Arc<Registry>,
1534 sync_journal: bool,
1535 outputs: &crate::content::AuditOutputs,
1536) -> Result<Option<PersistedJob>, String> {
1537 if job.session.capture_failed() {
1543 return Ok(None);
1544 }
1545 let response_marker = job.response_marker.clone();
1546 let is_llm_agent_response = job
1547 .atif
1548 .as_ref()
1549 .is_some_and(|capture| capture.source == av_atif::Source::Agent && capture.llm_call_count != Some(0));
1550 let step_tokens = job
1551 .metrics
1552 .prompt_tokens
1553 .unwrap_or(0)
1554 .saturating_add(job.metrics.completion_tokens.unwrap_or(0));
1555 let breaker = if job.analyze_loop {
1556 let text = job.text.clone();
1557 let embedding_result = tokio::task::spawn_blocking(move || embedder.try_embed(&text))
1569 .await
1570 .map_err(|error| error.to_string())?;
1571 match embedding_result {
1572 Ok(embedding) => {
1573 let similarity = match vector_sink
1574 .nearest_similarity(&job.session.session_scope, &embedding)
1575 .await
1576 {
1577 Ok(similarity) => similarity,
1578 Err(error) => {
1579 tracing::warn!(
1580 session = %job.session.id,
1581 %error,
1582 "vector-sink nearest_similarity failed; skipping breaker for this step"
1583 );
1584 metrics
1585 .counter(
1586 "av_vector_sink_errors_total",
1587 "Vector-sink errors demoted to warn",
1588 )
1589 .inc();
1590 None
1591 }
1592 };
1593 let verdict = job.session.loop_state.observe_embedding_with_similarity(
1594 embedding.clone(),
1595 step_tokens,
1596 similarity,
1597 );
1598 if let Err(error) = vector_sink.record(&job.session.session_scope, &embedding).await {
1599 tracing::warn!(
1600 session = %job.session.id,
1601 %error,
1602 "vector-sink record failed; the local breaker still observed this step"
1603 );
1604 metrics
1605 .counter(
1606 "av_vector_sink_errors_total",
1607 "Vector-sink errors demoted to warn",
1608 )
1609 .inc();
1610 }
1611 Some(verdict)
1612 }
1613 Err(error) => {
1614 tracing::warn!(
1615 session = %job.session.id,
1616 %error,
1617 "embedder try_embed failed; skipping breaker for this step"
1618 );
1619 metrics
1620 .counter("av_embedder_errors_total", "Embedder errors demoted to warn")
1621 .inc();
1622 None
1623 }
1624 }
1625 } else {
1626 None
1627 };
1628 let submitted_class = job.class;
1635 let (class, status, stop_reason, payload) = match breaker {
1636 Some(BreakerVerdict::Tripped {
1637 delta,
1638 streak,
1639 tokens_consumed,
1640 action,
1641 }) => {
1642 tracing::warn!(
1646 session = %job.session.id,
1647 delta,
1648 streak,
1649 tokens_consumed,
1650 action = ?action,
1651 "semantic loop circuit breaker tripped"
1652 );
1653 metrics
1654 .counter("av_breaker_trips_total", "Semantic loop breaker trips")
1655 .inc();
1656 if matches!(
1665 action,
1666 av_loopdetect::BreakerAction::Abort | av_loopdetect::BreakerAction::Reject
1667 ) {
1668 job.session.latch_enforcement(StopReason::LoopDetected);
1669 }
1670 (
1671 EventClass::StopReason,
1672 StatusId::Failure,
1673 Some(StopReason::LoopDetected),
1674 serde_json::json!({
1675 "delta": delta,
1676 "streak": streak,
1677 "tokens_consumed": tokens_consumed,
1678 "action": action,
1679 }),
1680 )
1681 }
1682 _ => (job.class, job.status, job.stop_reason, job.payload),
1683 };
1684
1685 let native_finish_reason = job.native_stop_reason.clone();
1687 let mut builder = OcsfEventBuilder::new(
1688 class,
1689 job.session.id.clone(),
1690 job.identity.clone(),
1691 job.session.next_seq(),
1692 )
1693 .status(status)
1694 .payload(payload)
1695 .metrics(job.metrics);
1696 if let Some(reason) = stop_reason {
1697 job.session.record_stop_reason(reason);
1698 builder = match job.native_stop_reason {
1699 Some(native) => builder.stop_reason_native(reason, native),
1700 None => builder.stop_reason(reason),
1701 };
1702 }
1703 let event = builder.build().map_err(|error| error.to_string())?;
1704 let event_uid = event.metadata.uid.clone();
1705 let value = serde_json::to_value(&event).map_err(|error| error.to_string())?;
1706
1707 let atif_step = if job.session.workflow == Workflow::Unsigned {
1708 let capture = job
1709 .atif
1710 .take()
1711 .ok_or_else(|| "unsigned worker job has no ATIF capture".to_owned())?;
1712 let is_llm_step = capture.source == av_atif::Source::Agent && capture.llm_call_count != Some(0);
1713 Some(av_atif::Step {
1714 step_id: 0,
1715 timestamp: Some(av_core::time::now_iso8601()),
1716 source: capture.source,
1717 message: capture.message,
1718 reasoning_effort: None,
1719 reasoning_content: capture.reasoning_content,
1720 model_name: capture.model_name,
1721 tool_calls: capture.tool_calls,
1722 observation: capture.observation,
1723 metrics: is_llm_step.then(|| av_atif::Metrics {
1724 prompt_tokens: Some(job.metrics.prompt_tokens.unwrap_or(0)),
1725 completion_tokens: Some(job.metrics.completion_tokens.unwrap_or(0)),
1726 cached_tokens: Some(job.metrics.cached_tokens.unwrap_or(0)),
1727 cost_usd: Some(job.cost_usd_micros as f64 / av_core::units::USD_MICROS_PER_DOLLAR as f64),
1728 logprobs: None,
1729 completion_token_ids: None,
1730 prompt_token_ids: None,
1731 extra: None,
1732 }),
1733 is_copied_context: None,
1734 llm_call_count: capture.llm_call_count,
1735 extra: None,
1736 })
1737 } else {
1738 None
1739 };
1740 let (value, atif_step) = if let Some(engine) = outputs.redaction.as_ref() {
1743 let engine = Arc::clone(engine);
1744 tokio::task::spawn_blocking(move || redact_audit_capture(value, atif_step, submitted_class, &engine))
1745 .await
1746 .map_err(|error| format!("redaction task failed: {error}"))?
1747 } else {
1748 (value, atif_step)
1749 };
1750 let is_tool_call = submitted_class == EventClass::ToolCall;
1751 let is_response_accounting = is_llm_agent_response && submitted_class != EventClass::Compression;
1752 let record = ActiveJournalRecord {
1753 event: value.clone(),
1754 identity: job.identity.clone(),
1755 atif_step: atif_step.clone(),
1756 tool_calls: u64::from(is_tool_call),
1757 tool_allowed: u64::from(is_tool_call && status == StatusId::Success),
1758 tool_blocked: u64::from(is_tool_call && status == StatusId::Failure),
1759 prompt_tokens: if submitted_class == EventClass::Compression {
1760 job.metrics.prompt_tokens.unwrap_or(0)
1761 } else {
1762 0
1763 },
1764 completion_tokens: if is_response_accounting {
1765 job.metrics.completion_tokens.unwrap_or(0)
1766 } else {
1767 0
1768 },
1769 cached_tokens: if is_response_accounting {
1770 job.metrics.cached_tokens.unwrap_or(0)
1771 } else {
1772 0
1773 },
1774 cost_usd_micros: if is_response_accounting {
1775 job.cost_usd_micros
1776 } else {
1777 0
1778 },
1779 prompt_token_correction: if is_response_accounting {
1780 job.prompt_token_correction
1781 } else {
1782 0
1783 },
1784 stop_reason_id: event.stop_reason_id,
1785 response_attempt: job.response_attempt.clone(),
1786 };
1787 if let Some(directory) = spool_dir {
1788 append_active_event_journal(
1789 directory,
1790 &job.session,
1791 &job.identity,
1792 &record,
1793 &journal_key,
1794 sync_journal,
1795 )
1796 .await?;
1797 }
1798 let content = outputs.dispatcher().map(|dispatcher| {
1799 (
1800 dispatcher,
1801 content_record(
1802 ContentSource {
1803 session_id: &job.session.id,
1804 identity: &job.identity,
1805 capture: job.atif.as_ref(),
1806 metrics: &job.metrics,
1807 attempt: job.response_attempt.as_ref(),
1808 },
1809 atif_step.as_ref(),
1810 class,
1811 status,
1812 &value,
1813 &event_uid,
1814 native_finish_reason,
1815 ),
1816 )
1817 });
1818 Ok(Some(PersistedJob {
1819 session: job.session,
1820 identity: job.identity,
1821 class,
1822 value,
1823 event_uid,
1824 record,
1825 response_marker,
1826 content,
1827 }))
1828}
1829
1830struct ContentSource<'a> {
1834 session_id: &'a str,
1835 identity: &'a av_events::AgentIdentity,
1836 capture: Option<&'a AtifCapture>,
1837 metrics: &'a EventMetrics,
1838 attempt: Option<&'a ResponseAttempt>,
1839}
1840
1841fn content_record(
1842 job: ContentSource<'_>,
1843 step: Option<&av_atif::Step>,
1844 class: EventClass,
1845 status: StatusId,
1846 event: &Value,
1847 event_uid: &str,
1848 finish_reason: Option<String>,
1849) -> crate::content::ContentRecord {
1850 let label = |source: av_atif::Source| {
1851 match source {
1852 av_atif::Source::User => "user",
1853 av_atif::Source::Agent => "agent",
1854 _ => "system",
1855 }
1856 .to_owned()
1857 };
1858 let (source, message, reasoning, model, tool_calls, observation) = match (step, job.capture) {
1859 (Some(step), _) => (
1860 Some(label(step.source)),
1861 Some(step.message.clone()),
1862 step.reasoning_content.clone(),
1863 step.model_name.clone(),
1864 step.tool_calls
1865 .as_ref()
1866 .and_then(|calls| serde_json::to_value(calls).ok()),
1867 step.observation
1868 .as_ref()
1869 .and_then(|observation| serde_json::to_value(observation).ok()),
1870 ),
1871 (None, Some(capture)) => (
1872 Some(label(capture.source)),
1873 Some(capture.message.clone()),
1874 capture.reasoning_content.clone(),
1875 capture.model_name.clone(),
1876 capture
1877 .tool_calls
1878 .as_ref()
1879 .and_then(|calls| serde_json::to_value(calls).ok()),
1880 capture
1881 .observation
1882 .as_ref()
1883 .and_then(|observation| serde_json::to_value(observation).ok()),
1884 ),
1885 (None, None) => (None, None, None, None, None, None),
1886 };
1887 crate::content::ContentRecord {
1888 session_id: job.session_id.to_owned(),
1889 event_uid: event_uid.to_owned(),
1890 class,
1891 success: status == StatusId::Success,
1892 identity: job.identity.clone(),
1893 time_ms: av_core::time::now_ms(),
1894 event: event.clone(),
1895 source,
1896 message,
1897 reasoning,
1898 model,
1899 tool_calls,
1900 observation,
1901 prompt_tokens: job.metrics.prompt_tokens,
1902 completion_tokens: job.metrics.completion_tokens,
1903 finish_reason,
1904 attempt_id: job.attempt.map(|attempt| attempt.id.clone()),
1905 terminal: job.attempt.is_some_and(|attempt| attempt.terminal),
1906 }
1907}
1908
1909async fn finish_job(
1915 persisted: PersistedJob,
1916 bridge: Arc<dyn EventBus>,
1917 spool_dir: Option<&std::path::Path>,
1918 journal_key: [u8; 32],
1919) -> Result<(), String> {
1920 let PersistedJob {
1921 session,
1922 identity,
1923 class,
1924 value,
1925 event_uid,
1926 record,
1927 response_marker,
1928 content,
1929 } = persisted;
1930 match session.workflow {
1931 Workflow::Signed => session
1932 .chain
1933 .lock()
1934 .append(&value)
1935 .map_err(|error| error.to_string())?,
1936 Workflow::Unsigned => {
1937 if record.atif_step.is_none() {
1944 return Err("unsigned ATIF step disappeared".to_owned());
1945 }
1946 session.note_atif_step();
1947 }
1948 }
1949
1950 let key = identity.instance_uid.clone();
1951 let publish_topic = class.topic().to_owned();
1952 let publish_uid = event_uid.clone();
1953 let ack = tokio::task::spawn_blocking(move || {
1954 bridge.publish_idempotent(&publish_topic, &key, &value, &publish_uid)
1955 })
1956 .await
1957 .map_err(|error| error.to_string())?
1958 .map_err(|error| error.to_string())?;
1959 if let Some(directory) = spool_dir {
1960 persist_broker_ack(directory, &session.id, &event_uid, &ack, &journal_key).await?;
1961 }
1962 record
1968 .apply_to_totals(&session.totals)
1969 .map_err(|error| error.to_string())?;
1970 if let (Some(directory), Some(attempt_id)) = (spool_dir, response_marker.as_deref()) {
1971 clear_response_marker(directory, &journal_key, &session.id, attempt_id).await?;
1972 }
1973 if let Some((dispatcher, content)) = content {
1976 dispatcher.submit(content);
1977 }
1978 Ok(())
1979}
1980
1981#[cfg(test)]
1982fn checked_atomic_add(counter: &std::sync::atomic::AtomicU64, value: u64, field: &str) -> Result<(), String> {
1983 counter
1984 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
1985 current
1986 .checked_add(value)
1987 .filter(|next| *next <= av_core::error::JCS_SAFE_MAX)
1988 })
1989 .map(|_| ())
1990 .map_err(|_| format!("{field} accounting exceeds JCS-safe bounds"))
1991}
1992
1993pub(crate) fn reserve_response_attempt_id() -> String {
1998 av_core::new_event_uid()
1999}
2000
2001pub(crate) async fn write_response_marker(
2002 spool_dir: &std::path::Path,
2003 journal_key: &[u8; 32],
2004 session_id: &str,
2005 attempt_id: &str,
2006 request_digest: String,
2007) -> Result<(), String> {
2008 let marker = InFlightResponse {
2009 session_id: session_id.to_owned(),
2010 attempt_id: attempt_id.to_owned(),
2011 request_digest,
2012 };
2013 let sealed = crate::journal::seal(journal_key, "in-flight-response", 0, &marker)?;
2014 let path = response_marker_path(spool_dir, session_id, attempt_id);
2015 tokio::task::spawn_blocking(move || write_atomic_control(&path, &sealed))
2016 .await
2017 .map_err(|error| error.to_string())??;
2018 Ok(())
2019}
2020
2021#[allow(dead_code)]
2025pub(crate) async fn create_response_marker(
2026 spool_dir: &std::path::Path,
2027 journal_key: &[u8; 32],
2028 session_id: &str,
2029 request_digest: String,
2030) -> Result<String, String> {
2031 let attempt_id = reserve_response_attempt_id();
2032 write_response_marker(spool_dir, journal_key, session_id, &attempt_id, request_digest).await?;
2033 Ok(attempt_id)
2034}
2035
2036#[cfg(test)]
2037pub(crate) async fn ensure_no_inflight_responses(
2038 spool_dir: &std::path::Path,
2039 journal_key: &[u8; 32],
2040) -> Result<(), String> {
2041 let sessions = inflight_response_sessions(spool_dir, journal_key).await?;
2042 if let Some(session_id) = sessions.into_iter().next() {
2043 Err(format!(
2044 "provider response for session {session_id} was not durably captured"
2045 ))
2046 } else {
2047 Ok(())
2048 }
2049}
2050
2051pub(crate) async fn inflight_response_sessions(
2052 spool_dir: &std::path::Path,
2053 journal_key: &[u8; 32],
2054) -> Result<std::collections::HashSet<String>, String> {
2055 let spool_dir = spool_dir.to_path_buf();
2056 let directory = spool_dir.join(crate::spool::INFLIGHT_RESPONSES);
2057 let journal_key = *journal_key;
2058 tokio::task::spawn_blocking(move || {
2059 let entries = match std::fs::read_dir(&directory) {
2060 Ok(entries) => entries,
2061 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2062 return Ok(std::collections::HashSet::new());
2063 }
2064 Err(error) => return Err(error.to_string()),
2065 };
2066 let mut sessions = std::collections::HashSet::new();
2067 for entry in entries {
2068 let path = entry.map_err(|error| error.to_string())?.path();
2069 if path.extension().and_then(std::ffi::OsStr::to_str) != Some("json") {
2070 continue;
2071 }
2072 let marker_bytes = match av_core::fsutil::read_capped(&path, av_core::fsutil::MAX_CONTROL_BYTES) {
2073 Ok(bytes) => bytes,
2074 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2078 Err(error) => return Err(error.to_string()),
2079 };
2080 let marker: InFlightResponse =
2087 match crate::journal::open(&journal_key, "in-flight-response", 0, &marker_bytes) {
2088 Ok(marker) => marker,
2089 Err(error) => {
2090 quarantine_inflight_marker(&path, &error);
2091 continue;
2092 }
2093 };
2094 if path != response_marker_path(&spool_dir, &marker.session_id, &marker.attempt_id) {
2095 quarantine_inflight_marker(
2098 &path,
2099 "in-flight response marker path does not match its payload",
2100 );
2101 continue;
2102 }
2103 match journal_witnesses_terminal_attempt(
2124 &spool_dir,
2125 &journal_key,
2126 &marker.session_id,
2127 &marker.attempt_id,
2128 ) {
2129 Ok(true) => {
2130 match std::fs::remove_file(&path) {
2131 Ok(()) => tracing::info!(
2132 session = %marker.session_id,
2133 attempt = %marker.attempt_id,
2134 "cleared stale in-flight response marker (journal witnesses terminal record)"
2135 ),
2136 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2137 Err(error) => tracing::warn!(
2138 marker = %av_core::fsutil::basename(&path),
2139 %error,
2140 "failed to clear stale in-flight response marker after journal witness"
2141 ),
2142 }
2143 continue;
2144 }
2145 Ok(false) => {}
2146 Err(error) => {
2147 tracing::warn!(
2155 session = %marker.session_id,
2156 attempt = %marker.attempt_id,
2157 %error,
2158 "in-flight response journal-witness check failed; retaining marker"
2159 );
2160 }
2161 }
2162 sessions.insert(marker.session_id);
2163 }
2164 Ok(sessions)
2165 })
2166 .await
2167 .map_err(|error| error.to_string())?
2168}
2169
2170fn journal_witnesses_terminal_attempt(
2201 spool_dir: &std::path::Path,
2202 journal_key: &[u8; 32],
2203 session_id: &str,
2204 attempt_id: &str,
2205) -> Result<bool, String> {
2206 let digest = av_core::digest::sha256_hex(session_id.as_bytes());
2207 let stem = digest.get(..32).unwrap_or(&digest);
2208 let journal_path = spool_dir.join(format!("{stem}.events.ndjson"));
2209 let bytes = match av_core::fsutil::read_capped(&journal_path, av_core::fsutil::MAX_ATIF_BYTES) {
2210 Ok(bytes) => bytes,
2211 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2212 Err(error) => return Err(error.to_string()),
2213 };
2214 if bytes.is_empty() {
2215 return Ok(false);
2216 }
2217 let complete_len = if bytes.last() == Some(&b'\n') {
2218 bytes.len()
2219 } else {
2220 bytes
2221 .iter()
2222 .rposition(|byte| *byte == b'\n')
2223 .map_or(0, |index| index + 1)
2224 };
2225 let domain = format!("{session_id}:active");
2226 let mut index: u64 = 0;
2227 let mut cursor = 0usize;
2228 while cursor < complete_len {
2229 let Some(remaining) = bytes.get(cursor..complete_len) else {
2230 break;
2231 };
2232 let Some(newline_offset) = remaining.iter().position(|byte| *byte == b'\n') else {
2233 break;
2234 };
2235 let Some(line) = remaining.get(..newline_offset) else {
2236 break;
2237 };
2238 cursor += newline_offset + 1;
2239 if line.is_empty() {
2240 return Err("active journal contains an empty line".to_owned());
2247 }
2248 let record: ActiveJournalRecord = crate::journal::open(journal_key, &domain, index, line)
2249 .map_err(|error| format!("active journal record open failed: {error}"))?;
2250 if let Some(attempt) = record.response_attempt {
2251 if attempt.terminal && attempt.id == attempt_id {
2252 return Ok(true);
2253 }
2254 }
2255 index = index
2256 .checked_add(1)
2257 .ok_or_else(|| "active journal index overflow".to_owned())?;
2258 }
2259 Ok(false)
2260}
2261
2262async fn clear_response_marker(
2263 spool_dir: &std::path::Path,
2264 journal_key: &[u8; 32],
2265 session_id: &str,
2266 attempt_id: &str,
2267) -> Result<(), String> {
2268 let path = response_marker_path(spool_dir, session_id, attempt_id);
2269 let journal_key = *journal_key;
2270 let session_id = session_id.to_owned();
2271 let attempt_id = attempt_id.to_owned();
2272 tokio::task::spawn_blocking(move || {
2273 let marker_bytes = match av_core::fsutil::read_capped(&path, av_core::fsutil::MAX_CONTROL_BYTES) {
2280 Ok(bytes) => bytes,
2281 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2282 Err(error) => return Err(error.to_string()),
2283 };
2284 let marker: InFlightResponse =
2285 crate::journal::open(&journal_key, "in-flight-response", 0, &marker_bytes)?;
2286 if marker.session_id != session_id || marker.attempt_id != attempt_id {
2287 return Err("in-flight response marker does not match completed job".to_owned());
2288 }
2289 match std::fs::remove_file(&path) {
2290 Ok(()) => {}
2291 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2292 Err(error) => return Err(error.to_string()),
2293 }
2294 let parent = path
2295 .parent()
2296 .ok_or_else(|| "in-flight response marker has no parent".to_owned())?;
2297 av_core::fsutil::sync_directory(parent).map_err(|error| error.to_string())
2298 })
2299 .await
2300 .map_err(|error| error.to_string())?
2301}
2302
2303fn quarantine_inflight_marker(path: &std::path::Path, error: impl std::fmt::Display) {
2312 let uid = av_core::new_event_uid();
2313 let name = path
2314 .file_stem()
2315 .and_then(std::ffi::OsStr::to_str)
2316 .map(|stem| format!("{stem}.corrupt-{uid}"))
2317 .unwrap_or_else(|| format!("corrupt-{uid}"));
2318 let quarantine = path.with_file_name(name);
2319 tracing::warn!(
2320 %error,
2321 original = %av_core::fsutil::basename(path),
2322 quarantine = %av_core::fsutil::basename(&quarantine),
2323 "unverifiable in-flight-response marker quarantined so recovery can proceed"
2324 );
2325 if let Err(rename_err) = std::fs::rename(path, &quarantine) {
2326 tracing::warn!(
2327 %rename_err,
2328 "failed to quarantine in-flight-response marker — leaving in place"
2329 );
2330 }
2331}
2332
2333fn response_marker_path(
2334 spool_dir: &std::path::Path,
2335 session_id: &str,
2336 attempt_id: &str,
2337) -> std::path::PathBuf {
2338 let digest = av_core::digest::sha256_hex(format!("{session_id}:{attempt_id}").as_bytes());
2339 spool_dir
2340 .join(crate::spool::INFLIGHT_RESPONSES)
2341 .join(format!("{}.json", &digest[..32]))
2342}
2343
2344fn write_atomic_control(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
2345 av_core::fsutil::write_atomic(path, bytes).map_err(|error| error.to_string())
2346}
2347
2348async fn append_active_event_journal(
2349 directory: &std::path::Path,
2350 session: &Session,
2351 _identity: &av_events::AgentIdentity,
2352 record: &ActiveJournalRecord,
2353 journal_key: &[u8; 32],
2354 sync: bool,
2355) -> Result<(), String> {
2356 let index = session.journal_index();
2357 let domain = format!("{}:active", session.id);
2358 let line = crate::journal::seal(journal_key, &domain, index, record)?;
2359 append_journal(directory, session, line, *journal_key, sync).await?;
2360 session.commit_journal_index(index)
2361}
2362
2363async fn append_journal(
2364 directory: &std::path::Path,
2365 session: &Session,
2366 line: Vec<u8>,
2367 journal_key: [u8; 32],
2368 sync: bool,
2369) -> Result<(), String> {
2370 let directory = directory.to_path_buf();
2371 let session_id = session.id.clone();
2372 let identity = session.identity.clone();
2373 let principal_binding = session.principal_binding();
2374 let workflow = session.workflow.as_str();
2375 tokio::task::spawn_blocking(move || -> Result<(), String> {
2376 use std::io::Write as _;
2377 if !directory.is_dir() {
2384 av_core::fsutil::create_dir_all_synced(&directory).map_err(|error| error.to_string())?;
2385 }
2386 let digest = av_core::digest::sha256_hex(session_id.as_bytes());
2387 let stem = digest.get(..32).unwrap_or(&digest);
2388 let metadata_path = directory.join(format!("{stem}.session.json"));
2389 let metadata_payload = serde_json::json!({
2390 "journal_version": 2,
2391 "session_id": session_id,
2392 "identity": identity,
2393 "principal_binding": principal_binding,
2394 "workflow": workflow,
2395 });
2396 if std::fs::symlink_metadata(&metadata_path).is_err() {
2405 let metadata = crate::journal::seal(&journal_key, "metadata", 0, &metadata_payload)?;
2406 av_core::fsutil::write_atomic(&metadata_path, &metadata).map_err(|error| {
2422 format!(
2423 "write journal metadata {}: {error}",
2424 av_core::fsutil::basename(&metadata_path)
2425 )
2426 })?;
2427 } else {
2428 let mut stored: serde_json::Value = crate::journal::open(
2429 &journal_key,
2430 "metadata",
2431 0,
2432 &av_core::fsutil::read_capped(&metadata_path, av_core::fsutil::MAX_CONTROL_BYTES)
2435 .map_err(|error| error.to_string())?,
2436 )?;
2437 if principal_binding.is_none() && stored.get("principal_binding").is_none() {
2441 if let Some(metadata) = stored.as_object_mut() {
2442 metadata.insert("principal_binding".into(), Value::Null);
2443 }
2444 }
2445 if stored != metadata_payload {
2446 return Err(
2447 "journal metadata does not match session workflow, identity, and principal".to_owned(),
2448 );
2449 }
2450 }
2451 let journal_path = directory.join(format!("{stem}.events.ndjson"));
2452 let journal_created = std::fs::symlink_metadata(&journal_path).is_err();
2467 let mut open_options = std::fs::OpenOptions::new();
2468 open_options.create(true).append(true);
2469 #[cfg(unix)]
2470 {
2471 use std::os::unix::fs::OpenOptionsExt as _;
2472 open_options
2486 .custom_flags(av_core::fsutil::unix_o_nofollow())
2487 .mode(0o600);
2488 }
2489 let mut journal = open_options.open(&journal_path).map_err(|error| {
2490 format!(
2491 "open event journal {}: {error}",
2492 av_core::fsutil::basename(&journal_path)
2493 )
2494 })?;
2495 journal.write_all(&line).map_err(|error| error.to_string())?;
2496 journal.write_all(b"\n").map_err(|error| error.to_string())?;
2497 if sync {
2504 journal.sync_data().map_err(|error| error.to_string())?;
2505 }
2506 if journal_created {
2507 std::fs::File::open(&directory)
2508 .and_then(|dir| dir.sync_all())
2509 .map_err(|error| {
2510 format!("fsync journal directory: {error}")
2517 })?;
2518 }
2519 Ok(())
2520 })
2521 .await
2522 .map_err(|error| error.to_string())?
2523}
2524
2525pub(crate) async fn persist_broker_ack(
2538 directory: &std::path::Path,
2539 session_id: &str,
2540 event_uid: &str,
2541 ack: &PublishAck,
2542 journal_key: &[u8; 32],
2543) -> Result<(), String> {
2544 let path = ack_journal_path(directory, session_id);
2545 let record = BrokerAckRecord {
2546 session_id: session_id.to_owned(),
2547 event_uid: event_uid.to_owned(),
2548 ack: ack.clone(),
2549 };
2550 let sealed = crate::journal::seal(journal_key, "broker-ack", 0, &record)?;
2551 tokio::task::spawn_blocking(move || -> Result<(), String> {
2552 use std::io::Write as _;
2553 let created = std::fs::symlink_metadata(&path).is_err();
2563 let mut open_options = std::fs::OpenOptions::new();
2564 open_options.create(true).append(true);
2565 #[cfg(unix)]
2566 {
2567 use std::os::unix::fs::OpenOptionsExt as _;
2568 open_options
2569 .custom_flags(av_core::fsutil::unix_o_nofollow())
2570 .mode(0o600);
2571 }
2572 let mut journal = open_options
2573 .open(&path)
2574 .map_err(|error| format!("open ack journal {}: {error}", av_core::fsutil::basename(&path)))?;
2575 journal.write_all(&sealed).map_err(|error| error.to_string())?;
2576 journal.write_all(b"\n").map_err(|error| error.to_string())?;
2577 journal.sync_data().map_err(|error| error.to_string())?;
2578 if created {
2579 let parent = path
2583 .parent()
2584 .ok_or_else(|| "ack journal has no parent".to_owned())?;
2585 av_core::fsutil::sync_directory(parent).map_err(|error| error.to_string())?;
2586 }
2587 Ok(())
2588 })
2589 .await
2590 .map_err(|error| error.to_string())?
2591}
2592
2593const MAX_ACK_RECORD_BYTES: u64 = 1024 * 1024;
2606
2607pub(crate) async fn read_broker_ack(
2608 directory: &std::path::Path,
2609 session_id: &str,
2610 event_uid: &str,
2611 journal_key: &[u8; 32],
2612) -> Result<Option<PublishAck>, String> {
2613 {
2617 let path = ack_journal_path(directory, session_id);
2618 let session = session_id.to_owned();
2619 let uid = event_uid.to_owned();
2620 let key = *journal_key;
2621 let found = tokio::task::spawn_blocking(move || -> Result<Option<PublishAck>, String> {
2622 use std::io::{BufRead as _, Read as _};
2623 let file = match std::fs::File::open(&path) {
2624 Ok(file) => file,
2625 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2626 Err(error) => return Err(error.to_string()),
2627 };
2628 let mut reader = std::io::BufReader::new(file);
2629 let mut buf: Vec<u8> = Vec::new();
2630 loop {
2631 buf.clear();
2632 let read = {
2633 let mut limited = (&mut reader).take(MAX_ACK_RECORD_BYTES);
2634 limited
2635 .read_until(b'\n', &mut buf)
2636 .map_err(|error| error.to_string())?
2637 };
2638 if read == 0 {
2639 break;
2640 }
2641 if buf.last() != Some(&b'\n') && read as u64 == MAX_ACK_RECORD_BYTES {
2642 loop {
2647 buf.clear();
2648 let drained = {
2649 let mut limited = (&mut reader).take(MAX_ACK_RECORD_BYTES);
2650 limited
2651 .read_until(b'\n', &mut buf)
2652 .map_err(|error| error.to_string())?
2653 };
2654 if drained == 0 || buf.last() == Some(&b'\n') {
2655 break;
2656 }
2657 }
2658 continue;
2659 }
2660 let line = buf.strip_suffix(b"\n").unwrap_or(&buf);
2661 if line.is_empty() {
2662 continue;
2663 }
2664 let Ok(record) = crate::journal::open::<BrokerAckRecord>(&key, "broker-ack", 0, line) else {
2669 continue;
2670 };
2671 if record.session_id == session && record.event_uid == uid {
2672 return Ok(Some(record.ack));
2673 }
2674 }
2675 Ok(None)
2676 })
2677 .await
2678 .map_err(|error| error.to_string())??;
2679 if found.is_some() {
2680 return Ok(found);
2681 }
2682 }
2683 let path = broker_ack_path(directory, session_id, event_uid);
2686 let bytes = match tokio::task::spawn_blocking({
2696 let path = path.clone();
2697 move || av_core::fsutil::read_capped(&path, av_core::fsutil::MAX_CONTROL_BYTES)
2698 })
2699 .await
2700 .map_err(|error| error.to_string())?
2701 {
2702 Ok(bytes) => bytes,
2703 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2704 Err(error) => return Err(error.to_string()),
2705 };
2706 let record: BrokerAckRecord = crate::journal::open(journal_key, "broker-ack", 0, &bytes)?;
2707 if record.session_id != session_id || record.event_uid != event_uid {
2708 return Err("broker acknowledgment does not match event".to_owned());
2709 }
2710 Ok(Some(record.ack))
2711}
2712
2713fn ack_journal_path(directory: &std::path::Path, session_id: &str) -> std::path::PathBuf {
2715 let digest = av_core::digest::sha256_hex(session_id.as_bytes());
2716 directory.join(format!("{}.acks.ndjson", digest.get(..32).unwrap_or(&digest)))
2717}
2718
2719fn broker_ack_path(directory: &std::path::Path, session_id: &str, event_uid: &str) -> std::path::PathBuf {
2721 let session_digest = av_core::digest::sha256_hex(session_id.as_bytes());
2722 let event_digest = av_core::digest::sha256_hex(event_uid.as_bytes());
2723 directory
2724 .join("broker-acks")
2725 .join(&session_digest[..32])
2726 .join(format!("{}.json", &event_digest[..32]))
2727}
2728
2729#[cfg(test)]
2730mod tests {
2731 #![allow(
2732 clippy::expect_used,
2733 clippy::indexing_slicing,
2734 clippy::panic,
2735 clippy::unwrap_used
2736 )]
2737
2738 use super::*;
2739 use av_bridge::{BusError, PublishAck, StoredEvent};
2740 use av_events::AgentIdentity;
2741 use av_loopdetect::{BreakerConfig, HashEmbedder};
2742 use parking_lot::Mutex;
2743 use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
2744
2745 #[tokio::test]
2752 async fn wait_idle_wakes_on_the_final_job() {
2753 let bus = Arc::new(GatedBus::new("drain-session"));
2754 let worker = Arc::new(spawn_worker(
2755 64,
2756 Arc::clone(&bus) as Arc<dyn EventBus>,
2757 Arc::new(HashEmbedder::default()),
2758 Arc::new(Registry::new()),
2759 ));
2760 let session = session_with_id("drain-session");
2761 worker.try_submit(job(Arc::clone(&session))).unwrap();
2762 let waiter = tokio::spawn({
2764 let worker = Arc::clone(&worker);
2765 async move { worker.wait_idle().await }
2766 });
2767 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2768 assert!(
2769 !waiter.is_finished(),
2770 "wait_idle must block while the job is gated"
2771 );
2772 bus.release.store(true, std::sync::atomic::Ordering::Release);
2773 tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
2774 .await
2775 .expect("wait_idle must wake on the FINAL job's completion")
2776 .unwrap();
2777 }
2778
2779 #[tokio::test]
2785 async fn sync_session_journal_tolerates_a_missing_journal() {
2786 let directory = tempfile::tempdir().unwrap();
2787 let session = session_with_id("never-journaled");
2788 sync_session_journal(directory.path(), &session)
2789 .await
2790 .expect("a missing journal is the every-job-skipped shape, not a fault");
2791 }
2792
2793 #[tokio::test]
2801 async fn markers_and_acks_pin_identity_and_first_use() {
2802 let a = reserve_response_attempt_id();
2803 let b = reserve_response_attempt_id();
2804 assert!(!a.is_empty());
2805 assert_ne!(a, b, "attempt ids must be unique");
2806
2807 let directory = tempfile::tempdir().unwrap();
2808 let key = [21u8; 32];
2809 write_response_marker(directory.path(), &key, "marker-session", &a, "digest".to_owned())
2810 .await
2811 .expect("the first marker write must create the spool subdirectory");
2812
2813 let ack = PublishAck {
2814 topic: "agent.session".to_owned(),
2815 partition: 0,
2816 offset: 7,
2817 };
2818 persist_broker_ack(directory.path(), "ack-session", "uid-1", &ack, &key)
2819 .await
2820 .unwrap();
2821 assert_eq!(
2822 read_broker_ack(directory.path(), "ack-session", "uid-1", &key)
2823 .await
2824 .unwrap(),
2825 Some(ack),
2826 "the matching (session, uid) pair must resolve"
2827 );
2828 assert!(
2829 read_broker_ack(directory.path(), "ack-session", "uid-2", &key)
2830 .await
2831 .unwrap()
2832 .is_none()
2833 || read_broker_ack(directory.path(), "ack-session", "uid-2", &key)
2834 .await
2835 .is_err(),
2836 "a half-matching ack (same session, different uid) must never resolve"
2837 );
2838 assert!(
2839 read_broker_ack(directory.path(), "other-session", "uid-1", &key)
2840 .await
2841 .unwrap()
2842 .is_none()
2843 || read_broker_ack(directory.path(), "other-session", "uid-1", &key)
2844 .await
2845 .is_err(),
2846 "a half-matching ack (different session, same uid) must never resolve"
2847 );
2848 }
2849
2850 #[test]
2856 fn tool_accounting_invariant_is_enforced() {
2857 let consistent = RecoveredTotals {
2858 tool_calls: 5,
2859 tool_allowed: 3,
2860 tool_blocked: 2,
2861 ..RecoveredTotals::default()
2862 };
2863 assert!(consistent.validate_tool_accounting().is_ok());
2864 let inconsistent = RecoveredTotals {
2865 tool_calls: 4,
2866 tool_allowed: 3,
2867 tool_blocked: 2,
2868 ..RecoveredTotals::default()
2869 };
2870 assert!(
2871 inconsistent.validate_tool_accounting().is_err(),
2872 "classified > calls must be refused"
2873 );
2874 let overflowing = RecoveredTotals {
2875 tool_calls: u64::MAX,
2876 tool_allowed: u64::MAX,
2877 tool_blocked: 1,
2878 ..RecoveredTotals::default()
2879 };
2880 assert!(
2881 overflowing.validate_tool_accounting().is_err(),
2882 "the classified sum overflowing u64 must be refused"
2883 );
2884 }
2885
2886 #[derive(Default)]
2887 struct RecordingBus {
2888 events: Mutex<Vec<(String, String, Value)>>,
2889 }
2890
2891 struct PanicOnceSink {
2892 panicked: AtomicBool,
2893 }
2894
2895 struct SlowEmbedder;
2896
2897 impl Embedder for SlowEmbedder {
2898 fn dim(&self) -> usize {
2899 1
2900 }
2901
2902 fn embed(&self, _text: &str) -> Vec<f32> {
2903 std::thread::sleep(std::time::Duration::from_millis(100));
2904 vec![1.0]
2905 }
2906 }
2907
2908 struct GatedBus {
2912 gated_session: String,
2913 release: AtomicBool,
2914 published: Mutex<Vec<String>>,
2915 }
2916
2917 impl GatedBus {
2918 fn new(gated_session: &str) -> Self {
2919 Self {
2920 gated_session: gated_session.to_owned(),
2921 release: AtomicBool::new(false),
2922 published: Mutex::new(Vec::new()),
2923 }
2924 }
2925 }
2926
2927 impl EventBus for GatedBus {
2928 fn publish(&self, topic: &str, _key: &str, value: &Value) -> Result<PublishAck, BusError> {
2929 let text = value.to_string();
2930 if text.contains(&self.gated_session) {
2931 while !self.release.load(Ordering::Acquire) {
2932 std::thread::sleep(std::time::Duration::from_millis(5));
2933 }
2934 }
2935 self.published.lock().push(text);
2936 Ok(PublishAck {
2937 topic: topic.to_owned(),
2938 partition: 0,
2939 offset: 0,
2940 })
2941 }
2942
2943 fn fetch(
2944 &self,
2945 _topic: &str,
2946 _partition: u32,
2947 _offset: u64,
2948 _max: usize,
2949 ) -> Result<Vec<StoredEvent>, BusError> {
2950 Ok(Vec::new())
2951 }
2952
2953 fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
2954 Ok(1)
2955 }
2956
2957 fn topics(&self) -> Vec<String> {
2958 EventClass::all()
2959 .iter()
2960 .map(|class| class.topic().to_owned())
2961 .collect()
2962 }
2963 }
2964
2965 struct SlowBus;
2966
2967 impl EventBus for SlowBus {
2968 fn publish(&self, topic: &str, _key: &str, _value: &Value) -> Result<PublishAck, BusError> {
2969 std::thread::sleep(std::time::Duration::from_millis(100));
2970 Ok(PublishAck {
2971 topic: topic.to_owned(),
2972 partition: 0,
2973 offset: 0,
2974 })
2975 }
2976
2977 fn fetch(
2978 &self,
2979 _topic: &str,
2980 _partition: u32,
2981 _offset: u64,
2982 _max: usize,
2983 ) -> Result<Vec<StoredEvent>, BusError> {
2984 Ok(Vec::new())
2985 }
2986
2987 fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
2988 Ok(1)
2989 }
2990
2991 fn topics(&self) -> Vec<String> {
2992 EventClass::all()
2993 .iter()
2994 .map(|class| class.topic().to_owned())
2995 .collect()
2996 }
2997 }
2998
2999 impl VectorSink for PanicOnceSink {
3000 fn record<'a>(
3001 &'a self,
3002 _session_id: &'a str,
3003 _vector: &'a [f32],
3004 ) -> av_loopdetect::VectorSinkFuture<'a> {
3005 Box::pin(async move {
3006 if !self.panicked.swap(true, AtomicOrdering::AcqRel) {
3007 panic!("injected vector sink panic");
3008 }
3009 Ok(())
3010 })
3011 }
3012 }
3013
3014 impl EventBus for RecordingBus {
3015 fn publish(&self, topic: &str, key: &str, value: &Value) -> Result<PublishAck, BusError> {
3016 let mut events = self.events.lock();
3017 let offset = events.len() as u64;
3018 events.push((topic.to_owned(), key.to_owned(), value.clone()));
3019 Ok(PublishAck {
3020 topic: topic.to_owned(),
3021 partition: 0,
3022 offset,
3023 })
3024 }
3025
3026 fn publish_idempotent(
3027 &self,
3028 topic: &str,
3029 key: &str,
3030 value: &Value,
3031 event_uid: &str,
3032 ) -> Result<PublishAck, BusError> {
3033 if let Some((offset, _)) = self
3034 .events
3035 .lock()
3036 .iter()
3037 .enumerate()
3038 .find(|(_, (_, _, event))| event["metadata"]["uid"] == event_uid)
3039 {
3040 return Ok(PublishAck {
3041 topic: topic.to_owned(),
3042 partition: 0,
3043 offset: offset as u64,
3044 });
3045 }
3046 self.publish(topic, key, value)
3047 }
3048
3049 fn fetch(
3050 &self,
3051 _topic: &str,
3052 _partition: u32,
3053 _offset: u64,
3054 _max: usize,
3055 ) -> Result<Vec<StoredEvent>, BusError> {
3056 Ok(Vec::new())
3057 }
3058
3059 fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
3060 Ok(1)
3061 }
3062
3063 fn topics(&self) -> Vec<String> {
3064 EventClass::all()
3065 .iter()
3066 .map(|class| class.topic().to_owned())
3067 .collect()
3068 }
3069 }
3070
3071 fn session(workflow: Workflow) -> Arc<Session> {
3072 Arc::new(Session::new(
3073 "session-1".to_owned(),
3074 workflow,
3075 AgentIdentity {
3076 version: "1".to_owned(),
3077 charter: "test".into(),
3078 instance_uid: "instance-1".to_owned(),
3079 ttl_remaining_s: Some(600),
3080 },
3081 BreakerConfig {
3082 min_tokens: u64::MAX,
3083 ..BreakerConfig::default()
3084 },
3085 ))
3086 }
3087
3088 fn job(session: Arc<Session>) -> WorkerJob {
3089 WorkerJob {
3090 identity: session.current_identity(),
3091 session,
3092 class: EventClass::Compression,
3093 payload: serde_json::json!({"kind": "response"}),
3094 text: "a useful response".to_owned(),
3095 analyze_loop: true,
3096 status: StatusId::Success,
3097 stop_reason: None,
3098 native_stop_reason: None,
3099 metrics: EventMetrics {
3100 prompt_tokens: Some(100),
3101 completion_tokens: Some(20),
3102 cached_tokens: Some(40),
3103 pruned_tokens: Some(10),
3104 pruning_ratio_millis: Some(100),
3105 },
3106 cost_usd_micros: 250,
3107 prompt_token_correction: 0,
3108 atif: Some(AtifCapture {
3109 source: av_atif::Source::Agent,
3110 message: Value::String("a useful response".to_owned()),
3111 reasoning_content: None,
3112 model_name: None,
3113 tool_calls: None,
3114 observation: None,
3115 llm_call_count: Some(1),
3116 }),
3117 response_marker: None,
3118 response_attempt: None,
3119 }
3120 }
3121
3122 #[test]
3123 fn receipt_accounting_never_wraps_or_exceeds_jcs_bounds() {
3124 let counter = std::sync::atomic::AtomicU64::new(av_core::error::JCS_SAFE_MAX);
3125 assert!(checked_atomic_add(&counter, 1, "test").is_err());
3126 assert_eq!(counter.load(Ordering::Acquire), av_core::error::JCS_SAFE_MAX);
3127 }
3128
3129 #[tokio::test(flavor = "current_thread")]
3130 async fn synchronous_backends_do_not_block_tokio_worker_thread() {
3131 let worker = spawn_worker(
3132 4,
3133 Arc::new(SlowBus),
3134 Arc::new(SlowEmbedder),
3135 Arc::new(Registry::new()),
3136 );
3137 worker.try_submit(job(session(Workflow::Signed))).unwrap();
3138
3139 let started = std::time::Instant::now();
3140 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3141 assert!(
3146 started.elapsed() < std::time::Duration::from_millis(250),
3147 "synchronous worker dependency blocked the Tokio reactor"
3148 );
3149 worker.wait_idle().await;
3150 }
3151
3152 #[tokio::test]
3153 async fn response_marker_is_authenticated_and_cleared_after_publish() {
3154 let directory = tempfile::tempdir().unwrap();
3155 let journal_key = [13; 32];
3156 let marker = create_response_marker(
3157 directory.path(),
3158 &journal_key,
3159 "session-1",
3160 "request-digest".to_owned(),
3161 )
3162 .await
3163 .unwrap();
3164 assert!(ensure_no_inflight_responses(directory.path(), &journal_key)
3165 .await
3166 .unwrap_err()
3167 .contains("was not durably captured"));
3168
3169 let worker = spawn_worker_with_spool_authenticated(
3170 4,
3171 Arc::new(RecordingBus::default()),
3172 Arc::new(HashEmbedder::default()),
3173 Arc::new(NoopVectorSink),
3174 Some(directory.path().to_path_buf()),
3175 journal_key,
3176 Arc::new(Registry::new()),
3177 None,
3178 );
3179 let session = session(Workflow::Signed);
3180 let mut response = job(session);
3181 response.response_marker = Some(marker);
3182 worker.submit_and_wait(response).await.unwrap();
3183 ensure_no_inflight_responses(directory.path(), &journal_key)
3184 .await
3185 .unwrap();
3186 }
3187
3188 #[tokio::test]
3189 async fn response_marker_rejects_payload_mutation() {
3190 let directory = tempfile::tempdir().unwrap();
3198 let journal_key = [17; 32];
3199 create_response_marker(
3200 directory.path(),
3201 &journal_key,
3202 "session-1",
3203 "request-digest".to_owned(),
3204 )
3205 .await
3206 .unwrap();
3207 let marker_path = std::fs::read_dir(directory.path().join(crate::spool::INFLIGHT_RESPONSES))
3208 .unwrap()
3209 .next()
3210 .unwrap()
3211 .unwrap()
3212 .path();
3213 let mut envelope: Value = serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap();
3214 envelope["payload"]["request_digest"] = Value::String("forged".to_owned());
3215 std::fs::write(&marker_path, serde_json::to_vec(&envelope).unwrap()).unwrap();
3216 ensure_no_inflight_responses(directory.path(), &journal_key)
3217 .await
3218 .expect("tampered marker quarantines cleanly instead of poisoning recovery");
3219 assert!(
3222 !marker_path.exists(),
3223 "tampered marker should be quarantined out of the primary name"
3224 );
3225 let inflight_dir = directory.path().join(crate::spool::INFLIGHT_RESPONSES);
3226 let mut has_corrupt = false;
3227 for entry in std::fs::read_dir(&inflight_dir).unwrap() {
3228 let name = entry.unwrap().file_name();
3229 if name.to_string_lossy().contains(".corrupt-") {
3230 has_corrupt = true;
3231 break;
3232 }
3233 }
3234 assert!(
3235 has_corrupt,
3236 "expected a `.corrupt-<uid>.json` sibling as evidence"
3237 );
3238 }
3239
3240 #[tokio::test]
3241 async fn signed_job_publishes_and_extends_chain() {
3242 let bridge = Arc::new(RecordingBus::default());
3243 let metrics = Arc::new(Registry::new());
3244 let worker = spawn_worker(4, bridge.clone(), Arc::new(HashEmbedder::default()), metrics);
3245 let session = session(Workflow::Signed);
3246 worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3247
3248 assert_eq!(session.chain.lock().count(), 1);
3249 assert_eq!(session.totals.prompt_tokens.load(Ordering::Acquire), 100);
3250 let events = bridge.events.lock();
3251 assert_eq!(events.len(), 1);
3252 assert_eq!(events[0].0, "agent.compression");
3253 assert_eq!(events[0].1, "instance-1");
3254 }
3255
3256 #[tokio::test]
3257 async fn signed_job_journals_event_and_accounting() {
3258 let directory = tempfile::tempdir().unwrap();
3259 let journal_key = [9; 32];
3260 let worker = spawn_worker_with_spool_authenticated(
3261 4,
3262 Arc::new(RecordingBus::default()),
3263 Arc::new(HashEmbedder::default()),
3264 Arc::new(NoopVectorSink),
3265 Some(directory.path().to_path_buf()),
3266 journal_key,
3267 Arc::new(Registry::new()),
3268 None,
3269 );
3270 let session = session(Workflow::Signed);
3271 worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3272
3273 let digest = av_core::digest::sha256_hex(session.id.as_bytes());
3274 let stem = digest.get(..32).unwrap();
3275 let metadata: Value = crate::journal::open(
3276 &journal_key,
3277 "metadata",
3278 0,
3279 &std::fs::read(directory.path().join(format!("{stem}.session.json"))).unwrap(),
3280 )
3281 .unwrap();
3282 assert_eq!(metadata["workflow"], "signed");
3283 let journal =
3284 std::fs::read_to_string(directory.path().join(format!("{stem}.events.ndjson"))).unwrap();
3285 let record: ActiveJournalRecord =
3286 crate::journal::open(&journal_key, "session-1:active", 0, journal.trim().as_bytes()).unwrap();
3287 assert_eq!(record.prompt_tokens, 100);
3288 assert_eq!(record.completion_tokens, 0);
3289 assert_eq!(record.event["class_name"], "agent.compression");
3290 }
3291
3292 #[tokio::test]
3293 async fn journal_metadata_binds_principal_and_rejects_mismatch() {
3294 let directory = tempfile::tempdir().unwrap();
3295 let key = [9; 32];
3296 let first = session(Workflow::Signed);
3297 assert!(first.bind_principal(Some("principal-a")));
3298 append_journal(directory.path(), &first, b"first".to_vec(), key, true)
3299 .await
3300 .unwrap();
3301 let digest = av_core::digest::sha256_hex(first.id.as_bytes());
3302 let stem = digest.get(..32).unwrap();
3303 let metadata: Value = crate::journal::open(
3304 &key,
3305 "metadata",
3306 0,
3307 &std::fs::read(directory.path().join(format!("{stem}.session.json"))).unwrap(),
3308 )
3309 .unwrap();
3310 assert_eq!(metadata["principal_binding"], "principal-a");
3311 let journal_path = directory.path().join(format!("{stem}.events.ndjson"));
3312 let original_journal = std::fs::read(&journal_path).unwrap();
3313 for principal in [Some("principal-b"), None] {
3314 let other = session(Workflow::Signed);
3315 assert!(other.bind_principal(principal));
3316 let error = append_journal(directory.path(), &other, b"second".to_vec(), key, true)
3317 .await
3318 .unwrap_err();
3319 assert!(error.contains("principal"));
3320 assert_eq!(std::fs::read(&journal_path).unwrap(), original_journal);
3321 }
3322 }
3323
3324 #[tokio::test]
3325 async fn legacy_journal_metadata_only_allows_anonymous_continuation() {
3326 let directory = tempfile::tempdir().unwrap();
3327 let key = [9; 32];
3328 let anonymous = session(Workflow::Signed);
3329 let digest = av_core::digest::sha256_hex(anonymous.id.as_bytes());
3330 let stem = digest.get(..32).unwrap();
3331 let legacy = serde_json::json!({
3332 "journal_version": 2,
3333 "session_id": anonymous.id,
3334 "identity": anonymous.identity,
3335 "workflow": "signed",
3336 });
3337 std::fs::write(
3338 directory.path().join(format!("{stem}.session.json")),
3339 crate::journal::seal(&key, "metadata", 0, &legacy).unwrap(),
3340 )
3341 .unwrap();
3342 append_journal(directory.path(), &anonymous, b"first".to_vec(), key, true)
3343 .await
3344 .unwrap();
3345 let authenticated = session(Workflow::Signed);
3346 assert!(authenticated.bind_principal(Some("principal-a")));
3347 assert!(
3348 append_journal(directory.path(), &authenticated, b"second".to_vec(), key, true)
3349 .await
3350 .unwrap_err()
3351 .contains("principal")
3352 );
3353 assert_eq!(
3354 std::fs::read(directory.path().join(format!("{stem}.events.ndjson"))).unwrap(),
3355 b"first\n"
3356 );
3357 }
3358
3359 #[tokio::test]
3367 async fn capture_failed_session_does_not_write_further_journal_entries() {
3368 let directory = tempfile::tempdir().unwrap();
3369 let journal_key = [23; 32];
3370 let worker = spawn_worker_with_spool_authenticated(
3371 4,
3372 Arc::new(RecordingBus::default()),
3373 Arc::new(HashEmbedder::default()),
3374 Arc::new(NoopVectorSink),
3375 Some(directory.path().to_path_buf()),
3376 journal_key,
3377 Arc::new(Registry::new()),
3378 None,
3379 );
3380 let session = session(Workflow::Signed);
3381 worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3382 let digest = av_core::digest::sha256_hex(session.id.as_bytes());
3383 let stem = digest.get(..32).unwrap();
3384 let journal_path = directory.path().join(format!("{stem}.events.ndjson"));
3385 let bytes_after_first = std::fs::read(&journal_path).unwrap();
3386 assert!(
3387 !bytes_after_first.is_empty(),
3388 "first envelope must journal one line"
3389 );
3390 assert_eq!(
3391 bytes_after_first.iter().filter(|byte| **byte == b'\n').count(),
3392 1,
3393 "first envelope must produce exactly one journal line",
3394 );
3395 session.mark_capture_failed();
3396 let seq_before = session.next_seq();
3399 worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3400 let seq_after = session.next_seq();
3401 assert_eq!(
3402 seq_after,
3403 seq_before + 1,
3404 "process_job for a capture_failed session must not advance next_seq",
3405 );
3406 assert_eq!(
3407 std::fs::read(&journal_path).unwrap(),
3408 bytes_after_first,
3409 "process_job for a capture_failed session must not append to the journal",
3410 );
3411 assert_eq!(
3412 session.chain.lock().count(),
3413 1,
3414 "process_job for a capture_failed session must not extend the chain",
3415 );
3416 }
3417
3418 #[tokio::test]
3419 async fn signed_job_journal_recovers_replays_and_issues_one_receipt() {
3420 let directory = tempfile::tempdir().unwrap();
3421 let bridge = Arc::new(RecordingBus::default());
3422 let metrics = Arc::new(Registry::new());
3423 let signer = Arc::new(av_receipts::Ed25519Signer::from_seed(&[14; 32]));
3424 let journal_key = crate::journal::key_from_signer(signer.as_ref());
3425 let worker = spawn_worker_with_spool_authenticated(
3426 4,
3427 bridge.clone(),
3428 Arc::new(HashEmbedder::default()),
3429 Arc::new(NoopVectorSink),
3430 Some(directory.path().to_path_buf()),
3431 journal_key,
3432 Arc::clone(&metrics),
3433 None,
3434 );
3435 let active = session(Workflow::Signed);
3436 worker.submit_and_wait(job(Arc::clone(&active))).await.unwrap();
3437 let digest = av_core::digest::sha256_hex(active.id.as_bytes());
3438 let stem = digest.get(..32).unwrap();
3439 let ack_path = directory.path().join(format!("{stem}.acks.ndjson"));
3440 std::fs::remove_file(&ack_path).unwrap();
3441
3442 let registry = crate::session::SessionRegistry::new();
3443 let finalizer = crate::reconciler::Finalizer::with_bridge(
3444 signer,
3445 directory.path().to_path_buf(),
3446 metrics,
3447 bridge.clone(),
3448 );
3449 assert_eq!(
3450 finalizer
3451 .recover_spooled_sessions(®istry, &Default::default())
3452 .await
3453 .unwrap(),
3454 1
3455 );
3456 let recovered = registry.get(&active.id).unwrap();
3457 let receipt = recovered.receipt.lock().clone().unwrap();
3458 receipt.verify_embedded().unwrap();
3459 assert!(matches!(
3460 receipt.body.subject,
3461 av_receipts::ReceiptSubject::EventChain { event_count: 1, .. }
3462 ));
3463 assert_eq!(receipt.body.cost.prompt_tokens, 100);
3464 let events = bridge.events.lock();
3465 assert_eq!(events.len(), 3, "acknowledged active event must not be replayed");
3466 assert_eq!(events[0].2["metadata"]["sequence"], 0);
3467 assert_eq!(events[1].2["metadata"]["sequence"], 1);
3468 assert_eq!(events[2].2["metadata"]["sequence"], 2);
3469 assert!(!directory.path().join(format!("{stem}.events.ndjson")).exists());
3470 }
3471
3472 #[tokio::test]
3473 async fn recovery_quarantines_only_the_incomplete_signed_session() {
3474 let directory = tempfile::tempdir().unwrap();
3475 let bridge = Arc::new(RecordingBus::default());
3476 let metrics = Arc::new(Registry::new());
3477 let signer = Arc::new(av_receipts::Ed25519Signer::from_seed(&[27; 32]));
3478 let journal_key = crate::journal::key_from_signer(signer.as_ref());
3479 let worker = spawn_worker_with_spool_authenticated(
3480 8,
3481 bridge.clone(),
3482 Arc::new(HashEmbedder::default()),
3483 Arc::new(NoopVectorSink),
3484 Some(directory.path().to_path_buf()),
3485 journal_key,
3486 Arc::clone(&metrics),
3487 None,
3488 );
3489 let incomplete = Arc::new(Session::new(
3490 "incomplete-signed".to_owned(),
3491 Workflow::Signed,
3492 session(Workflow::Signed).current_identity(),
3493 BreakerConfig::default(),
3494 ));
3495 let complete = Arc::new(Session::new(
3496 "complete-signed".to_owned(),
3497 Workflow::Signed,
3498 session(Workflow::Signed).current_identity(),
3499 BreakerConfig::default(),
3500 ));
3501 let mut incomplete_request = job(Arc::clone(&incomplete));
3502 incomplete_request.response_attempt = Some(ResponseAttempt {
3503 id: "incomplete-attempt".to_owned(),
3504 terminal: false,
3505 });
3506 worker.submit_and_wait(incomplete_request).await.unwrap();
3507 worker.submit_and_wait(job(Arc::clone(&complete))).await.unwrap();
3508
3509 let registry = crate::session::SessionRegistry::new();
3510 let finalizer = crate::reconciler::Finalizer::with_bridge(
3511 signer,
3512 directory.path().to_path_buf(),
3513 metrics,
3514 bridge,
3515 );
3516 assert_eq!(
3517 finalizer
3518 .recover_spooled_sessions(®istry, &Default::default())
3519 .await
3520 .unwrap(),
3521 2
3522 );
3523 let quarantined = registry.get("incomplete-signed").unwrap();
3524 assert!(quarantined.capture_failed());
3525 assert!(quarantined.receipt.lock().is_none());
3526 assert!(registry.get("complete-signed").unwrap().receipt.lock().is_some());
3527 }
3528
3529 #[tokio::test]
3539 async fn recovery_quarantines_the_incomplete_unsigned_session_in_place() {
3540 let directory = tempfile::tempdir().unwrap();
3541 let bridge = Arc::new(RecordingBus::default());
3542 let metrics = Arc::new(Registry::new());
3543 let signer = Arc::new(av_receipts::Ed25519Signer::from_seed(&[29; 32]));
3544 let journal_key = crate::journal::key_from_signer(signer.as_ref());
3545 let worker = spawn_worker_with_spool_authenticated(
3546 4,
3547 bridge.clone(),
3548 Arc::new(HashEmbedder::default()),
3549 Arc::new(NoopVectorSink),
3550 Some(directory.path().to_path_buf()),
3551 journal_key,
3552 Arc::clone(&metrics),
3553 None,
3554 );
3555 let active = session(Workflow::Unsigned);
3556 let mut incomplete_request = job(Arc::clone(&active));
3557 incomplete_request.response_attempt = Some(ResponseAttempt {
3558 id: "incomplete-attempt".to_owned(),
3559 terminal: false,
3560 });
3561 worker.submit_and_wait(incomplete_request).await.unwrap();
3562
3563 let registry = crate::session::SessionRegistry::new();
3564 let finalizer = crate::reconciler::Finalizer::with_bridge(
3565 signer,
3566 directory.path().to_path_buf(),
3567 metrics,
3568 bridge,
3569 );
3570 finalizer
3571 .recover_spooled_sessions(®istry, &Default::default())
3572 .await
3573 .unwrap();
3574 let quarantined = registry
3575 .get(&active.id)
3576 .expect("quarantined unsigned session must stay registered");
3577 assert!(quarantined.capture_failed());
3578 assert!(
3579 quarantined.is_closed() && quarantined.artifact_committed_flag(),
3580 "quarantine must be sealed so the idle sweeper skips it"
3581 );
3582 assert_eq!(
3583 quarantined.totals.prompt_tokens.load(Ordering::Acquire),
3584 100,
3585 "folded journal totals must land on the quarantined session"
3586 );
3587 finalizer
3590 .recover_spooled_sessions(®istry, &Default::default())
3591 .await
3592 .unwrap();
3593 let still = registry
3594 .get(&active.id)
3595 .expect("second pass must not evict the quarantined session");
3596 assert!(still.capture_failed());
3597 }
3598
3599 #[tokio::test]
3600 async fn tampered_signed_journal_is_never_turned_into_a_receipt() {
3601 let directory = tempfile::tempdir().unwrap();
3602 let signer = Arc::new(av_receipts::Ed25519Signer::from_seed(&[18; 32]));
3603 let journal_key = crate::journal::key_from_signer(signer.as_ref());
3604 let worker = spawn_worker_with_spool_authenticated(
3605 4,
3606 Arc::new(RecordingBus::default()),
3607 Arc::new(HashEmbedder::default()),
3608 Arc::new(NoopVectorSink),
3609 Some(directory.path().to_path_buf()),
3610 journal_key,
3611 Arc::new(Registry::new()),
3612 None,
3613 );
3614 let active = session(Workflow::Signed);
3615 worker.submit_and_wait(job(Arc::clone(&active))).await.unwrap();
3616 let digest = av_core::digest::sha256_hex(active.id.as_bytes());
3617 let stem = digest.get(..32).unwrap();
3618 let journal_path = directory.path().join(format!("{stem}.events.ndjson"));
3619 let mut envelope: Value =
3620 serde_json::from_str(std::fs::read_to_string(&journal_path).unwrap().trim()).unwrap();
3621 envelope["payload"]["prompt_tokens"] = serde_json::json!(999_999);
3622 std::fs::write(
3623 &journal_path,
3624 format!("{}\n", serde_json::to_string(&envelope).unwrap()),
3625 )
3626 .unwrap();
3627 let registry = crate::session::SessionRegistry::new();
3628 let finalizer = crate::reconciler::Finalizer::new(
3629 signer,
3630 directory.path().to_path_buf(),
3631 Arc::new(Registry::new()),
3632 );
3633 let outcome = finalizer
3644 .recover_spooled_sessions(®istry, &Default::default())
3645 .await;
3646 assert!(
3647 outcome.is_ok(),
3648 "per-session HMAC failures warn+continue instead of propagating, got {outcome:?}"
3649 );
3650 assert!(registry.get(&active.id).is_none());
3654 }
3655
3656 #[tokio::test]
3657 async fn signed_restart_reuses_receipt_persisted_before_journal_cleanup() {
3658 let directory = tempfile::tempdir().unwrap();
3659 let signer = Arc::new(av_receipts::Ed25519Signer::from_seed(&[15; 32]));
3660 let journal_key = crate::journal::key_from_signer(signer.as_ref());
3661 let worker = spawn_worker_with_spool_authenticated(
3662 4,
3663 Arc::new(RecordingBus::default()),
3664 Arc::new(HashEmbedder::default()),
3665 Arc::new(NoopVectorSink),
3666 Some(directory.path().to_path_buf()),
3667 journal_key,
3668 Arc::new(Registry::new()),
3669 None,
3670 );
3671 let active = session(Workflow::Signed);
3672 worker.submit_and_wait(job(Arc::clone(&active))).await.unwrap();
3673 let digest = av_core::digest::sha256_hex(active.id.as_bytes());
3674 let stem = digest.get(..32).unwrap();
3675 let metadata_path = directory.path().join(format!("{stem}.session.json"));
3676 let journal_path = directory.path().join(format!("{stem}.events.ndjson"));
3677 let metadata = std::fs::read(&metadata_path).unwrap();
3678 let journal = std::fs::read(&journal_path).unwrap();
3679 let ack_path = directory.path().join(format!("{stem}.acks.ndjson"));
3680 let ack = std::fs::read(&ack_path).unwrap();
3681 let first_finalizer = crate::reconciler::Finalizer::new(
3682 signer.clone(),
3683 directory.path().to_path_buf(),
3684 Arc::new(Registry::new()),
3685 );
3686 let crate::reconciler::FinalizeOutcome::Receipt { receipt: first } = first_finalizer
3687 .close_session(active, av_events::StopReason::SessionClosed)
3688 .await
3689 .unwrap()
3690 else {
3691 panic!("expected signed receipt")
3692 };
3693
3694 std::fs::write(&metadata_path, metadata).unwrap();
3695 std::fs::write(&journal_path, journal).unwrap();
3696 std::fs::write(&ack_path, ack).unwrap();
3697 let registry = crate::session::SessionRegistry::new();
3698 let after_restart = crate::reconciler::Finalizer::new(
3699 signer,
3700 directory.path().to_path_buf(),
3701 Arc::new(Registry::new()),
3702 );
3703 assert_eq!(
3704 after_restart
3705 .recover_spooled_sessions(®istry, &Default::default())
3706 .await
3707 .unwrap(),
3708 1
3709 );
3710 let recovered = registry.get("session-1").unwrap();
3711 assert_eq!(
3712 recovered.receipt.lock().as_ref().unwrap().body.receipt_id,
3713 first.body.receipt_id
3714 );
3715 }
3716
3717 #[tokio::test]
3718 async fn unsigned_job_preserves_required_atif_metrics() {
3719 let bridge = Arc::new(RecordingBus::default());
3723 let directory = tempfile::tempdir().unwrap();
3724 let journal_key = [4u8; 32];
3725 let worker = spawn_worker_with_spool_authenticated(
3726 4,
3727 bridge,
3728 Arc::new(HashEmbedder::default()),
3729 Arc::new(NoopVectorSink),
3730 Some(directory.path().to_path_buf()),
3731 journal_key,
3732 Arc::new(Registry::new()),
3733 None,
3734 );
3735 let session = session(Workflow::Unsigned);
3736 worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3737
3738 assert_eq!(session.atif_steps_count(), 1);
3739 assert!(
3740 session.atif.lock().is_empty(),
3741 "steps must not be retained in RAM (RAM-cliff guard)"
3742 );
3743 let digest = av_core::digest::sha256_hex(session.id.as_bytes());
3744 let stem = digest.get(..32).unwrap();
3745 let journal =
3746 std::fs::read_to_string(directory.path().join(format!("{stem}.events.ndjson"))).unwrap();
3747 let line = journal.lines().next().unwrap();
3748 let record: ActiveJournalRecord = crate::journal::open(
3749 &journal_key,
3750 &format!("{}:active", session.id),
3751 0,
3752 line.as_bytes(),
3753 )
3754 .unwrap();
3755 let step = record.atif_step.unwrap();
3756 assert_eq!(step.metrics.as_ref().unwrap().cached_tokens, Some(40));
3757 assert_eq!(session.totals.cached_tokens.load(Ordering::Acquire), 0);
3758 }
3759
3760 #[tokio::test]
3761 async fn unsigned_job_journal_survives_restart_and_promotes() {
3762 let directory = tempfile::tempdir().unwrap();
3763 let bridge = Arc::new(RecordingBus::default());
3764 let metrics = Arc::new(Registry::new());
3765 let signer = Arc::new(av_receipts::Ed25519Signer::from_seed(&[13; 32]));
3766 let journal_key = crate::journal::key_from_signer(signer.as_ref());
3767 let worker = spawn_worker_with_spool_authenticated(
3768 4,
3769 bridge.clone(),
3770 Arc::new(HashEmbedder::default()),
3771 Arc::new(NoopVectorSink),
3772 Some(directory.path().to_path_buf()),
3773 journal_key,
3774 Arc::clone(&metrics),
3775 None,
3776 );
3777 let active = session(Workflow::Unsigned);
3778 worker.submit_and_wait(job(Arc::clone(&active))).await.unwrap();
3779 let mut tool = job(Arc::clone(&active));
3780 tool.class = EventClass::ToolCall;
3781 tool.status = StatusId::Failure;
3782 tool.stop_reason = Some(StopReason::PolicyBlocked);
3783 tool.metrics = EventMetrics::default();
3784 tool.cost_usd_micros = 0;
3785 tool.atif.as_mut().unwrap().llm_call_count = Some(0);
3786 worker.submit_and_wait(tool).await.unwrap();
3787 assert_eq!(bridge.events.lock().len(), 2);
3788
3789 let registry = crate::session::SessionRegistry::new();
3790 let finalizer = crate::reconciler::Finalizer::with_bridge(
3791 signer,
3792 directory.path().to_path_buf(),
3793 metrics,
3794 bridge.clone(),
3795 );
3796 assert_eq!(
3797 finalizer
3798 .recover_spooled_sessions(®istry, &Default::default())
3799 .await
3800 .unwrap(),
3801 1
3802 );
3803 let recovered = registry.get(&active.id).unwrap();
3804 assert!(recovered.atif_path.lock().as_ref().unwrap().exists());
3805 assert_eq!(
3806 bridge.events.lock().len(),
3807 2,
3808 "acknowledged unsigned events must not replay"
3809 );
3810 assert_eq!(recovered.current_identity().ttl_remaining_s, Some(600));
3811 let receipt = finalizer.promote(recovered).await.unwrap();
3812 receipt.verify_embedded().unwrap();
3813 assert_eq!(receipt.body.cost.prompt_tokens, 100);
3814 assert_eq!(receipt.body.tool_calls.total, 1);
3815 assert_eq!(receipt.body.tool_calls.blocked, 1);
3816 assert_eq!(receipt.body.stop_reason_id, StopReason::PolicyBlocked.id());
3817 assert!(matches!(
3818 receipt.body.subject,
3819 av_receipts::ReceiptSubject::AtifTrajectory {
3820 step_count: 2,
3821 retroactive: true,
3822 ..
3823 }
3824 ));
3825 }
3826
3827 #[tokio::test]
3828 async fn full_queue_is_counted_without_blocking() {
3829 let metrics = Arc::new(Registry::new());
3830 let (sender, _receiver) = mpsc::channel(1);
3831 let worker = WorkerHandle {
3832 senders: Arc::new(vec![sender]),
3833 metrics: Arc::clone(&metrics),
3834 pending: Arc::new(std::sync::atomic::AtomicU64::new(0)),
3835 drained: Arc::new(tokio::sync::Notify::new()),
3836 capacity: Arc::new(tokio::sync::Semaphore::new(1)),
3837 response_capacity: Arc::new(tokio::sync::Semaphore::new(1)),
3838 };
3839 worker.try_submit(job(session(Workflow::Signed))).unwrap();
3840 assert_eq!(
3841 worker.try_submit(job(session(Workflow::Signed))),
3842 Err(SubmitError::Full)
3843 );
3844 assert!(metrics
3845 .render()
3846 .contains("av_events_dropped_total{stage=\"worker_queue\"} 1"));
3847 }
3848
3849 #[tokio::test]
3854 async fn worker_queue_and_response_slot_counters_are_distinct() {
3855 let metrics = Arc::new(Registry::new());
3856 let (sender, _receiver) = mpsc::channel(1);
3857 let worker = WorkerHandle {
3858 senders: Arc::new(vec![sender]),
3859 metrics: Arc::clone(&metrics),
3860 pending: Arc::new(std::sync::atomic::AtomicU64::new(0)),
3861 drained: Arc::new(tokio::sync::Notify::new()),
3862 capacity: Arc::new(tokio::sync::Semaphore::new(1)),
3863 response_capacity: Arc::new(tokio::sync::Semaphore::new(1)),
3864 };
3865 let first = worker.try_reserve_pair("s").expect("first pair");
3868 assert_eq!(worker.try_reserve_pair("s").err(), Some(SubmitError::Full));
3871 let rendered = metrics.render();
3872 assert!(
3873 rendered.contains("av_events_dropped_total{stage=\"worker_queue\"} 1"),
3874 "expected worker_queue counter increment, got:\n{rendered}"
3875 );
3876 assert!(
3877 !rendered.contains("av_events_dropped_total{stage=\"response_slot\"}"),
3878 "response_slot must not have been touched when worker_queue exhausts first, got:\n{rendered}"
3879 );
3880 drop(first.worker);
3884 let _still_holding_response = first.response;
3885 assert_eq!(worker.try_reserve_pair("s").err(), Some(SubmitError::Full));
3886 let rendered = metrics.render();
3887 assert!(
3888 rendered.contains("av_events_dropped_total{stage=\"response_slot\"} 1"),
3889 "expected response_slot counter increment, got:\n{rendered}"
3890 );
3891 assert!(
3892 rendered.contains("av_events_dropped_total{stage=\"worker_queue\"} 1"),
3893 "worker_queue must remain at 1 (only response stage exhausted this time), got:\n{rendered}"
3894 );
3895 }
3896
3897 #[tokio::test]
3909 async fn response_permit_submit_succeeds_on_worker_capacity_exhaustion() {
3910 let bridge = Arc::new(RecordingBus::default());
3911 let metrics = Arc::new(Registry::new());
3912 let worker = spawn_worker(
3913 2,
3914 bridge.clone(),
3915 Arc::new(HashEmbedder::default()),
3916 Arc::clone(&metrics),
3917 );
3918 let permits = worker
3921 .try_reserve_pair("mid-stream-response")
3922 .expect("initial pair");
3923 drop(permits.worker);
3926 let hog_a = worker.try_reserve("hog-a").expect("hog-a");
3930 let hog_b = worker.try_reserve("hog-b").expect("hog-b");
3931 let job = job(session(Workflow::Signed));
3935 permits
3936 .response
3937 .submit(&worker, job)
3938 .expect("an admitted stream's capture must not be starved by worker admissions");
3939 let rendered = metrics.render();
3940 assert!(
3941 !rendered.contains("av_events_dropped_total{stage=\"response_slot\"} 1"),
3942 "no response_slot drop: the reserved slot was used, not re-acquired; got:\n{rendered}"
3943 );
3944 assert!(
3945 !rendered.contains("av_events_dropped_total{stage=\"worker_queue\"} 1"),
3946 "no worker_queue drop either; got:\n{rendered}"
3947 );
3948 drop(hog_a);
3949 drop(hog_b);
3950 worker.wait_idle().await;
3951 }
3952
3953 #[tokio::test]
3954 async fn one_shard_can_borrow_all_global_capacity() {
3955 const CAPACITY: usize = 16;
3956 let worker = spawn_worker(
3957 CAPACITY,
3958 Arc::new(RecordingBus::default()),
3959 Arc::new(HashEmbedder::default()),
3960 Arc::new(Registry::new()),
3961 );
3962 let partitions = u32::try_from(CAPACITY).unwrap();
3963 let target = av_bridge::bus::partition_for("target", partitions);
3964 let session_ids: Vec<String> = (0..10_000)
3965 .map(|index| format!("same-shard-{index}"))
3966 .filter(|id| av_bridge::bus::partition_for(id, partitions) == target)
3967 .take(CAPACITY)
3968 .collect();
3969 assert_eq!(session_ids.len(), CAPACITY);
3970 let permits: Vec<_> = session_ids
3971 .iter()
3972 .map(|id| worker.try_reserve(id).unwrap())
3973 .collect();
3974 assert!(matches!(
3975 worker.try_reserve("globally-full"),
3976 Err(SubmitError::Full)
3977 ));
3978 drop(permits);
3979 assert!(worker.try_reserve("capacity-released").is_ok());
3980 }
3981
3982 #[tokio::test]
3983 async fn panic_is_counted_and_supervisor_processes_next_job() {
3984 let bridge = Arc::new(RecordingBus::default());
3985 let metrics = Arc::new(Registry::new());
3986 let worker = spawn_worker_with_sink(
3987 4,
3988 bridge.clone(),
3989 Arc::new(HashEmbedder::default()),
3990 Arc::new(PanicOnceSink {
3991 panicked: AtomicBool::new(false),
3992 }),
3993 Arc::clone(&metrics),
3994 );
3995 let failed_session = session(Workflow::Signed);
3996 assert!(worker
3997 .submit_and_wait(job(Arc::clone(&failed_session)))
3998 .await
3999 .is_err());
4000 assert!(failed_session.capture_failed());
4001 worker
4002 .submit_and_wait(job(session(Workflow::Signed)))
4003 .await
4004 .unwrap();
4005 assert_eq!(bridge.events.lock().len(), 1);
4006 assert!(metrics.render().contains("av_worker_panics_total 1"));
4007 }
4008
4009 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4012 async fn wait_idle_survives_notify_races_under_contention() {
4013 let bridge = Arc::new(RecordingBus::default());
4014 let worker = spawn_worker(
4015 64,
4016 bridge.clone(),
4017 Arc::new(HashEmbedder::default()),
4018 Arc::new(Registry::new()),
4019 );
4020 for _ in 0..64 {
4021 for _ in 0..4 {
4022 worker.try_submit(job(session(Workflow::Signed))).unwrap();
4023 }
4024 tokio::time::timeout(std::time::Duration::from_secs(5), worker.wait_idle())
4025 .await
4026 .expect("wait_idle deadlocked: notify_waiters was lost during subscribe/check race");
4027 }
4028 }
4029
4030 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4036 async fn notified_enable_check_await_pattern_is_race_free() {
4037 use std::sync::atomic::AtomicUsize;
4038 use tokio::sync::Notify;
4039
4040 for _ in 0..64 {
4041 let notify = Arc::new(Notify::new());
4042 let pending = Arc::new(AtomicUsize::new(1));
4043 let (check_done_tx, check_done_rx) = tokio::sync::oneshot::channel::<()>();
4044 let (notify_done_tx, notify_done_rx) = tokio::sync::oneshot::channel::<()>();
4045
4046 let n_c = Arc::clone(¬ify);
4047 let p_c = Arc::clone(&pending);
4048 let consumer = tokio::spawn(async move {
4049 let notified = n_c.notified();
4050 let mut notified = std::pin::pin!(notified);
4051 notified.as_mut().enable();
4052 assert_ne!(p_c.load(AtomicOrdering::Acquire), 0, "producer ran early");
4053 check_done_tx.send(()).unwrap();
4054 notify_done_rx.await.unwrap();
4055 notified.await;
4056 });
4057
4058 let n_p = Arc::clone(¬ify);
4059 let p_p = Arc::clone(&pending);
4060 let producer = tokio::spawn(async move {
4061 check_done_rx.await.unwrap();
4062 p_p.store(0, AtomicOrdering::Release);
4063 n_p.notify_waiters();
4064 notify_done_tx.send(()).unwrap();
4065 });
4066
4067 tokio::time::timeout(std::time::Duration::from_secs(2), async {
4068 producer.await.unwrap();
4069 consumer.await.unwrap();
4070 })
4071 .await
4072 .expect("consumer deadlocked: enable/await pattern violated");
4073 }
4074 }
4075
4076 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4085 async fn try_submit_returns_full_within_the_first_millisecond_when_saturated() {
4086 const CAPACITY: usize = 4;
4087 let worker = spawn_worker(
4088 CAPACITY,
4089 Arc::new(RecordingBus::default()),
4090 Arc::new(HashEmbedder::default()),
4091 Arc::new(Registry::new()),
4092 );
4093 let session = session(Workflow::Signed);
4095 let _held: Vec<_> = (0..CAPACITY)
4096 .map(|i| worker.try_reserve(&format!("hold-{i}")).unwrap())
4097 .collect();
4098 let started = std::time::Instant::now();
4099 let result = worker.try_submit(job(Arc::clone(&session)));
4100 let elapsed = started.elapsed();
4101 assert!(matches!(result, Err(SubmitError::Full)));
4102 assert!(
4103 elapsed < std::time::Duration::from_millis(1),
4104 "try_submit blocked for {elapsed:?} when saturated \
4105 (backpressure should be synchronous)",
4106 );
4107 }
4108
4109 #[tokio::test]
4114 async fn every_dropped_job_increments_the_drop_metric() {
4115 const CAPACITY: usize = 2;
4116 let bridge = Arc::new(RecordingBus::default());
4117 let metrics = Arc::new(Registry::new());
4118 let worker = spawn_worker(
4119 CAPACITY,
4120 bridge,
4121 Arc::new(HashEmbedder::default()),
4122 Arc::clone(&metrics),
4123 );
4124 let _hold: Vec<_> = (0..CAPACITY)
4125 .map(|i| worker.try_reserve(&format!("s-{i}")).unwrap())
4126 .collect();
4127 for _ in 0..17 {
4128 assert!(matches!(worker.try_reserve("x"), Err(SubmitError::Full)));
4129 }
4130 let rendered = metrics.render();
4131 assert!(
4132 rendered.contains("av_events_dropped_total{stage=\"worker_queue\"} 17"),
4133 "expected 17 drops in metrics, got:\n{rendered}",
4134 );
4135 }
4136
4137 #[tokio::test]
4142 async fn capacity_is_reusable_after_permit_drop() {
4143 const CAPACITY: usize = 8;
4144 let worker = spawn_worker(
4145 CAPACITY,
4146 Arc::new(RecordingBus::default()),
4147 Arc::new(HashEmbedder::default()),
4148 Arc::new(Registry::new()),
4149 );
4150 for cycle in 0..1_000 {
4151 let permits: Vec<_> = (0..CAPACITY)
4152 .map(|i| worker.try_reserve(&format!("cycle-{cycle}-{i}")).unwrap())
4153 .collect();
4154 assert!(matches!(worker.try_reserve("overflow"), Err(SubmitError::Full)));
4155 drop(permits);
4156 let recycled: Vec<_> = (0..CAPACITY)
4158 .map(|i| worker.try_reserve(&format!("recycle-{cycle}-{i}")).unwrap())
4159 .collect();
4160 drop(recycled);
4161 }
4162 }
4163
4164 #[test]
4170 fn partition_for_distributes_uniformly_across_shards() {
4171 const SHARDS: u32 = 16;
4172 const SAMPLES: u32 = 100_000;
4173 let mut hits = [0u32; SHARDS as usize];
4174 for i in 0..SAMPLES {
4175 let id = format!("session-{i}");
4176 let s = av_bridge::bus::partition_for(&id, SHARDS) as usize;
4177 hits[s] += 1;
4178 }
4179 let expected = SAMPLES / SHARDS;
4180 for (shard, count) in hits.iter().enumerate() {
4181 let lower = expected * 9 / 10;
4182 let upper = expected * 11 / 10;
4183 assert!(
4184 (lower..=upper).contains(count),
4185 "shard {shard} got {count} hits, expected {expected} (10% band)",
4186 );
4187 }
4188 }
4189
4190 #[tokio::test]
4197 async fn every_shard_is_routable_at_capacity_one() {
4198 let worker = spawn_worker(
4199 1,
4200 Arc::new(RecordingBus::default()),
4201 Arc::new(HashEmbedder::default()),
4202 Arc::new(Registry::new()),
4203 );
4204 for target in 0..16u32 {
4207 let mut candidate = None;
4208 for i in 0..10_000u64 {
4209 let id = format!("probe-{target}-{i}");
4210 if av_bridge::bus::partition_for(&id, 16) == target {
4211 candidate = Some(id);
4212 break;
4213 }
4214 }
4215 let id = candidate.unwrap_or_else(|| panic!("no id found for shard {target}"));
4216 let permit = worker
4217 .try_reserve(&id)
4218 .unwrap_or_else(|error| panic!("shard {target} not routable: {error:?}"));
4219 drop(permit);
4220 }
4221 }
4222
4223 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4232 async fn cancelling_submit_and_wait_never_leaks_pending() {
4233 const CAPACITY: usize = 4;
4234 let bridge = Arc::new(RecordingBus::default());
4235 let worker = Arc::new(spawn_worker(
4236 CAPACITY,
4237 bridge.clone(),
4238 Arc::new(HashEmbedder::default()),
4239 Arc::new(Registry::new()),
4240 ));
4241 let held: Vec<_> = (0..CAPACITY)
4245 .map(|i| worker.try_reserve(&format!("hold-{i}")).unwrap())
4246 .collect();
4247 for delay_us in [0u64, 50, 200, 800, 3_200] {
4248 let hostile_session = session(Workflow::Signed);
4249 let worker_for_task = Arc::clone(&worker);
4250 let task = tokio::spawn(async move {
4251 let _ = worker_for_task
4252 .submit_and_wait(job(Arc::clone(&hostile_session)))
4253 .await;
4254 });
4255 if delay_us > 0 {
4256 tokio::time::sleep(std::time::Duration::from_micros(delay_us)).await;
4257 }
4258 task.abort();
4259 let _ = task.await;
4260 }
4261 drop(held);
4262 tokio::time::timeout(std::time::Duration::from_secs(2), worker.wait_idle())
4263 .await
4264 .expect("wait_idle deadlocked — a canceled submit_and_wait leaked pending");
4265 }
4266
4267 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4274 async fn session_backlog_group_commits_in_order() {
4275 let directory = tempfile::tempdir().unwrap();
4276 let journal_key = [6u8; 32];
4277 let bus = Arc::new(GatedBus::new("batched-session"));
4278 let worker = spawn_worker_with_spool_authenticated(
4279 64,
4280 Arc::clone(&bus) as Arc<dyn EventBus>,
4281 Arc::new(HashEmbedder::default()),
4282 Arc::new(NoopVectorSink),
4283 Some(directory.path().to_path_buf()),
4284 journal_key,
4285 Arc::new(Registry::new()),
4286 None,
4287 );
4288 let session = session_with_id("batched-session");
4289 for n in 0..11 {
4295 let mut submitted = job(Arc::clone(&session));
4296 submitted.payload = serde_json::json!({"kind": "response", "burst": n});
4297 worker.try_submit(submitted).unwrap();
4298 }
4299 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4300 bus.release.store(true, std::sync::atomic::Ordering::Release);
4301 session.wait_for_worker_jobs().await;
4302 assert!(!session.capture_failed(), "a batched burst must not fail capture");
4303
4304 let digest = av_core::digest::sha256_hex(session.id.as_bytes());
4306 let stem = digest.get(..32).unwrap();
4307 let journal =
4308 std::fs::read_to_string(directory.path().join(format!("{stem}.events.ndjson"))).unwrap();
4309 let lines: Vec<&str> = journal.lines().collect();
4310 assert_eq!(lines.len(), 11, "every batched record must be journaled");
4311 for (index, line) in lines.iter().enumerate() {
4312 let record: ActiveJournalRecord = crate::journal::open(
4313 &journal_key,
4314 &format!("{}:active", session.id),
4315 index as u64,
4316 line.as_bytes(),
4317 )
4318 .unwrap();
4319 assert_eq!(
4320 record.event.pointer("/payload/burst").and_then(Value::as_u64),
4321 Some(index as u64),
4322 "journal record {index} must carry the {index}th submitted payload"
4323 );
4324 }
4325 let published = bus.published.lock();
4327 let bursts: Vec<u64> = published
4328 .iter()
4329 .filter_map(|text| {
4330 serde_json::from_str::<Value>(text)
4331 .ok()?
4332 .pointer("/payload/burst")
4333 .and_then(Value::as_u64)
4334 })
4335 .collect();
4336 assert_eq!(
4337 bursts,
4338 (0..11).collect::<Vec<_>>(),
4339 "publish order must match submission order"
4340 );
4341 }
4342
4343 fn session_with_id(id: &str) -> Arc<Session> {
4344 Arc::new(Session::new(
4345 id.to_owned(),
4346 Workflow::Signed,
4347 AgentIdentity {
4348 version: "1".to_owned(),
4349 charter: "test".into(),
4350 instance_uid: "instance-1".to_owned(),
4351 ttl_remaining_s: Some(600),
4352 },
4353 BreakerConfig {
4354 min_tokens: u64::MAX,
4355 ..BreakerConfig::default()
4356 },
4357 ))
4358 }
4359
4360 fn colliding_session_id(reference: &str, shard_count: usize) -> String {
4363 let target = av_bridge::bus::partition_for(reference, u32::try_from(shard_count).unwrap());
4364 (0..)
4365 .map(|n| format!("victim-{n}"))
4366 .find(|candidate| {
4367 candidate != reference
4368 && av_bridge::bus::partition_for(candidate, u32::try_from(shard_count).unwrap()) == target
4369 })
4370 .unwrap()
4371 }
4372
4373 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4378 async fn stalled_session_does_not_block_shard_neighbors() {
4379 let shard_count = std::thread::available_parallelism()
4380 .map(std::num::NonZero::get)
4381 .unwrap_or(16)
4382 .max(16);
4383 let stalled_id = "stalled-session".to_owned();
4384 let victim_id = colliding_session_id(&stalled_id, shard_count);
4385 let bus = Arc::new(GatedBus::new(&stalled_id));
4386 let worker = spawn_worker(
4387 64,
4388 Arc::clone(&bus) as Arc<dyn EventBus>,
4389 Arc::new(HashEmbedder::default()),
4390 Arc::new(Registry::new()),
4391 );
4392 let stalled = session_with_id(&stalled_id);
4393 let victim = session_with_id(&victim_id);
4394
4395 let stalled_wait = {
4396 let worker = worker.clone();
4397 let job = job(Arc::clone(&stalled));
4398 tokio::spawn(async move { worker.submit_and_wait(job).await })
4399 };
4400 tokio::time::timeout(
4402 std::time::Duration::from_secs(5),
4403 worker.submit_and_wait(job(Arc::clone(&victim))),
4404 )
4405 .await
4406 .expect("victim session must not be head-of-line blocked by a stalled shard neighbor")
4407 .unwrap();
4408 assert!(
4409 !stalled_wait.is_finished(),
4410 "the stalled session must still be gated (the victim did not just win a race)"
4411 );
4412 bus.release.store(true, Ordering::Release);
4413 stalled_wait.await.unwrap().unwrap();
4414 }
4415
4416 fn read_redaction_test_record(spool: &std::path::Path, session: &Session) -> ActiveJournalRecord {
4417 let digest = av_core::digest::sha256_hex(session.id.as_bytes());
4418 let stem = digest.get(..32).unwrap();
4419 let contents = std::fs::read_to_string(spool.join(format!("{stem}.events.ndjson"))).unwrap();
4420 assert_eq!(contents.lines().count(), 1, "exactly one event must be journaled");
4421 crate::journal::open(
4422 &[0; 32],
4423 &format!("{}:active", session.id),
4424 0,
4425 contents.trim().as_bytes(),
4426 )
4427 .unwrap()
4428 }
4429
4430 #[tokio::test]
4431 async fn redaction_engine_strips_sensitive_payload_on_spool() {
4432 let spool = tempfile::tempdir().unwrap();
4433 let bus = Arc::new(RecordingBus::default());
4434 let engine = av_redact::RedactionEngine::new(av_redact::RedactionConfig {
4435 include_builtin_patterns: true,
4436 ..av_redact::RedactionConfig::default()
4437 })
4438 .unwrap();
4439 let worker = spawn_worker_with_spool_authenticated(
4440 4,
4441 Arc::clone(&bus) as Arc<dyn EventBus>,
4442 Arc::new(HashEmbedder::default()),
4443 Arc::new(NoopVectorSink),
4444 Some(spool.path().to_owned()),
4445 [0; 32],
4446 Arc::new(Registry::new()),
4447 Some(Arc::new(engine)),
4448 );
4449 let sess = session(Workflow::Unsigned);
4450 let mut j = job(Arc::clone(&sess));
4451 j.payload = serde_json::json!({
4452 "kind": "tool_response",
4453 "api_key": "sk-BBBBBBBBBBBBBBBBBBBBBB"
4454 });
4455 worker.submit_and_wait(j).await.unwrap();
4456 let record = read_redaction_test_record(spool.path(), &sess);
4457 assert_eq!(record.event["payload"]["api_key"], "[REDACTED]");
4458 assert_eq!(record.event["payload"]["kind"], "tool_response");
4459 }
4460
4461 #[tokio::test]
4462 async fn redaction_engine_strips_sensitive_payload_on_bus() {
4463 let bus = Arc::new(RecordingBus::default());
4464 let engine = av_redact::RedactionEngine::new(av_redact::RedactionConfig {
4465 include_builtin_patterns: true,
4466 ..av_redact::RedactionConfig::default()
4467 })
4468 .unwrap();
4469 let worker = spawn_worker_with_spool_authenticated(
4470 4,
4471 Arc::clone(&bus) as Arc<dyn EventBus>,
4472 Arc::new(HashEmbedder::default()),
4473 Arc::new(NoopVectorSink),
4474 None,
4475 [0; 32],
4476 Arc::new(Registry::new()),
4477 Some(Arc::new(engine)),
4478 );
4479 let sess = session(Workflow::Unsigned);
4480 let mut j = job(Arc::clone(&sess));
4481 j.payload = serde_json::json!({
4482 "kind": "tool_response",
4483 "api_key": "sk-AAAAAAAAAAAAAAAAAAAAAA"
4484 });
4485 worker.submit_and_wait(j).await.unwrap();
4486 let events = bus.events.lock();
4487 let published = events
4488 .iter()
4489 .find(|(_, _, v)| v.pointer("/payload").is_some())
4490 .expect("at least one event with a payload must reach the bus");
4491 let payload = &published.2["payload"];
4492 assert_eq!(payload["api_key"], "[REDACTED]");
4493 }
4494
4495 #[tokio::test]
4496 async fn redaction_engine_strips_sensitive_atif_step_fields() {
4497 let spool = tempfile::tempdir().unwrap();
4498 let bus = Arc::new(RecordingBus::default());
4499 let engine = av_redact::RedactionEngine::new(av_redact::RedactionConfig {
4500 include_builtin_patterns: true,
4501 ..av_redact::RedactionConfig::default()
4502 })
4503 .unwrap();
4504 let worker = spawn_worker_with_spool_authenticated(
4505 4,
4506 Arc::clone(&bus) as Arc<dyn EventBus>,
4507 Arc::new(HashEmbedder::default()),
4508 Arc::new(NoopVectorSink),
4509 Some(spool.path().to_owned()),
4510 [0; 32],
4511 Arc::new(Registry::new()),
4512 Some(Arc::new(engine)),
4513 );
4514 let sess = session(Workflow::Unsigned);
4515 let mut j = job(Arc::clone(&sess));
4516 j.atif = Some(AtifCapture {
4517 source: av_atif::Source::Agent,
4518 message: serde_json::json!("The API key is sk-CCCCCCCCCCCCCCCCCCCCCC"),
4519 reasoning_content: Some("User leaked sk-DDDDDDDDDDDDDDDDDDDDDD in context".into()),
4520 model_name: None,
4521 tool_calls: Some(vec![av_atif::ToolCall {
4522 tool_call_id: "call-1".into(),
4523 function_name: "send".into(),
4524 arguments: serde_json::json!({
4525 "key": "sk-proj-AbCdEf0123456789_-",
4526 "card": 4_111_111_111_111_111_u64,
4527 "share_with": { "alice@example.com": "editor" }
4528 }),
4529 extra: None,
4530 }]),
4531 observation: Some(av_atif::Observation {
4532 results: vec![av_atif::ObservationResult {
4533 source_call_id: Some("call-1".into()),
4534 content: Some(serde_json::json!("result: sk-EEEEEEEEEEEEEEEEEEEEEE")),
4535 subagent_trajectory_ref: None,
4536 extra: None,
4537 }],
4538 }),
4539 llm_call_count: Some(1),
4540 });
4541 worker.submit_and_wait(j).await.unwrap();
4542 let record = read_redaction_test_record(spool.path(), &sess);
4543 let step = record.atif_step.unwrap();
4544 assert_eq!(step.message, "The API key is [REDACTED]");
4545 assert_eq!(
4546 step.reasoning_content.as_deref(),
4547 Some("User leaked [REDACTED] in context")
4548 );
4549 assert_eq!(
4550 step.observation.unwrap().results[0].content,
4551 Some(serde_json::json!("result: [REDACTED]"))
4552 );
4553 assert_eq!(
4554 step.tool_calls.unwrap()[0].arguments,
4555 serde_json::json!({
4556 "key": "[REDACTED]",
4557 "card": "[REDACTED]",
4558 "share_with": { "[REDACTED]": "editor" }
4559 })
4560 );
4561 }
4562
4563 #[tokio::test]
4564 async fn redaction_engine_removes_paths_from_serialized_tool_payload_copies() {
4565 let spool = tempfile::tempdir().unwrap();
4566 let bus = Arc::new(RecordingBus::default());
4567 let engine = av_redact::RedactionEngine::new(av_redact::RedactionConfig {
4568 pointer_paths: vec!["/reason".into()],
4569 include_builtin_patterns: false,
4570 ..av_redact::RedactionConfig::default()
4571 })
4572 .unwrap();
4573 let worker = spawn_worker_with_spool_authenticated(
4574 4,
4575 Arc::clone(&bus) as Arc<dyn EventBus>,
4576 Arc::new(HashEmbedder::default()),
4577 Arc::new(NoopVectorSink),
4578 Some(spool.path().to_owned()),
4579 [0; 32],
4580 Arc::new(Registry::new()),
4581 Some(Arc::new(engine)),
4582 );
4583 let sess = session(Workflow::Unsigned);
4584 let mut j = job(Arc::clone(&sess));
4585 j.class = EventClass::ToolCall;
4586 j.analyze_loop = false;
4587 j.status = StatusId::Failure;
4588 j.payload =
4589 serde_json::json!({"tool": "transfer", "allowed": false, "reason": "private denial detail"});
4590 j.atif.as_mut().unwrap().observation = Some(av_atif::Observation {
4591 results: vec![
4592 av_atif::ObservationResult {
4593 source_call_id: Some("call-1".into()),
4594 content: Some(Value::String(j.payload.to_string())),
4595 subagent_trajectory_ref: None,
4596 extra: None,
4597 },
4598 av_atif::ObservationResult {
4599 source_call_id: Some("unrelated".into()),
4600 content: Some(Value::String(r#"{"reason":"unrelated tool data"}"#.into())),
4601 subagent_trajectory_ref: None,
4602 extra: None,
4603 },
4604 ],
4605 });
4606 worker.submit_and_wait(j).await.unwrap();
4607 let record = read_redaction_test_record(spool.path(), &sess);
4608 assert_eq!(record.event["payload"]["reason"], "[REDACTED]");
4609 let results = record.atif_step.unwrap().observation.unwrap().results;
4610 let copied_payload: Value =
4611 serde_json::from_str(results[0].content.as_ref().unwrap().as_str().unwrap()).unwrap();
4612 assert_eq!(copied_payload, record.event["payload"]);
4613 assert_eq!(
4614 results[1].content,
4615 Some(Value::String(r#"{"reason":"unrelated tool data"}"#.into()))
4616 );
4617 assert_eq!(bus.events.lock()[0].2["payload"], copied_payload);
4618 }
4619
4620 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4624 async fn per_session_ordering_is_preserved() {
4625 let bus = Arc::new(RecordingBus::default());
4626 let worker = spawn_worker(
4627 64,
4628 Arc::clone(&bus) as Arc<dyn EventBus>,
4629 Arc::new(HashEmbedder::default()),
4630 Arc::new(Registry::new()),
4631 );
4632 let session = session_with_id("ordered-session");
4633 for n in 0..8u64 {
4634 let mut ordered = job(Arc::clone(&session));
4635 ordered.payload = serde_json::json!({"kind": "response", "step": n});
4636 worker.submit_and_wait(ordered).await.unwrap();
4637 }
4638 let events = bus.events.lock();
4639 let steps: Vec<u64> = events
4640 .iter()
4641 .filter_map(|(_, _, value)| value.pointer("/payload/step").and_then(Value::as_u64))
4642 .collect();
4643 assert_eq!(
4644 steps,
4645 (0..8).collect::<Vec<_>>(),
4646 "per-session publish order must match submission order"
4647 );
4648 }
4649}
4650
4651#[cfg(test)]
4652mod unified_fold_tests {
4653 #![allow(clippy::unwrap_used, clippy::panic)]
4654
4655 use super::*;
4656
4657 fn record(
4658 tool: (u64, u64, u64),
4659 prompt: u64,
4660 completion: u64,
4661 cached: u64,
4662 cost: u64,
4663 ) -> ActiveJournalRecord {
4664 ActiveJournalRecord {
4665 event: serde_json::json!({"k": "v"}),
4666 identity: av_events::AgentIdentity {
4667 version: "1".to_owned(),
4668 charter: "test".into(),
4669 instance_uid: "i".to_owned(),
4670 ttl_remaining_s: None,
4671 },
4672 atif_step: None,
4673 tool_calls: tool.0,
4674 tool_allowed: tool.1,
4675 tool_blocked: tool.2,
4676 prompt_tokens: prompt,
4677 completion_tokens: completion,
4678 cached_tokens: cached,
4679 cost_usd_micros: cost,
4680 prompt_token_correction: 0,
4681 stop_reason_id: None,
4682 response_attempt: None,
4683 }
4684 }
4685
4686 #[test]
4694 fn unified_fold_matches_live_path() {
4695 let records = [
4700 record((0, 0, 0), 4_822, 0, 0, 0),
4701 record((0, 0, 0), 0, 319, 12, 84_340),
4702 record((1, 1, 0), 0, 0, 0, 0),
4703 record((1, 0, 1), 0, 0, 0, 0),
4704 record((1, 0, 0), 0, 0, 0, 0),
4705 record((0, 0, 0), 0, 0, 0, 0),
4706 ];
4707
4708 let mut folded = RecoveredTotals::default();
4710 for r in &records {
4711 r.fold_into(&mut folded).unwrap();
4712 }
4713 folded.validate_tool_accounting().unwrap();
4714
4715 let live = crate::session::Totals::default();
4717 for r in &records {
4718 r.apply_to_totals(&live).unwrap();
4719 }
4720
4721 use std::sync::atomic::Ordering;
4722 assert_eq!(folded.tool_calls, live.tool_calls.load(Ordering::Acquire));
4723 assert_eq!(folded.tool_allowed, live.tool_allowed.load(Ordering::Acquire));
4724 assert_eq!(folded.tool_blocked, live.tool_blocked.load(Ordering::Acquire));
4725 assert_eq!(folded.prompt_tokens, live.prompt_tokens.load(Ordering::Acquire));
4726 assert_eq!(
4727 folded.completion_tokens,
4728 live.completion_tokens.load(Ordering::Acquire)
4729 );
4730 assert_eq!(folded.cached_tokens, live.cached_tokens.load(Ordering::Acquire));
4731 assert_eq!(
4732 folded.cost_usd_micros,
4733 live.cost_usd_micros.load(Ordering::Acquire)
4734 );
4735 assert_eq!(folded.prompt_tokens, 4_822);
4736 assert_eq!(folded.tool_calls, 3);
4737 }
4738
4739 #[test]
4743 fn both_folds_refuse_jcs_overflow() {
4744 let poisoned = record((0, 0, 0), av_core::error::JCS_SAFE_MAX, 0, 0, 0);
4745 let mut folded = RecoveredTotals::default();
4746 poisoned.fold_into(&mut folded).unwrap();
4747 assert!(
4748 record((0, 0, 0), 1, 0, 0, 0).fold_into(&mut folded).is_err(),
4749 "recovery fold must refuse crossing JCS_SAFE_MAX"
4750 );
4751
4752 let live = crate::session::Totals::default();
4753 poisoned.apply_to_totals(&live).unwrap();
4754 assert!(
4755 record((0, 0, 0), 1, 0, 0, 0).apply_to_totals(&live).is_err(),
4756 "live fold must refuse crossing JCS_SAFE_MAX"
4757 );
4758 }
4759
4760 #[test]
4766 fn correction_applies_identically_and_refuses_underflow() {
4767 let mut charged = record((0, 0, 0), 30, 0, 0, 0);
4769 let mut terminal = record((0, 0, 0), 0, 5, 0, 100);
4770 terminal.prompt_token_correction = -20;
4771
4772 let mut folded = RecoveredTotals::default();
4773 charged.fold_into(&mut folded).unwrap();
4774 terminal.fold_into(&mut folded).unwrap();
4775 assert_eq!(folded.prompt_tokens, 10);
4776
4777 let live = crate::session::Totals::default();
4778 charged.apply_to_totals(&live).unwrap();
4779 terminal.apply_to_totals(&live).unwrap();
4780 use std::sync::atomic::Ordering;
4781 assert_eq!(live.prompt_tokens.load(Ordering::Acquire), 10);
4782
4783 charged = record((0, 0, 0), 5, 0, 0, 0);
4786 terminal.prompt_token_correction = -20;
4787 let mut folded = RecoveredTotals::default();
4788 charged.fold_into(&mut folded).unwrap();
4789 assert!(
4790 terminal.fold_into(&mut folded).is_err(),
4791 "recovery fold must refuse correction underflow"
4792 );
4793 let live = crate::session::Totals::default();
4794 charged.apply_to_totals(&live).unwrap();
4795 assert!(
4796 terminal.apply_to_totals(&live).is_err(),
4797 "live fold must refuse correction underflow"
4798 );
4799 }
4800}
4801
4802#[cfg(test)]
4803mod ack_journal_tests {
4804 #![allow(clippy::unwrap_used)]
4805
4806 use super::*;
4807
4808 #[tokio::test]
4813 async fn ack_journal_roundtrips_tolerates_torn_tail_and_reads_legacy() {
4814 let directory = tempfile::tempdir().unwrap();
4815 let key = [9u8; 32];
4816 let ack = PublishAck {
4817 topic: "t".to_owned(),
4818 partition: 0,
4819 offset: 7,
4820 };
4821 persist_broker_ack(directory.path(), "s1", "evt-1", &ack, &key)
4822 .await
4823 .unwrap();
4824 persist_broker_ack(directory.path(), "s1", "evt-2", &ack, &key)
4825 .await
4826 .unwrap();
4827 let journal = ack_journal_path(directory.path(), "s1");
4829 let mut bytes = std::fs::read(&journal).unwrap();
4830 bytes.extend_from_slice(b"{\"torn");
4831 std::fs::write(&journal, bytes).unwrap();
4832
4833 let found = read_broker_ack(directory.path(), "s1", "evt-1", &key)
4834 .await
4835 .unwrap();
4836 assert_eq!(found.map(|ack| ack.offset), Some(7));
4837 assert!(
4838 read_broker_ack(directory.path(), "s1", "missing", &key)
4839 .await
4840 .unwrap()
4841 .is_none(),
4842 "an unknown event has no ack (torn tail must not error the scan)"
4843 );
4844
4845 let legacy = broker_ack_path(directory.path(), "s2", "evt-9");
4847 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
4848 let record = BrokerAckRecord {
4849 session_id: "s2".to_owned(),
4850 event_uid: "evt-9".to_owned(),
4851 ack: ack.clone(),
4852 };
4853 let sealed = crate::journal::seal(&key, "broker-ack", 0, &record).unwrap();
4854 std::fs::write(&legacy, sealed).unwrap();
4855 let found = read_broker_ack(directory.path(), "s2", "evt-9", &key)
4856 .await
4857 .unwrap();
4858 assert_eq!(found.map(|ack| ack.offset), Some(7));
4859 }
4860
4861 #[tokio::test]
4870 async fn ack_scan_survives_journals_past_the_old_cap_and_oversized_lines() {
4871 let directory = tempfile::tempdir().unwrap();
4872 let key = [9u8; 32];
4873 let ack = PublishAck {
4874 topic: "t".to_owned(),
4875 partition: 0,
4876 offset: 42,
4877 };
4878 persist_broker_ack(directory.path(), "s1", "evt-first", &ack, &key)
4879 .await
4880 .unwrap();
4881 let journal = ack_journal_path(directory.path(), "s1");
4882 {
4883 use std::io::Write as _;
4884 let mut file = std::fs::OpenOptions::new().append(true).open(&journal).unwrap();
4885 let oversized = vec![b'x'; 2 * 1024 * 1024];
4888 file.write_all(&oversized).unwrap();
4889 file.write_all(b"\n").unwrap();
4890 let filler = vec![b'y'; 8 * 1024];
4893 for _ in 0..2_200 {
4894 file.write_all(&filler).unwrap();
4895 file.write_all(b"\n").unwrap();
4896 }
4897 }
4898 let tail_ack = PublishAck {
4901 topic: "t".to_owned(),
4902 partition: 0,
4903 offset: 77,
4904 };
4905 persist_broker_ack(directory.path(), "s1", "evt-tail", &tail_ack, &key)
4906 .await
4907 .unwrap();
4908 assert!(
4909 std::fs::metadata(&journal).unwrap().len() > 16 * 1024 * 1024,
4910 "test setup: journal must exceed the old whole-file cap",
4911 );
4912
4913 let found = read_broker_ack(directory.path(), "s1", "evt-tail", &key)
4914 .await
4915 .unwrap();
4916 assert_eq!(
4917 found.map(|ack| ack.offset),
4918 Some(77),
4919 "an ack past the old 16 MiB cap must still be found",
4920 );
4921 let first = read_broker_ack(directory.path(), "s1", "evt-first", &key)
4922 .await
4923 .unwrap();
4924 assert_eq!(
4925 first.map(|ack| ack.offset),
4926 Some(42),
4927 "the oversized garbage line must not poison earlier acks",
4928 );
4929 }
4930}