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