1use crate::session::{Session, Workflow};
4use pv_bridge::{EventBus, PublishAck};
5use pv_core::metrics::Registry;
6use pv_events::{EventClass, EventMetrics, OcsfEventBuilder, StatusId, StopReason};
7use pv_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: pv_atif::Source,
18 pub message: Value,
20 pub reasoning_content: Option<String>,
22 pub model_name: Option<String>,
24 pub tool_calls: Option<Vec<pv_atif::ToolCall>>,
26 pub observation: Option<pv_atif::Observation>,
28 pub llm_call_count: Option<u64>,
30}
31
32pub struct WorkerJob {
34 pub session: Arc<Session>,
36 pub identity: pv_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: pv_events::AgentIdentity,
90 pub(crate) atif_step: Option<pv_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 <= pv_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 <= pv_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 <= pv_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 => "pv_events_dropped_total{stage=\"worker_queue\"}",
312 Self::ResponseSlot => "pv_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 "pv_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 "pv_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 "pv_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 "pv_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 = pv_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<pv_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 "pv_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 = pv_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(pv_core::fsutil::unix_o_nofollow());
1066 }
1067 #[cfg(not(unix))]
1071 options.write(true);
1072 match options.open(&path) {
1073 Ok(journal) => journal.sync_data().map_err(|error| error.to_string()),
1074 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
1077 Err(error) => Err(error.to_string()),
1078 }
1079 })
1080 .await
1081 .map_err(|error| error.to_string())?
1082}
1083
1084#[allow(clippy::too_many_arguments)]
1102async fn process_envelope_batch(
1103 envelopes: Vec<Envelope>,
1104 bridge: Arc<dyn EventBus>,
1105 embedder: Arc<dyn Embedder>,
1106 vector_sink: Arc<dyn VectorSink>,
1107 spool_dir: Option<std::path::PathBuf>,
1108 journal_key: [u8; 32],
1109 worker_metrics: Arc<Registry>,
1110 worker_pending: Arc<std::sync::atomic::AtomicU64>,
1111 worker_drained: Arc<tokio::sync::Notify>,
1112 outputs: crate::content::AuditOutputs,
1113) {
1114 let mut iterator = envelopes.into_iter();
1115 let Some(first) = iterator.next() else { return };
1116 let rest: Vec<Envelope> = iterator.collect();
1117 if rest.is_empty() {
1118 return process_envelope(
1119 first,
1120 bridge,
1121 embedder,
1122 vector_sink,
1123 spool_dir,
1124 journal_key,
1125 worker_metrics,
1126 worker_pending,
1127 worker_drained,
1128 outputs,
1129 )
1130 .await;
1131 }
1132 let session = Arc::clone(&first.job.session);
1133 let mut guards = Vec::new();
1136 let mut completions = Vec::new();
1137 let mut jobs = Vec::new();
1138 for envelope in std::iter::once(first).chain(rest) {
1139 let Envelope {
1140 job,
1141 completion,
1142 span,
1143 _capacity_permit: capacity_permit,
1144 } = envelope;
1145 guards.push((
1146 PendingGuard::new(Arc::clone(&worker_pending), Arc::clone(&worker_drained)),
1147 SessionPendingGuard {
1148 session: Arc::clone(&job.session),
1149 },
1150 capacity_permit,
1151 ));
1152 completions.push(completion);
1153 jobs.push((job, span));
1154 }
1155 let batch_metrics = Arc::clone(&worker_metrics);
1156 let batch_session = Arc::clone(&session);
1157 let spool = spool_dir.clone();
1158 let result = tokio::spawn(async move {
1159 let mut outcomes: Vec<Result<(), String>> = Vec::new();
1160 let mut persisted: Vec<Option<PersistedJob>> = Vec::new();
1161 let mut failed = false;
1162 for (job, span) in jobs {
1164 if failed {
1165 outcomes.push(Ok(()));
1168 persisted.push(None);
1169 continue;
1170 }
1171 let outcome = persist_job(
1172 job,
1173 Arc::clone(&embedder),
1174 Arc::clone(&vector_sink),
1175 spool.as_deref(),
1176 journal_key,
1177 &batch_metrics,
1178 false,
1179 &outputs,
1180 )
1181 .instrument(span)
1182 .await;
1183 match outcome {
1184 Ok(slot) => {
1185 persisted.push(slot);
1186 outcomes.push(Ok(()));
1187 }
1188 Err(error) => {
1189 batch_session.mark_capture_failed();
1192 persisted.push(None);
1193 outcomes.push(Err(error));
1194 failed = true;
1195 }
1196 }
1197 }
1198 if let Some(directory) = spool.as_deref() {
1201 if persisted.iter().any(Option::is_some) {
1202 if let Err(error) = sync_session_journal(directory, &batch_session).await {
1203 for (index, slot) in persisted.iter_mut().enumerate() {
1207 if slot.take().is_some() {
1208 if let Some(outcome) = outcomes.get_mut(index) {
1209 *outcome = Err(format!("batch journal sync failed: {error}"));
1210 }
1211 }
1212 }
1213 }
1214 }
1215 }
1216 let mut tail_failed = false;
1218 for (index, slot) in persisted.into_iter().enumerate() {
1219 let Some(slot) = slot else { continue };
1220 if tail_failed {
1221 if let Some(outcome) = outcomes.get_mut(index) {
1222 *outcome = Err("skipped after a batch sibling's post-persist failure".to_owned());
1223 }
1224 continue;
1225 }
1226 if let Err(error) = finish_job(slot, Arc::clone(&bridge), spool.as_deref(), journal_key).await {
1227 if let Some(outcome) = outcomes.get_mut(index) {
1228 *outcome = Err(error);
1229 }
1230 tail_failed = true;
1231 }
1232 }
1233 outcomes
1234 })
1235 .await;
1236 let outcomes = match result {
1237 Ok(outcomes) => outcomes,
1238 Err(error) => {
1239 worker_metrics
1240 .counter(
1241 "pv_worker_panics_total",
1242 "Worker job panics isolated by supervisor",
1243 )
1244 .inc();
1245 let failure = format!("worker batch panicked: {error}");
1246 vec![Err(failure); completions.len()]
1247 }
1248 };
1249 let mut any_failed = false;
1250 for (completion, outcome) in completions.into_iter().zip(outcomes) {
1251 if let Err(error) = &outcome {
1252 any_failed = true;
1253 tracing::warn!(session = %session.id, %error, "capture job failed; session is fail-closed");
1254 worker_metrics
1255 .counter("pv_worker_errors_total", "Worker jobs that failed")
1256 .inc();
1257 }
1258 if let Some(completion) = completion {
1259 let _ = completion.send(outcome);
1260 }
1261 }
1262 if any_failed {
1263 session.mark_capture_failed();
1264 }
1265 drop(guards);
1266}
1267
1268#[allow(clippy::too_many_arguments)]
1271async fn process_envelope(
1272 envelope: Envelope,
1273 bridge: Arc<dyn EventBus>,
1274 embedder: Arc<dyn Embedder>,
1275 vector_sink: Arc<dyn VectorSink>,
1276 spool_dir: Option<std::path::PathBuf>,
1277 journal_key: [u8; 32],
1278 worker_metrics: Arc<Registry>,
1279 worker_pending: Arc<std::sync::atomic::AtomicU64>,
1280 worker_drained: Arc<tokio::sync::Notify>,
1281 outputs: crate::content::AuditOutputs,
1282) {
1283 let _pending_guard = PendingGuard::new(Arc::clone(&worker_pending), Arc::clone(&worker_drained));
1295 let Envelope {
1296 job,
1297 completion,
1298 span,
1299 _capacity_permit: capacity_permit,
1300 } = envelope;
1301 let session = Arc::clone(&job.session);
1302 let _session_pending_guard = SessionPendingGuard {
1314 session: Arc::clone(&session),
1315 };
1316 let result = tokio::spawn(
1317 {
1318 let job_metrics = Arc::clone(&worker_metrics);
1319 async move {
1320 process_job(
1321 job,
1322 bridge,
1323 embedder,
1324 vector_sink,
1325 spool_dir,
1326 journal_key,
1327 job_metrics,
1328 outputs,
1329 )
1330 .await
1331 }
1332 }
1333 .instrument(span),
1334 )
1335 .await;
1336 let outcome = match result {
1337 Ok(result) => result,
1338 Err(error) => {
1339 worker_metrics
1340 .counter(
1341 "pv_worker_panics_total",
1342 "Worker job panics isolated by supervisor",
1343 )
1344 .inc();
1345 Err(format!("worker job panicked: {error}"))
1346 }
1347 };
1348 if let Err(error) = &outcome {
1349 tracing::warn!(session = %session.id, %error, "capture job failed; session is fail-closed");
1354 session.mark_capture_failed();
1355 worker_metrics
1356 .counter("pv_worker_errors_total", "Worker jobs that failed")
1357 .inc();
1358 }
1359 drop(capacity_permit);
1364 if let Some(completion) = completion {
1370 let _ = completion.send(outcome);
1371 }
1372}
1373
1374struct PendingGuard {
1379 pending: Arc<std::sync::atomic::AtomicU64>,
1380 drained: Arc<tokio::sync::Notify>,
1381}
1382
1383impl PendingGuard {
1384 fn new(pending: Arc<std::sync::atomic::AtomicU64>, drained: Arc<tokio::sync::Notify>) -> Self {
1385 Self { pending, drained }
1386 }
1387}
1388
1389impl Drop for PendingGuard {
1390 fn drop(&mut self) {
1391 if self.pending.fetch_sub(1, Ordering::AcqRel) == 1 {
1392 self.drained.notify_waiters();
1393 }
1394 }
1395}
1396
1397struct SessionPendingGuard {
1408 session: Arc<crate::session::Session>,
1409}
1410
1411impl Drop for SessionPendingGuard {
1412 fn drop(&mut self) {
1413 self.session.worker_job_finished();
1414 }
1415}
1416
1417fn worker_span(job: &WorkerJob) -> tracing::Span {
1418 tracing::info_span!(
1419 parent: &tracing::Span::current(),
1420 "proofvisor.worker",
1421 session.id = %job.session.id,
1422 event.class = ?job.class,
1423 )
1424}
1425
1426#[allow(clippy::too_many_arguments)]
1427struct PersistedJob {
1434 session: Arc<Session>,
1435 identity: pv_events::AgentIdentity,
1436 class: EventClass,
1437 value: Value,
1438 event_uid: String,
1439 record: ActiveJournalRecord,
1440 response_marker: Option<String>,
1441 content: Option<(
1444 Arc<crate::content::ContentDispatcher>,
1445 crate::content::ContentRecord,
1446 )>,
1447}
1448
1449#[allow(clippy::too_many_arguments)]
1450async fn process_job(
1451 job: WorkerJob,
1452 bridge: Arc<dyn EventBus>,
1453 embedder: Arc<dyn Embedder>,
1454 vector_sink: Arc<dyn VectorSink>,
1455 spool_dir: Option<std::path::PathBuf>,
1456 journal_key: [u8; 32],
1457 metrics: Arc<Registry>,
1458 outputs: crate::content::AuditOutputs,
1459) -> Result<(), String> {
1460 match persist_job(
1461 job,
1462 embedder,
1463 vector_sink,
1464 spool_dir.as_deref(),
1465 journal_key,
1466 &metrics,
1467 true,
1468 &outputs,
1469 )
1470 .await?
1471 {
1472 None => Ok(()),
1473 Some(persisted) => finish_job(persisted, bridge, spool_dir.as_deref(), journal_key).await,
1474 }
1475}
1476
1477fn redact_audit_capture(
1479 mut value: Value,
1480 mut atif_step: Option<pv_atif::Step>,
1481 class: EventClass,
1482 engine: &pv_redact::RedactionEngine,
1483) -> (Value, Option<pv_atif::Step>) {
1484 let original_payload = (class == EventClass::ToolCall)
1488 .then(|| value.get("payload").map(Value::to_string))
1489 .flatten();
1490 if let Some(payload) = value.get_mut("payload") {
1491 engine.redact_value(payload);
1492 }
1493 if let Some(step) = &mut atif_step {
1494 engine.redact_value(&mut step.message);
1495 if let Some(reasoning) = &mut step.reasoning_content {
1496 let mut text = Value::String(std::mem::take(reasoning));
1497 engine.redact_value(&mut text);
1498 if let Value::String(redacted) = text {
1499 *reasoning = redacted;
1500 }
1501 }
1502 if let Some(calls) = &mut step.tool_calls {
1503 for call in calls {
1504 engine.redact_user_value(&mut call.arguments);
1505 }
1506 }
1507 if let Some(observation) = &mut step.observation {
1508 for result in &mut observation.results {
1509 if let Some(content) = &mut result.content {
1510 if original_payload
1511 .as_deref()
1512 .is_some_and(|original| content.as_str() == Some(original))
1513 {
1514 if let Some(payload) = value.get("payload") {
1515 *content = Value::String(payload.to_string());
1516 }
1517 }
1518 engine.redact_value(content);
1519 }
1520 }
1521 }
1522 }
1523 (value, atif_step)
1524}
1525
1526#[allow(clippy::too_many_arguments)]
1532async fn persist_job(
1533 mut job: WorkerJob,
1534 embedder: Arc<dyn Embedder>,
1535 vector_sink: Arc<dyn VectorSink>,
1536 spool_dir: Option<&std::path::Path>,
1537 journal_key: [u8; 32],
1538 metrics: &Arc<Registry>,
1539 sync_journal: bool,
1540 outputs: &crate::content::AuditOutputs,
1541) -> Result<Option<PersistedJob>, String> {
1542 if job.session.capture_failed() {
1548 return Ok(None);
1549 }
1550 let response_marker = job.response_marker.clone();
1551 let is_llm_agent_response = job
1552 .atif
1553 .as_ref()
1554 .is_some_and(|capture| capture.source == pv_atif::Source::Agent && capture.llm_call_count != Some(0));
1555 let step_tokens = job
1556 .metrics
1557 .prompt_tokens
1558 .unwrap_or(0)
1559 .saturating_add(job.metrics.completion_tokens.unwrap_or(0));
1560 let breaker = if job.analyze_loop {
1561 let text = job.text.clone();
1562 let embedding_result = tokio::task::spawn_blocking(move || embedder.try_embed(&text))
1574 .await
1575 .map_err(|error| error.to_string())?;
1576 match embedding_result {
1577 Ok(embedding) => {
1578 let similarity = match vector_sink
1579 .nearest_similarity(&job.session.session_scope, &embedding)
1580 .await
1581 {
1582 Ok(similarity) => similarity,
1583 Err(error) => {
1584 tracing::warn!(
1585 session = %job.session.id,
1586 %error,
1587 "vector-sink nearest_similarity failed; skipping breaker for this step"
1588 );
1589 metrics
1590 .counter(
1591 "pv_vector_sink_errors_total",
1592 "Vector-sink errors demoted to warn",
1593 )
1594 .inc();
1595 None
1596 }
1597 };
1598 let verdict = job.session.loop_state.observe_embedding_with_similarity(
1599 embedding.clone(),
1600 step_tokens,
1601 similarity,
1602 );
1603 if let Err(error) = vector_sink.record(&job.session.session_scope, &embedding).await {
1604 tracing::warn!(
1605 session = %job.session.id,
1606 %error,
1607 "vector-sink record failed; the local breaker still observed this step"
1608 );
1609 metrics
1610 .counter(
1611 "pv_vector_sink_errors_total",
1612 "Vector-sink errors demoted to warn",
1613 )
1614 .inc();
1615 }
1616 Some(verdict)
1617 }
1618 Err(error) => {
1619 tracing::warn!(
1620 session = %job.session.id,
1621 %error,
1622 "embedder try_embed failed; skipping breaker for this step"
1623 );
1624 metrics
1625 .counter("pv_embedder_errors_total", "Embedder errors demoted to warn")
1626 .inc();
1627 None
1628 }
1629 }
1630 } else {
1631 None
1632 };
1633 let submitted_class = job.class;
1640 let (class, status, stop_reason, payload) = match breaker {
1641 Some(BreakerVerdict::Tripped {
1642 delta,
1643 streak,
1644 tokens_consumed,
1645 action,
1646 }) => {
1647 tracing::warn!(
1651 session = %job.session.id,
1652 delta,
1653 streak,
1654 tokens_consumed,
1655 action = ?action,
1656 "semantic loop circuit breaker tripped"
1657 );
1658 metrics
1659 .counter("pv_breaker_trips_total", "Semantic loop breaker trips")
1660 .inc();
1661 if matches!(
1670 action,
1671 pv_loopdetect::BreakerAction::Abort | pv_loopdetect::BreakerAction::Reject
1672 ) {
1673 job.session.latch_enforcement(StopReason::LoopDetected);
1674 }
1675 (
1676 EventClass::StopReason,
1677 StatusId::Failure,
1678 Some(StopReason::LoopDetected),
1679 serde_json::json!({
1680 "delta": delta,
1681 "streak": streak,
1682 "tokens_consumed": tokens_consumed,
1683 "action": action,
1684 }),
1685 )
1686 }
1687 _ => (job.class, job.status, job.stop_reason, job.payload),
1688 };
1689
1690 let native_finish_reason = job.native_stop_reason.clone();
1692 let mut builder = OcsfEventBuilder::new(
1693 class,
1694 job.session.id.clone(),
1695 job.identity.clone(),
1696 job.session.next_seq(),
1697 )
1698 .status(status)
1699 .payload(payload)
1700 .metrics(job.metrics);
1701 if let Some(reason) = stop_reason {
1702 job.session.record_stop_reason(reason);
1703 builder = match job.native_stop_reason {
1704 Some(native) => builder.stop_reason_native(reason, native),
1705 None => builder.stop_reason(reason),
1706 };
1707 }
1708 let event = builder.build().map_err(|error| error.to_string())?;
1709 let event_uid = event.metadata.uid.clone();
1710 let value = serde_json::to_value(&event).map_err(|error| error.to_string())?;
1711
1712 let atif_step = if job.session.workflow == Workflow::Unsigned {
1713 let capture = job
1714 .atif
1715 .take()
1716 .ok_or_else(|| "unsigned worker job has no ATIF capture".to_owned())?;
1717 let is_llm_step = capture.source == pv_atif::Source::Agent && capture.llm_call_count != Some(0);
1718 Some(pv_atif::Step {
1719 step_id: 0,
1720 timestamp: Some(pv_core::time::now_iso8601()),
1721 source: capture.source,
1722 message: capture.message,
1723 reasoning_effort: None,
1724 reasoning_content: capture.reasoning_content,
1725 model_name: capture.model_name,
1726 tool_calls: capture.tool_calls,
1727 observation: capture.observation,
1728 metrics: is_llm_step.then(|| pv_atif::Metrics {
1729 prompt_tokens: Some(job.metrics.prompt_tokens.unwrap_or(0)),
1730 completion_tokens: Some(job.metrics.completion_tokens.unwrap_or(0)),
1731 cached_tokens: Some(job.metrics.cached_tokens.unwrap_or(0)),
1732 cost_usd: Some(job.cost_usd_micros as f64 / pv_core::units::USD_MICROS_PER_DOLLAR as f64),
1733 logprobs: None,
1734 completion_token_ids: None,
1735 prompt_token_ids: None,
1736 extra: None,
1737 }),
1738 is_copied_context: None,
1739 llm_call_count: capture.llm_call_count,
1740 extra: None,
1741 })
1742 } else {
1743 None
1744 };
1745 let (value, atif_step) = if let Some(engine) = outputs.redaction.as_ref() {
1748 let engine = Arc::clone(engine);
1749 tokio::task::spawn_blocking(move || redact_audit_capture(value, atif_step, submitted_class, &engine))
1750 .await
1751 .map_err(|error| format!("redaction task failed: {error}"))?
1752 } else {
1753 (value, atif_step)
1754 };
1755 let is_tool_call = submitted_class == EventClass::ToolCall;
1756 let is_response_accounting = is_llm_agent_response && submitted_class != EventClass::Compression;
1757 let record = ActiveJournalRecord {
1758 event: value.clone(),
1759 identity: job.identity.clone(),
1760 atif_step: atif_step.clone(),
1761 tool_calls: u64::from(is_tool_call),
1762 tool_allowed: u64::from(is_tool_call && status == StatusId::Success),
1763 tool_blocked: u64::from(is_tool_call && status == StatusId::Failure),
1764 prompt_tokens: if submitted_class == EventClass::Compression {
1765 job.metrics.prompt_tokens.unwrap_or(0)
1766 } else {
1767 0
1768 },
1769 completion_tokens: if is_response_accounting {
1770 job.metrics.completion_tokens.unwrap_or(0)
1771 } else {
1772 0
1773 },
1774 cached_tokens: if is_response_accounting {
1775 job.metrics.cached_tokens.unwrap_or(0)
1776 } else {
1777 0
1778 },
1779 cost_usd_micros: if is_response_accounting {
1780 job.cost_usd_micros
1781 } else {
1782 0
1783 },
1784 prompt_token_correction: if is_response_accounting {
1785 job.prompt_token_correction
1786 } else {
1787 0
1788 },
1789 stop_reason_id: event.stop_reason_id,
1790 response_attempt: job.response_attempt.clone(),
1791 };
1792 if let Some(directory) = spool_dir {
1793 append_active_event_journal(
1794 directory,
1795 &job.session,
1796 &job.identity,
1797 &record,
1798 &journal_key,
1799 sync_journal,
1800 )
1801 .await?;
1802 }
1803 let content = outputs.dispatcher().map(|dispatcher| {
1804 (
1805 dispatcher,
1806 content_record(
1807 ContentSource {
1808 session_id: &job.session.id,
1809 identity: &job.identity,
1810 capture: job.atif.as_ref(),
1811 metrics: &job.metrics,
1812 attempt: job.response_attempt.as_ref(),
1813 },
1814 atif_step.as_ref(),
1815 class,
1816 status,
1817 &value,
1818 &event_uid,
1819 native_finish_reason,
1820 ),
1821 )
1822 });
1823 Ok(Some(PersistedJob {
1824 session: job.session,
1825 identity: job.identity,
1826 class,
1827 value,
1828 event_uid,
1829 record,
1830 response_marker,
1831 content,
1832 }))
1833}
1834
1835struct ContentSource<'a> {
1839 session_id: &'a str,
1840 identity: &'a pv_events::AgentIdentity,
1841 capture: Option<&'a AtifCapture>,
1842 metrics: &'a EventMetrics,
1843 attempt: Option<&'a ResponseAttempt>,
1844}
1845
1846fn content_record(
1847 job: ContentSource<'_>,
1848 step: Option<&pv_atif::Step>,
1849 class: EventClass,
1850 status: StatusId,
1851 event: &Value,
1852 event_uid: &str,
1853 finish_reason: Option<String>,
1854) -> crate::content::ContentRecord {
1855 let label = |source: pv_atif::Source| {
1856 match source {
1857 pv_atif::Source::User => "user",
1858 pv_atif::Source::Agent => "agent",
1859 _ => "system",
1860 }
1861 .to_owned()
1862 };
1863 let (source, message, reasoning, model, tool_calls, observation) = match (step, job.capture) {
1864 (Some(step), _) => (
1865 Some(label(step.source)),
1866 Some(step.message.clone()),
1867 step.reasoning_content.clone(),
1868 step.model_name.clone(),
1869 step.tool_calls
1870 .as_ref()
1871 .and_then(|calls| serde_json::to_value(calls).ok()),
1872 step.observation
1873 .as_ref()
1874 .and_then(|observation| serde_json::to_value(observation).ok()),
1875 ),
1876 (None, Some(capture)) => (
1877 Some(label(capture.source)),
1878 Some(capture.message.clone()),
1879 capture.reasoning_content.clone(),
1880 capture.model_name.clone(),
1881 capture
1882 .tool_calls
1883 .as_ref()
1884 .and_then(|calls| serde_json::to_value(calls).ok()),
1885 capture
1886 .observation
1887 .as_ref()
1888 .and_then(|observation| serde_json::to_value(observation).ok()),
1889 ),
1890 (None, None) => (None, None, None, None, None, None),
1891 };
1892 crate::content::ContentRecord {
1893 session_id: job.session_id.to_owned(),
1894 event_uid: event_uid.to_owned(),
1895 class,
1896 success: status == StatusId::Success,
1897 identity: job.identity.clone(),
1898 time_ms: pv_core::time::now_ms(),
1899 event: event.clone(),
1900 source,
1901 message,
1902 reasoning,
1903 model,
1904 tool_calls,
1905 observation,
1906 prompt_tokens: job.metrics.prompt_tokens,
1907 completion_tokens: job.metrics.completion_tokens,
1908 finish_reason,
1909 attempt_id: job.attempt.map(|attempt| attempt.id.clone()),
1910 terminal: job.attempt.is_some_and(|attempt| attempt.terminal),
1911 }
1912}
1913
1914async fn finish_job(
1920 persisted: PersistedJob,
1921 bridge: Arc<dyn EventBus>,
1922 spool_dir: Option<&std::path::Path>,
1923 journal_key: [u8; 32],
1924) -> Result<(), String> {
1925 let PersistedJob {
1926 session,
1927 identity,
1928 class,
1929 value,
1930 event_uid,
1931 record,
1932 response_marker,
1933 content,
1934 } = persisted;
1935 match session.workflow {
1936 Workflow::Signed => session
1937 .chain
1938 .lock()
1939 .append(&value)
1940 .map_err(|error| error.to_string())?,
1941 Workflow::Unsigned => {
1942 if record.atif_step.is_none() {
1949 return Err("unsigned ATIF step disappeared".to_owned());
1950 }
1951 session.note_atif_step();
1952 }
1953 }
1954
1955 let key = identity.instance_uid.clone();
1956 let publish_topic = class.topic().to_owned();
1957 let publish_uid = event_uid.clone();
1958 let ack = tokio::task::spawn_blocking(move || {
1959 bridge.publish_idempotent(&publish_topic, &key, &value, &publish_uid)
1960 })
1961 .await
1962 .map_err(|error| error.to_string())?
1963 .map_err(|error| error.to_string())?;
1964 if let Some(directory) = spool_dir {
1965 persist_broker_ack(directory, &session.id, &event_uid, &ack, &journal_key).await?;
1966 }
1967 record
1973 .apply_to_totals(&session.totals)
1974 .map_err(|error| error.to_string())?;
1975 if let (Some(directory), Some(attempt_id)) = (spool_dir, response_marker.as_deref()) {
1976 clear_response_marker(directory, &journal_key, &session.id, attempt_id).await?;
1977 }
1978 if let Some((dispatcher, content)) = content {
1981 dispatcher.submit(content);
1982 }
1983 Ok(())
1984}
1985
1986#[cfg(test)]
1987fn checked_atomic_add(counter: &std::sync::atomic::AtomicU64, value: u64, field: &str) -> Result<(), String> {
1988 counter
1989 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| {
1990 current
1991 .checked_add(value)
1992 .filter(|next| *next <= pv_core::error::JCS_SAFE_MAX)
1993 })
1994 .map(|_| ())
1995 .map_err(|_| format!("{field} accounting exceeds JCS-safe bounds"))
1996}
1997
1998pub(crate) fn reserve_response_attempt_id() -> String {
2003 pv_core::new_event_uid()
2004}
2005
2006pub(crate) async fn write_response_marker(
2007 spool_dir: &std::path::Path,
2008 journal_key: &[u8; 32],
2009 session_id: &str,
2010 attempt_id: &str,
2011 request_digest: String,
2012) -> Result<(), String> {
2013 let marker = InFlightResponse {
2014 session_id: session_id.to_owned(),
2015 attempt_id: attempt_id.to_owned(),
2016 request_digest,
2017 };
2018 let sealed = crate::journal::seal(journal_key, "in-flight-response", 0, &marker)?;
2019 let path = response_marker_path(spool_dir, session_id, attempt_id);
2020 tokio::task::spawn_blocking(move || write_atomic_control(&path, &sealed))
2021 .await
2022 .map_err(|error| error.to_string())??;
2023 Ok(())
2024}
2025
2026#[allow(dead_code)]
2030pub(crate) async fn create_response_marker(
2031 spool_dir: &std::path::Path,
2032 journal_key: &[u8; 32],
2033 session_id: &str,
2034 request_digest: String,
2035) -> Result<String, String> {
2036 let attempt_id = reserve_response_attempt_id();
2037 write_response_marker(spool_dir, journal_key, session_id, &attempt_id, request_digest).await?;
2038 Ok(attempt_id)
2039}
2040
2041#[cfg(test)]
2042pub(crate) async fn ensure_no_inflight_responses(
2043 spool_dir: &std::path::Path,
2044 journal_key: &[u8; 32],
2045) -> Result<(), String> {
2046 let sessions = inflight_response_sessions(spool_dir, journal_key).await?;
2047 if let Some(session_id) = sessions.into_iter().next() {
2048 Err(format!(
2049 "provider response for session {session_id} was not durably captured"
2050 ))
2051 } else {
2052 Ok(())
2053 }
2054}
2055
2056pub(crate) async fn inflight_response_sessions(
2057 spool_dir: &std::path::Path,
2058 journal_key: &[u8; 32],
2059) -> Result<std::collections::HashSet<String>, String> {
2060 let spool_dir = spool_dir.to_path_buf();
2061 let directory = spool_dir.join(crate::spool::INFLIGHT_RESPONSES);
2062 let journal_key = *journal_key;
2063 tokio::task::spawn_blocking(move || {
2064 let entries = match std::fs::read_dir(&directory) {
2065 Ok(entries) => entries,
2066 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
2067 return Ok(std::collections::HashSet::new());
2068 }
2069 Err(error) => return Err(error.to_string()),
2070 };
2071 let mut sessions = std::collections::HashSet::new();
2072 for entry in entries {
2073 let path = entry.map_err(|error| error.to_string())?.path();
2074 if path.extension().and_then(std::ffi::OsStr::to_str) != Some("json") {
2075 continue;
2076 }
2077 let marker_bytes = match pv_core::fsutil::read_capped(&path, pv_core::fsutil::MAX_CONTROL_BYTES) {
2078 Ok(bytes) => bytes,
2079 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
2083 Err(error) => return Err(error.to_string()),
2084 };
2085 let marker: InFlightResponse =
2092 match crate::journal::open(&journal_key, "in-flight-response", 0, &marker_bytes) {
2093 Ok(marker) => marker,
2094 Err(error) => {
2095 quarantine_inflight_marker(&path, &error);
2096 continue;
2097 }
2098 };
2099 if path != response_marker_path(&spool_dir, &marker.session_id, &marker.attempt_id) {
2100 quarantine_inflight_marker(
2103 &path,
2104 "in-flight response marker path does not match its payload",
2105 );
2106 continue;
2107 }
2108 match journal_witnesses_terminal_attempt(
2129 &spool_dir,
2130 &journal_key,
2131 &marker.session_id,
2132 &marker.attempt_id,
2133 ) {
2134 Ok(true) => {
2135 match std::fs::remove_file(&path) {
2136 Ok(()) => tracing::info!(
2137 session = %marker.session_id,
2138 attempt = %marker.attempt_id,
2139 "cleared stale in-flight response marker (journal witnesses terminal record)"
2140 ),
2141 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2142 Err(error) => tracing::warn!(
2143 marker = %pv_core::fsutil::basename(&path),
2144 %error,
2145 "failed to clear stale in-flight response marker after journal witness"
2146 ),
2147 }
2148 continue;
2149 }
2150 Ok(false) => {}
2151 Err(error) => {
2152 tracing::warn!(
2160 session = %marker.session_id,
2161 attempt = %marker.attempt_id,
2162 %error,
2163 "in-flight response journal-witness check failed; retaining marker"
2164 );
2165 }
2166 }
2167 sessions.insert(marker.session_id);
2168 }
2169 Ok(sessions)
2170 })
2171 .await
2172 .map_err(|error| error.to_string())?
2173}
2174
2175fn journal_witnesses_terminal_attempt(
2206 spool_dir: &std::path::Path,
2207 journal_key: &[u8; 32],
2208 session_id: &str,
2209 attempt_id: &str,
2210) -> Result<bool, String> {
2211 let digest = pv_core::digest::sha256_hex(session_id.as_bytes());
2212 let stem = digest.get(..32).unwrap_or(&digest);
2213 let journal_path = spool_dir.join(format!("{stem}.events.ndjson"));
2214 let bytes = match pv_core::fsutil::read_capped(&journal_path, pv_core::fsutil::MAX_ATIF_BYTES) {
2215 Ok(bytes) => bytes,
2216 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
2217 Err(error) => return Err(error.to_string()),
2218 };
2219 if bytes.is_empty() {
2220 return Ok(false);
2221 }
2222 let complete_len = if bytes.last() == Some(&b'\n') {
2223 bytes.len()
2224 } else {
2225 bytes
2226 .iter()
2227 .rposition(|byte| *byte == b'\n')
2228 .map_or(0, |index| index + 1)
2229 };
2230 let domain = format!("{session_id}:active");
2231 let mut index: u64 = 0;
2232 let mut cursor = 0usize;
2233 while cursor < complete_len {
2234 let Some(remaining) = bytes.get(cursor..complete_len) else {
2235 break;
2236 };
2237 let Some(newline_offset) = remaining.iter().position(|byte| *byte == b'\n') else {
2238 break;
2239 };
2240 let Some(line) = remaining.get(..newline_offset) else {
2241 break;
2242 };
2243 cursor += newline_offset + 1;
2244 if line.is_empty() {
2245 return Err("active journal contains an empty line".to_owned());
2252 }
2253 let record: ActiveJournalRecord = crate::journal::open(journal_key, &domain, index, line)
2254 .map_err(|error| format!("active journal record open failed: {error}"))?;
2255 if let Some(attempt) = record.response_attempt {
2256 if attempt.terminal && attempt.id == attempt_id {
2257 return Ok(true);
2258 }
2259 }
2260 index = index
2261 .checked_add(1)
2262 .ok_or_else(|| "active journal index overflow".to_owned())?;
2263 }
2264 Ok(false)
2265}
2266
2267async fn clear_response_marker(
2268 spool_dir: &std::path::Path,
2269 journal_key: &[u8; 32],
2270 session_id: &str,
2271 attempt_id: &str,
2272) -> Result<(), String> {
2273 let path = response_marker_path(spool_dir, session_id, attempt_id);
2274 let journal_key = *journal_key;
2275 let session_id = session_id.to_owned();
2276 let attempt_id = attempt_id.to_owned();
2277 tokio::task::spawn_blocking(move || {
2278 let marker_bytes = match pv_core::fsutil::read_capped(&path, pv_core::fsutil::MAX_CONTROL_BYTES) {
2285 Ok(bytes) => bytes,
2286 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2287 Err(error) => return Err(error.to_string()),
2288 };
2289 let marker: InFlightResponse =
2290 crate::journal::open(&journal_key, "in-flight-response", 0, &marker_bytes)?;
2291 if marker.session_id != session_id || marker.attempt_id != attempt_id {
2292 return Err("in-flight response marker does not match completed job".to_owned());
2293 }
2294 match std::fs::remove_file(&path) {
2295 Ok(()) => {}
2296 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
2297 Err(error) => return Err(error.to_string()),
2298 }
2299 let parent = path
2300 .parent()
2301 .ok_or_else(|| "in-flight response marker has no parent".to_owned())?;
2302 pv_core::fsutil::sync_directory(parent).map_err(|error| error.to_string())
2303 })
2304 .await
2305 .map_err(|error| error.to_string())?
2306}
2307
2308fn quarantine_inflight_marker(path: &std::path::Path, error: impl std::fmt::Display) {
2317 let uid = pv_core::new_event_uid();
2318 let name = path
2319 .file_stem()
2320 .and_then(std::ffi::OsStr::to_str)
2321 .map(|stem| format!("{stem}.corrupt-{uid}"))
2322 .unwrap_or_else(|| format!("corrupt-{uid}"));
2323 let quarantine = path.with_file_name(name);
2324 tracing::warn!(
2325 %error,
2326 original = %pv_core::fsutil::basename(path),
2327 quarantine = %pv_core::fsutil::basename(&quarantine),
2328 "unverifiable in-flight-response marker quarantined so recovery can proceed"
2329 );
2330 if let Err(rename_err) = std::fs::rename(path, &quarantine) {
2331 tracing::warn!(
2332 %rename_err,
2333 "failed to quarantine in-flight-response marker — leaving in place"
2334 );
2335 }
2336}
2337
2338fn response_marker_path(
2339 spool_dir: &std::path::Path,
2340 session_id: &str,
2341 attempt_id: &str,
2342) -> std::path::PathBuf {
2343 let digest = pv_core::digest::sha256_hex(format!("{session_id}:{attempt_id}").as_bytes());
2344 spool_dir
2345 .join(crate::spool::INFLIGHT_RESPONSES)
2346 .join(format!("{}.json", &digest[..32]))
2347}
2348
2349fn write_atomic_control(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
2350 pv_core::fsutil::write_atomic(path, bytes).map_err(|error| error.to_string())
2351}
2352
2353async fn append_active_event_journal(
2354 directory: &std::path::Path,
2355 session: &Session,
2356 _identity: &pv_events::AgentIdentity,
2357 record: &ActiveJournalRecord,
2358 journal_key: &[u8; 32],
2359 sync: bool,
2360) -> Result<(), String> {
2361 let index = session.journal_index();
2362 let domain = format!("{}:active", session.id);
2363 let line = crate::journal::seal(journal_key, &domain, index, record)?;
2364 append_journal(directory, session, line, *journal_key, sync).await?;
2365 session.commit_journal_index(index)
2366}
2367
2368async fn append_journal(
2369 directory: &std::path::Path,
2370 session: &Session,
2371 line: Vec<u8>,
2372 journal_key: [u8; 32],
2373 sync: bool,
2374) -> Result<(), String> {
2375 let directory = directory.to_path_buf();
2376 let session_id = session.id.clone();
2377 let identity = session.identity.clone();
2378 let principal_binding = session.principal_binding();
2379 let workflow = session.workflow.as_str();
2380 tokio::task::spawn_blocking(move || -> Result<(), String> {
2381 use std::io::Write as _;
2382 if !directory.is_dir() {
2389 pv_core::fsutil::create_dir_all_synced(&directory).map_err(|error| error.to_string())?;
2390 }
2391 let digest = pv_core::digest::sha256_hex(session_id.as_bytes());
2392 let stem = digest.get(..32).unwrap_or(&digest);
2393 let metadata_path = directory.join(format!("{stem}.session.json"));
2394 let metadata_payload = serde_json::json!({
2395 "journal_version": 2,
2396 "session_id": session_id,
2397 "identity": identity,
2398 "principal_binding": principal_binding,
2399 "workflow": workflow,
2400 });
2401 if std::fs::symlink_metadata(&metadata_path).is_err() {
2410 let metadata = crate::journal::seal(&journal_key, "metadata", 0, &metadata_payload)?;
2411 pv_core::fsutil::write_atomic(&metadata_path, &metadata).map_err(|error| {
2427 format!(
2428 "write journal metadata {}: {error}",
2429 pv_core::fsutil::basename(&metadata_path)
2430 )
2431 })?;
2432 } else {
2433 let mut stored: serde_json::Value = crate::journal::open(
2434 &journal_key,
2435 "metadata",
2436 0,
2437 &pv_core::fsutil::read_capped(&metadata_path, pv_core::fsutil::MAX_CONTROL_BYTES)
2440 .map_err(|error| error.to_string())?,
2441 )?;
2442 if principal_binding.is_none() && stored.get("principal_binding").is_none() {
2446 if let Some(metadata) = stored.as_object_mut() {
2447 metadata.insert("principal_binding".into(), Value::Null);
2448 }
2449 }
2450 if stored != metadata_payload {
2451 return Err(
2452 "journal metadata does not match session workflow, identity, and principal".to_owned(),
2453 );
2454 }
2455 }
2456 let journal_path = directory.join(format!("{stem}.events.ndjson"));
2457 let journal_created = std::fs::symlink_metadata(&journal_path).is_err();
2472 let mut open_options = std::fs::OpenOptions::new();
2473 open_options.create(true).append(true);
2474 #[cfg(unix)]
2475 {
2476 use std::os::unix::fs::OpenOptionsExt as _;
2477 open_options
2491 .custom_flags(pv_core::fsutil::unix_o_nofollow())
2492 .mode(0o600);
2493 }
2494 let mut journal = open_options.open(&journal_path).map_err(|error| {
2495 format!(
2496 "open event journal {}: {error}",
2497 pv_core::fsutil::basename(&journal_path)
2498 )
2499 })?;
2500 journal.write_all(&line).map_err(|error| error.to_string())?;
2501 journal.write_all(b"\n").map_err(|error| error.to_string())?;
2502 if sync {
2509 journal.sync_data().map_err(|error| error.to_string())?;
2510 }
2511 if journal_created {
2512 pv_core::fsutil::sync_directory(&directory).map_err(|error| {
2513 format!("fsync journal directory: {error}")
2520 })?;
2521 }
2522 Ok(())
2523 })
2524 .await
2525 .map_err(|error| error.to_string())?
2526}
2527
2528pub(crate) async fn persist_broker_ack(
2541 directory: &std::path::Path,
2542 session_id: &str,
2543 event_uid: &str,
2544 ack: &PublishAck,
2545 journal_key: &[u8; 32],
2546) -> Result<(), String> {
2547 let path = ack_journal_path(directory, session_id);
2548 let record = BrokerAckRecord {
2549 session_id: session_id.to_owned(),
2550 event_uid: event_uid.to_owned(),
2551 ack: ack.clone(),
2552 };
2553 let sealed = crate::journal::seal(journal_key, "broker-ack", 0, &record)?;
2554 tokio::task::spawn_blocking(move || -> Result<(), String> {
2555 use std::io::Write as _;
2556 let created = std::fs::symlink_metadata(&path).is_err();
2566 let mut open_options = std::fs::OpenOptions::new();
2567 open_options.create(true).append(true);
2568 #[cfg(unix)]
2569 {
2570 use std::os::unix::fs::OpenOptionsExt as _;
2571 open_options
2572 .custom_flags(pv_core::fsutil::unix_o_nofollow())
2573 .mode(0o600);
2574 }
2575 let mut journal = open_options
2576 .open(&path)
2577 .map_err(|error| format!("open ack journal {}: {error}", pv_core::fsutil::basename(&path)))?;
2578 journal.write_all(&sealed).map_err(|error| error.to_string())?;
2579 journal.write_all(b"\n").map_err(|error| error.to_string())?;
2580 journal.sync_data().map_err(|error| error.to_string())?;
2581 if created {
2582 let parent = path
2586 .parent()
2587 .ok_or_else(|| "ack journal has no parent".to_owned())?;
2588 pv_core::fsutil::sync_directory(parent).map_err(|error| error.to_string())?;
2589 }
2590 Ok(())
2591 })
2592 .await
2593 .map_err(|error| error.to_string())?
2594}
2595
2596const MAX_ACK_RECORD_BYTES: u64 = 1024 * 1024;
2609
2610pub(crate) async fn read_broker_ack(
2611 directory: &std::path::Path,
2612 session_id: &str,
2613 event_uid: &str,
2614 journal_key: &[u8; 32],
2615) -> Result<Option<PublishAck>, String> {
2616 {
2620 let path = ack_journal_path(directory, session_id);
2621 let session = session_id.to_owned();
2622 let uid = event_uid.to_owned();
2623 let key = *journal_key;
2624 let found = tokio::task::spawn_blocking(move || -> Result<Option<PublishAck>, String> {
2625 use std::io::{BufRead as _, Read as _};
2626 let file = match std::fs::File::open(&path) {
2627 Ok(file) => file,
2628 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2629 Err(error) => return Err(error.to_string()),
2630 };
2631 let mut reader = std::io::BufReader::new(file);
2632 let mut buf: Vec<u8> = Vec::new();
2633 loop {
2634 buf.clear();
2635 let read = {
2636 let mut limited = (&mut reader).take(MAX_ACK_RECORD_BYTES);
2637 limited
2638 .read_until(b'\n', &mut buf)
2639 .map_err(|error| error.to_string())?
2640 };
2641 if read == 0 {
2642 break;
2643 }
2644 if buf.last() != Some(&b'\n') && read as u64 == MAX_ACK_RECORD_BYTES {
2645 loop {
2650 buf.clear();
2651 let drained = {
2652 let mut limited = (&mut reader).take(MAX_ACK_RECORD_BYTES);
2653 limited
2654 .read_until(b'\n', &mut buf)
2655 .map_err(|error| error.to_string())?
2656 };
2657 if drained == 0 || buf.last() == Some(&b'\n') {
2658 break;
2659 }
2660 }
2661 continue;
2662 }
2663 let line = buf.strip_suffix(b"\n").unwrap_or(&buf);
2664 if line.is_empty() {
2665 continue;
2666 }
2667 let Ok(record) = crate::journal::open::<BrokerAckRecord>(&key, "broker-ack", 0, line) else {
2672 continue;
2673 };
2674 if record.session_id == session && record.event_uid == uid {
2675 return Ok(Some(record.ack));
2676 }
2677 }
2678 Ok(None)
2679 })
2680 .await
2681 .map_err(|error| error.to_string())??;
2682 if found.is_some() {
2683 return Ok(found);
2684 }
2685 }
2686 let path = broker_ack_path(directory, session_id, event_uid);
2689 let bytes = match tokio::task::spawn_blocking({
2699 let path = path.clone();
2700 move || pv_core::fsutil::read_capped(&path, pv_core::fsutil::MAX_CONTROL_BYTES)
2701 })
2702 .await
2703 .map_err(|error| error.to_string())?
2704 {
2705 Ok(bytes) => bytes,
2706 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2707 Err(error) => return Err(error.to_string()),
2708 };
2709 let record: BrokerAckRecord = crate::journal::open(journal_key, "broker-ack", 0, &bytes)?;
2710 if record.session_id != session_id || record.event_uid != event_uid {
2711 return Err("broker acknowledgment does not match event".to_owned());
2712 }
2713 Ok(Some(record.ack))
2714}
2715
2716fn ack_journal_path(directory: &std::path::Path, session_id: &str) -> std::path::PathBuf {
2718 let digest = pv_core::digest::sha256_hex(session_id.as_bytes());
2719 directory.join(format!("{}.acks.ndjson", digest.get(..32).unwrap_or(&digest)))
2720}
2721
2722fn broker_ack_path(directory: &std::path::Path, session_id: &str, event_uid: &str) -> std::path::PathBuf {
2724 let session_digest = pv_core::digest::sha256_hex(session_id.as_bytes());
2725 let event_digest = pv_core::digest::sha256_hex(event_uid.as_bytes());
2726 directory
2727 .join("broker-acks")
2728 .join(&session_digest[..32])
2729 .join(format!("{}.json", &event_digest[..32]))
2730}
2731
2732#[cfg(test)]
2733mod tests {
2734 #![allow(
2735 clippy::expect_used,
2736 clippy::indexing_slicing,
2737 clippy::panic,
2738 clippy::unwrap_used
2739 )]
2740
2741 use super::*;
2742 use parking_lot::Mutex;
2743 use pv_bridge::{BusError, PublishAck, StoredEvent};
2744 use pv_events::AgentIdentity;
2745 use pv_loopdetect::{BreakerConfig, HashEmbedder};
2746 use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
2747
2748 #[tokio::test]
2755 async fn wait_idle_wakes_on_the_final_job() {
2756 let bus = Arc::new(GatedBus::new("drain-session"));
2757 let worker = Arc::new(spawn_worker(
2758 64,
2759 Arc::clone(&bus) as Arc<dyn EventBus>,
2760 Arc::new(HashEmbedder::default()),
2761 Arc::new(Registry::new()),
2762 ));
2763 let session = session_with_id("drain-session");
2764 worker.try_submit(job(Arc::clone(&session))).unwrap();
2765 let waiter = tokio::spawn({
2767 let worker = Arc::clone(&worker);
2768 async move { worker.wait_idle().await }
2769 });
2770 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2771 assert!(
2772 !waiter.is_finished(),
2773 "wait_idle must block while the job is gated"
2774 );
2775 bus.release.store(true, std::sync::atomic::Ordering::Release);
2776 tokio::time::timeout(std::time::Duration::from_secs(5), waiter)
2777 .await
2778 .expect("wait_idle must wake on the FINAL job's completion")
2779 .unwrap();
2780 }
2781
2782 #[tokio::test]
2788 async fn sync_session_journal_tolerates_a_missing_journal() {
2789 let directory = tempfile::tempdir().unwrap();
2790 let session = session_with_id("never-journaled");
2791 sync_session_journal(directory.path(), &session)
2792 .await
2793 .expect("a missing journal is the every-job-skipped shape, not a fault");
2794 }
2795
2796 #[tokio::test]
2804 async fn markers_and_acks_pin_identity_and_first_use() {
2805 let a = reserve_response_attempt_id();
2806 let b = reserve_response_attempt_id();
2807 assert!(!a.is_empty());
2808 assert_ne!(a, b, "attempt ids must be unique");
2809
2810 let directory = tempfile::tempdir().unwrap();
2811 let key = [21u8; 32];
2812 write_response_marker(directory.path(), &key, "marker-session", &a, "digest".to_owned())
2813 .await
2814 .expect("the first marker write must create the spool subdirectory");
2815
2816 let ack = PublishAck {
2817 topic: "agent.session".to_owned(),
2818 partition: 0,
2819 offset: 7,
2820 };
2821 persist_broker_ack(directory.path(), "ack-session", "uid-1", &ack, &key)
2822 .await
2823 .unwrap();
2824 assert_eq!(
2825 read_broker_ack(directory.path(), "ack-session", "uid-1", &key)
2826 .await
2827 .unwrap(),
2828 Some(ack),
2829 "the matching (session, uid) pair must resolve"
2830 );
2831 assert!(
2832 read_broker_ack(directory.path(), "ack-session", "uid-2", &key)
2833 .await
2834 .unwrap()
2835 .is_none()
2836 || read_broker_ack(directory.path(), "ack-session", "uid-2", &key)
2837 .await
2838 .is_err(),
2839 "a half-matching ack (same session, different uid) must never resolve"
2840 );
2841 assert!(
2842 read_broker_ack(directory.path(), "other-session", "uid-1", &key)
2843 .await
2844 .unwrap()
2845 .is_none()
2846 || read_broker_ack(directory.path(), "other-session", "uid-1", &key)
2847 .await
2848 .is_err(),
2849 "a half-matching ack (different session, same uid) must never resolve"
2850 );
2851 }
2852
2853 #[test]
2859 fn tool_accounting_invariant_is_enforced() {
2860 let consistent = RecoveredTotals {
2861 tool_calls: 5,
2862 tool_allowed: 3,
2863 tool_blocked: 2,
2864 ..RecoveredTotals::default()
2865 };
2866 assert!(consistent.validate_tool_accounting().is_ok());
2867 let inconsistent = RecoveredTotals {
2868 tool_calls: 4,
2869 tool_allowed: 3,
2870 tool_blocked: 2,
2871 ..RecoveredTotals::default()
2872 };
2873 assert!(
2874 inconsistent.validate_tool_accounting().is_err(),
2875 "classified > calls must be refused"
2876 );
2877 let overflowing = RecoveredTotals {
2878 tool_calls: u64::MAX,
2879 tool_allowed: u64::MAX,
2880 tool_blocked: 1,
2881 ..RecoveredTotals::default()
2882 };
2883 assert!(
2884 overflowing.validate_tool_accounting().is_err(),
2885 "the classified sum overflowing u64 must be refused"
2886 );
2887 }
2888
2889 #[derive(Default)]
2890 struct RecordingBus {
2891 events: Mutex<Vec<(String, String, Value)>>,
2892 }
2893
2894 struct PanicOnceSink {
2895 panicked: AtomicBool,
2896 }
2897
2898 struct SlowEmbedder;
2899
2900 impl Embedder for SlowEmbedder {
2901 fn dim(&self) -> usize {
2902 1
2903 }
2904
2905 fn embed(&self, _text: &str) -> Vec<f32> {
2906 std::thread::sleep(std::time::Duration::from_millis(100));
2907 vec![1.0]
2908 }
2909 }
2910
2911 struct GatedBus {
2915 gated_session: String,
2916 release: AtomicBool,
2917 published: Mutex<Vec<String>>,
2918 }
2919
2920 impl GatedBus {
2921 fn new(gated_session: &str) -> Self {
2922 Self {
2923 gated_session: gated_session.to_owned(),
2924 release: AtomicBool::new(false),
2925 published: Mutex::new(Vec::new()),
2926 }
2927 }
2928 }
2929
2930 impl EventBus for GatedBus {
2931 fn publish(&self, topic: &str, _key: &str, value: &Value) -> Result<PublishAck, BusError> {
2932 let text = value.to_string();
2933 if text.contains(&self.gated_session) {
2934 while !self.release.load(Ordering::Acquire) {
2935 std::thread::sleep(std::time::Duration::from_millis(5));
2936 }
2937 }
2938 self.published.lock().push(text);
2939 Ok(PublishAck {
2940 topic: topic.to_owned(),
2941 partition: 0,
2942 offset: 0,
2943 })
2944 }
2945
2946 fn fetch(
2947 &self,
2948 _topic: &str,
2949 _partition: u32,
2950 _offset: u64,
2951 _max: usize,
2952 ) -> Result<Vec<StoredEvent>, BusError> {
2953 Ok(Vec::new())
2954 }
2955
2956 fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
2957 Ok(1)
2958 }
2959
2960 fn topics(&self) -> Vec<String> {
2961 EventClass::all()
2962 .iter()
2963 .map(|class| class.topic().to_owned())
2964 .collect()
2965 }
2966 }
2967
2968 struct SlowBus;
2969
2970 impl EventBus for SlowBus {
2971 fn publish(&self, topic: &str, _key: &str, _value: &Value) -> Result<PublishAck, BusError> {
2972 std::thread::sleep(std::time::Duration::from_millis(100));
2973 Ok(PublishAck {
2974 topic: topic.to_owned(),
2975 partition: 0,
2976 offset: 0,
2977 })
2978 }
2979
2980 fn fetch(
2981 &self,
2982 _topic: &str,
2983 _partition: u32,
2984 _offset: u64,
2985 _max: usize,
2986 ) -> Result<Vec<StoredEvent>, BusError> {
2987 Ok(Vec::new())
2988 }
2989
2990 fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
2991 Ok(1)
2992 }
2993
2994 fn topics(&self) -> Vec<String> {
2995 EventClass::all()
2996 .iter()
2997 .map(|class| class.topic().to_owned())
2998 .collect()
2999 }
3000 }
3001
3002 impl VectorSink for PanicOnceSink {
3003 fn record<'a>(
3004 &'a self,
3005 _session_id: &'a str,
3006 _vector: &'a [f32],
3007 ) -> pv_loopdetect::VectorSinkFuture<'a> {
3008 Box::pin(async move {
3009 if !self.panicked.swap(true, AtomicOrdering::AcqRel) {
3010 panic!("injected vector sink panic");
3011 }
3012 Ok(())
3013 })
3014 }
3015 }
3016
3017 impl EventBus for RecordingBus {
3018 fn publish(&self, topic: &str, key: &str, value: &Value) -> Result<PublishAck, BusError> {
3019 let mut events = self.events.lock();
3020 let offset = events.len() as u64;
3021 events.push((topic.to_owned(), key.to_owned(), value.clone()));
3022 Ok(PublishAck {
3023 topic: topic.to_owned(),
3024 partition: 0,
3025 offset,
3026 })
3027 }
3028
3029 fn publish_idempotent(
3030 &self,
3031 topic: &str,
3032 key: &str,
3033 value: &Value,
3034 event_uid: &str,
3035 ) -> Result<PublishAck, BusError> {
3036 if let Some((offset, _)) = self
3037 .events
3038 .lock()
3039 .iter()
3040 .enumerate()
3041 .find(|(_, (_, _, event))| event["metadata"]["uid"] == event_uid)
3042 {
3043 return Ok(PublishAck {
3044 topic: topic.to_owned(),
3045 partition: 0,
3046 offset: offset as u64,
3047 });
3048 }
3049 self.publish(topic, key, value)
3050 }
3051
3052 fn fetch(
3053 &self,
3054 _topic: &str,
3055 _partition: u32,
3056 _offset: u64,
3057 _max: usize,
3058 ) -> Result<Vec<StoredEvent>, BusError> {
3059 Ok(Vec::new())
3060 }
3061
3062 fn partitions(&self, _topic: &str) -> Result<u32, BusError> {
3063 Ok(1)
3064 }
3065
3066 fn topics(&self) -> Vec<String> {
3067 EventClass::all()
3068 .iter()
3069 .map(|class| class.topic().to_owned())
3070 .collect()
3071 }
3072 }
3073
3074 fn session(workflow: Workflow) -> Arc<Session> {
3075 Arc::new(Session::new(
3076 "session-1".to_owned(),
3077 workflow,
3078 AgentIdentity {
3079 version: "1".to_owned(),
3080 charter: "test".into(),
3081 instance_uid: "instance-1".to_owned(),
3082 ttl_remaining_s: Some(600),
3083 },
3084 BreakerConfig {
3085 min_tokens: u64::MAX,
3086 ..BreakerConfig::default()
3087 },
3088 ))
3089 }
3090
3091 fn job(session: Arc<Session>) -> WorkerJob {
3092 WorkerJob {
3093 identity: session.current_identity(),
3094 session,
3095 class: EventClass::Compression,
3096 payload: serde_json::json!({"kind": "response"}),
3097 text: "a useful response".to_owned(),
3098 analyze_loop: true,
3099 status: StatusId::Success,
3100 stop_reason: None,
3101 native_stop_reason: None,
3102 metrics: EventMetrics {
3103 prompt_tokens: Some(100),
3104 completion_tokens: Some(20),
3105 cached_tokens: Some(40),
3106 pruned_tokens: Some(10),
3107 pruning_ratio_millis: Some(100),
3108 },
3109 cost_usd_micros: 250,
3110 prompt_token_correction: 0,
3111 atif: Some(AtifCapture {
3112 source: pv_atif::Source::Agent,
3113 message: Value::String("a useful response".to_owned()),
3114 reasoning_content: None,
3115 model_name: None,
3116 tool_calls: None,
3117 observation: None,
3118 llm_call_count: Some(1),
3119 }),
3120 response_marker: None,
3121 response_attempt: None,
3122 }
3123 }
3124
3125 #[test]
3126 fn receipt_accounting_never_wraps_or_exceeds_jcs_bounds() {
3127 let counter = std::sync::atomic::AtomicU64::new(pv_core::error::JCS_SAFE_MAX);
3128 assert!(checked_atomic_add(&counter, 1, "test").is_err());
3129 assert_eq!(counter.load(Ordering::Acquire), pv_core::error::JCS_SAFE_MAX);
3130 }
3131
3132 #[tokio::test(flavor = "current_thread")]
3133 async fn synchronous_backends_do_not_block_tokio_worker_thread() {
3134 let worker = spawn_worker(
3135 4,
3136 Arc::new(SlowBus),
3137 Arc::new(SlowEmbedder),
3138 Arc::new(Registry::new()),
3139 );
3140 worker.try_submit(job(session(Workflow::Signed))).unwrap();
3141
3142 let started = std::time::Instant::now();
3143 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
3144 assert!(
3149 started.elapsed() < std::time::Duration::from_millis(250),
3150 "synchronous worker dependency blocked the Tokio reactor"
3151 );
3152 worker.wait_idle().await;
3153 }
3154
3155 #[tokio::test]
3156 async fn response_marker_is_authenticated_and_cleared_after_publish() {
3157 let directory = tempfile::tempdir().unwrap();
3158 let journal_key = [13; 32];
3159 let marker = create_response_marker(
3160 directory.path(),
3161 &journal_key,
3162 "session-1",
3163 "request-digest".to_owned(),
3164 )
3165 .await
3166 .unwrap();
3167 assert!(ensure_no_inflight_responses(directory.path(), &journal_key)
3168 .await
3169 .unwrap_err()
3170 .contains("was not durably captured"));
3171
3172 let worker = spawn_worker_with_spool_authenticated(
3173 4,
3174 Arc::new(RecordingBus::default()),
3175 Arc::new(HashEmbedder::default()),
3176 Arc::new(NoopVectorSink),
3177 Some(directory.path().to_path_buf()),
3178 journal_key,
3179 Arc::new(Registry::new()),
3180 None,
3181 );
3182 let session = session(Workflow::Signed);
3183 let mut response = job(session);
3184 response.response_marker = Some(marker);
3185 worker.submit_and_wait(response).await.unwrap();
3186 ensure_no_inflight_responses(directory.path(), &journal_key)
3187 .await
3188 .unwrap();
3189 }
3190
3191 #[tokio::test]
3192 async fn response_marker_rejects_payload_mutation() {
3193 let directory = tempfile::tempdir().unwrap();
3201 let journal_key = [17; 32];
3202 create_response_marker(
3203 directory.path(),
3204 &journal_key,
3205 "session-1",
3206 "request-digest".to_owned(),
3207 )
3208 .await
3209 .unwrap();
3210 let marker_path = std::fs::read_dir(directory.path().join(crate::spool::INFLIGHT_RESPONSES))
3211 .unwrap()
3212 .next()
3213 .unwrap()
3214 .unwrap()
3215 .path();
3216 let mut envelope: Value = serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap();
3217 envelope["payload"]["request_digest"] = Value::String("forged".to_owned());
3218 std::fs::write(&marker_path, serde_json::to_vec(&envelope).unwrap()).unwrap();
3219 ensure_no_inflight_responses(directory.path(), &journal_key)
3220 .await
3221 .expect("tampered marker quarantines cleanly instead of poisoning recovery");
3222 assert!(
3225 !marker_path.exists(),
3226 "tampered marker should be quarantined out of the primary name"
3227 );
3228 let inflight_dir = directory.path().join(crate::spool::INFLIGHT_RESPONSES);
3229 let mut has_corrupt = false;
3230 for entry in std::fs::read_dir(&inflight_dir).unwrap() {
3231 let name = entry.unwrap().file_name();
3232 if name.to_string_lossy().contains(".corrupt-") {
3233 has_corrupt = true;
3234 break;
3235 }
3236 }
3237 assert!(
3238 has_corrupt,
3239 "expected a `.corrupt-<uid>.json` sibling as evidence"
3240 );
3241 }
3242
3243 #[tokio::test]
3244 async fn signed_job_publishes_and_extends_chain() {
3245 let bridge = Arc::new(RecordingBus::default());
3246 let metrics = Arc::new(Registry::new());
3247 let worker = spawn_worker(4, bridge.clone(), Arc::new(HashEmbedder::default()), metrics);
3248 let session = session(Workflow::Signed);
3249 worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3250
3251 assert_eq!(session.chain.lock().count(), 1);
3252 assert_eq!(session.totals.prompt_tokens.load(Ordering::Acquire), 100);
3253 let events = bridge.events.lock();
3254 assert_eq!(events.len(), 1);
3255 assert_eq!(events[0].0, "agent.compression");
3256 assert_eq!(events[0].1, "instance-1");
3257 }
3258
3259 #[tokio::test]
3260 async fn signed_job_journals_event_and_accounting() {
3261 let directory = tempfile::tempdir().unwrap();
3262 let journal_key = [9; 32];
3263 let worker = spawn_worker_with_spool_authenticated(
3264 4,
3265 Arc::new(RecordingBus::default()),
3266 Arc::new(HashEmbedder::default()),
3267 Arc::new(NoopVectorSink),
3268 Some(directory.path().to_path_buf()),
3269 journal_key,
3270 Arc::new(Registry::new()),
3271 None,
3272 );
3273 let session = session(Workflow::Signed);
3274 worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3275
3276 let digest = pv_core::digest::sha256_hex(session.id.as_bytes());
3277 let stem = digest.get(..32).unwrap();
3278 let metadata: Value = crate::journal::open(
3279 &journal_key,
3280 "metadata",
3281 0,
3282 &std::fs::read(directory.path().join(format!("{stem}.session.json"))).unwrap(),
3283 )
3284 .unwrap();
3285 assert_eq!(metadata["workflow"], "signed");
3286 let journal =
3287 std::fs::read_to_string(directory.path().join(format!("{stem}.events.ndjson"))).unwrap();
3288 let record: ActiveJournalRecord =
3289 crate::journal::open(&journal_key, "session-1:active", 0, journal.trim().as_bytes()).unwrap();
3290 assert_eq!(record.prompt_tokens, 100);
3291 assert_eq!(record.completion_tokens, 0);
3292 assert_eq!(record.event["class_name"], "agent.compression");
3293 }
3294
3295 #[tokio::test]
3296 async fn journal_metadata_binds_principal_and_rejects_mismatch() {
3297 let directory = tempfile::tempdir().unwrap();
3298 let key = [9; 32];
3299 let first = session(Workflow::Signed);
3300 assert!(first.bind_principal(Some("principal-a")));
3301 append_journal(directory.path(), &first, b"first".to_vec(), key, true)
3302 .await
3303 .unwrap();
3304 let digest = pv_core::digest::sha256_hex(first.id.as_bytes());
3305 let stem = digest.get(..32).unwrap();
3306 let metadata: Value = crate::journal::open(
3307 &key,
3308 "metadata",
3309 0,
3310 &std::fs::read(directory.path().join(format!("{stem}.session.json"))).unwrap(),
3311 )
3312 .unwrap();
3313 assert_eq!(metadata["principal_binding"], "principal-a");
3314 let journal_path = directory.path().join(format!("{stem}.events.ndjson"));
3315 let original_journal = std::fs::read(&journal_path).unwrap();
3316 for principal in [Some("principal-b"), None] {
3317 let other = session(Workflow::Signed);
3318 assert!(other.bind_principal(principal));
3319 let error = append_journal(directory.path(), &other, b"second".to_vec(), key, true)
3320 .await
3321 .unwrap_err();
3322 assert!(error.contains("principal"));
3323 assert_eq!(std::fs::read(&journal_path).unwrap(), original_journal);
3324 }
3325 }
3326
3327 #[tokio::test]
3328 async fn legacy_journal_metadata_only_allows_anonymous_continuation() {
3329 let directory = tempfile::tempdir().unwrap();
3330 let key = [9; 32];
3331 let anonymous = session(Workflow::Signed);
3332 let digest = pv_core::digest::sha256_hex(anonymous.id.as_bytes());
3333 let stem = digest.get(..32).unwrap();
3334 let legacy = serde_json::json!({
3335 "journal_version": 2,
3336 "session_id": anonymous.id,
3337 "identity": anonymous.identity,
3338 "workflow": "signed",
3339 });
3340 std::fs::write(
3341 directory.path().join(format!("{stem}.session.json")),
3342 crate::journal::seal(&key, "metadata", 0, &legacy).unwrap(),
3343 )
3344 .unwrap();
3345 append_journal(directory.path(), &anonymous, b"first".to_vec(), key, true)
3346 .await
3347 .unwrap();
3348 let authenticated = session(Workflow::Signed);
3349 assert!(authenticated.bind_principal(Some("principal-a")));
3350 assert!(
3351 append_journal(directory.path(), &authenticated, b"second".to_vec(), key, true)
3352 .await
3353 .unwrap_err()
3354 .contains("principal")
3355 );
3356 assert_eq!(
3357 std::fs::read(directory.path().join(format!("{stem}.events.ndjson"))).unwrap(),
3358 b"first\n"
3359 );
3360 }
3361
3362 #[tokio::test]
3370 async fn capture_failed_session_does_not_write_further_journal_entries() {
3371 let directory = tempfile::tempdir().unwrap();
3372 let journal_key = [23; 32];
3373 let worker = spawn_worker_with_spool_authenticated(
3374 4,
3375 Arc::new(RecordingBus::default()),
3376 Arc::new(HashEmbedder::default()),
3377 Arc::new(NoopVectorSink),
3378 Some(directory.path().to_path_buf()),
3379 journal_key,
3380 Arc::new(Registry::new()),
3381 None,
3382 );
3383 let session = session(Workflow::Signed);
3384 worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3385 let digest = pv_core::digest::sha256_hex(session.id.as_bytes());
3386 let stem = digest.get(..32).unwrap();
3387 let journal_path = directory.path().join(format!("{stem}.events.ndjson"));
3388 let bytes_after_first = std::fs::read(&journal_path).unwrap();
3389 assert!(
3390 !bytes_after_first.is_empty(),
3391 "first envelope must journal one line"
3392 );
3393 assert_eq!(
3394 bytes_after_first.iter().filter(|byte| **byte == b'\n').count(),
3395 1,
3396 "first envelope must produce exactly one journal line",
3397 );
3398 session.mark_capture_failed();
3399 let seq_before = session.next_seq();
3402 worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3403 let seq_after = session.next_seq();
3404 assert_eq!(
3405 seq_after,
3406 seq_before + 1,
3407 "process_job for a capture_failed session must not advance next_seq",
3408 );
3409 assert_eq!(
3410 std::fs::read(&journal_path).unwrap(),
3411 bytes_after_first,
3412 "process_job for a capture_failed session must not append to the journal",
3413 );
3414 assert_eq!(
3415 session.chain.lock().count(),
3416 1,
3417 "process_job for a capture_failed session must not extend the chain",
3418 );
3419 }
3420
3421 #[tokio::test]
3422 async fn signed_job_journal_recovers_replays_and_issues_one_receipt() {
3423 let directory = tempfile::tempdir().unwrap();
3424 let bridge = Arc::new(RecordingBus::default());
3425 let metrics = Arc::new(Registry::new());
3426 let signer = Arc::new(pv_receipts::Ed25519Signer::from_seed(&[14; 32]));
3427 let journal_key = crate::journal::key_from_signer(signer.as_ref());
3428 let worker = spawn_worker_with_spool_authenticated(
3429 4,
3430 bridge.clone(),
3431 Arc::new(HashEmbedder::default()),
3432 Arc::new(NoopVectorSink),
3433 Some(directory.path().to_path_buf()),
3434 journal_key,
3435 Arc::clone(&metrics),
3436 None,
3437 );
3438 let active = session(Workflow::Signed);
3439 worker.submit_and_wait(job(Arc::clone(&active))).await.unwrap();
3440 let digest = pv_core::digest::sha256_hex(active.id.as_bytes());
3441 let stem = digest.get(..32).unwrap();
3442 let ack_path = directory.path().join(format!("{stem}.acks.ndjson"));
3443 std::fs::remove_file(&ack_path).unwrap();
3444
3445 let registry = crate::session::SessionRegistry::new();
3446 let finalizer = crate::reconciler::Finalizer::with_bridge(
3447 signer,
3448 directory.path().to_path_buf(),
3449 metrics,
3450 bridge.clone(),
3451 );
3452 assert_eq!(
3453 finalizer
3454 .recover_spooled_sessions(®istry, &Default::default())
3455 .await
3456 .unwrap(),
3457 1
3458 );
3459 let recovered = registry.get(&active.id).unwrap();
3460 let receipt = recovered.receipt.lock().clone().unwrap();
3461 receipt.verify_embedded().unwrap();
3462 assert!(matches!(
3463 receipt.body.subject,
3464 pv_receipts::ReceiptSubject::EventChain { event_count: 1, .. }
3465 ));
3466 assert_eq!(receipt.body.cost.prompt_tokens, 100);
3467 let events = bridge.events.lock();
3468 assert_eq!(events.len(), 3, "acknowledged active event must not be replayed");
3469 assert_eq!(events[0].2["metadata"]["sequence"], 0);
3470 assert_eq!(events[1].2["metadata"]["sequence"], 1);
3471 assert_eq!(events[2].2["metadata"]["sequence"], 2);
3472 assert!(!directory.path().join(format!("{stem}.events.ndjson")).exists());
3473 }
3474
3475 #[tokio::test]
3476 async fn recovery_quarantines_only_the_incomplete_signed_session() {
3477 let directory = tempfile::tempdir().unwrap();
3478 let bridge = Arc::new(RecordingBus::default());
3479 let metrics = Arc::new(Registry::new());
3480 let signer = Arc::new(pv_receipts::Ed25519Signer::from_seed(&[27; 32]));
3481 let journal_key = crate::journal::key_from_signer(signer.as_ref());
3482 let worker = spawn_worker_with_spool_authenticated(
3483 8,
3484 bridge.clone(),
3485 Arc::new(HashEmbedder::default()),
3486 Arc::new(NoopVectorSink),
3487 Some(directory.path().to_path_buf()),
3488 journal_key,
3489 Arc::clone(&metrics),
3490 None,
3491 );
3492 let incomplete = Arc::new(Session::new(
3493 "incomplete-signed".to_owned(),
3494 Workflow::Signed,
3495 session(Workflow::Signed).current_identity(),
3496 BreakerConfig::default(),
3497 ));
3498 let complete = Arc::new(Session::new(
3499 "complete-signed".to_owned(),
3500 Workflow::Signed,
3501 session(Workflow::Signed).current_identity(),
3502 BreakerConfig::default(),
3503 ));
3504 let mut incomplete_request = job(Arc::clone(&incomplete));
3505 incomplete_request.response_attempt = Some(ResponseAttempt {
3506 id: "incomplete-attempt".to_owned(),
3507 terminal: false,
3508 });
3509 worker.submit_and_wait(incomplete_request).await.unwrap();
3510 worker.submit_and_wait(job(Arc::clone(&complete))).await.unwrap();
3511
3512 let registry = crate::session::SessionRegistry::new();
3513 let finalizer = crate::reconciler::Finalizer::with_bridge(
3514 signer,
3515 directory.path().to_path_buf(),
3516 metrics,
3517 bridge,
3518 );
3519 assert_eq!(
3520 finalizer
3521 .recover_spooled_sessions(®istry, &Default::default())
3522 .await
3523 .unwrap(),
3524 2
3525 );
3526 let quarantined = registry.get("incomplete-signed").unwrap();
3527 assert!(quarantined.capture_failed());
3528 assert!(quarantined.receipt.lock().is_none());
3529 assert!(registry.get("complete-signed").unwrap().receipt.lock().is_some());
3530 }
3531
3532 #[tokio::test]
3542 async fn recovery_quarantines_the_incomplete_unsigned_session_in_place() {
3543 let directory = tempfile::tempdir().unwrap();
3544 let bridge = Arc::new(RecordingBus::default());
3545 let metrics = Arc::new(Registry::new());
3546 let signer = Arc::new(pv_receipts::Ed25519Signer::from_seed(&[29; 32]));
3547 let journal_key = crate::journal::key_from_signer(signer.as_ref());
3548 let worker = spawn_worker_with_spool_authenticated(
3549 4,
3550 bridge.clone(),
3551 Arc::new(HashEmbedder::default()),
3552 Arc::new(NoopVectorSink),
3553 Some(directory.path().to_path_buf()),
3554 journal_key,
3555 Arc::clone(&metrics),
3556 None,
3557 );
3558 let active = session(Workflow::Unsigned);
3559 let mut incomplete_request = job(Arc::clone(&active));
3560 incomplete_request.response_attempt = Some(ResponseAttempt {
3561 id: "incomplete-attempt".to_owned(),
3562 terminal: false,
3563 });
3564 worker.submit_and_wait(incomplete_request).await.unwrap();
3565
3566 let registry = crate::session::SessionRegistry::new();
3567 let finalizer = crate::reconciler::Finalizer::with_bridge(
3568 signer,
3569 directory.path().to_path_buf(),
3570 metrics,
3571 bridge,
3572 );
3573 finalizer
3574 .recover_spooled_sessions(®istry, &Default::default())
3575 .await
3576 .unwrap();
3577 let quarantined = registry
3578 .get(&active.id)
3579 .expect("quarantined unsigned session must stay registered");
3580 assert!(quarantined.capture_failed());
3581 assert!(
3582 quarantined.is_closed() && quarantined.artifact_committed_flag(),
3583 "quarantine must be sealed so the idle sweeper skips it"
3584 );
3585 assert_eq!(
3586 quarantined.totals.prompt_tokens.load(Ordering::Acquire),
3587 100,
3588 "folded journal totals must land on the quarantined session"
3589 );
3590 finalizer
3593 .recover_spooled_sessions(®istry, &Default::default())
3594 .await
3595 .unwrap();
3596 let still = registry
3597 .get(&active.id)
3598 .expect("second pass must not evict the quarantined session");
3599 assert!(still.capture_failed());
3600 }
3601
3602 #[tokio::test]
3603 async fn tampered_signed_journal_is_never_turned_into_a_receipt() {
3604 let directory = tempfile::tempdir().unwrap();
3605 let signer = Arc::new(pv_receipts::Ed25519Signer::from_seed(&[18; 32]));
3606 let journal_key = crate::journal::key_from_signer(signer.as_ref());
3607 let worker = spawn_worker_with_spool_authenticated(
3608 4,
3609 Arc::new(RecordingBus::default()),
3610 Arc::new(HashEmbedder::default()),
3611 Arc::new(NoopVectorSink),
3612 Some(directory.path().to_path_buf()),
3613 journal_key,
3614 Arc::new(Registry::new()),
3615 None,
3616 );
3617 let active = session(Workflow::Signed);
3618 worker.submit_and_wait(job(Arc::clone(&active))).await.unwrap();
3619 let digest = pv_core::digest::sha256_hex(active.id.as_bytes());
3620 let stem = digest.get(..32).unwrap();
3621 let journal_path = directory.path().join(format!("{stem}.events.ndjson"));
3622 let mut envelope: Value =
3623 serde_json::from_str(std::fs::read_to_string(&journal_path).unwrap().trim()).unwrap();
3624 envelope["payload"]["prompt_tokens"] = serde_json::json!(999_999);
3625 std::fs::write(
3626 &journal_path,
3627 format!("{}\n", serde_json::to_string(&envelope).unwrap()),
3628 )
3629 .unwrap();
3630 let registry = crate::session::SessionRegistry::new();
3631 let finalizer = crate::reconciler::Finalizer::new(
3632 signer,
3633 directory.path().to_path_buf(),
3634 Arc::new(Registry::new()),
3635 );
3636 let outcome = finalizer
3647 .recover_spooled_sessions(®istry, &Default::default())
3648 .await;
3649 assert!(
3650 outcome.is_ok(),
3651 "per-session HMAC failures warn+continue instead of propagating, got {outcome:?}"
3652 );
3653 assert!(registry.get(&active.id).is_none());
3657 }
3658
3659 #[tokio::test]
3660 async fn signed_restart_reuses_receipt_persisted_before_journal_cleanup() {
3661 let directory = tempfile::tempdir().unwrap();
3662 let signer = Arc::new(pv_receipts::Ed25519Signer::from_seed(&[15; 32]));
3663 let journal_key = crate::journal::key_from_signer(signer.as_ref());
3664 let worker = spawn_worker_with_spool_authenticated(
3665 4,
3666 Arc::new(RecordingBus::default()),
3667 Arc::new(HashEmbedder::default()),
3668 Arc::new(NoopVectorSink),
3669 Some(directory.path().to_path_buf()),
3670 journal_key,
3671 Arc::new(Registry::new()),
3672 None,
3673 );
3674 let active = session(Workflow::Signed);
3675 worker.submit_and_wait(job(Arc::clone(&active))).await.unwrap();
3676 let digest = pv_core::digest::sha256_hex(active.id.as_bytes());
3677 let stem = digest.get(..32).unwrap();
3678 let metadata_path = directory.path().join(format!("{stem}.session.json"));
3679 let journal_path = directory.path().join(format!("{stem}.events.ndjson"));
3680 let metadata = std::fs::read(&metadata_path).unwrap();
3681 let journal = std::fs::read(&journal_path).unwrap();
3682 let ack_path = directory.path().join(format!("{stem}.acks.ndjson"));
3683 let ack = std::fs::read(&ack_path).unwrap();
3684 let first_finalizer = crate::reconciler::Finalizer::new(
3685 signer.clone(),
3686 directory.path().to_path_buf(),
3687 Arc::new(Registry::new()),
3688 );
3689 let crate::reconciler::FinalizeOutcome::Receipt { receipt: first } = first_finalizer
3690 .close_session(active, pv_events::StopReason::SessionClosed)
3691 .await
3692 .unwrap()
3693 else {
3694 panic!("expected signed receipt")
3695 };
3696
3697 std::fs::write(&metadata_path, metadata).unwrap();
3698 std::fs::write(&journal_path, journal).unwrap();
3699 std::fs::write(&ack_path, ack).unwrap();
3700 let registry = crate::session::SessionRegistry::new();
3701 let after_restart = crate::reconciler::Finalizer::new(
3702 signer,
3703 directory.path().to_path_buf(),
3704 Arc::new(Registry::new()),
3705 );
3706 assert_eq!(
3707 after_restart
3708 .recover_spooled_sessions(®istry, &Default::default())
3709 .await
3710 .unwrap(),
3711 1
3712 );
3713 let recovered = registry.get("session-1").unwrap();
3714 assert_eq!(
3715 recovered.receipt.lock().as_ref().unwrap().body.receipt_id,
3716 first.body.receipt_id
3717 );
3718 }
3719
3720 #[tokio::test]
3721 async fn unsigned_job_preserves_required_atif_metrics() {
3722 let bridge = Arc::new(RecordingBus::default());
3726 let directory = tempfile::tempdir().unwrap();
3727 let journal_key = [4u8; 32];
3728 let worker = spawn_worker_with_spool_authenticated(
3729 4,
3730 bridge,
3731 Arc::new(HashEmbedder::default()),
3732 Arc::new(NoopVectorSink),
3733 Some(directory.path().to_path_buf()),
3734 journal_key,
3735 Arc::new(Registry::new()),
3736 None,
3737 );
3738 let session = session(Workflow::Unsigned);
3739 worker.submit_and_wait(job(Arc::clone(&session))).await.unwrap();
3740
3741 assert_eq!(session.atif_steps_count(), 1);
3742 assert!(
3743 session.atif.lock().is_empty(),
3744 "steps must not be retained in RAM (RAM-cliff guard)"
3745 );
3746 let digest = pv_core::digest::sha256_hex(session.id.as_bytes());
3747 let stem = digest.get(..32).unwrap();
3748 let journal =
3749 std::fs::read_to_string(directory.path().join(format!("{stem}.events.ndjson"))).unwrap();
3750 let line = journal.lines().next().unwrap();
3751 let record: ActiveJournalRecord = crate::journal::open(
3752 &journal_key,
3753 &format!("{}:active", session.id),
3754 0,
3755 line.as_bytes(),
3756 )
3757 .unwrap();
3758 let step = record.atif_step.unwrap();
3759 assert_eq!(step.metrics.as_ref().unwrap().cached_tokens, Some(40));
3760 assert_eq!(session.totals.cached_tokens.load(Ordering::Acquire), 0);
3761 }
3762
3763 #[tokio::test]
3764 async fn unsigned_job_journal_survives_restart_and_promotes() {
3765 let directory = tempfile::tempdir().unwrap();
3766 let bridge = Arc::new(RecordingBus::default());
3767 let metrics = Arc::new(Registry::new());
3768 let signer = Arc::new(pv_receipts::Ed25519Signer::from_seed(&[13; 32]));
3769 let journal_key = crate::journal::key_from_signer(signer.as_ref());
3770 let worker = spawn_worker_with_spool_authenticated(
3771 4,
3772 bridge.clone(),
3773 Arc::new(HashEmbedder::default()),
3774 Arc::new(NoopVectorSink),
3775 Some(directory.path().to_path_buf()),
3776 journal_key,
3777 Arc::clone(&metrics),
3778 None,
3779 );
3780 let active = session(Workflow::Unsigned);
3781 worker.submit_and_wait(job(Arc::clone(&active))).await.unwrap();
3782 let mut tool = job(Arc::clone(&active));
3783 tool.class = EventClass::ToolCall;
3784 tool.status = StatusId::Failure;
3785 tool.stop_reason = Some(StopReason::PolicyBlocked);
3786 tool.metrics = EventMetrics::default();
3787 tool.cost_usd_micros = 0;
3788 tool.atif.as_mut().unwrap().llm_call_count = Some(0);
3789 worker.submit_and_wait(tool).await.unwrap();
3790 assert_eq!(bridge.events.lock().len(), 2);
3791
3792 let registry = crate::session::SessionRegistry::new();
3793 let finalizer = crate::reconciler::Finalizer::with_bridge(
3794 signer,
3795 directory.path().to_path_buf(),
3796 metrics,
3797 bridge.clone(),
3798 );
3799 assert_eq!(
3800 finalizer
3801 .recover_spooled_sessions(®istry, &Default::default())
3802 .await
3803 .unwrap(),
3804 1
3805 );
3806 let recovered = registry.get(&active.id).unwrap();
3807 assert!(recovered.atif_path.lock().as_ref().unwrap().exists());
3808 assert_eq!(
3809 bridge.events.lock().len(),
3810 2,
3811 "acknowledged unsigned events must not replay"
3812 );
3813 assert_eq!(recovered.current_identity().ttl_remaining_s, Some(600));
3814 let receipt = finalizer.promote(recovered).await.unwrap();
3815 receipt.verify_embedded().unwrap();
3816 assert_eq!(receipt.body.cost.prompt_tokens, 100);
3817 assert_eq!(receipt.body.tool_calls.total, 1);
3818 assert_eq!(receipt.body.tool_calls.blocked, 1);
3819 assert_eq!(receipt.body.stop_reason_id, StopReason::PolicyBlocked.id());
3820 assert!(matches!(
3821 receipt.body.subject,
3822 pv_receipts::ReceiptSubject::AtifTrajectory {
3823 step_count: 2,
3824 retroactive: true,
3825 ..
3826 }
3827 ));
3828 }
3829
3830 #[tokio::test]
3831 async fn full_queue_is_counted_without_blocking() {
3832 let metrics = Arc::new(Registry::new());
3833 let (sender, _receiver) = mpsc::channel(1);
3834 let worker = WorkerHandle {
3835 senders: Arc::new(vec![sender]),
3836 metrics: Arc::clone(&metrics),
3837 pending: Arc::new(std::sync::atomic::AtomicU64::new(0)),
3838 drained: Arc::new(tokio::sync::Notify::new()),
3839 capacity: Arc::new(tokio::sync::Semaphore::new(1)),
3840 response_capacity: Arc::new(tokio::sync::Semaphore::new(1)),
3841 };
3842 worker.try_submit(job(session(Workflow::Signed))).unwrap();
3843 assert_eq!(
3844 worker.try_submit(job(session(Workflow::Signed))),
3845 Err(SubmitError::Full)
3846 );
3847 assert!(metrics
3848 .render()
3849 .contains("pv_events_dropped_total{stage=\"worker_queue\"} 1"));
3850 }
3851
3852 #[tokio::test]
3857 async fn worker_queue_and_response_slot_counters_are_distinct() {
3858 let metrics = Arc::new(Registry::new());
3859 let (sender, _receiver) = mpsc::channel(1);
3860 let worker = WorkerHandle {
3861 senders: Arc::new(vec![sender]),
3862 metrics: Arc::clone(&metrics),
3863 pending: Arc::new(std::sync::atomic::AtomicU64::new(0)),
3864 drained: Arc::new(tokio::sync::Notify::new()),
3865 capacity: Arc::new(tokio::sync::Semaphore::new(1)),
3866 response_capacity: Arc::new(tokio::sync::Semaphore::new(1)),
3867 };
3868 let first = worker.try_reserve_pair("s").expect("first pair");
3871 assert_eq!(worker.try_reserve_pair("s").err(), Some(SubmitError::Full));
3874 let rendered = metrics.render();
3875 assert!(
3876 rendered.contains("pv_events_dropped_total{stage=\"worker_queue\"} 1"),
3877 "expected worker_queue counter increment, got:\n{rendered}"
3878 );
3879 assert!(
3880 !rendered.contains("pv_events_dropped_total{stage=\"response_slot\"}"),
3881 "response_slot must not have been touched when worker_queue exhausts first, got:\n{rendered}"
3882 );
3883 drop(first.worker);
3887 let _still_holding_response = first.response;
3888 assert_eq!(worker.try_reserve_pair("s").err(), Some(SubmitError::Full));
3889 let rendered = metrics.render();
3890 assert!(
3891 rendered.contains("pv_events_dropped_total{stage=\"response_slot\"} 1"),
3892 "expected response_slot counter increment, got:\n{rendered}"
3893 );
3894 assert!(
3895 rendered.contains("pv_events_dropped_total{stage=\"worker_queue\"} 1"),
3896 "worker_queue must remain at 1 (only response stage exhausted this time), got:\n{rendered}"
3897 );
3898 }
3899
3900 #[tokio::test]
3912 async fn response_permit_submit_succeeds_on_worker_capacity_exhaustion() {
3913 let bridge = Arc::new(RecordingBus::default());
3914 let metrics = Arc::new(Registry::new());
3915 let worker = spawn_worker(
3916 2,
3917 bridge.clone(),
3918 Arc::new(HashEmbedder::default()),
3919 Arc::clone(&metrics),
3920 );
3921 let permits = worker
3924 .try_reserve_pair("mid-stream-response")
3925 .expect("initial pair");
3926 drop(permits.worker);
3929 let hog_a = worker.try_reserve("hog-a").expect("hog-a");
3933 let hog_b = worker.try_reserve("hog-b").expect("hog-b");
3934 let job = job(session(Workflow::Signed));
3938 permits
3939 .response
3940 .submit(&worker, job)
3941 .expect("an admitted stream's capture must not be starved by worker admissions");
3942 let rendered = metrics.render();
3943 assert!(
3944 !rendered.contains("pv_events_dropped_total{stage=\"response_slot\"} 1"),
3945 "no response_slot drop: the reserved slot was used, not re-acquired; got:\n{rendered}"
3946 );
3947 assert!(
3948 !rendered.contains("pv_events_dropped_total{stage=\"worker_queue\"} 1"),
3949 "no worker_queue drop either; got:\n{rendered}"
3950 );
3951 drop(hog_a);
3952 drop(hog_b);
3953 worker.wait_idle().await;
3954 }
3955
3956 #[tokio::test]
3957 async fn one_shard_can_borrow_all_global_capacity() {
3958 const CAPACITY: usize = 16;
3959 let worker = spawn_worker(
3960 CAPACITY,
3961 Arc::new(RecordingBus::default()),
3962 Arc::new(HashEmbedder::default()),
3963 Arc::new(Registry::new()),
3964 );
3965 let partitions = u32::try_from(CAPACITY).unwrap();
3966 let target = pv_bridge::bus::partition_for("target", partitions);
3967 let session_ids: Vec<String> = (0..10_000)
3968 .map(|index| format!("same-shard-{index}"))
3969 .filter(|id| pv_bridge::bus::partition_for(id, partitions) == target)
3970 .take(CAPACITY)
3971 .collect();
3972 assert_eq!(session_ids.len(), CAPACITY);
3973 let permits: Vec<_> = session_ids
3974 .iter()
3975 .map(|id| worker.try_reserve(id).unwrap())
3976 .collect();
3977 assert!(matches!(
3978 worker.try_reserve("globally-full"),
3979 Err(SubmitError::Full)
3980 ));
3981 drop(permits);
3982 assert!(worker.try_reserve("capacity-released").is_ok());
3983 }
3984
3985 #[tokio::test]
3986 async fn panic_is_counted_and_supervisor_processes_next_job() {
3987 let bridge = Arc::new(RecordingBus::default());
3988 let metrics = Arc::new(Registry::new());
3989 let worker = spawn_worker_with_sink(
3990 4,
3991 bridge.clone(),
3992 Arc::new(HashEmbedder::default()),
3993 Arc::new(PanicOnceSink {
3994 panicked: AtomicBool::new(false),
3995 }),
3996 Arc::clone(&metrics),
3997 );
3998 let failed_session = session(Workflow::Signed);
3999 assert!(worker
4000 .submit_and_wait(job(Arc::clone(&failed_session)))
4001 .await
4002 .is_err());
4003 assert!(failed_session.capture_failed());
4004 worker
4005 .submit_and_wait(job(session(Workflow::Signed)))
4006 .await
4007 .unwrap();
4008 assert_eq!(bridge.events.lock().len(), 1);
4009 assert!(metrics.render().contains("pv_worker_panics_total 1"));
4010 }
4011
4012 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4015 async fn wait_idle_survives_notify_races_under_contention() {
4016 let bridge = Arc::new(RecordingBus::default());
4017 let worker = spawn_worker(
4018 64,
4019 bridge.clone(),
4020 Arc::new(HashEmbedder::default()),
4021 Arc::new(Registry::new()),
4022 );
4023 for _ in 0..64 {
4024 for _ in 0..4 {
4025 worker.try_submit(job(session(Workflow::Signed))).unwrap();
4026 }
4027 tokio::time::timeout(std::time::Duration::from_secs(5), worker.wait_idle())
4028 .await
4029 .expect("wait_idle deadlocked: notify_waiters was lost during subscribe/check race");
4030 }
4031 }
4032
4033 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4039 async fn notified_enable_check_await_pattern_is_race_free() {
4040 use std::sync::atomic::AtomicUsize;
4041 use tokio::sync::Notify;
4042
4043 for _ in 0..64 {
4044 let notify = Arc::new(Notify::new());
4045 let pending = Arc::new(AtomicUsize::new(1));
4046 let (check_done_tx, check_done_rx) = tokio::sync::oneshot::channel::<()>();
4047 let (notify_done_tx, notify_done_rx) = tokio::sync::oneshot::channel::<()>();
4048
4049 let n_c = Arc::clone(¬ify);
4050 let p_c = Arc::clone(&pending);
4051 let consumer = tokio::spawn(async move {
4052 let notified = n_c.notified();
4053 let mut notified = std::pin::pin!(notified);
4054 notified.as_mut().enable();
4055 assert_ne!(p_c.load(AtomicOrdering::Acquire), 0, "producer ran early");
4056 check_done_tx.send(()).unwrap();
4057 notify_done_rx.await.unwrap();
4058 notified.await;
4059 });
4060
4061 let n_p = Arc::clone(¬ify);
4062 let p_p = Arc::clone(&pending);
4063 let producer = tokio::spawn(async move {
4064 check_done_rx.await.unwrap();
4065 p_p.store(0, AtomicOrdering::Release);
4066 n_p.notify_waiters();
4067 notify_done_tx.send(()).unwrap();
4068 });
4069
4070 tokio::time::timeout(std::time::Duration::from_secs(2), async {
4071 producer.await.unwrap();
4072 consumer.await.unwrap();
4073 })
4074 .await
4075 .expect("consumer deadlocked: enable/await pattern violated");
4076 }
4077 }
4078
4079 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4088 async fn try_submit_returns_full_within_the_first_millisecond_when_saturated() {
4089 const CAPACITY: usize = 4;
4090 let worker = spawn_worker(
4091 CAPACITY,
4092 Arc::new(RecordingBus::default()),
4093 Arc::new(HashEmbedder::default()),
4094 Arc::new(Registry::new()),
4095 );
4096 let session = session(Workflow::Signed);
4098 let _held: Vec<_> = (0..CAPACITY)
4099 .map(|i| worker.try_reserve(&format!("hold-{i}")).unwrap())
4100 .collect();
4101 let started = std::time::Instant::now();
4102 let result = worker.try_submit(job(Arc::clone(&session)));
4103 let elapsed = started.elapsed();
4104 assert!(matches!(result, Err(SubmitError::Full)));
4105 assert!(
4106 elapsed < std::time::Duration::from_millis(1),
4107 "try_submit blocked for {elapsed:?} when saturated \
4108 (backpressure should be synchronous)",
4109 );
4110 }
4111
4112 #[tokio::test]
4117 async fn every_dropped_job_increments_the_drop_metric() {
4118 const CAPACITY: usize = 2;
4119 let bridge = Arc::new(RecordingBus::default());
4120 let metrics = Arc::new(Registry::new());
4121 let worker = spawn_worker(
4122 CAPACITY,
4123 bridge,
4124 Arc::new(HashEmbedder::default()),
4125 Arc::clone(&metrics),
4126 );
4127 let _hold: Vec<_> = (0..CAPACITY)
4128 .map(|i| worker.try_reserve(&format!("s-{i}")).unwrap())
4129 .collect();
4130 for _ in 0..17 {
4131 assert!(matches!(worker.try_reserve("x"), Err(SubmitError::Full)));
4132 }
4133 let rendered = metrics.render();
4134 assert!(
4135 rendered.contains("pv_events_dropped_total{stage=\"worker_queue\"} 17"),
4136 "expected 17 drops in metrics, got:\n{rendered}",
4137 );
4138 }
4139
4140 #[tokio::test]
4145 async fn capacity_is_reusable_after_permit_drop() {
4146 const CAPACITY: usize = 8;
4147 let worker = spawn_worker(
4148 CAPACITY,
4149 Arc::new(RecordingBus::default()),
4150 Arc::new(HashEmbedder::default()),
4151 Arc::new(Registry::new()),
4152 );
4153 for cycle in 0..1_000 {
4154 let permits: Vec<_> = (0..CAPACITY)
4155 .map(|i| worker.try_reserve(&format!("cycle-{cycle}-{i}")).unwrap())
4156 .collect();
4157 assert!(matches!(worker.try_reserve("overflow"), Err(SubmitError::Full)));
4158 drop(permits);
4159 let recycled: Vec<_> = (0..CAPACITY)
4161 .map(|i| worker.try_reserve(&format!("recycle-{cycle}-{i}")).unwrap())
4162 .collect();
4163 drop(recycled);
4164 }
4165 }
4166
4167 #[test]
4173 fn partition_for_distributes_uniformly_across_shards() {
4174 const SHARDS: u32 = 16;
4175 const SAMPLES: u32 = 100_000;
4176 let mut hits = [0u32; SHARDS as usize];
4177 for i in 0..SAMPLES {
4178 let id = format!("session-{i}");
4179 let s = pv_bridge::bus::partition_for(&id, SHARDS) as usize;
4180 hits[s] += 1;
4181 }
4182 let expected = SAMPLES / SHARDS;
4183 for (shard, count) in hits.iter().enumerate() {
4184 let lower = expected * 9 / 10;
4185 let upper = expected * 11 / 10;
4186 assert!(
4187 (lower..=upper).contains(count),
4188 "shard {shard} got {count} hits, expected {expected} (10% band)",
4189 );
4190 }
4191 }
4192
4193 #[tokio::test]
4200 async fn every_shard_is_routable_at_capacity_one() {
4201 let worker = spawn_worker(
4202 1,
4203 Arc::new(RecordingBus::default()),
4204 Arc::new(HashEmbedder::default()),
4205 Arc::new(Registry::new()),
4206 );
4207 for target in 0..16u32 {
4210 let mut candidate = None;
4211 for i in 0..10_000u64 {
4212 let id = format!("probe-{target}-{i}");
4213 if pv_bridge::bus::partition_for(&id, 16) == target {
4214 candidate = Some(id);
4215 break;
4216 }
4217 }
4218 let id = candidate.unwrap_or_else(|| panic!("no id found for shard {target}"));
4219 let permit = worker
4220 .try_reserve(&id)
4221 .unwrap_or_else(|error| panic!("shard {target} not routable: {error:?}"));
4222 drop(permit);
4223 }
4224 }
4225
4226 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4235 async fn cancelling_submit_and_wait_never_leaks_pending() {
4236 const CAPACITY: usize = 4;
4237 let bridge = Arc::new(RecordingBus::default());
4238 let worker = Arc::new(spawn_worker(
4239 CAPACITY,
4240 bridge.clone(),
4241 Arc::new(HashEmbedder::default()),
4242 Arc::new(Registry::new()),
4243 ));
4244 let held: Vec<_> = (0..CAPACITY)
4248 .map(|i| worker.try_reserve(&format!("hold-{i}")).unwrap())
4249 .collect();
4250 for delay_us in [0u64, 50, 200, 800, 3_200] {
4251 let hostile_session = session(Workflow::Signed);
4252 let worker_for_task = Arc::clone(&worker);
4253 let task = tokio::spawn(async move {
4254 let _ = worker_for_task
4255 .submit_and_wait(job(Arc::clone(&hostile_session)))
4256 .await;
4257 });
4258 if delay_us > 0 {
4259 tokio::time::sleep(std::time::Duration::from_micros(delay_us)).await;
4260 }
4261 task.abort();
4262 let _ = task.await;
4263 }
4264 drop(held);
4265 tokio::time::timeout(std::time::Duration::from_secs(2), worker.wait_idle())
4266 .await
4267 .expect("wait_idle deadlocked — a canceled submit_and_wait leaked pending");
4268 }
4269
4270 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4277 async fn session_backlog_group_commits_in_order() {
4278 let directory = tempfile::tempdir().unwrap();
4279 let journal_key = [6u8; 32];
4280 let bus = Arc::new(GatedBus::new("batched-session"));
4281 let worker = spawn_worker_with_spool_authenticated(
4282 64,
4283 Arc::clone(&bus) as Arc<dyn EventBus>,
4284 Arc::new(HashEmbedder::default()),
4285 Arc::new(NoopVectorSink),
4286 Some(directory.path().to_path_buf()),
4287 journal_key,
4288 Arc::new(Registry::new()),
4289 None,
4290 );
4291 let session = session_with_id("batched-session");
4292 for n in 0..11 {
4298 let mut submitted = job(Arc::clone(&session));
4299 submitted.payload = serde_json::json!({"kind": "response", "burst": n});
4300 worker.try_submit(submitted).unwrap();
4301 }
4302 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
4303 bus.release.store(true, std::sync::atomic::Ordering::Release);
4304 session.wait_for_worker_jobs().await;
4305 assert!(!session.capture_failed(), "a batched burst must not fail capture");
4306
4307 let digest = pv_core::digest::sha256_hex(session.id.as_bytes());
4309 let stem = digest.get(..32).unwrap();
4310 let journal =
4311 std::fs::read_to_string(directory.path().join(format!("{stem}.events.ndjson"))).unwrap();
4312 let lines: Vec<&str> = journal.lines().collect();
4313 assert_eq!(lines.len(), 11, "every batched record must be journaled");
4314 for (index, line) in lines.iter().enumerate() {
4315 let record: ActiveJournalRecord = crate::journal::open(
4316 &journal_key,
4317 &format!("{}:active", session.id),
4318 index as u64,
4319 line.as_bytes(),
4320 )
4321 .unwrap();
4322 assert_eq!(
4323 record.event.pointer("/payload/burst").and_then(Value::as_u64),
4324 Some(index as u64),
4325 "journal record {index} must carry the {index}th submitted payload"
4326 );
4327 }
4328 let published = bus.published.lock();
4330 let bursts: Vec<u64> = published
4331 .iter()
4332 .filter_map(|text| {
4333 serde_json::from_str::<Value>(text)
4334 .ok()?
4335 .pointer("/payload/burst")
4336 .and_then(Value::as_u64)
4337 })
4338 .collect();
4339 assert_eq!(
4340 bursts,
4341 (0..11).collect::<Vec<_>>(),
4342 "publish order must match submission order"
4343 );
4344 }
4345
4346 fn session_with_id(id: &str) -> Arc<Session> {
4347 Arc::new(Session::new(
4348 id.to_owned(),
4349 Workflow::Signed,
4350 AgentIdentity {
4351 version: "1".to_owned(),
4352 charter: "test".into(),
4353 instance_uid: "instance-1".to_owned(),
4354 ttl_remaining_s: Some(600),
4355 },
4356 BreakerConfig {
4357 min_tokens: u64::MAX,
4358 ..BreakerConfig::default()
4359 },
4360 ))
4361 }
4362
4363 fn colliding_session_id(reference: &str, shard_count: usize) -> String {
4366 let target = pv_bridge::bus::partition_for(reference, u32::try_from(shard_count).unwrap());
4367 (0..)
4368 .map(|n| format!("victim-{n}"))
4369 .find(|candidate| {
4370 candidate != reference
4371 && pv_bridge::bus::partition_for(candidate, u32::try_from(shard_count).unwrap()) == target
4372 })
4373 .unwrap()
4374 }
4375
4376 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4381 async fn stalled_session_does_not_block_shard_neighbors() {
4382 let shard_count = std::thread::available_parallelism()
4383 .map(std::num::NonZero::get)
4384 .unwrap_or(16)
4385 .max(16);
4386 let stalled_id = "stalled-session".to_owned();
4387 let victim_id = colliding_session_id(&stalled_id, shard_count);
4388 let bus = Arc::new(GatedBus::new(&stalled_id));
4389 let worker = spawn_worker(
4390 64,
4391 Arc::clone(&bus) as Arc<dyn EventBus>,
4392 Arc::new(HashEmbedder::default()),
4393 Arc::new(Registry::new()),
4394 );
4395 let stalled = session_with_id(&stalled_id);
4396 let victim = session_with_id(&victim_id);
4397
4398 let stalled_wait = {
4399 let worker = worker.clone();
4400 let job = job(Arc::clone(&stalled));
4401 tokio::spawn(async move { worker.submit_and_wait(job).await })
4402 };
4403 tokio::time::timeout(
4405 std::time::Duration::from_secs(5),
4406 worker.submit_and_wait(job(Arc::clone(&victim))),
4407 )
4408 .await
4409 .expect("victim session must not be head-of-line blocked by a stalled shard neighbor")
4410 .unwrap();
4411 assert!(
4412 !stalled_wait.is_finished(),
4413 "the stalled session must still be gated (the victim did not just win a race)"
4414 );
4415 bus.release.store(true, Ordering::Release);
4416 stalled_wait.await.unwrap().unwrap();
4417 }
4418
4419 fn read_redaction_test_record(spool: &std::path::Path, session: &Session) -> ActiveJournalRecord {
4420 let digest = pv_core::digest::sha256_hex(session.id.as_bytes());
4421 let stem = digest.get(..32).unwrap();
4422 let contents = std::fs::read_to_string(spool.join(format!("{stem}.events.ndjson"))).unwrap();
4423 assert_eq!(contents.lines().count(), 1, "exactly one event must be journaled");
4424 crate::journal::open(
4425 &[0; 32],
4426 &format!("{}:active", session.id),
4427 0,
4428 contents.trim().as_bytes(),
4429 )
4430 .unwrap()
4431 }
4432
4433 #[tokio::test]
4434 async fn redaction_engine_strips_sensitive_payload_on_spool() {
4435 let spool = tempfile::tempdir().unwrap();
4436 let bus = Arc::new(RecordingBus::default());
4437 let engine = pv_redact::RedactionEngine::new(pv_redact::RedactionConfig {
4438 include_builtin_patterns: true,
4439 ..pv_redact::RedactionConfig::default()
4440 })
4441 .unwrap();
4442 let worker = spawn_worker_with_spool_authenticated(
4443 4,
4444 Arc::clone(&bus) as Arc<dyn EventBus>,
4445 Arc::new(HashEmbedder::default()),
4446 Arc::new(NoopVectorSink),
4447 Some(spool.path().to_owned()),
4448 [0; 32],
4449 Arc::new(Registry::new()),
4450 Some(Arc::new(engine)),
4451 );
4452 let sess = session(Workflow::Unsigned);
4453 let mut j = job(Arc::clone(&sess));
4454 j.payload = serde_json::json!({
4455 "kind": "tool_response",
4456 "api_key": "sk-BBBBBBBBBBBBBBBBBBBBBB"
4457 });
4458 worker.submit_and_wait(j).await.unwrap();
4459 let record = read_redaction_test_record(spool.path(), &sess);
4460 assert_eq!(record.event["payload"]["api_key"], "[REDACTED]");
4461 assert_eq!(record.event["payload"]["kind"], "tool_response");
4462 }
4463
4464 #[tokio::test]
4465 async fn redaction_engine_strips_sensitive_payload_on_bus() {
4466 let bus = Arc::new(RecordingBus::default());
4467 let engine = pv_redact::RedactionEngine::new(pv_redact::RedactionConfig {
4468 include_builtin_patterns: true,
4469 ..pv_redact::RedactionConfig::default()
4470 })
4471 .unwrap();
4472 let worker = spawn_worker_with_spool_authenticated(
4473 4,
4474 Arc::clone(&bus) as Arc<dyn EventBus>,
4475 Arc::new(HashEmbedder::default()),
4476 Arc::new(NoopVectorSink),
4477 None,
4478 [0; 32],
4479 Arc::new(Registry::new()),
4480 Some(Arc::new(engine)),
4481 );
4482 let sess = session(Workflow::Unsigned);
4483 let mut j = job(Arc::clone(&sess));
4484 j.payload = serde_json::json!({
4485 "kind": "tool_response",
4486 "api_key": "sk-AAAAAAAAAAAAAAAAAAAAAA"
4487 });
4488 worker.submit_and_wait(j).await.unwrap();
4489 let events = bus.events.lock();
4490 let published = events
4491 .iter()
4492 .find(|(_, _, v)| v.pointer("/payload").is_some())
4493 .expect("at least one event with a payload must reach the bus");
4494 let payload = &published.2["payload"];
4495 assert_eq!(payload["api_key"], "[REDACTED]");
4496 }
4497
4498 #[tokio::test]
4499 async fn redaction_engine_strips_sensitive_atif_step_fields() {
4500 let spool = tempfile::tempdir().unwrap();
4501 let bus = Arc::new(RecordingBus::default());
4502 let engine = pv_redact::RedactionEngine::new(pv_redact::RedactionConfig {
4503 include_builtin_patterns: true,
4504 ..pv_redact::RedactionConfig::default()
4505 })
4506 .unwrap();
4507 let worker = spawn_worker_with_spool_authenticated(
4508 4,
4509 Arc::clone(&bus) as Arc<dyn EventBus>,
4510 Arc::new(HashEmbedder::default()),
4511 Arc::new(NoopVectorSink),
4512 Some(spool.path().to_owned()),
4513 [0; 32],
4514 Arc::new(Registry::new()),
4515 Some(Arc::new(engine)),
4516 );
4517 let sess = session(Workflow::Unsigned);
4518 let mut j = job(Arc::clone(&sess));
4519 j.atif = Some(AtifCapture {
4520 source: pv_atif::Source::Agent,
4521 message: serde_json::json!("The API key is sk-CCCCCCCCCCCCCCCCCCCCCC"),
4522 reasoning_content: Some("User leaked sk-DDDDDDDDDDDDDDDDDDDDDD in context".into()),
4523 model_name: None,
4524 tool_calls: Some(vec![pv_atif::ToolCall {
4525 tool_call_id: "call-1".into(),
4526 function_name: "send".into(),
4527 arguments: serde_json::json!({
4528 "key": "sk-proj-AbCdEf0123456789_-",
4529 "card": 4_111_111_111_111_111_u64,
4530 "share_with": { "alice@example.com": "editor" }
4531 }),
4532 extra: None,
4533 }]),
4534 observation: Some(pv_atif::Observation {
4535 results: vec![pv_atif::ObservationResult {
4536 source_call_id: Some("call-1".into()),
4537 content: Some(serde_json::json!("result: sk-EEEEEEEEEEEEEEEEEEEEEE")),
4538 subagent_trajectory_ref: None,
4539 extra: None,
4540 }],
4541 }),
4542 llm_call_count: Some(1),
4543 });
4544 worker.submit_and_wait(j).await.unwrap();
4545 let record = read_redaction_test_record(spool.path(), &sess);
4546 let step = record.atif_step.unwrap();
4547 assert_eq!(step.message, "The API key is [REDACTED]");
4548 assert_eq!(
4549 step.reasoning_content.as_deref(),
4550 Some("User leaked [REDACTED] in context")
4551 );
4552 assert_eq!(
4553 step.observation.unwrap().results[0].content,
4554 Some(serde_json::json!("result: [REDACTED]"))
4555 );
4556 assert_eq!(
4557 step.tool_calls.unwrap()[0].arguments,
4558 serde_json::json!({
4559 "key": "[REDACTED]",
4560 "card": "[REDACTED]",
4561 "share_with": { "[REDACTED]": "editor" }
4562 })
4563 );
4564 }
4565
4566 #[tokio::test]
4567 async fn redaction_engine_removes_paths_from_serialized_tool_payload_copies() {
4568 let spool = tempfile::tempdir().unwrap();
4569 let bus = Arc::new(RecordingBus::default());
4570 let engine = pv_redact::RedactionEngine::new(pv_redact::RedactionConfig {
4571 pointer_paths: vec!["/reason".into()],
4572 include_builtin_patterns: false,
4573 ..pv_redact::RedactionConfig::default()
4574 })
4575 .unwrap();
4576 let worker = spawn_worker_with_spool_authenticated(
4577 4,
4578 Arc::clone(&bus) as Arc<dyn EventBus>,
4579 Arc::new(HashEmbedder::default()),
4580 Arc::new(NoopVectorSink),
4581 Some(spool.path().to_owned()),
4582 [0; 32],
4583 Arc::new(Registry::new()),
4584 Some(Arc::new(engine)),
4585 );
4586 let sess = session(Workflow::Unsigned);
4587 let mut j = job(Arc::clone(&sess));
4588 j.class = EventClass::ToolCall;
4589 j.analyze_loop = false;
4590 j.status = StatusId::Failure;
4591 j.payload =
4592 serde_json::json!({"tool": "transfer", "allowed": false, "reason": "private denial detail"});
4593 j.atif.as_mut().unwrap().observation = Some(pv_atif::Observation {
4594 results: vec![
4595 pv_atif::ObservationResult {
4596 source_call_id: Some("call-1".into()),
4597 content: Some(Value::String(j.payload.to_string())),
4598 subagent_trajectory_ref: None,
4599 extra: None,
4600 },
4601 pv_atif::ObservationResult {
4602 source_call_id: Some("unrelated".into()),
4603 content: Some(Value::String(r#"{"reason":"unrelated tool data"}"#.into())),
4604 subagent_trajectory_ref: None,
4605 extra: None,
4606 },
4607 ],
4608 });
4609 worker.submit_and_wait(j).await.unwrap();
4610 let record = read_redaction_test_record(spool.path(), &sess);
4611 assert_eq!(record.event["payload"]["reason"], "[REDACTED]");
4612 let results = record.atif_step.unwrap().observation.unwrap().results;
4613 let copied_payload: Value =
4614 serde_json::from_str(results[0].content.as_ref().unwrap().as_str().unwrap()).unwrap();
4615 assert_eq!(copied_payload, record.event["payload"]);
4616 assert_eq!(
4617 results[1].content,
4618 Some(Value::String(r#"{"reason":"unrelated tool data"}"#.into()))
4619 );
4620 assert_eq!(bus.events.lock()[0].2["payload"], copied_payload);
4621 }
4622
4623 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
4627 async fn per_session_ordering_is_preserved() {
4628 let bus = Arc::new(RecordingBus::default());
4629 let worker = spawn_worker(
4630 64,
4631 Arc::clone(&bus) as Arc<dyn EventBus>,
4632 Arc::new(HashEmbedder::default()),
4633 Arc::new(Registry::new()),
4634 );
4635 let session = session_with_id("ordered-session");
4636 for n in 0..8u64 {
4637 let mut ordered = job(Arc::clone(&session));
4638 ordered.payload = serde_json::json!({"kind": "response", "step": n});
4639 worker.submit_and_wait(ordered).await.unwrap();
4640 }
4641 let events = bus.events.lock();
4642 let steps: Vec<u64> = events
4643 .iter()
4644 .filter_map(|(_, _, value)| value.pointer("/payload/step").and_then(Value::as_u64))
4645 .collect();
4646 assert_eq!(
4647 steps,
4648 (0..8).collect::<Vec<_>>(),
4649 "per-session publish order must match submission order"
4650 );
4651 }
4652}
4653
4654#[cfg(test)]
4655mod unified_fold_tests {
4656 #![allow(clippy::unwrap_used, clippy::panic)]
4657
4658 use super::*;
4659
4660 fn record(
4661 tool: (u64, u64, u64),
4662 prompt: u64,
4663 completion: u64,
4664 cached: u64,
4665 cost: u64,
4666 ) -> ActiveJournalRecord {
4667 ActiveJournalRecord {
4668 event: serde_json::json!({"k": "v"}),
4669 identity: pv_events::AgentIdentity {
4670 version: "1".to_owned(),
4671 charter: "test".into(),
4672 instance_uid: "i".to_owned(),
4673 ttl_remaining_s: None,
4674 },
4675 atif_step: None,
4676 tool_calls: tool.0,
4677 tool_allowed: tool.1,
4678 tool_blocked: tool.2,
4679 prompt_tokens: prompt,
4680 completion_tokens: completion,
4681 cached_tokens: cached,
4682 cost_usd_micros: cost,
4683 prompt_token_correction: 0,
4684 stop_reason_id: None,
4685 response_attempt: None,
4686 }
4687 }
4688
4689 #[test]
4697 fn unified_fold_matches_live_path() {
4698 let records = [
4703 record((0, 0, 0), 4_822, 0, 0, 0),
4704 record((0, 0, 0), 0, 319, 12, 84_340),
4705 record((1, 1, 0), 0, 0, 0, 0),
4706 record((1, 0, 1), 0, 0, 0, 0),
4707 record((1, 0, 0), 0, 0, 0, 0),
4708 record((0, 0, 0), 0, 0, 0, 0),
4709 ];
4710
4711 let mut folded = RecoveredTotals::default();
4713 for r in &records {
4714 r.fold_into(&mut folded).unwrap();
4715 }
4716 folded.validate_tool_accounting().unwrap();
4717
4718 let live = crate::session::Totals::default();
4720 for r in &records {
4721 r.apply_to_totals(&live).unwrap();
4722 }
4723
4724 use std::sync::atomic::Ordering;
4725 assert_eq!(folded.tool_calls, live.tool_calls.load(Ordering::Acquire));
4726 assert_eq!(folded.tool_allowed, live.tool_allowed.load(Ordering::Acquire));
4727 assert_eq!(folded.tool_blocked, live.tool_blocked.load(Ordering::Acquire));
4728 assert_eq!(folded.prompt_tokens, live.prompt_tokens.load(Ordering::Acquire));
4729 assert_eq!(
4730 folded.completion_tokens,
4731 live.completion_tokens.load(Ordering::Acquire)
4732 );
4733 assert_eq!(folded.cached_tokens, live.cached_tokens.load(Ordering::Acquire));
4734 assert_eq!(
4735 folded.cost_usd_micros,
4736 live.cost_usd_micros.load(Ordering::Acquire)
4737 );
4738 assert_eq!(folded.prompt_tokens, 4_822);
4739 assert_eq!(folded.tool_calls, 3);
4740 }
4741
4742 #[test]
4746 fn both_folds_refuse_jcs_overflow() {
4747 let poisoned = record((0, 0, 0), pv_core::error::JCS_SAFE_MAX, 0, 0, 0);
4748 let mut folded = RecoveredTotals::default();
4749 poisoned.fold_into(&mut folded).unwrap();
4750 assert!(
4751 record((0, 0, 0), 1, 0, 0, 0).fold_into(&mut folded).is_err(),
4752 "recovery fold must refuse crossing JCS_SAFE_MAX"
4753 );
4754
4755 let live = crate::session::Totals::default();
4756 poisoned.apply_to_totals(&live).unwrap();
4757 assert!(
4758 record((0, 0, 0), 1, 0, 0, 0).apply_to_totals(&live).is_err(),
4759 "live fold must refuse crossing JCS_SAFE_MAX"
4760 );
4761 }
4762
4763 #[test]
4769 fn correction_applies_identically_and_refuses_underflow() {
4770 let mut charged = record((0, 0, 0), 30, 0, 0, 0);
4772 let mut terminal = record((0, 0, 0), 0, 5, 0, 100);
4773 terminal.prompt_token_correction = -20;
4774
4775 let mut folded = RecoveredTotals::default();
4776 charged.fold_into(&mut folded).unwrap();
4777 terminal.fold_into(&mut folded).unwrap();
4778 assert_eq!(folded.prompt_tokens, 10);
4779
4780 let live = crate::session::Totals::default();
4781 charged.apply_to_totals(&live).unwrap();
4782 terminal.apply_to_totals(&live).unwrap();
4783 use std::sync::atomic::Ordering;
4784 assert_eq!(live.prompt_tokens.load(Ordering::Acquire), 10);
4785
4786 charged = record((0, 0, 0), 5, 0, 0, 0);
4789 terminal.prompt_token_correction = -20;
4790 let mut folded = RecoveredTotals::default();
4791 charged.fold_into(&mut folded).unwrap();
4792 assert!(
4793 terminal.fold_into(&mut folded).is_err(),
4794 "recovery fold must refuse correction underflow"
4795 );
4796 let live = crate::session::Totals::default();
4797 charged.apply_to_totals(&live).unwrap();
4798 assert!(
4799 terminal.apply_to_totals(&live).is_err(),
4800 "live fold must refuse correction underflow"
4801 );
4802 }
4803}
4804
4805#[cfg(test)]
4806mod ack_journal_tests {
4807 #![allow(clippy::unwrap_used)]
4808
4809 use super::*;
4810
4811 #[tokio::test]
4816 async fn ack_journal_roundtrips_tolerates_torn_tail_and_reads_legacy() {
4817 let directory = tempfile::tempdir().unwrap();
4818 let key = [9u8; 32];
4819 let ack = PublishAck {
4820 topic: "t".to_owned(),
4821 partition: 0,
4822 offset: 7,
4823 };
4824 persist_broker_ack(directory.path(), "s1", "evt-1", &ack, &key)
4825 .await
4826 .unwrap();
4827 persist_broker_ack(directory.path(), "s1", "evt-2", &ack, &key)
4828 .await
4829 .unwrap();
4830 let journal = ack_journal_path(directory.path(), "s1");
4832 let mut bytes = std::fs::read(&journal).unwrap();
4833 bytes.extend_from_slice(b"{\"torn");
4834 std::fs::write(&journal, bytes).unwrap();
4835
4836 let found = read_broker_ack(directory.path(), "s1", "evt-1", &key)
4837 .await
4838 .unwrap();
4839 assert_eq!(found.map(|ack| ack.offset), Some(7));
4840 assert!(
4841 read_broker_ack(directory.path(), "s1", "missing", &key)
4842 .await
4843 .unwrap()
4844 .is_none(),
4845 "an unknown event has no ack (torn tail must not error the scan)"
4846 );
4847
4848 let legacy = broker_ack_path(directory.path(), "s2", "evt-9");
4850 std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
4851 let record = BrokerAckRecord {
4852 session_id: "s2".to_owned(),
4853 event_uid: "evt-9".to_owned(),
4854 ack: ack.clone(),
4855 };
4856 let sealed = crate::journal::seal(&key, "broker-ack", 0, &record).unwrap();
4857 std::fs::write(&legacy, sealed).unwrap();
4858 let found = read_broker_ack(directory.path(), "s2", "evt-9", &key)
4859 .await
4860 .unwrap();
4861 assert_eq!(found.map(|ack| ack.offset), Some(7));
4862 }
4863
4864 #[tokio::test]
4873 async fn ack_scan_survives_journals_past_the_old_cap_and_oversized_lines() {
4874 let directory = tempfile::tempdir().unwrap();
4875 let key = [9u8; 32];
4876 let ack = PublishAck {
4877 topic: "t".to_owned(),
4878 partition: 0,
4879 offset: 42,
4880 };
4881 persist_broker_ack(directory.path(), "s1", "evt-first", &ack, &key)
4882 .await
4883 .unwrap();
4884 let journal = ack_journal_path(directory.path(), "s1");
4885 {
4886 use std::io::Write as _;
4887 let mut file = std::fs::OpenOptions::new().append(true).open(&journal).unwrap();
4888 let oversized = vec![b'x'; 2 * 1024 * 1024];
4891 file.write_all(&oversized).unwrap();
4892 file.write_all(b"\n").unwrap();
4893 let filler = vec![b'y'; 8 * 1024];
4896 for _ in 0..2_200 {
4897 file.write_all(&filler).unwrap();
4898 file.write_all(b"\n").unwrap();
4899 }
4900 }
4901 let tail_ack = PublishAck {
4904 topic: "t".to_owned(),
4905 partition: 0,
4906 offset: 77,
4907 };
4908 persist_broker_ack(directory.path(), "s1", "evt-tail", &tail_ack, &key)
4909 .await
4910 .unwrap();
4911 assert!(
4912 std::fs::metadata(&journal).unwrap().len() > 16 * 1024 * 1024,
4913 "test setup: journal must exceed the old whole-file cap",
4914 );
4915
4916 let found = read_broker_ack(directory.path(), "s1", "evt-tail", &key)
4917 .await
4918 .unwrap();
4919 assert_eq!(
4920 found.map(|ack| ack.offset),
4921 Some(77),
4922 "an ack past the old 16 MiB cap must still be found",
4923 );
4924 let first = read_broker_ack(directory.path(), "s1", "evt-first", &key)
4925 .await
4926 .unwrap();
4927 assert_eq!(
4928 first.map(|ack| ack.offset),
4929 Some(42),
4930 "the oversized garbage line must not poison earlier acks",
4931 );
4932 }
4933}