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