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, Serialize, Deserialize)]
32pub struct RunEventRecord {
33 pub sequence: usize,
34 pub timestamp_ms: u64,
35 pub event: AgentEvent,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ActiveToolSnapshot {
40 pub id: String,
41 pub name: String,
42 pub started_at_ms: u64,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct RunSnapshot {
47 pub id: String,
48 pub session_id: String,
49 pub status: RunStatus,
50 pub prompt: String,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub cognitive_package_binding: Option<crate::cognitive_context::CognitivePackageBindingV1>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub capability_binding: Option<crate::capability::RunCapabilityBindingV1>,
61 pub created_at_ms: u64,
62 pub updated_at_ms: u64,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub result_text: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
66 pub error: Option<String>,
67 pub event_count: usize,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub workspace_change_set: Option<RunWorkspaceChangeSet>,
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct RunWorkspaceChangeSet {
75 pub base_tree: String,
76 pub result_tree: String,
77 pub patch_digest: String,
78 pub patch_bytes: u64,
79 pub patch_base64: String,
80 pub observed_at_ms: u64,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
84pub enum RunWorkspaceChangeSetError {
85 #[error("run was not found")]
86 RunNotFound,
87 #[error("run is not terminal")]
88 RunNotTerminal,
89 #[error("run workspace change set conflicts with immutable evidence")]
90 Conflict,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct RunRecord {
95 pub snapshot: RunSnapshot,
96 pub events: Vec<RunEventRecord>,
97}
98
99#[derive(Debug, Clone)]
101pub enum RunReservation {
102 Created(RunSnapshot),
103 Existing(RunSnapshot),
104}
105
106impl RunReservation {
107 pub fn snapshot(&self) -> &RunSnapshot {
108 match self {
109 Self::Created(snapshot) | Self::Existing(snapshot) => snapshot,
110 }
111 }
112
113 pub const fn replayed(&self) -> bool {
114 matches!(self, Self::Existing(_))
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct RunEventPage {
121 pub events: Vec<RunEventRecord>,
122 pub first_available_sequence: Option<usize>,
124 pub latest_sequence_exclusive: usize,
126 pub next_after_sequence: Option<usize>,
128 pub retention_gap: bool,
130 pub has_more: bool,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
134pub enum RunCognitiveBindingError {
135 #[error("run was not found")]
136 RunNotFound,
137 #[error("cognitive binding is invalid: {0}")]
138 InvalidBinding(String),
139 #[error("run has already crossed its cognitive binding admission boundary")]
140 AlreadyObserved,
141 #[error("run cognitive binding conflicts with immutable admission evidence")]
142 Conflict,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
146pub enum RunCapabilityAdmissionError {
147 #[error("run was not found")]
148 RunNotFound,
149 #[error("capability binding is invalid: {0}")]
150 InvalidBinding(String),
151 #[error("run has already crossed its capability binding admission boundary")]
152 AlreadyObserved,
153 #[error("run capability binding conflicts with immutable admission evidence")]
154 Conflict,
155}
156
157#[derive(Debug, Clone)]
163pub(crate) struct RunEventObservation {
164 pub(crate) snapshot: RunSnapshot,
165 pub(crate) page: RunEventPage,
166}
167
168#[derive(Debug, Default)]
169struct RetainedRunEvents {
170 records: Vec<RunEventRecord>,
171 serialized_bytes: usize,
172}
173
174impl RunSnapshot {
175 fn new(id: String, session_id: String, prompt: String) -> Self {
176 let now = now_ms();
177 Self {
178 id,
179 session_id,
180 status: RunStatus::Created,
181 prompt,
182 cognitive_package_binding: None,
183 capability_binding: None,
184 created_at_ms: now,
185 updated_at_ms: now,
186 result_text: None,
187 error: None,
188 event_count: 0,
189 workspace_change_set: None,
190 }
191 }
192}
193
194#[derive(Debug, Default)]
195pub struct InMemoryRunStore {
196 runs: RwLock<HashMap<String, RunSnapshot>>,
197 events: RwLock<HashMap<String, RetainedRunEvents>>,
198 insertion_order: RwLock<VecDeque<String>>,
201 max_runs: Option<usize>,
204 max_events_per_run: Option<usize>,
209 max_event_bytes_per_run: Option<usize>,
212}
213
214impl InMemoryRunStore {
215 pub fn new() -> Self {
216 Self::default()
217 }
218
219 pub fn with_retention(max_runs: Option<usize>, max_events_per_run: Option<usize>) -> Self {
222 Self::with_retention_limits(max_runs, max_events_per_run, None)
223 }
224
225 pub fn with_retention_limits(
227 max_runs: Option<usize>,
228 max_events_per_run: Option<usize>,
229 max_event_bytes_per_run: Option<usize>,
230 ) -> Self {
231 Self {
232 runs: RwLock::new(HashMap::new()),
233 events: RwLock::new(HashMap::new()),
234 insertion_order: RwLock::new(VecDeque::new()),
235 max_runs,
236 max_events_per_run,
237 max_event_bytes_per_run,
238 }
239 }
240
241 pub async fn create_run(&self, session_id: &str, prompt: &str) -> RunSnapshot {
242 let id = format!("run-{}", uuid::Uuid::new_v4());
246 self.create_run_with_id(id, session_id, prompt).await
247 }
248
249 pub async fn create_run_with_id(
255 &self,
256 id: String,
257 session_id: &str,
258 prompt: &str,
259 ) -> RunSnapshot {
260 let snapshot = RunSnapshot::new(id.clone(), session_id.to_string(), prompt.to_string());
261 {
270 let mut order = self.insertion_order.write().await;
271 let mut events = self.events.write().await;
272 let mut runs = self.runs.write().await;
273 runs.insert(id.clone(), snapshot.clone());
274 events.insert(id.clone(), RetainedRunEvents::default());
275 order.push_back(id);
276 if let Some(cap) = self.max_runs {
277 while order.len() > cap {
278 if let Some(victim) = order.pop_front() {
279 runs.remove(&victim);
280 events.remove(&victim);
281 }
282 }
283 }
284 }
285 snapshot
286 }
287
288 pub async fn reserve_run_with_id(
293 &self,
294 id: String,
295 session_id: &str,
296 prompt: &str,
297 ) -> RunReservation {
298 let mut order = self.insertion_order.write().await;
302 let mut events = self.events.write().await;
303 let mut runs = self.runs.write().await;
304 if let Some(existing) = runs.get(&id) {
305 return RunReservation::Existing(existing.clone());
306 }
307
308 let snapshot = RunSnapshot::new(id.clone(), session_id.to_string(), prompt.to_string());
309 runs.insert(id.clone(), snapshot.clone());
310 events.insert(id.clone(), RetainedRunEvents::default());
311 order.push_back(id);
312 if let Some(cap) = self.max_runs {
313 while order.len() > cap {
314 if let Some(victim) = order.pop_front() {
315 runs.remove(&victim);
316 events.remove(&victim);
317 }
318 }
319 }
320 RunReservation::Created(snapshot)
321 }
322
323 pub async fn record_event(&self, run_id: &str, event: AgentEvent) -> Option<RunSnapshot> {
324 let mut events = self.events.write().await;
325 let mut runs = self.runs.write().await;
326 let run_events = events.get_mut(run_id)?;
327 let run = runs.get_mut(run_id)?;
328
329 let sequence = run.event_count;
335 let timestamp_ms = now_ms().max(run.updated_at_ms);
339 let record = RunEventRecord {
340 sequence,
341 timestamp_ms,
342 event: event.clone(),
343 };
344 run_events.serialized_bytes = run_events
345 .serialized_bytes
346 .saturating_add(serialized_event_record_len(&record));
347 run_events.records.push(record);
348 trim_retained_events(
349 run_events,
350 self.max_events_per_run,
351 self.max_event_bytes_per_run,
352 );
353 apply_event_to_snapshot(run, &event);
354 run.event_count += 1;
355 run.updated_at_ms = timestamp_ms;
356 Some(run.clone())
357 }
358
359 pub async fn mark_failed(&self, run_id: &str, error: impl Into<String>) -> Option<RunSnapshot> {
360 let mut runs = self.runs.write().await;
361 let run = runs.get_mut(run_id)?;
362 if run.status == RunStatus::Cancelled {
363 return Some(run.clone());
364 }
365 run.status = RunStatus::Failed;
366 run.error = Some(error.into());
367 run.updated_at_ms = now_ms().max(run.updated_at_ms);
368 Some(run.clone())
369 }
370
371 pub async fn mark_cancelled(&self, run_id: &str) -> Option<RunSnapshot> {
372 let mut runs = self.runs.write().await;
373 let run = runs.get_mut(run_id)?;
374 run.status = RunStatus::Cancelled;
375 run.updated_at_ms = now_ms().max(run.updated_at_ms);
376 Some(run.clone())
377 }
378
379 pub async fn snapshot(&self, run_id: &str) -> Option<RunSnapshot> {
380 self.runs.read().await.get(run_id).cloned()
381 }
382
383 pub async fn bind_cognitive_package(
386 &self,
387 run_id: &str,
388 binding: crate::cognitive_context::CognitivePackageBindingV1,
389 ) -> Result<RunSnapshot, RunCognitiveBindingError> {
390 binding
391 .validate()
392 .map_err(|error| RunCognitiveBindingError::InvalidBinding(error.to_string()))?;
393 let mut runs = self.runs.write().await;
394 let run = runs
395 .get_mut(run_id)
396 .ok_or(RunCognitiveBindingError::RunNotFound)?;
397 match &run.cognitive_package_binding {
398 Some(existing) if existing == &binding => return Ok(run.clone()),
399 Some(_) => return Err(RunCognitiveBindingError::Conflict),
400 None => {}
401 }
402 if run.event_count != 0 || run.status != RunStatus::Created {
403 return Err(RunCognitiveBindingError::AlreadyObserved);
404 }
405 run.cognitive_package_binding = Some(binding);
406 run.updated_at_ms = now_ms().max(run.updated_at_ms);
407 Ok(run.clone())
408 }
409
410 pub async fn bind_capability_generation(
414 &self,
415 run_id: &str,
416 binding: crate::capability::RunCapabilityBindingV1,
417 ) -> Result<RunSnapshot, RunCapabilityAdmissionError> {
418 binding
419 .validate()
420 .map_err(|error| RunCapabilityAdmissionError::InvalidBinding(error.to_string()))?;
421 let mut runs = self.runs.write().await;
422 let run = runs
423 .get_mut(run_id)
424 .ok_or(RunCapabilityAdmissionError::RunNotFound)?;
425 match &run.capability_binding {
426 Some(existing) if existing == &binding => return Ok(run.clone()),
427 Some(_) => return Err(RunCapabilityAdmissionError::Conflict),
428 None => {}
429 }
430 if run.event_count != 0 || run.status != RunStatus::Created {
431 return Err(RunCapabilityAdmissionError::AlreadyObserved);
432 }
433 run.capability_binding = Some(binding);
434 run.updated_at_ms = now_ms().max(run.updated_at_ms);
435 Ok(run.clone())
436 }
437
438 pub async fn record_workspace_change_set(
441 &self,
442 run_id: &str,
443 change_set: RunWorkspaceChangeSet,
444 ) -> Result<RunSnapshot, RunWorkspaceChangeSetError> {
445 let mut runs = self.runs.write().await;
446 let run = runs
447 .get_mut(run_id)
448 .ok_or(RunWorkspaceChangeSetError::RunNotFound)?;
449 if !run.status.is_terminal() {
450 return Err(RunWorkspaceChangeSetError::RunNotTerminal);
451 }
452 match &run.workspace_change_set {
453 Some(existing) if existing == &change_set => return Ok(run.clone()),
454 Some(_) => return Err(RunWorkspaceChangeSetError::Conflict),
455 None => {}
456 }
457 run.workspace_change_set = Some(change_set);
458 Ok(run.clone())
459 }
460
461 pub async fn events(&self, run_id: &str) -> Vec<RunEventRecord> {
462 self.events
463 .read()
464 .await
465 .get(run_id)
466 .map(|events| events.records.clone())
467 .unwrap_or_default()
468 }
469
470 pub async fn event_page(
475 &self,
476 run_id: &str,
477 after_sequence: Option<usize>,
478 limit: usize,
479 ) -> Option<RunEventPage> {
480 self.event_observation(run_id, after_sequence, limit)
481 .await
482 .map(|observation| observation.page)
483 }
484
485 pub(crate) async fn event_observation(
487 &self,
488 run_id: &str,
489 after_sequence: Option<usize>,
490 limit: usize,
491 ) -> Option<RunEventObservation> {
492 let events = self.events.read().await;
494 let runs = self.runs.read().await;
495 let retained = events.get(run_id)?;
496 let run = runs.get(run_id)?;
497 Some(RunEventObservation {
498 snapshot: run.clone(),
499 page: retained_event_page(retained, run, after_sequence, limit),
500 })
501 }
502
503 pub async fn list(&self) -> Vec<RunSnapshot> {
504 let order = self.insertion_order.read().await;
505 let runs = self.runs.read().await;
506 order
507 .iter()
508 .filter_map(|run_id| runs.get(run_id).cloned())
509 .collect()
510 }
511
512 pub async fn records(&self) -> Vec<RunRecord> {
513 let order = self.insertion_order.read().await;
519 let events = self.events.read().await;
520 let runs = self.runs.read().await;
521 order
522 .iter()
523 .filter_map(|run_id| {
524 let snapshot = runs.get(run_id)?.clone();
525 Some(RunRecord {
526 events: events
527 .get(run_id)
528 .map(|events| events.records.clone())
529 .unwrap_or_default(),
530 snapshot,
531 })
532 })
533 .collect()
534 }
535
536 pub async fn replace_records(&self, records: Vec<RunRecord>) {
537 let mut sorted = records;
541 sorted.sort_by_key(|r| r.snapshot.created_at_ms);
542 if let Some(cap) = self.max_runs {
543 let excess = sorted.len().saturating_sub(cap);
544 if excess > 0 {
545 sorted.drain(..excess);
546 }
547 }
548 let mut run_map = HashMap::new();
549 let mut event_map = HashMap::new();
550 let mut order = VecDeque::with_capacity(sorted.len());
551 for record in sorted {
552 let id = record.snapshot.id.clone();
553 let mut retained = RetainedRunEvents {
561 serialized_bytes: record
562 .events
563 .iter()
564 .map(serialized_event_record_len)
565 .fold(0usize, usize::saturating_add),
566 records: record.events,
567 };
568 trim_retained_events(
569 &mut retained,
570 self.max_events_per_run,
571 self.max_event_bytes_per_run,
572 );
573 event_map.insert(id.clone(), retained);
574 run_map.insert(id.clone(), record.snapshot);
575 order.push_back(id);
576 }
577 let mut stored_order = self.insertion_order.write().await;
581 let mut stored_events = self.events.write().await;
582 let mut stored_runs = self.runs.write().await;
583 *stored_runs = run_map;
584 *stored_events = event_map;
585 *stored_order = order;
586 }
587}
588
589fn retained_event_page(
590 retained: &RetainedRunEvents,
591 run: &RunSnapshot,
592 after_sequence: Option<usize>,
593 limit: usize,
594) -> RunEventPage {
595 let first_available_sequence = retained.records.first().map(|event| event.sequence);
596 let requested_start = after_sequence
597 .map(|sequence| sequence.saturating_add(1))
598 .unwrap_or(0);
599 let retention_gap = if requested_start >= run.event_count {
600 false
601 } else {
602 first_available_sequence
603 .map(|first| requested_start < first)
604 .unwrap_or(true)
605 };
606 let mut matching = retained
607 .records
608 .iter()
609 .filter(|event| after_sequence.is_none_or(|cursor| event.sequence > cursor));
610 let page_events = matching.by_ref().take(limit).cloned().collect::<Vec<_>>();
611 let has_more = matching.next().is_some();
612 let next_after_sequence = page_events
613 .last()
614 .map(|event| event.sequence)
615 .or(after_sequence);
616 RunEventPage {
617 events: page_events,
618 first_available_sequence,
619 latest_sequence_exclusive: run.event_count,
620 next_after_sequence,
621 retention_gap,
622 has_more,
623 }
624}
625
626fn serialized_event_record_len(record: &RunEventRecord) -> usize {
627 serde_json::to_vec(record)
628 .map(|encoded| encoded.len())
629 .unwrap_or(usize::MAX)
630}
631
632fn trim_retained_events(
633 events: &mut RetainedRunEvents,
634 max_events: Option<usize>,
635 max_bytes: Option<usize>,
636) {
637 let count_excess = max_events
638 .map(|cap| events.records.len().saturating_sub(cap))
639 .unwrap_or(0);
640 let mut remove_count = count_excess;
641 let mut remaining_bytes = events.serialized_bytes;
642 for record in events.records.iter().take(remove_count) {
643 remaining_bytes = remaining_bytes.saturating_sub(serialized_event_record_len(record));
644 }
645 while max_bytes.is_some_and(|cap| remaining_bytes > cap) && remove_count < events.records.len()
646 {
647 remaining_bytes = remaining_bytes
648 .saturating_sub(serialized_event_record_len(&events.records[remove_count]));
649 remove_count += 1;
650 }
651 for record in events.records.iter().take(remove_count) {
652 events.serialized_bytes = events
653 .serialized_bytes
654 .saturating_sub(serialized_event_record_len(record));
655 }
656 if remove_count > 0 {
657 events.records.drain(..remove_count);
658 }
659}
660
661#[cfg(test)]
662mod retention_tests {
663 use super::*;
664
665 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
666 async fn exact_run_reservation_is_atomic_and_never_replaces_the_winner() {
667 let store = Arc::new(InMemoryRunStore::new());
668 let mut reservations = Vec::new();
669 for index in 0..32 {
670 let store = Arc::clone(&store);
671 reservations.push(tokio::spawn(async move {
672 store
673 .reserve_run_with_id(
674 "run-cloud-1".to_string(),
675 "session-cloud-1",
676 &format!("prompt-{index}"),
677 )
678 .await
679 }));
680 }
681
682 let mut created = 0;
683 for reservation in reservations {
684 if !reservation.await.unwrap().replayed() {
685 created += 1;
686 }
687 }
688 assert_eq!(created, 1);
689
690 let winner = store.snapshot("run-cloud-1").await.unwrap();
691 let replay = store
692 .reserve_run_with_id(
693 "run-cloud-1".to_string(),
694 "another-session",
695 "replacement prompt",
696 )
697 .await;
698 assert!(replay.replayed());
699 assert_eq!(replay.snapshot().session_id, winner.session_id);
700 assert_eq!(replay.snapshot().prompt, winner.prompt);
701 assert_eq!(store.list().await.len(), 1);
702 }
703
704 #[tokio::test]
705 async fn workspace_change_set_is_terminal_and_immutable() {
706 let store = InMemoryRunStore::new();
707 let run = store.create_run("session-1", "change the workspace").await;
708 let evidence = RunWorkspaceChangeSet {
709 base_tree: format!("git-tree:{}", "1".repeat(40)),
710 result_tree: format!("git-tree:{}", "2".repeat(40)),
711 patch_digest: format!("sha256:{}", "3".repeat(64)),
712 patch_bytes: 0,
713 patch_base64: String::new(),
714 observed_at_ms: 1,
715 };
716
717 assert!(matches!(
718 store
719 .record_workspace_change_set(&run.id, evidence.clone())
720 .await,
721 Err(RunWorkspaceChangeSetError::RunNotTerminal)
722 ));
723 store.mark_failed(&run.id, "fixture failure").await.unwrap();
724 assert_eq!(
725 store
726 .record_workspace_change_set(&run.id, evidence.clone())
727 .await
728 .unwrap()
729 .workspace_change_set,
730 Some(evidence.clone())
731 );
732 store
733 .record_workspace_change_set(&run.id, evidence.clone())
734 .await
735 .expect("exact evidence replay is idempotent");
736
737 let mut conflict = evidence;
738 conflict.result_tree = format!("git-tree:{}", "4".repeat(40));
739 assert!(matches!(
740 store.record_workspace_change_set(&run.id, conflict).await,
741 Err(RunWorkspaceChangeSetError::Conflict)
742 ));
743 }
744
745 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
746 async fn concurrent_create_and_record_under_cap_does_not_deadlock() {
747 let store = std::sync::Arc::new(InMemoryRunStore::with_retention(Some(10), None));
751 let mut handles = Vec::new();
752 for i in 0..100 {
753 let s = std::sync::Arc::clone(&store);
754 handles.push(tokio::spawn(async move {
755 let r = s.create_run("sess", &format!("p{i}")).await;
756 for _ in 0..5 {
757 s.record_event(
758 &r.id,
759 AgentEvent::TextDelta {
760 text: "x".to_string(),
761 },
762 )
763 .await;
764 }
765 }));
766 }
767 for h in handles {
768 h.await.unwrap();
769 }
770 assert!(store.list().await.len() <= 10);
773 }
774
775 #[tokio::test]
776 async fn replace_records_preserves_cumulative_event_count_after_trim() {
777 let src = InMemoryRunStore::with_retention(None, Some(3));
779 let run = src.create_run("s", "p").await;
780 for _ in 0..10 {
781 src.record_event(
782 &run.id,
783 AgentEvent::TextDelta {
784 text: "x".to_string(),
785 },
786 )
787 .await;
788 }
789 let records = src.records().await;
790 assert_eq!(records.len(), 1);
792 assert_eq!(records[0].events.len(), 3, "buffer trimmed to cap");
793 assert_eq!(records[0].snapshot.event_count, 10, "cumulative preserved");
794
795 let dst = InMemoryRunStore::new();
797 dst.replace_records(records).await;
798 let restored = dst.snapshot(&run.id).await.unwrap();
799 assert_eq!(
800 restored.event_count, 10,
801 "replace_records must NOT reset event_count to the trimmed buffer length"
802 );
803 assert_eq!(dst.events(&run.id).await.len(), 3);
805 }
806
807 #[tokio::test]
808 async fn replace_records_enforces_run_and_event_caps() {
809 let source = InMemoryRunStore::new();
810 for run_index in 0..4 {
811 let run = source
812 .create_run_with_id(
813 format!("run-{run_index}"),
814 "session-1",
815 &format!("prompt-{run_index}"),
816 )
817 .await;
818 for event_index in 0..5 {
819 source
820 .record_event(
821 &run.id,
822 AgentEvent::TextDelta {
823 text: format!("{run_index}:{event_index}"),
824 },
825 )
826 .await;
827 }
828 }
829
830 let restored = InMemoryRunStore::with_retention(Some(2), Some(2));
831 restored.replace_records(source.records().await).await;
832
833 let records = restored.records().await;
834 assert_eq!(
835 records
836 .iter()
837 .map(|record| record.snapshot.id.as_str())
838 .collect::<Vec<_>>(),
839 vec!["run-2", "run-3"],
840 "restore must keep the newest runs under the same FIFO policy as live writes"
841 );
842 for (run_index, record) in records.iter().enumerate() {
843 assert_eq!(record.snapshot.event_count, 5);
844 assert_eq!(record.events.len(), 2);
845 assert_eq!(record.events[0].sequence, 3);
846 assert_eq!(record.events[1].sequence, 4);
847 assert_eq!(record.snapshot.id, format!("run-{}", run_index + 2));
848 }
849 }
850
851 #[tokio::test]
852 async fn replace_records_honors_zero_caps() {
853 let source = InMemoryRunStore::new();
854 let run = source.create_run("session-1", "prompt").await;
855 source
856 .record_event(
857 &run.id,
858 AgentEvent::TextDelta {
859 text: "event".to_string(),
860 },
861 )
862 .await;
863
864 let no_runs = InMemoryRunStore::with_retention(Some(0), Some(0));
865 no_runs.replace_records(source.records().await).await;
866 assert!(no_runs.records().await.is_empty());
867
868 let no_events = InMemoryRunStore::with_retention(None, Some(0));
869 no_events.replace_records(source.records().await).await;
870 let records = no_events.records().await;
871 assert_eq!(records.len(), 1);
872 assert!(records[0].events.is_empty());
873 assert_eq!(records[0].snapshot.event_count, 1);
874 }
875
876 #[tokio::test]
877 async fn max_runs_evicts_oldest() {
878 let store = InMemoryRunStore::with_retention(Some(2), None);
879 let _ = store.create_run("session-1", "prompt-1").await;
880 let r2 = store.create_run("session-1", "prompt-2").await;
881 let r3 = store.create_run("session-1", "prompt-3").await;
882
883 assert_eq!(store.list().await.len(), 2);
885 let ids: Vec<String> = store.list().await.into_iter().map(|r| r.id).collect();
886 assert!(ids.contains(&r2.id));
887 assert!(ids.contains(&r3.id));
888 assert!(store.events(&r2.id).await.is_empty());
889 let surviving_event_count: usize =
891 store.events(&r2.id).await.len() + store.events(&r3.id).await.len();
892 assert_eq!(surviving_event_count, 0);
893 }
894
895 #[tokio::test]
896 async fn max_events_per_run_caps_event_buffer() {
897 let store = InMemoryRunStore::with_retention(None, Some(3));
898 let run = store.create_run("session-1", "prompt").await;
899 for _ in 0..10 {
900 store
901 .record_event(
902 &run.id,
903 AgentEvent::TextDelta {
904 text: "x".to_string(),
905 },
906 )
907 .await;
908 }
909 let events = store.events(&run.id).await;
910 assert_eq!(
911 events.len(),
912 3,
913 "buffer must be capped at max_events_per_run"
914 );
915 let snap = store.snapshot(&run.id).await.unwrap();
918 assert_eq!(snap.event_count, 10);
919 }
920
921 #[tokio::test]
922 async fn max_event_bytes_per_run_drops_oversized_live_event_but_advances_cursor() {
923 let store = InMemoryRunStore::with_retention_limits(None, None, Some(0));
924 let run = store.create_run("session-1", "prompt").await;
925
926 store
927 .record_event(
928 &run.id,
929 AgentEvent::TextDelta {
930 text: "oversized".to_string(),
931 },
932 )
933 .await;
934
935 assert!(store.events(&run.id).await.is_empty());
936 let snapshot = store.snapshot(&run.id).await.unwrap();
937 assert_eq!(snapshot.event_count, 1);
938 }
939
940 #[tokio::test]
941 async fn replace_records_enforces_serialized_event_byte_cap_fifo() {
942 let source = InMemoryRunStore::new();
943 let run = source.create_run("session-1", "prompt").await;
944 for text in ["old", "middle", "new"] {
945 source
946 .record_event(
947 &run.id,
948 AgentEvent::TextDelta {
949 text: text.to_string(),
950 },
951 )
952 .await;
953 }
954 let source_records = source.records().await;
955 let retained_bytes = source_records[0].events[1..]
956 .iter()
957 .map(serialized_event_record_len)
958 .sum();
959
960 let restored = InMemoryRunStore::with_retention_limits(None, None, Some(retained_bytes));
961 restored.replace_records(source_records).await;
962
963 let records = restored.records().await;
964 assert_eq!(records[0].snapshot.event_count, 3);
965 assert_eq!(
966 records[0]
967 .events
968 .iter()
969 .map(|event| event.sequence)
970 .collect::<Vec<_>>(),
971 vec![1, 2]
972 );
973 }
974
975 #[tokio::test]
976 async fn retained_event_sequences_remain_monotonic_after_fifo_trim() {
977 let store = InMemoryRunStore::with_retention(None, Some(3));
978 let run = store.create_run("session-1", "prompt").await;
979
980 for index in 0..10 {
981 store
982 .record_event(
983 &run.id,
984 AgentEvent::TextDelta {
985 text: index.to_string(),
986 },
987 )
988 .await;
989 }
990
991 let sequences = store
992 .events(&run.id)
993 .await
994 .into_iter()
995 .map(|record| record.sequence)
996 .collect::<Vec<_>>();
997 assert_eq!(sequences, vec![7, 8, 9]);
998 assert!(sequences.windows(2).all(|pair| pair[0] < pair[1]));
999 }
1000
1001 #[tokio::test]
1002 async fn restored_run_continues_sequence_from_cumulative_event_count() {
1003 let source = InMemoryRunStore::with_retention(None, Some(3));
1004 let run = source.create_run("session-1", "prompt").await;
1005 for index in 0..10 {
1006 source
1007 .record_event(
1008 &run.id,
1009 AgentEvent::TextDelta {
1010 text: index.to_string(),
1011 },
1012 )
1013 .await;
1014 }
1015
1016 let restored = InMemoryRunStore::with_retention(None, Some(3));
1017 restored.replace_records(source.records().await).await;
1018 restored
1019 .record_event(
1020 &run.id,
1021 AgentEvent::TextDelta {
1022 text: "after restore".to_string(),
1023 },
1024 )
1025 .await;
1026
1027 let sequences = restored
1028 .events(&run.id)
1029 .await
1030 .into_iter()
1031 .map(|record| record.sequence)
1032 .collect::<Vec<_>>();
1033 assert_eq!(sequences, vec![8, 9, 10]);
1034 assert_eq!(restored.snapshot(&run.id).await.unwrap().event_count, 11);
1035 }
1036
1037 #[tokio::test]
1038 async fn event_page_reports_retention_gap_and_paginates_from_cursor() {
1039 let store = InMemoryRunStore::with_retention(None, Some(3));
1040 let run = store.create_run("session-1", "prompt").await;
1041 for index in 0..6 {
1042 store
1043 .record_event(
1044 &run.id,
1045 AgentEvent::TextDelta {
1046 text: index.to_string(),
1047 },
1048 )
1049 .await;
1050 }
1051
1052 let first = store.event_page(&run.id, None, 2).await.unwrap();
1053 assert_eq!(first.first_available_sequence, Some(3));
1054 assert_eq!(first.latest_sequence_exclusive, 6);
1055 assert!(first.retention_gap);
1056 assert!(first.has_more);
1057 assert_eq!(first.next_after_sequence, Some(4));
1058 assert_eq!(
1059 first
1060 .events
1061 .iter()
1062 .map(|event| event.sequence)
1063 .collect::<Vec<_>>(),
1064 vec![3, 4]
1065 );
1066
1067 let second = store
1068 .event_page(&run.id, first.next_after_sequence, 2)
1069 .await
1070 .unwrap();
1071 assert!(!second.retention_gap);
1072 assert!(!second.has_more);
1073 assert_eq!(second.next_after_sequence, Some(5));
1074 assert_eq!(second.events[0].sequence, 5);
1075 assert!(store.event_page("missing", None, 10).await.is_none());
1076 }
1077
1078 #[tokio::test]
1079 async fn event_page_reports_gap_when_retention_keeps_no_events() {
1080 let store = InMemoryRunStore::with_retention(None, Some(0));
1081 let run = store.create_run("session-1", "prompt").await;
1082 store
1083 .record_event(
1084 &run.id,
1085 AgentEvent::TextDelta {
1086 text: "gone".to_string(),
1087 },
1088 )
1089 .await;
1090
1091 let page = store.event_page(&run.id, None, 10).await.unwrap();
1092 assert!(page.events.is_empty());
1093 assert_eq!(page.first_available_sequence, None);
1094 assert_eq!(page.latest_sequence_exclusive, 1);
1095 assert!(page.retention_gap);
1096 assert!(!page.has_more);
1097 }
1098
1099 #[tokio::test]
1100 async fn unlimited_retention_is_the_default() {
1101 let store = InMemoryRunStore::new();
1102 for i in 0..50 {
1103 let r = store.create_run("s", &format!("p{i}")).await;
1104 for _ in 0..20 {
1105 store
1106 .record_event(
1107 &r.id,
1108 AgentEvent::TextDelta {
1109 text: "y".to_string(),
1110 },
1111 )
1112 .await;
1113 }
1114 }
1115 assert_eq!(store.list().await.len(), 50);
1116 }
1117}
1118
1119#[derive(Clone)]
1120pub struct RunHandle {
1121 id: String,
1122 session_id: String,
1123 store: Arc<InMemoryRunStore>,
1124 cancel_token: Arc<Mutex<Option<CancellationToken>>>,
1125 current_run_id: Arc<Mutex<Option<String>>>,
1126 hook_executor: Option<Arc<dyn crate::hooks::HookExecutor>>,
1127}
1128
1129impl RunHandle {
1130 pub(crate) fn new(
1131 id: String,
1132 session_id: String,
1133 store: Arc<InMemoryRunStore>,
1134 cancel_token: Arc<Mutex<Option<CancellationToken>>>,
1135 current_run_id: Arc<Mutex<Option<String>>>,
1136 hook_executor: Option<Arc<dyn crate::hooks::HookExecutor>>,
1137 ) -> Self {
1138 Self {
1139 id,
1140 session_id,
1141 store,
1142 cancel_token,
1143 current_run_id,
1144 hook_executor,
1145 }
1146 }
1147
1148 pub fn id(&self) -> &str {
1149 &self.id
1150 }
1151
1152 pub fn session_id(&self) -> &str {
1153 &self.session_id
1154 }
1155
1156 pub async fn snapshot(&self) -> Option<RunSnapshot> {
1157 self.store.snapshot(&self.id).await
1158 }
1159
1160 pub async fn events(&self) -> Vec<RunEventRecord> {
1161 self.store.events(&self.id).await
1162 }
1163
1164 pub async fn status(&self) -> Option<RunStatus> {
1165 self.snapshot().await.map(|snapshot| snapshot.status)
1166 }
1167
1168 pub async fn cancel(&self) -> bool {
1169 let current_run_id = self.current_run_id.lock().await.clone();
1170 if current_run_id.as_deref() != Some(self.id.as_str()) {
1171 return false;
1172 }
1173
1174 let token = self.cancel_token.lock().await.clone();
1175 if let Some(token) = token {
1176 token.cancel();
1177 let _ = self.store.mark_cancelled(&self.id).await;
1178 if let Some(executor) = &self.hook_executor {
1179 executor
1180 .record_run_cancelled(&self.id, &self.session_id, Some("cancelled by host"))
1181 .await;
1182 }
1183 true
1184 } else {
1185 false
1186 }
1187 }
1188}
1189
1190fn apply_event_to_snapshot(run: &mut RunSnapshot, event: &AgentEvent) {
1191 if run.status.is_terminal() {
1195 return;
1196 }
1197
1198 match event {
1199 AgentEvent::Start { prompt } => {
1200 run.status = RunStatus::Executing;
1201 if run.prompt.is_empty() {
1202 run.prompt = prompt.clone();
1203 }
1204 }
1205 AgentEvent::PlanningStart { .. } => {
1206 run.status = RunStatus::Planning;
1207 }
1208 AgentEvent::StepStart { .. }
1209 | AgentEvent::ToolStart { .. }
1210 | AgentEvent::ToolExecutionStart { .. }
1211 | AgentEvent::TurnStart { .. }
1212 if !matches!(run.status, RunStatus::Planning) =>
1213 {
1214 run.status = RunStatus::Executing;
1215 }
1216 AgentEvent::End { text, .. } => {
1217 run.status = RunStatus::Completed;
1218 run.result_text = Some(text.clone());
1219 run.error = None;
1220 }
1221 AgentEvent::Error { message } => {
1222 run.status = RunStatus::Failed;
1223 run.error = Some(message.clone());
1224 }
1225 _ => {}
1226 }
1227}
1228
1229fn now_ms() -> u64 {
1230 std::time::SystemTime::now()
1231 .duration_since(std::time::UNIX_EPOCH)
1232 .map(|duration| duration.as_millis() as u64)
1233 .unwrap_or(0)
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238 use super::*;
1239
1240 fn cognitive_binding() -> crate::cognitive_context::CognitivePackageBindingV1 {
1241 let generation_digest =
1242 "sha256:aa0beeb62f1b7b21bf70f21e6f0e858a1e4b720d313f0907209b5b9dad2eeb20";
1243 let knowledge = crate::cognitive_context::CognitiveKnowledgeBindingV1::new(
1244 "domain-knowledge",
1245 "0.2",
1246 "sha256:1def786da6d190b7b3ce0176e71d99ff1cac3f8c8cc7c0f8b76a893c544e7a90",
1247 7,
1248 generation_digest,
1249 )
1250 .unwrap();
1251 crate::cognitive_context::CognitivePackageBindingV1::new(
1252 "contra-sense/handbook",
1253 "0.1.0",
1254 7,
1255 generation_digest,
1256 "sha256:1e0f0a0162f5b290887ade8886af69fbba4548c863df026178e3550c77813455",
1257 knowledge,
1258 crate::cognitive_context::CognitiveContextLimits::default(),
1259 )
1260 .unwrap()
1261 }
1262
1263 #[tokio::test]
1264 async fn run_store_tracks_status_and_events() {
1265 let store = InMemoryRunStore::new();
1266 let run = store.create_run("session-1", "fix tests").await;
1267
1268 store
1269 .record_event(
1270 &run.id,
1271 AgentEvent::Start {
1272 prompt: "fix tests".to_string(),
1273 },
1274 )
1275 .await;
1276 store
1277 .record_event(
1278 &run.id,
1279 AgentEvent::End {
1280 text: "done".to_string(),
1281 usage: Default::default(),
1282 verification_summary: Box::new(
1283 crate::verification::VerificationSummary::from_reports(&[]),
1284 ),
1285 meta: None,
1286 },
1287 )
1288 .await;
1289
1290 let snapshot = store.snapshot(&run.id).await.unwrap();
1291 assert_eq!(snapshot.status, RunStatus::Completed);
1292 assert_eq!(snapshot.result_text.as_deref(), Some("done"));
1293 assert_eq!(snapshot.event_count, 2);
1294 assert_eq!(store.events(&run.id).await.len(), 2);
1295 }
1296
1297 #[tokio::test]
1298 async fn cognitive_binding_is_exact_idempotent_and_pre_observation_only() {
1299 let store = InMemoryRunStore::new();
1300 let run = store.create_run("session-1", "query knowledge").await;
1301 let binding = cognitive_binding();
1302
1303 let bound = store
1304 .bind_cognitive_package(&run.id, binding.clone())
1305 .await
1306 .unwrap();
1307 assert_eq!(bound.cognitive_package_binding.as_ref(), Some(&binding));
1308 store
1309 .bind_cognitive_package(&run.id, binding.clone())
1310 .await
1311 .expect("exact binding replay is idempotent");
1312
1313 let mut conflict = binding.clone();
1314 conflict.limits.max_results -= 1;
1315 conflict.validate().unwrap();
1316 assert!(matches!(
1317 store.bind_cognitive_package(&run.id, conflict).await,
1318 Err(RunCognitiveBindingError::Conflict)
1319 ));
1320
1321 let late = store.create_run("session-1", "late binding").await;
1322 store
1323 .record_event(
1324 &late.id,
1325 AgentEvent::Start {
1326 prompt: "late binding".to_owned(),
1327 },
1328 )
1329 .await
1330 .unwrap();
1331 assert!(matches!(
1332 store.bind_cognitive_package(&late.id, binding).await,
1333 Err(RunCognitiveBindingError::AlreadyObserved)
1334 ));
1335 }
1336
1337 #[tokio::test]
1338 async fn event_observation_keeps_snapshot_and_page_in_one_generation() {
1339 let store = InMemoryRunStore::new();
1340 let run = store.create_run("session-1", "observe exactly").await;
1341 store
1342 .record_event(
1343 &run.id,
1344 AgentEvent::End {
1345 text: "done".to_string(),
1346 usage: Default::default(),
1347 verification_summary: Box::new(
1348 crate::verification::VerificationSummary::from_reports(&[]),
1349 ),
1350 meta: None,
1351 },
1352 )
1353 .await;
1354
1355 let observation = store
1356 .event_observation(&run.id, None, 64)
1357 .await
1358 .expect("known run observation");
1359
1360 assert_eq!(observation.snapshot.status, RunStatus::Completed);
1361 assert_eq!(
1362 observation.snapshot.event_count,
1363 observation.page.latest_sequence_exclusive
1364 );
1365 assert!(observation
1366 .page
1367 .events
1368 .iter()
1369 .all(|event| event.timestamp_ms <= observation.snapshot.updated_at_ms));
1370 assert!(store
1371 .event_observation("missing-run", None, 64)
1372 .await
1373 .is_none());
1374 }
1375
1376 #[tokio::test]
1377 async fn restored_logical_time_cannot_regress_new_event_observations() {
1378 let source = InMemoryRunStore::new();
1379 let run = source.create_run("session-1", "resume exactly").await;
1380 let failed_run = source.create_run("session-1", "fail exactly").await;
1381 let mut records = source.records().await;
1382 let persisted_time = now_ms().saturating_add(60_000);
1383 for record in &mut records {
1384 record.snapshot.updated_at_ms = persisted_time;
1385 }
1386
1387 let restored = InMemoryRunStore::new();
1388 restored.replace_records(records).await;
1389 restored
1390 .record_event(
1391 &run.id,
1392 AgentEvent::TextDelta {
1393 text: "after recovery".to_string(),
1394 },
1395 )
1396 .await;
1397
1398 let observation = restored
1399 .event_observation(&run.id, None, 64)
1400 .await
1401 .expect("restored run observation");
1402 assert!(observation.snapshot.updated_at_ms >= persisted_time);
1403 assert!(observation
1404 .page
1405 .events
1406 .iter()
1407 .all(|event| event.timestamp_ms >= persisted_time));
1408
1409 let cancelled = restored
1410 .mark_cancelled(&run.id)
1411 .await
1412 .expect("restored run cancellation");
1413 assert!(cancelled.updated_at_ms >= persisted_time);
1414 let failed = restored
1415 .mark_failed(&failed_run.id, "provider failed")
1416 .await
1417 .expect("restored run failure");
1418 assert!(failed.updated_at_ms >= persisted_time);
1419 }
1420
1421 #[tokio::test]
1422 async fn run_store_replaces_persisted_records() {
1423 let source = InMemoryRunStore::new();
1424 let run = source.create_run("session-1", "persist").await;
1425 source
1426 .record_event(
1427 &run.id,
1428 AgentEvent::Start {
1429 prompt: "persist".to_string(),
1430 },
1431 )
1432 .await;
1433
1434 let target = InMemoryRunStore::new();
1435 target.replace_records(source.records().await).await;
1436
1437 assert_eq!(target.list().await.len(), 1);
1438 assert_eq!(target.events(&run.id).await.len(), 1);
1439 assert_eq!(target.snapshot(&run.id).await.unwrap().event_count, 1);
1440 }
1441
1442 #[tokio::test]
1443 async fn run_handle_only_cancels_current_run() {
1444 let store = Arc::new(InMemoryRunStore::new());
1445 let run = store.create_run("session-1", "fix tests").await;
1446 let cancel_token = Arc::new(Mutex::new(Some(CancellationToken::new())));
1447 let current_run_id = Arc::new(Mutex::new(Some(run.id.clone())));
1448 let handle = RunHandle::new(
1449 run.id.clone(),
1450 run.session_id.clone(),
1451 store.clone(),
1452 cancel_token,
1453 current_run_id.clone(),
1454 None,
1455 );
1456
1457 assert!(handle.cancel().await);
1458 assert_eq!(handle.status().await, Some(RunStatus::Cancelled));
1459
1460 *current_run_id.lock().await = Some("other-run".to_string());
1461 assert!(!handle.cancel().await);
1462 }
1463
1464 #[tokio::test]
1465 async fn late_events_cannot_regress_a_terminal_run_status() {
1466 let store = InMemoryRunStore::new();
1467 let cancelled = store.create_run("session-1", "cancelled").await;
1468 store.mark_cancelled(&cancelled.id).await;
1469 store
1470 .record_event(&cancelled.id, AgentEvent::TurnStart { turn: 2 })
1471 .await;
1472 assert_eq!(
1473 store.snapshot(&cancelled.id).await.unwrap().status,
1474 RunStatus::Cancelled
1475 );
1476
1477 let completed = store.create_run("session-1", "completed").await;
1478 store
1479 .record_event(
1480 &completed.id,
1481 AgentEvent::End {
1482 text: "done".to_string(),
1483 usage: Default::default(),
1484 verification_summary: Box::new(
1485 crate::verification::VerificationSummary::from_reports(&[]),
1486 ),
1487 meta: None,
1488 },
1489 )
1490 .await;
1491 store
1492 .record_event(
1493 &completed.id,
1494 AgentEvent::ToolExecutionStart {
1495 id: "late-tool".to_string(),
1496 name: "bash".to_string(),
1497 args: serde_json::json!({}),
1498 },
1499 )
1500 .await;
1501 assert_eq!(
1502 store.snapshot(&completed.id).await.unwrap().status,
1503 RunStatus::Completed
1504 );
1505 }
1506}