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