1use crate::agent::AgentEvent;
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, VecDeque};
9use std::sync::Arc;
10use tokio::sync::{Mutex, RwLock};
11use tokio_util::sync::CancellationToken;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum RunStatus {
16 Created,
17 Planning,
18 Executing,
19 Verifying,
20 Completed,
21 Failed,
22 Cancelled,
23}
24
25impl RunStatus {
26 pub fn is_terminal(self) -> bool {
27 matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
38pub(crate) enum RunTerminalTransition {
39 #[allow(dead_code)]
40 Completed,
41 Cancelled,
42 Failed(String),
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct RunEventRecord {
47 pub sequence: usize,
48 pub timestamp_ms: u64,
49 pub event: AgentEvent,
50}
51
52impl RunEventRecord {
53 pub fn core_identity(
58 &self,
59 operation_id: crate::core_identity::OperationId,
60 source_revision: crate::core_identity::SourceRevision,
61 capability_stamp: Option<crate::core_identity::CapabilityStamp>,
62 ) -> Result<crate::core_identity::CoreEventIdentity, crate::core_identity::CoreIdentityError>
63 {
64 crate::core_identity::CoreEventIdentity::from_run_event(
65 operation_id,
66 source_revision,
67 capability_stamp,
68 self,
69 )
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct ActiveToolSnapshot {
75 pub id: String,
76 pub name: String,
77 pub started_at_ms: u64,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct RunSnapshot {
82 pub id: String,
83 pub session_id: String,
84 pub status: RunStatus,
85 pub prompt: String,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub cognitive_package_binding: Option<crate::cognitive_context::CognitivePackageBindingV1>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub capability_binding: Option<crate::capability::RunCapabilityBindingV1>,
96 pub created_at_ms: u64,
97 pub updated_at_ms: u64,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub result_text: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 pub error: Option<String>,
102 pub event_count: usize,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub workspace_change_set: Option<RunWorkspaceChangeSet>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109pub struct RunWorkspaceChangeSet {
110 pub base_tree: String,
111 pub result_tree: String,
112 pub patch_digest: String,
113 pub patch_bytes: u64,
114 pub patch_base64: String,
115 pub observed_at_ms: u64,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
119pub enum RunWorkspaceChangeSetError {
120 #[error("run was not found")]
121 RunNotFound,
122 #[error("run is not terminal")]
123 RunNotTerminal,
124 #[error("run workspace change set conflicts with immutable evidence")]
125 Conflict,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct RunRecord {
130 pub snapshot: RunSnapshot,
131 pub events: Vec<RunEventRecord>,
132}
133
134#[derive(Debug, Clone)]
136pub enum RunReservation {
137 Created(RunSnapshot),
138 Existing(RunSnapshot),
139}
140
141impl RunReservation {
142 pub fn snapshot(&self) -> &RunSnapshot {
143 match self {
144 Self::Created(snapshot) | Self::Existing(snapshot) => snapshot,
145 }
146 }
147
148 pub const fn replayed(&self) -> bool {
149 matches!(self, Self::Existing(_))
150 }
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct RunEventPage {
156 pub events: Vec<RunEventRecord>,
157 pub first_available_sequence: Option<usize>,
159 pub latest_sequence_exclusive: usize,
161 pub next_after_sequence: Option<usize>,
163 pub retention_gap: bool,
165 pub has_more: bool,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
169pub enum RunCognitiveBindingError {
170 #[error("run was not found")]
171 RunNotFound,
172 #[error("cognitive binding is invalid: {0}")]
173 InvalidBinding(String),
174 #[error("run has already crossed its cognitive binding admission boundary")]
175 AlreadyObserved,
176 #[error("run cognitive binding conflicts with immutable admission evidence")]
177 Conflict,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
181pub enum RunCapabilityAdmissionError {
182 #[error("run was not found")]
183 RunNotFound,
184 #[error("capability binding is invalid: {0}")]
185 InvalidBinding(String),
186 #[error("run has already crossed its capability binding admission boundary")]
187 AlreadyObserved,
188 #[error("run capability binding conflicts with immutable admission evidence")]
189 Conflict,
190}
191
192#[derive(Debug, Clone)]
198pub(crate) struct RunEventObservation {
199 pub(crate) snapshot: RunSnapshot,
200 pub(crate) page: RunEventPage,
201}
202
203#[derive(Debug, Default)]
204struct RetainedRunEvents {
205 records: Vec<RunEventRecord>,
206 serialized_bytes: usize,
207}
208
209impl RunSnapshot {
210 fn new(id: String, session_id: String, prompt: String) -> Self {
211 let now = now_ms();
212 Self {
213 id,
214 session_id,
215 status: RunStatus::Created,
216 prompt,
217 cognitive_package_binding: None,
218 capability_binding: None,
219 created_at_ms: now,
220 updated_at_ms: now,
221 result_text: None,
222 error: None,
223 event_count: 0,
224 workspace_change_set: None,
225 }
226 }
227}
228
229#[derive(Debug, Default)]
230pub struct InMemoryRunStore {
231 runs: RwLock<HashMap<String, RunSnapshot>>,
232 events: RwLock<HashMap<String, RetainedRunEvents>>,
233 insertion_order: RwLock<VecDeque<String>>,
236 max_runs: Option<usize>,
239 max_events_per_run: Option<usize>,
244 max_event_bytes_per_run: Option<usize>,
247}
248
249impl InMemoryRunStore {
250 pub fn new() -> Self {
251 Self::default()
252 }
253
254 pub fn with_retention(max_runs: Option<usize>, max_events_per_run: Option<usize>) -> Self {
257 Self::with_retention_limits(max_runs, max_events_per_run, None)
258 }
259
260 pub fn with_retention_limits(
262 max_runs: Option<usize>,
263 max_events_per_run: Option<usize>,
264 max_event_bytes_per_run: Option<usize>,
265 ) -> Self {
266 Self {
267 runs: RwLock::new(HashMap::new()),
268 events: RwLock::new(HashMap::new()),
269 insertion_order: RwLock::new(VecDeque::new()),
270 max_runs,
271 max_events_per_run,
272 max_event_bytes_per_run,
273 }
274 }
275
276 pub async fn create_run(&self, session_id: &str, prompt: &str) -> RunSnapshot {
277 let id = format!("run-{}", uuid::Uuid::new_v4());
281 self.create_run_with_id(id, session_id, prompt).await
282 }
283
284 pub async fn create_run_with_id(
290 &self,
291 id: String,
292 session_id: &str,
293 prompt: &str,
294 ) -> RunSnapshot {
295 let snapshot = RunSnapshot::new(id.clone(), session_id.to_string(), prompt.to_string());
296 {
305 let mut order = self.insertion_order.write().await;
306 let mut events = self.events.write().await;
307 let mut runs = self.runs.write().await;
308 runs.insert(id.clone(), snapshot.clone());
309 events.insert(id.clone(), RetainedRunEvents::default());
310 order.retain(|run_id| run_id != &id);
314 order.push_back(id);
315 if let Some(cap) = self.max_runs {
316 while order.len() > cap {
317 if let Some(victim) = order.pop_front() {
318 runs.remove(&victim);
319 events.remove(&victim);
320 }
321 }
322 }
323 }
324 snapshot
325 }
326
327 pub async fn reserve_run_with_id(
332 &self,
333 id: String,
334 session_id: &str,
335 prompt: &str,
336 ) -> RunReservation {
337 let mut order = self.insertion_order.write().await;
341 let mut events = self.events.write().await;
342 let mut runs = self.runs.write().await;
343 if let Some(existing) = runs.get(&id) {
344 return RunReservation::Existing(existing.clone());
345 }
346
347 let snapshot = RunSnapshot::new(id.clone(), session_id.to_string(), prompt.to_string());
348 runs.insert(id.clone(), snapshot.clone());
349 events.insert(id.clone(), RetainedRunEvents::default());
350 order.push_back(id);
351 if let Some(cap) = self.max_runs {
352 while order.len() > cap {
353 if let Some(victim) = order.pop_front() {
354 runs.remove(&victim);
355 events.remove(&victim);
356 }
357 }
358 }
359 RunReservation::Created(snapshot)
360 }
361
362 pub async fn record_event(&self, run_id: &str, event: AgentEvent) -> Option<RunSnapshot> {
363 let mut events = self.events.write().await;
364 let mut runs = self.runs.write().await;
365 let run_events = events.get_mut(run_id)?;
366 let run = runs.get_mut(run_id)?;
367
368 let sequence = run.event_count;
374 let next_event_count = sequence.checked_add(1)?;
375 let timestamp_ms = now_ms().max(run.updated_at_ms);
379 let record = RunEventRecord {
380 sequence,
381 timestamp_ms,
382 event: event.clone(),
383 };
384 run_events.serialized_bytes = run_events
385 .serialized_bytes
386 .saturating_add(serialized_event_record_len(&record));
387 run_events.records.push(record);
388 trim_retained_events(
389 run_events,
390 self.max_events_per_run,
391 self.max_event_bytes_per_run,
392 );
393 apply_event_to_snapshot(run, &event);
394 run.event_count = next_event_count;
395 run.updated_at_ms = timestamp_ms;
396 Some(run.clone())
397 }
398
399 pub async fn mark_failed(&self, run_id: &str, error: impl Into<String>) -> Option<RunSnapshot> {
400 let mut runs = self.runs.write().await;
401 let run = runs.get_mut(run_id)?;
402 if run.status.is_terminal() {
406 return Some(run.clone());
407 }
408 run.status = RunStatus::Failed;
409 run.error = Some(error.into());
410 run.updated_at_ms = now_ms().max(run.updated_at_ms);
411 Some(run.clone())
412 }
413
414 pub async fn mark_cancelled(&self, run_id: &str) -> Option<RunSnapshot> {
415 let mut runs = self.runs.write().await;
416 let run = runs.get_mut(run_id)?;
417 if run.status.is_terminal() {
421 return Some(run.clone());
422 }
423 run.status = RunStatus::Cancelled;
424 run.updated_at_ms = now_ms().max(run.updated_at_ms);
425 Some(run.clone())
426 }
427
428 pub(crate) async fn settle_terminal(
437 &self,
438 run_id: &str,
439 transition: RunTerminalTransition,
440 ) -> Option<RunSnapshot> {
441 match transition {
442 RunTerminalTransition::Completed => self.snapshot(run_id).await,
443 RunTerminalTransition::Cancelled => self.mark_cancelled(run_id).await,
444 RunTerminalTransition::Failed(error) => self.mark_failed(run_id, error).await,
445 }
446 }
447
448 pub async fn snapshot(&self, run_id: &str) -> Option<RunSnapshot> {
449 self.runs.read().await.get(run_id).cloned()
450 }
451
452 pub async fn bind_cognitive_package(
455 &self,
456 run_id: &str,
457 binding: crate::cognitive_context::CognitivePackageBindingV1,
458 ) -> Result<RunSnapshot, RunCognitiveBindingError> {
459 binding
460 .validate()
461 .map_err(|error| RunCognitiveBindingError::InvalidBinding(error.to_string()))?;
462 let mut runs = self.runs.write().await;
463 let run = runs
464 .get_mut(run_id)
465 .ok_or(RunCognitiveBindingError::RunNotFound)?;
466 match &run.cognitive_package_binding {
467 Some(existing) if existing == &binding => return Ok(run.clone()),
468 Some(_) => return Err(RunCognitiveBindingError::Conflict),
469 None => {}
470 }
471 if run.event_count != 0 || run.status != RunStatus::Created {
472 return Err(RunCognitiveBindingError::AlreadyObserved);
473 }
474 run.cognitive_package_binding = Some(binding);
475 run.updated_at_ms = now_ms().max(run.updated_at_ms);
476 Ok(run.clone())
477 }
478
479 pub async fn bind_capability_generation(
483 &self,
484 run_id: &str,
485 binding: crate::capability::RunCapabilityBindingV1,
486 ) -> Result<RunSnapshot, RunCapabilityAdmissionError> {
487 binding
488 .validate()
489 .map_err(|error| RunCapabilityAdmissionError::InvalidBinding(error.to_string()))?;
490 let mut runs = self.runs.write().await;
491 let run = runs
492 .get_mut(run_id)
493 .ok_or(RunCapabilityAdmissionError::RunNotFound)?;
494 match &run.capability_binding {
495 Some(existing) if existing == &binding => return Ok(run.clone()),
496 Some(_) => return Err(RunCapabilityAdmissionError::Conflict),
497 None => {}
498 }
499 if run.event_count != 0 || run.status != RunStatus::Created {
500 return Err(RunCapabilityAdmissionError::AlreadyObserved);
501 }
502 run.capability_binding = Some(binding);
503 run.updated_at_ms = now_ms().max(run.updated_at_ms);
504 Ok(run.clone())
505 }
506
507 pub async fn record_workspace_change_set(
510 &self,
511 run_id: &str,
512 change_set: RunWorkspaceChangeSet,
513 ) -> Result<RunSnapshot, RunWorkspaceChangeSetError> {
514 let mut runs = self.runs.write().await;
515 let run = runs
516 .get_mut(run_id)
517 .ok_or(RunWorkspaceChangeSetError::RunNotFound)?;
518 if !run.status.is_terminal() {
519 return Err(RunWorkspaceChangeSetError::RunNotTerminal);
520 }
521 match &run.workspace_change_set {
522 Some(existing) if existing == &change_set => return Ok(run.clone()),
523 Some(_) => return Err(RunWorkspaceChangeSetError::Conflict),
524 None => {}
525 }
526 run.workspace_change_set = Some(change_set);
527 Ok(run.clone())
528 }
529
530 pub async fn events(&self, run_id: &str) -> Vec<RunEventRecord> {
531 self.events
532 .read()
533 .await
534 .get(run_id)
535 .map(|events| events.records.clone())
536 .unwrap_or_default()
537 }
538
539 pub async fn event_page(
544 &self,
545 run_id: &str,
546 after_sequence: Option<usize>,
547 limit: usize,
548 ) -> Option<RunEventPage> {
549 self.event_observation(run_id, after_sequence, limit)
550 .await
551 .map(|observation| observation.page)
552 }
553
554 pub(crate) async fn event_observation(
556 &self,
557 run_id: &str,
558 after_sequence: Option<usize>,
559 limit: usize,
560 ) -> Option<RunEventObservation> {
561 let events = self.events.read().await;
563 let runs = self.runs.read().await;
564 let retained = events.get(run_id)?;
565 let run = runs.get(run_id)?;
566 Some(RunEventObservation {
567 snapshot: run.clone(),
568 page: retained_event_page(retained, run, after_sequence, limit),
569 })
570 }
571
572 pub async fn list(&self) -> Vec<RunSnapshot> {
573 let order = self.insertion_order.read().await;
574 let runs = self.runs.read().await;
575 order
576 .iter()
577 .filter_map(|run_id| runs.get(run_id).cloned())
578 .collect()
579 }
580
581 pub async fn records(&self) -> Vec<RunRecord> {
582 let order = self.insertion_order.read().await;
588 let events = self.events.read().await;
589 let runs = self.runs.read().await;
590 order
591 .iter()
592 .filter_map(|run_id| {
593 let snapshot = runs.get(run_id)?.clone();
594 Some(RunRecord {
595 events: events
596 .get(run_id)
597 .map(|events| events.records.clone())
598 .unwrap_or_default(),
599 snapshot,
600 })
601 })
602 .collect()
603 }
604
605 pub async fn replace_records(&self, records: Vec<RunRecord>) {
606 let mut sorted = records;
610 sorted.sort_by_key(|r| r.snapshot.created_at_ms);
611 if let Some(cap) = self.max_runs {
612 let excess = sorted.len().saturating_sub(cap);
613 if excess > 0 {
614 sorted.drain(..excess);
615 }
616 }
617 let mut run_map = HashMap::new();
618 let mut event_map = HashMap::new();
619 let mut order = VecDeque::with_capacity(sorted.len());
620 for record in sorted {
621 let id = record.snapshot.id.clone();
622 let mut retained = RetainedRunEvents {
630 serialized_bytes: record
631 .events
632 .iter()
633 .map(serialized_event_record_len)
634 .fold(0usize, usize::saturating_add),
635 records: record.events,
636 };
637 trim_retained_events(
638 &mut retained,
639 self.max_events_per_run,
640 self.max_event_bytes_per_run,
641 );
642 event_map.insert(id.clone(), retained);
643 run_map.insert(id.clone(), record.snapshot);
644 order.push_back(id);
645 }
646 let mut stored_order = self.insertion_order.write().await;
650 let mut stored_events = self.events.write().await;
651 let mut stored_runs = self.runs.write().await;
652 *stored_runs = run_map;
653 *stored_events = event_map;
654 *stored_order = order;
655 }
656}
657
658fn retained_event_page(
659 retained: &RetainedRunEvents,
660 run: &RunSnapshot,
661 after_sequence: Option<usize>,
662 limit: usize,
663) -> RunEventPage {
664 let first_available_sequence = retained.records.first().map(|event| event.sequence);
665 let requested_start = after_sequence
666 .map(|sequence| sequence.saturating_add(1))
667 .unwrap_or(0);
668 let retention_gap = if requested_start >= run.event_count {
669 false
670 } else {
671 first_available_sequence
672 .map(|first| requested_start < first)
673 .unwrap_or(true)
674 };
675 let mut matching = retained
676 .records
677 .iter()
678 .filter(|event| after_sequence.is_none_or(|cursor| event.sequence > cursor));
679 let page_events = matching.by_ref().take(limit).cloned().collect::<Vec<_>>();
680 let has_more = matching.next().is_some();
681 let next_after_sequence = page_events
682 .last()
683 .map(|event| event.sequence)
684 .or(after_sequence);
685 RunEventPage {
686 events: page_events,
687 first_available_sequence,
688 latest_sequence_exclusive: run.event_count,
689 next_after_sequence,
690 retention_gap,
691 has_more,
692 }
693}
694
695fn serialized_event_record_len(record: &RunEventRecord) -> usize {
696 serde_json::to_vec(record)
697 .map(|encoded| encoded.len())
698 .unwrap_or(usize::MAX)
699}
700
701fn trim_retained_events(
702 events: &mut RetainedRunEvents,
703 max_events: Option<usize>,
704 max_bytes: Option<usize>,
705) {
706 let count_excess = max_events
707 .map(|cap| events.records.len().saturating_sub(cap))
708 .unwrap_or(0);
709 let mut remove_count = count_excess;
710 let mut remaining_bytes = events.serialized_bytes;
711 for record in events.records.iter().take(remove_count) {
712 remaining_bytes = remaining_bytes.saturating_sub(serialized_event_record_len(record));
713 }
714 while max_bytes.is_some_and(|cap| remaining_bytes > cap) && remove_count < events.records.len()
715 {
716 remaining_bytes = remaining_bytes
717 .saturating_sub(serialized_event_record_len(&events.records[remove_count]));
718 remove_count += 1;
719 }
720 for record in events.records.iter().take(remove_count) {
721 events.serialized_bytes = events
722 .serialized_bytes
723 .saturating_sub(serialized_event_record_len(record));
724 }
725 if remove_count > 0 {
726 events.records.drain(..remove_count);
727 }
728}
729
730#[cfg(test)]
731mod retention_tests {
732 use super::*;
733
734 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
735 async fn exact_run_reservation_is_atomic_and_never_replaces_the_winner() {
736 let store = Arc::new(InMemoryRunStore::new());
737 let mut reservations = Vec::new();
738 for index in 0..32 {
739 let store = Arc::clone(&store);
740 reservations.push(tokio::spawn(async move {
741 store
742 .reserve_run_with_id(
743 "run-cloud-1".to_string(),
744 "session-cloud-1",
745 &format!("prompt-{index}"),
746 )
747 .await
748 }));
749 }
750
751 let mut created = 0;
752 for reservation in reservations {
753 if !reservation.await.unwrap().replayed() {
754 created += 1;
755 }
756 }
757 assert_eq!(created, 1);
758
759 let winner = store.snapshot("run-cloud-1").await.unwrap();
760 let replay = store
761 .reserve_run_with_id(
762 "run-cloud-1".to_string(),
763 "another-session",
764 "replacement prompt",
765 )
766 .await;
767 assert!(replay.replayed());
768 assert_eq!(replay.snapshot().session_id, winner.session_id);
769 assert_eq!(replay.snapshot().prompt, winner.prompt);
770 assert_eq!(store.list().await.len(), 1);
771 }
772
773 #[tokio::test]
774 async fn compatibility_create_replacement_keeps_one_fifo_entry() {
775 let store = InMemoryRunStore::with_retention(Some(2), None);
776 let original = store
777 .create_run_with_id("run-replaced".to_owned(), "session-1", "first")
778 .await;
779 let replacement = store
780 .create_run_with_id("run-replaced".to_owned(), "session-1", "second")
781 .await;
782
783 assert_eq!(replacement.id, original.id);
784 assert_eq!(store.list().await.len(), 1);
785 assert_eq!(store.records().await.len(), 1);
786 assert_eq!(
787 store.snapshot("run-replaced").await.unwrap().prompt,
788 "second"
789 );
790 }
791
792 #[tokio::test]
793 async fn event_sequence_overflow_does_not_publish_a_partial_event() {
794 let store = InMemoryRunStore::new();
795 let run = store.create_run("session-1", "overflow").await;
796 store
797 .runs
798 .write()
799 .await
800 .get_mut(&run.id)
801 .unwrap()
802 .event_count = usize::MAX;
803
804 let recorded = store
805 .record_event(
806 &run.id,
807 AgentEvent::TextDelta {
808 text: "must not publish".to_owned(),
809 },
810 )
811 .await;
812
813 assert!(recorded.is_none());
814 assert!(store.events(&run.id).await.is_empty());
815 assert_eq!(
816 store.snapshot(&run.id).await.unwrap().event_count,
817 usize::MAX
818 );
819 }
820
821 #[tokio::test]
822 async fn workspace_change_set_is_terminal_and_immutable() {
823 let store = InMemoryRunStore::new();
824 let run = store.create_run("session-1", "change the workspace").await;
825 let evidence = RunWorkspaceChangeSet {
826 base_tree: format!("git-tree:{}", "1".repeat(40)),
827 result_tree: format!("git-tree:{}", "2".repeat(40)),
828 patch_digest: format!("sha256:{}", "3".repeat(64)),
829 patch_bytes: 0,
830 patch_base64: String::new(),
831 observed_at_ms: 1,
832 };
833
834 assert!(matches!(
835 store
836 .record_workspace_change_set(&run.id, evidence.clone())
837 .await,
838 Err(RunWorkspaceChangeSetError::RunNotTerminal)
839 ));
840 store.mark_failed(&run.id, "fixture failure").await.unwrap();
841 assert_eq!(
842 store
843 .record_workspace_change_set(&run.id, evidence.clone())
844 .await
845 .unwrap()
846 .workspace_change_set,
847 Some(evidence.clone())
848 );
849 store
850 .record_workspace_change_set(&run.id, evidence.clone())
851 .await
852 .expect("exact evidence replay is idempotent");
853
854 let mut conflict = evidence;
855 conflict.result_tree = format!("git-tree:{}", "4".repeat(40));
856 assert!(matches!(
857 store.record_workspace_change_set(&run.id, conflict).await,
858 Err(RunWorkspaceChangeSetError::Conflict)
859 ));
860 }
861
862 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
863 async fn concurrent_create_and_record_under_cap_does_not_deadlock() {
864 let store = std::sync::Arc::new(InMemoryRunStore::with_retention(Some(10), None));
868 let mut handles = Vec::new();
869 for i in 0..100 {
870 let s = std::sync::Arc::clone(&store);
871 handles.push(tokio::spawn(async move {
872 let r = s.create_run("sess", &format!("p{i}")).await;
873 for _ in 0..5 {
874 s.record_event(
875 &r.id,
876 AgentEvent::TextDelta {
877 text: "x".to_string(),
878 },
879 )
880 .await;
881 }
882 }));
883 }
884 for h in handles {
885 h.await.unwrap();
886 }
887 assert!(store.list().await.len() <= 10);
890 }
891
892 #[tokio::test]
893 async fn replace_records_preserves_cumulative_event_count_after_trim() {
894 let src = InMemoryRunStore::with_retention(None, Some(3));
896 let run = src.create_run("s", "p").await;
897 for _ in 0..10 {
898 src.record_event(
899 &run.id,
900 AgentEvent::TextDelta {
901 text: "x".to_string(),
902 },
903 )
904 .await;
905 }
906 let records = src.records().await;
907 assert_eq!(records.len(), 1);
909 assert_eq!(records[0].events.len(), 3, "buffer trimmed to cap");
910 assert_eq!(records[0].snapshot.event_count, 10, "cumulative preserved");
911
912 let dst = InMemoryRunStore::new();
914 dst.replace_records(records).await;
915 let restored = dst.snapshot(&run.id).await.unwrap();
916 assert_eq!(
917 restored.event_count, 10,
918 "replace_records must NOT reset event_count to the trimmed buffer length"
919 );
920 assert_eq!(dst.events(&run.id).await.len(), 3);
922 }
923
924 #[tokio::test]
925 async fn replace_records_enforces_run_and_event_caps() {
926 let source = InMemoryRunStore::new();
927 for run_index in 0..4 {
928 let run = source
929 .create_run_with_id(
930 format!("run-{run_index}"),
931 "session-1",
932 &format!("prompt-{run_index}"),
933 )
934 .await;
935 for event_index in 0..5 {
936 source
937 .record_event(
938 &run.id,
939 AgentEvent::TextDelta {
940 text: format!("{run_index}:{event_index}"),
941 },
942 )
943 .await;
944 }
945 }
946
947 let restored = InMemoryRunStore::with_retention(Some(2), Some(2));
948 restored.replace_records(source.records().await).await;
949
950 let records = restored.records().await;
951 assert_eq!(
952 records
953 .iter()
954 .map(|record| record.snapshot.id.as_str())
955 .collect::<Vec<_>>(),
956 vec!["run-2", "run-3"],
957 "restore must keep the newest runs under the same FIFO policy as live writes"
958 );
959 for (run_index, record) in records.iter().enumerate() {
960 assert_eq!(record.snapshot.event_count, 5);
961 assert_eq!(record.events.len(), 2);
962 assert_eq!(record.events[0].sequence, 3);
963 assert_eq!(record.events[1].sequence, 4);
964 assert_eq!(record.snapshot.id, format!("run-{}", run_index + 2));
965 }
966 }
967
968 #[tokio::test]
969 async fn replace_records_honors_zero_caps() {
970 let source = InMemoryRunStore::new();
971 let run = source.create_run("session-1", "prompt").await;
972 source
973 .record_event(
974 &run.id,
975 AgentEvent::TextDelta {
976 text: "event".to_string(),
977 },
978 )
979 .await;
980
981 let no_runs = InMemoryRunStore::with_retention(Some(0), Some(0));
982 no_runs.replace_records(source.records().await).await;
983 assert!(no_runs.records().await.is_empty());
984
985 let no_events = InMemoryRunStore::with_retention(None, Some(0));
986 no_events.replace_records(source.records().await).await;
987 let records = no_events.records().await;
988 assert_eq!(records.len(), 1);
989 assert!(records[0].events.is_empty());
990 assert_eq!(records[0].snapshot.event_count, 1);
991 }
992
993 #[tokio::test]
994 async fn max_runs_evicts_oldest() {
995 let store = InMemoryRunStore::with_retention(Some(2), None);
996 let _ = store.create_run("session-1", "prompt-1").await;
997 let r2 = store.create_run("session-1", "prompt-2").await;
998 let r3 = store.create_run("session-1", "prompt-3").await;
999
1000 assert_eq!(store.list().await.len(), 2);
1002 let ids: Vec<String> = store.list().await.into_iter().map(|r| r.id).collect();
1003 assert!(ids.contains(&r2.id));
1004 assert!(ids.contains(&r3.id));
1005 assert!(store.events(&r2.id).await.is_empty());
1006 let surviving_event_count: usize =
1008 store.events(&r2.id).await.len() + store.events(&r3.id).await.len();
1009 assert_eq!(surviving_event_count, 0);
1010 }
1011
1012 #[tokio::test]
1013 async fn max_events_per_run_caps_event_buffer() {
1014 let store = InMemoryRunStore::with_retention(None, Some(3));
1015 let run = store.create_run("session-1", "prompt").await;
1016 for _ in 0..10 {
1017 store
1018 .record_event(
1019 &run.id,
1020 AgentEvent::TextDelta {
1021 text: "x".to_string(),
1022 },
1023 )
1024 .await;
1025 }
1026 let events = store.events(&run.id).await;
1027 assert_eq!(
1028 events.len(),
1029 3,
1030 "buffer must be capped at max_events_per_run"
1031 );
1032 let snap = store.snapshot(&run.id).await.unwrap();
1035 assert_eq!(snap.event_count, 10);
1036 }
1037
1038 #[tokio::test]
1039 async fn max_event_bytes_per_run_drops_oversized_live_event_but_advances_cursor() {
1040 let store = InMemoryRunStore::with_retention_limits(None, None, Some(0));
1041 let run = store.create_run("session-1", "prompt").await;
1042
1043 store
1044 .record_event(
1045 &run.id,
1046 AgentEvent::TextDelta {
1047 text: "oversized".to_string(),
1048 },
1049 )
1050 .await;
1051
1052 assert!(store.events(&run.id).await.is_empty());
1053 let snapshot = store.snapshot(&run.id).await.unwrap();
1054 assert_eq!(snapshot.event_count, 1);
1055 }
1056
1057 #[tokio::test]
1058 async fn replace_records_enforces_serialized_event_byte_cap_fifo() {
1059 let source = InMemoryRunStore::new();
1060 let run = source.create_run("session-1", "prompt").await;
1061 for text in ["old", "middle", "new"] {
1062 source
1063 .record_event(
1064 &run.id,
1065 AgentEvent::TextDelta {
1066 text: text.to_string(),
1067 },
1068 )
1069 .await;
1070 }
1071 let source_records = source.records().await;
1072 let retained_bytes = source_records[0].events[1..]
1073 .iter()
1074 .map(serialized_event_record_len)
1075 .sum();
1076
1077 let restored = InMemoryRunStore::with_retention_limits(None, None, Some(retained_bytes));
1078 restored.replace_records(source_records).await;
1079
1080 let records = restored.records().await;
1081 assert_eq!(records[0].snapshot.event_count, 3);
1082 assert_eq!(
1083 records[0]
1084 .events
1085 .iter()
1086 .map(|event| event.sequence)
1087 .collect::<Vec<_>>(),
1088 vec![1, 2]
1089 );
1090 }
1091
1092 #[tokio::test]
1093 async fn retained_event_sequences_remain_monotonic_after_fifo_trim() {
1094 let store = InMemoryRunStore::with_retention(None, Some(3));
1095 let run = store.create_run("session-1", "prompt").await;
1096
1097 for index in 0..10 {
1098 store
1099 .record_event(
1100 &run.id,
1101 AgentEvent::TextDelta {
1102 text: index.to_string(),
1103 },
1104 )
1105 .await;
1106 }
1107
1108 let sequences = store
1109 .events(&run.id)
1110 .await
1111 .into_iter()
1112 .map(|record| record.sequence)
1113 .collect::<Vec<_>>();
1114 assert_eq!(sequences, vec![7, 8, 9]);
1115 assert!(sequences.windows(2).all(|pair| pair[0] < pair[1]));
1116 }
1117
1118 #[tokio::test]
1119 async fn restored_run_continues_sequence_from_cumulative_event_count() {
1120 let source = InMemoryRunStore::with_retention(None, Some(3));
1121 let run = source.create_run("session-1", "prompt").await;
1122 for index in 0..10 {
1123 source
1124 .record_event(
1125 &run.id,
1126 AgentEvent::TextDelta {
1127 text: index.to_string(),
1128 },
1129 )
1130 .await;
1131 }
1132
1133 let restored = InMemoryRunStore::with_retention(None, Some(3));
1134 restored.replace_records(source.records().await).await;
1135 restored
1136 .record_event(
1137 &run.id,
1138 AgentEvent::TextDelta {
1139 text: "after restore".to_string(),
1140 },
1141 )
1142 .await;
1143
1144 let sequences = restored
1145 .events(&run.id)
1146 .await
1147 .into_iter()
1148 .map(|record| record.sequence)
1149 .collect::<Vec<_>>();
1150 assert_eq!(sequences, vec![8, 9, 10]);
1151 assert_eq!(restored.snapshot(&run.id).await.unwrap().event_count, 11);
1152 }
1153
1154 #[tokio::test]
1155 async fn event_page_reports_retention_gap_and_paginates_from_cursor() {
1156 let store = InMemoryRunStore::with_retention(None, Some(3));
1157 let run = store.create_run("session-1", "prompt").await;
1158 for index in 0..6 {
1159 store
1160 .record_event(
1161 &run.id,
1162 AgentEvent::TextDelta {
1163 text: index.to_string(),
1164 },
1165 )
1166 .await;
1167 }
1168
1169 let first = store.event_page(&run.id, None, 2).await.unwrap();
1170 assert_eq!(first.first_available_sequence, Some(3));
1171 assert_eq!(first.latest_sequence_exclusive, 6);
1172 assert!(first.retention_gap);
1173 assert!(first.has_more);
1174 assert_eq!(first.next_after_sequence, Some(4));
1175 assert_eq!(
1176 first
1177 .events
1178 .iter()
1179 .map(|event| event.sequence)
1180 .collect::<Vec<_>>(),
1181 vec![3, 4]
1182 );
1183
1184 let second = store
1185 .event_page(&run.id, first.next_after_sequence, 2)
1186 .await
1187 .unwrap();
1188 assert!(!second.retention_gap);
1189 assert!(!second.has_more);
1190 assert_eq!(second.next_after_sequence, Some(5));
1191 assert_eq!(second.events[0].sequence, 5);
1192 assert!(store.event_page("missing", None, 10).await.is_none());
1193 }
1194
1195 #[tokio::test]
1196 async fn event_page_reports_gap_when_retention_keeps_no_events() {
1197 let store = InMemoryRunStore::with_retention(None, Some(0));
1198 let run = store.create_run("session-1", "prompt").await;
1199 store
1200 .record_event(
1201 &run.id,
1202 AgentEvent::TextDelta {
1203 text: "gone".to_string(),
1204 },
1205 )
1206 .await;
1207
1208 let page = store.event_page(&run.id, None, 10).await.unwrap();
1209 assert!(page.events.is_empty());
1210 assert_eq!(page.first_available_sequence, None);
1211 assert_eq!(page.latest_sequence_exclusive, 1);
1212 assert!(page.retention_gap);
1213 assert!(!page.has_more);
1214 }
1215
1216 #[tokio::test]
1217 async fn unlimited_retention_is_the_default() {
1218 let store = InMemoryRunStore::new();
1219 for i in 0..50 {
1220 let r = store.create_run("s", &format!("p{i}")).await;
1221 for _ in 0..20 {
1222 store
1223 .record_event(
1224 &r.id,
1225 AgentEvent::TextDelta {
1226 text: "y".to_string(),
1227 },
1228 )
1229 .await;
1230 }
1231 }
1232 assert_eq!(store.list().await.len(), 50);
1233 }
1234}
1235
1236#[derive(Clone)]
1237pub struct RunHandle {
1238 id: String,
1239 session_id: String,
1240 store: Arc<InMemoryRunStore>,
1241 cancel_token: Arc<Mutex<Option<CancellationToken>>>,
1242 current_run_id: Arc<Mutex<Option<String>>>,
1243 hook_executor: Option<Arc<dyn crate::hooks::HookExecutor>>,
1244}
1245
1246impl RunHandle {
1247 pub(crate) fn new(
1248 id: String,
1249 session_id: String,
1250 store: Arc<InMemoryRunStore>,
1251 cancel_token: Arc<Mutex<Option<CancellationToken>>>,
1252 current_run_id: Arc<Mutex<Option<String>>>,
1253 hook_executor: Option<Arc<dyn crate::hooks::HookExecutor>>,
1254 ) -> Self {
1255 Self {
1256 id,
1257 session_id,
1258 store,
1259 cancel_token,
1260 current_run_id,
1261 hook_executor,
1262 }
1263 }
1264
1265 pub fn id(&self) -> &str {
1266 &self.id
1267 }
1268
1269 pub fn session_id(&self) -> &str {
1270 &self.session_id
1271 }
1272
1273 pub async fn snapshot(&self) -> Option<RunSnapshot> {
1274 self.store.snapshot(&self.id).await
1275 }
1276
1277 pub async fn events(&self) -> Vec<RunEventRecord> {
1278 self.store.events(&self.id).await
1279 }
1280
1281 pub async fn status(&self) -> Option<RunStatus> {
1282 self.snapshot().await.map(|snapshot| snapshot.status)
1283 }
1284
1285 pub async fn cancel(&self) -> bool {
1286 let current_run_id = self.current_run_id.lock().await.clone();
1287 if current_run_id.as_deref() != Some(self.id.as_str()) {
1288 return false;
1289 }
1290
1291 let token = self.cancel_token.lock().await.clone();
1292 if let Some(token) = token {
1293 token.cancel();
1294 let _ = self
1295 .store
1296 .settle_terminal(&self.id, RunTerminalTransition::Cancelled)
1297 .await;
1298 if let Some(executor) = &self.hook_executor {
1299 executor
1300 .record_run_cancelled(&self.id, &self.session_id, Some("cancelled by host"))
1301 .await;
1302 }
1303 true
1304 } else {
1305 false
1306 }
1307 }
1308}
1309
1310fn apply_event_to_snapshot(run: &mut RunSnapshot, event: &AgentEvent) {
1311 if run.status.is_terminal() {
1315 return;
1316 }
1317
1318 match event {
1319 AgentEvent::Start { prompt } => {
1320 run.status = RunStatus::Executing;
1321 if run.prompt.is_empty() {
1322 run.prompt = prompt.clone();
1323 }
1324 }
1325 AgentEvent::PlanningStart { .. } => {
1326 run.status = RunStatus::Planning;
1327 }
1328 AgentEvent::StepStart { .. }
1329 | AgentEvent::ToolStart { .. }
1330 | AgentEvent::ToolExecutionStart { .. }
1331 | AgentEvent::TurnStart { .. }
1332 if !matches!(run.status, RunStatus::Planning) =>
1333 {
1334 run.status = RunStatus::Executing;
1335 }
1336 AgentEvent::End { text, .. } => {
1337 run.status = RunStatus::Completed;
1338 run.result_text = Some(text.clone());
1339 run.error = None;
1340 }
1341 AgentEvent::Error { message } => {
1342 run.status = RunStatus::Failed;
1343 run.error = Some(message.clone());
1344 }
1345 _ => {}
1346 }
1347}
1348
1349fn now_ms() -> u64 {
1350 std::time::SystemTime::now()
1351 .duration_since(std::time::UNIX_EPOCH)
1352 .map(|duration| duration.as_millis() as u64)
1353 .unwrap_or(0)
1354}
1355
1356#[cfg(test)]
1357mod tests {
1358 use super::*;
1359
1360 fn cognitive_binding() -> crate::cognitive_context::CognitivePackageBindingV1 {
1361 let generation_digest =
1362 "sha256:aa0beeb62f1b7b21bf70f21e6f0e858a1e4b720d313f0907209b5b9dad2eeb20";
1363 let knowledge = crate::cognitive_context::CognitiveKnowledgeBindingV1::new(
1364 "domain-knowledge",
1365 "0.2",
1366 "sha256:1def786da6d190b7b3ce0176e71d99ff1cac3f8c8cc7c0f8b76a893c544e7a90",
1367 7,
1368 generation_digest,
1369 )
1370 .unwrap();
1371 crate::cognitive_context::CognitivePackageBindingV1::new(
1372 "contra-sense/handbook",
1373 "0.1.0",
1374 7,
1375 generation_digest,
1376 "sha256:1e0f0a0162f5b290887ade8886af69fbba4548c863df026178e3550c77813455",
1377 knowledge,
1378 crate::cognitive_context::CognitiveContextLimits::default(),
1379 )
1380 .unwrap()
1381 }
1382
1383 #[tokio::test]
1384 async fn run_store_tracks_status_and_events() {
1385 let store = InMemoryRunStore::new();
1386 let run = store.create_run("session-1", "fix tests").await;
1387
1388 store
1389 .record_event(
1390 &run.id,
1391 AgentEvent::Start {
1392 prompt: "fix tests".to_string(),
1393 },
1394 )
1395 .await;
1396 store
1397 .record_event(
1398 &run.id,
1399 AgentEvent::End {
1400 text: "done".to_string(),
1401 usage: Default::default(),
1402 verification_summary: Box::new(
1403 crate::verification::VerificationSummary::from_reports(&[]),
1404 ),
1405 meta: None,
1406 },
1407 )
1408 .await;
1409
1410 let snapshot = store.snapshot(&run.id).await.unwrap();
1411 assert_eq!(snapshot.status, RunStatus::Completed);
1412 assert_eq!(snapshot.result_text.as_deref(), Some("done"));
1413 assert_eq!(snapshot.event_count, 2);
1414 assert_eq!(store.events(&run.id).await.len(), 2);
1415 }
1416
1417 #[tokio::test]
1418 async fn cognitive_binding_is_exact_idempotent_and_pre_observation_only() {
1419 let store = InMemoryRunStore::new();
1420 let run = store.create_run("session-1", "query knowledge").await;
1421 let binding = cognitive_binding();
1422
1423 let bound = store
1424 .bind_cognitive_package(&run.id, binding.clone())
1425 .await
1426 .unwrap();
1427 assert_eq!(bound.cognitive_package_binding.as_ref(), Some(&binding));
1428 store
1429 .bind_cognitive_package(&run.id, binding.clone())
1430 .await
1431 .expect("exact binding replay is idempotent");
1432
1433 let mut conflict = binding.clone();
1434 conflict.limits.max_results -= 1;
1435 conflict.validate().unwrap();
1436 assert!(matches!(
1437 store.bind_cognitive_package(&run.id, conflict).await,
1438 Err(RunCognitiveBindingError::Conflict)
1439 ));
1440
1441 let late = store.create_run("session-1", "late binding").await;
1442 store
1443 .record_event(
1444 &late.id,
1445 AgentEvent::Start {
1446 prompt: "late binding".to_owned(),
1447 },
1448 )
1449 .await
1450 .unwrap();
1451 assert!(matches!(
1452 store.bind_cognitive_package(&late.id, binding).await,
1453 Err(RunCognitiveBindingError::AlreadyObserved)
1454 ));
1455 }
1456
1457 #[tokio::test]
1458 async fn event_observation_keeps_snapshot_and_page_in_one_generation() {
1459 let store = InMemoryRunStore::new();
1460 let run = store.create_run("session-1", "observe exactly").await;
1461 store
1462 .record_event(
1463 &run.id,
1464 AgentEvent::End {
1465 text: "done".to_string(),
1466 usage: Default::default(),
1467 verification_summary: Box::new(
1468 crate::verification::VerificationSummary::from_reports(&[]),
1469 ),
1470 meta: None,
1471 },
1472 )
1473 .await;
1474
1475 let observation = store
1476 .event_observation(&run.id, None, 64)
1477 .await
1478 .expect("known run observation");
1479
1480 assert_eq!(observation.snapshot.status, RunStatus::Completed);
1481 assert_eq!(
1482 observation.snapshot.event_count,
1483 observation.page.latest_sequence_exclusive
1484 );
1485 assert!(observation
1486 .page
1487 .events
1488 .iter()
1489 .all(|event| event.timestamp_ms <= observation.snapshot.updated_at_ms));
1490 assert!(store
1491 .event_observation("missing-run", None, 64)
1492 .await
1493 .is_none());
1494 }
1495
1496 #[tokio::test]
1497 async fn restored_logical_time_cannot_regress_new_event_observations() {
1498 let source = InMemoryRunStore::new();
1499 let run = source.create_run("session-1", "resume exactly").await;
1500 let failed_run = source.create_run("session-1", "fail exactly").await;
1501 let mut records = source.records().await;
1502 let persisted_time = now_ms().saturating_add(60_000);
1503 for record in &mut records {
1504 record.snapshot.updated_at_ms = persisted_time;
1505 }
1506
1507 let restored = InMemoryRunStore::new();
1508 restored.replace_records(records).await;
1509 restored
1510 .record_event(
1511 &run.id,
1512 AgentEvent::TextDelta {
1513 text: "after recovery".to_string(),
1514 },
1515 )
1516 .await;
1517
1518 let observation = restored
1519 .event_observation(&run.id, None, 64)
1520 .await
1521 .expect("restored run observation");
1522 assert!(observation.snapshot.updated_at_ms >= persisted_time);
1523 assert!(observation
1524 .page
1525 .events
1526 .iter()
1527 .all(|event| event.timestamp_ms >= persisted_time));
1528
1529 let cancelled = restored
1530 .mark_cancelled(&run.id)
1531 .await
1532 .expect("restored run cancellation");
1533 assert!(cancelled.updated_at_ms >= persisted_time);
1534 let failed = restored
1535 .mark_failed(&failed_run.id, "provider failed")
1536 .await
1537 .expect("restored run failure");
1538 assert!(failed.updated_at_ms >= persisted_time);
1539 }
1540
1541 #[tokio::test]
1542 async fn run_store_replaces_persisted_records() {
1543 let source = InMemoryRunStore::new();
1544 let run = source.create_run("session-1", "persist").await;
1545 source
1546 .record_event(
1547 &run.id,
1548 AgentEvent::Start {
1549 prompt: "persist".to_string(),
1550 },
1551 )
1552 .await;
1553
1554 let target = InMemoryRunStore::new();
1555 target.replace_records(source.records().await).await;
1556
1557 assert_eq!(target.list().await.len(), 1);
1558 assert_eq!(target.events(&run.id).await.len(), 1);
1559 assert_eq!(target.snapshot(&run.id).await.unwrap().event_count, 1);
1560 }
1561
1562 #[tokio::test]
1563 async fn run_handle_only_cancels_current_run() {
1564 let store = Arc::new(InMemoryRunStore::new());
1565 let run = store.create_run("session-1", "fix tests").await;
1566 let cancel_token = Arc::new(Mutex::new(Some(CancellationToken::new())));
1567 let current_run_id = Arc::new(Mutex::new(Some(run.id.clone())));
1568 let handle = RunHandle::new(
1569 run.id.clone(),
1570 run.session_id.clone(),
1571 store.clone(),
1572 cancel_token,
1573 current_run_id.clone(),
1574 None,
1575 );
1576
1577 assert!(handle.cancel().await);
1578 assert_eq!(handle.status().await, Some(RunStatus::Cancelled));
1579
1580 *current_run_id.lock().await = Some("other-run".to_string());
1581 assert!(!handle.cancel().await);
1582 }
1583
1584 #[tokio::test]
1585 async fn late_events_cannot_regress_a_terminal_run_status() {
1586 let store = InMemoryRunStore::new();
1587 let cancelled = store.create_run("session-1", "cancelled").await;
1588 store.mark_cancelled(&cancelled.id).await;
1589 store
1590 .record_event(&cancelled.id, AgentEvent::TurnStart { turn: 2 })
1591 .await;
1592 assert_eq!(
1593 store.snapshot(&cancelled.id).await.unwrap().status,
1594 RunStatus::Cancelled
1595 );
1596
1597 let completed = store.create_run("session-1", "completed").await;
1598 store
1599 .record_event(
1600 &completed.id,
1601 AgentEvent::End {
1602 text: "done".to_string(),
1603 usage: Default::default(),
1604 verification_summary: Box::new(
1605 crate::verification::VerificationSummary::from_reports(&[]),
1606 ),
1607 meta: None,
1608 },
1609 )
1610 .await;
1611 store
1612 .record_event(
1613 &completed.id,
1614 AgentEvent::ToolExecutionStart {
1615 id: "late-tool".to_string(),
1616 name: "bash".to_string(),
1617 args: serde_json::json!({}),
1618 },
1619 )
1620 .await;
1621 assert_eq!(
1622 store.snapshot(&completed.id).await.unwrap().status,
1623 RunStatus::Completed
1624 );
1625 }
1626
1627 #[tokio::test]
1628 async fn late_terminal_markers_cannot_rewrite_the_first_terminal_outcome() {
1629 let store = InMemoryRunStore::new();
1630 let completed = store.create_run("session-1", "done").await;
1631 store
1632 .record_event(
1633 &completed.id,
1634 AgentEvent::End {
1635 text: "done".to_string(),
1636 usage: Default::default(),
1637 verification_summary: Box::new(
1638 crate::verification::VerificationSummary::from_reports(&[]),
1639 ),
1640 meta: None,
1641 },
1642 )
1643 .await;
1644 assert_eq!(
1645 store.mark_cancelled(&completed.id).await.unwrap().status,
1646 RunStatus::Completed
1647 );
1648 assert_eq!(
1649 store
1650 .mark_failed(&completed.id, "late failure")
1651 .await
1652 .unwrap()
1653 .status,
1654 RunStatus::Completed
1655 );
1656
1657 let failed = store.create_run("session-1", "failed").await;
1658 store.mark_failed(&failed.id, "provider failed").await;
1659 assert_eq!(
1660 store.mark_cancelled(&failed.id).await.unwrap().status,
1661 RunStatus::Failed
1662 );
1663 }
1664
1665 #[tokio::test]
1666 async fn terminal_sink_preserves_event_completion_and_rejects_late_outcomes() {
1667 let store = InMemoryRunStore::new();
1668 let completed = store.create_run("session-1", "done").await;
1669 store
1670 .record_event(
1671 &completed.id,
1672 AgentEvent::End {
1673 text: "done".to_string(),
1674 usage: Default::default(),
1675 verification_summary: Box::new(
1676 crate::verification::VerificationSummary::from_reports(&[]),
1677 ),
1678 meta: None,
1679 },
1680 )
1681 .await;
1682
1683 let acknowledged = store
1684 .settle_terminal(&completed.id, RunTerminalTransition::Completed)
1685 .await
1686 .expect("completed Run remains observable");
1687 assert_eq!(acknowledged.status, RunStatus::Completed);
1688
1689 let failed = store.create_run("session-1", "fails").await;
1690 let failed = store
1691 .settle_terminal(
1692 &failed.id,
1693 RunTerminalTransition::Failed("provider failed".to_string()),
1694 )
1695 .await
1696 .expect("failed Run remains observable");
1697 assert_eq!(failed.status, RunStatus::Failed);
1698 assert_eq!(failed.error.as_deref(), Some("provider failed"));
1699
1700 let unchanged = store
1701 .settle_terminal(&failed.id, RunTerminalTransition::Cancelled)
1702 .await
1703 .expect("late cancellation remains observable");
1704 assert_eq!(unchanged.status, RunStatus::Failed);
1705 assert_eq!(unchanged.error.as_deref(), Some("provider failed"));
1706 }
1707
1708 #[path = "run_cognitive_binding_soak.rs"]
1709 mod cognitive_binding_soak;
1710
1711 #[path = "run_protocol_soak.rs"]
1712 mod protocol_soak;
1713}