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 pub created_at_ms: u64,
52 pub updated_at_ms: u64,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub result_text: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub error: Option<String>,
57 pub event_count: usize,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub workspace_change_set: Option<RunWorkspaceChangeSet>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct RunWorkspaceChangeSet {
65 pub base_tree: String,
66 pub result_tree: String,
67 pub patch_digest: String,
68 pub patch_bytes: u64,
69 pub patch_base64: String,
70 pub observed_at_ms: u64,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
74pub enum RunWorkspaceChangeSetError {
75 #[error("run was not found")]
76 RunNotFound,
77 #[error("run is not terminal")]
78 RunNotTerminal,
79 #[error("run workspace change set conflicts with immutable evidence")]
80 Conflict,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct RunRecord {
85 pub snapshot: RunSnapshot,
86 pub events: Vec<RunEventRecord>,
87}
88
89#[derive(Debug, Clone)]
91pub enum RunReservation {
92 Created(RunSnapshot),
93 Existing(RunSnapshot),
94}
95
96impl RunReservation {
97 pub fn snapshot(&self) -> &RunSnapshot {
98 match self {
99 Self::Created(snapshot) | Self::Existing(snapshot) => snapshot,
100 }
101 }
102
103 pub const fn replayed(&self) -> bool {
104 matches!(self, Self::Existing(_))
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct RunEventPage {
111 pub events: Vec<RunEventRecord>,
112 pub first_available_sequence: Option<usize>,
114 pub latest_sequence_exclusive: usize,
116 pub next_after_sequence: Option<usize>,
118 pub retention_gap: bool,
120 pub has_more: bool,
121}
122
123#[derive(Debug, Default)]
124struct RetainedRunEvents {
125 records: Vec<RunEventRecord>,
126 serialized_bytes: usize,
127}
128
129impl RunSnapshot {
130 fn new(id: String, session_id: String, prompt: String) -> Self {
131 let now = now_ms();
132 Self {
133 id,
134 session_id,
135 status: RunStatus::Created,
136 prompt,
137 created_at_ms: now,
138 updated_at_ms: now,
139 result_text: None,
140 error: None,
141 event_count: 0,
142 workspace_change_set: None,
143 }
144 }
145}
146
147#[derive(Debug, Default)]
148pub struct InMemoryRunStore {
149 runs: RwLock<HashMap<String, RunSnapshot>>,
150 events: RwLock<HashMap<String, RetainedRunEvents>>,
151 insertion_order: RwLock<VecDeque<String>>,
154 max_runs: Option<usize>,
157 max_events_per_run: Option<usize>,
162 max_event_bytes_per_run: Option<usize>,
165}
166
167impl InMemoryRunStore {
168 pub fn new() -> Self {
169 Self::default()
170 }
171
172 pub fn with_retention(max_runs: Option<usize>, max_events_per_run: Option<usize>) -> Self {
175 Self::with_retention_limits(max_runs, max_events_per_run, None)
176 }
177
178 pub fn with_retention_limits(
180 max_runs: Option<usize>,
181 max_events_per_run: Option<usize>,
182 max_event_bytes_per_run: Option<usize>,
183 ) -> Self {
184 Self {
185 runs: RwLock::new(HashMap::new()),
186 events: RwLock::new(HashMap::new()),
187 insertion_order: RwLock::new(VecDeque::new()),
188 max_runs,
189 max_events_per_run,
190 max_event_bytes_per_run,
191 }
192 }
193
194 pub async fn create_run(&self, session_id: &str, prompt: &str) -> RunSnapshot {
195 let id = format!("run-{}", uuid::Uuid::new_v4());
199 self.create_run_with_id(id, session_id, prompt).await
200 }
201
202 pub async fn create_run_with_id(
206 &self,
207 id: String,
208 session_id: &str,
209 prompt: &str,
210 ) -> RunSnapshot {
211 let snapshot = RunSnapshot::new(id.clone(), session_id.to_string(), prompt.to_string());
212 {
221 let mut order = self.insertion_order.write().await;
222 let mut events = self.events.write().await;
223 let mut runs = self.runs.write().await;
224 runs.insert(id.clone(), snapshot.clone());
225 events.insert(id.clone(), RetainedRunEvents::default());
226 order.push_back(id);
227 if let Some(cap) = self.max_runs {
228 while order.len() > cap {
229 if let Some(victim) = order.pop_front() {
230 runs.remove(&victim);
231 events.remove(&victim);
232 }
233 }
234 }
235 }
236 snapshot
237 }
238
239 pub async fn reserve_run_with_id(
243 &self,
244 id: String,
245 session_id: &str,
246 prompt: &str,
247 ) -> RunReservation {
248 let mut order = self.insertion_order.write().await;
252 let mut events = self.events.write().await;
253 let mut runs = self.runs.write().await;
254 if let Some(existing) = runs.get(&id) {
255 return RunReservation::Existing(existing.clone());
256 }
257
258 let snapshot = RunSnapshot::new(id.clone(), session_id.to_string(), prompt.to_string());
259 runs.insert(id.clone(), snapshot.clone());
260 events.insert(id.clone(), RetainedRunEvents::default());
261 order.push_back(id);
262 if let Some(cap) = self.max_runs {
263 while order.len() > cap {
264 if let Some(victim) = order.pop_front() {
265 runs.remove(&victim);
266 events.remove(&victim);
267 }
268 }
269 }
270 RunReservation::Created(snapshot)
271 }
272
273 pub async fn record_event(&self, run_id: &str, event: AgentEvent) -> Option<RunSnapshot> {
274 let mut events = self.events.write().await;
275 let mut runs = self.runs.write().await;
276 let run_events = events.get_mut(run_id)?;
277 let run = runs.get_mut(run_id)?;
278
279 let sequence = run.event_count;
285 let record = RunEventRecord {
286 sequence,
287 timestamp_ms: now_ms(),
288 event: event.clone(),
289 };
290 run_events.serialized_bytes = run_events
291 .serialized_bytes
292 .saturating_add(serialized_event_record_len(&record));
293 run_events.records.push(record);
294 trim_retained_events(
295 run_events,
296 self.max_events_per_run,
297 self.max_event_bytes_per_run,
298 );
299 apply_event_to_snapshot(run, &event);
300 run.event_count += 1;
301 run.updated_at_ms = now_ms();
302 Some(run.clone())
303 }
304
305 pub async fn mark_failed(&self, run_id: &str, error: impl Into<String>) -> Option<RunSnapshot> {
306 let mut runs = self.runs.write().await;
307 let run = runs.get_mut(run_id)?;
308 if run.status == RunStatus::Cancelled {
309 return Some(run.clone());
310 }
311 run.status = RunStatus::Failed;
312 run.error = Some(error.into());
313 run.updated_at_ms = now_ms();
314 Some(run.clone())
315 }
316
317 pub async fn mark_cancelled(&self, run_id: &str) -> Option<RunSnapshot> {
318 let mut runs = self.runs.write().await;
319 let run = runs.get_mut(run_id)?;
320 run.status = RunStatus::Cancelled;
321 run.updated_at_ms = now_ms();
322 Some(run.clone())
323 }
324
325 pub async fn snapshot(&self, run_id: &str) -> Option<RunSnapshot> {
326 self.runs.read().await.get(run_id).cloned()
327 }
328
329 pub async fn record_workspace_change_set(
332 &self,
333 run_id: &str,
334 change_set: RunWorkspaceChangeSet,
335 ) -> Result<RunSnapshot, RunWorkspaceChangeSetError> {
336 let mut runs = self.runs.write().await;
337 let run = runs
338 .get_mut(run_id)
339 .ok_or(RunWorkspaceChangeSetError::RunNotFound)?;
340 if !run.status.is_terminal() {
341 return Err(RunWorkspaceChangeSetError::RunNotTerminal);
342 }
343 match &run.workspace_change_set {
344 Some(existing) if existing == &change_set => return Ok(run.clone()),
345 Some(_) => return Err(RunWorkspaceChangeSetError::Conflict),
346 None => {}
347 }
348 run.workspace_change_set = Some(change_set);
349 Ok(run.clone())
350 }
351
352 pub async fn events(&self, run_id: &str) -> Vec<RunEventRecord> {
353 self.events
354 .read()
355 .await
356 .get(run_id)
357 .map(|events| events.records.clone())
358 .unwrap_or_default()
359 }
360
361 pub async fn event_page(
366 &self,
367 run_id: &str,
368 after_sequence: Option<usize>,
369 limit: usize,
370 ) -> Option<RunEventPage> {
371 let events = self.events.read().await;
373 let runs = self.runs.read().await;
374 let retained = events.get(run_id)?;
375 let run = runs.get(run_id)?;
376 let first_available_sequence = retained.records.first().map(|event| event.sequence);
377 let requested_start = match after_sequence {
378 Some(sequence) => sequence.saturating_add(1),
379 None => 0,
380 };
381 let retention_gap = if requested_start >= run.event_count {
382 false
383 } else {
384 first_available_sequence
385 .map(|first| requested_start < first)
386 .unwrap_or(true)
387 };
388 let mut matching = retained
389 .records
390 .iter()
391 .filter(|event| after_sequence.is_none_or(|cursor| event.sequence > cursor));
392 let page_events = matching.by_ref().take(limit).cloned().collect::<Vec<_>>();
393 let has_more = matching.next().is_some();
394 let next_after_sequence = page_events
395 .last()
396 .map(|event| event.sequence)
397 .or(after_sequence);
398 Some(RunEventPage {
399 events: page_events,
400 first_available_sequence,
401 latest_sequence_exclusive: run.event_count,
402 next_after_sequence,
403 retention_gap,
404 has_more,
405 })
406 }
407
408 pub async fn list(&self) -> Vec<RunSnapshot> {
409 let order = self.insertion_order.read().await;
410 let runs = self.runs.read().await;
411 order
412 .iter()
413 .filter_map(|run_id| runs.get(run_id).cloned())
414 .collect()
415 }
416
417 pub async fn records(&self) -> Vec<RunRecord> {
418 let order = self.insertion_order.read().await;
424 let events = self.events.read().await;
425 let runs = self.runs.read().await;
426 order
427 .iter()
428 .filter_map(|run_id| {
429 let snapshot = runs.get(run_id)?.clone();
430 Some(RunRecord {
431 events: events
432 .get(run_id)
433 .map(|events| events.records.clone())
434 .unwrap_or_default(),
435 snapshot,
436 })
437 })
438 .collect()
439 }
440
441 pub async fn replace_records(&self, records: Vec<RunRecord>) {
442 let mut sorted = records;
446 sorted.sort_by_key(|r| r.snapshot.created_at_ms);
447 if let Some(cap) = self.max_runs {
448 let excess = sorted.len().saturating_sub(cap);
449 if excess > 0 {
450 sorted.drain(..excess);
451 }
452 }
453 let mut run_map = HashMap::new();
454 let mut event_map = HashMap::new();
455 let mut order = VecDeque::with_capacity(sorted.len());
456 for record in sorted {
457 let id = record.snapshot.id.clone();
458 let mut retained = RetainedRunEvents {
466 serialized_bytes: record
467 .events
468 .iter()
469 .map(serialized_event_record_len)
470 .fold(0usize, usize::saturating_add),
471 records: record.events,
472 };
473 trim_retained_events(
474 &mut retained,
475 self.max_events_per_run,
476 self.max_event_bytes_per_run,
477 );
478 event_map.insert(id.clone(), retained);
479 run_map.insert(id.clone(), record.snapshot);
480 order.push_back(id);
481 }
482 let mut stored_order = self.insertion_order.write().await;
486 let mut stored_events = self.events.write().await;
487 let mut stored_runs = self.runs.write().await;
488 *stored_runs = run_map;
489 *stored_events = event_map;
490 *stored_order = order;
491 }
492}
493
494fn serialized_event_record_len(record: &RunEventRecord) -> usize {
495 serde_json::to_vec(record)
496 .map(|encoded| encoded.len())
497 .unwrap_or(usize::MAX)
498}
499
500fn trim_retained_events(
501 events: &mut RetainedRunEvents,
502 max_events: Option<usize>,
503 max_bytes: Option<usize>,
504) {
505 let count_excess = max_events
506 .map(|cap| events.records.len().saturating_sub(cap))
507 .unwrap_or(0);
508 let mut remove_count = count_excess;
509 let mut remaining_bytes = events.serialized_bytes;
510 for record in events.records.iter().take(remove_count) {
511 remaining_bytes = remaining_bytes.saturating_sub(serialized_event_record_len(record));
512 }
513 while max_bytes.is_some_and(|cap| remaining_bytes > cap) && remove_count < events.records.len()
514 {
515 remaining_bytes = remaining_bytes
516 .saturating_sub(serialized_event_record_len(&events.records[remove_count]));
517 remove_count += 1;
518 }
519 for record in events.records.iter().take(remove_count) {
520 events.serialized_bytes = events
521 .serialized_bytes
522 .saturating_sub(serialized_event_record_len(record));
523 }
524 if remove_count > 0 {
525 events.records.drain(..remove_count);
526 }
527}
528
529#[cfg(test)]
530mod retention_tests {
531 use super::*;
532
533 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
534 async fn exact_run_reservation_is_atomic_and_never_replaces_the_winner() {
535 let store = Arc::new(InMemoryRunStore::new());
536 let mut reservations = Vec::new();
537 for index in 0..32 {
538 let store = Arc::clone(&store);
539 reservations.push(tokio::spawn(async move {
540 store
541 .reserve_run_with_id(
542 "run-cloud-1".to_string(),
543 "session-cloud-1",
544 &format!("prompt-{index}"),
545 )
546 .await
547 }));
548 }
549
550 let mut created = 0;
551 for reservation in reservations {
552 if !reservation.await.unwrap().replayed() {
553 created += 1;
554 }
555 }
556 assert_eq!(created, 1);
557
558 let winner = store.snapshot("run-cloud-1").await.unwrap();
559 let replay = store
560 .reserve_run_with_id(
561 "run-cloud-1".to_string(),
562 "another-session",
563 "replacement prompt",
564 )
565 .await;
566 assert!(replay.replayed());
567 assert_eq!(replay.snapshot().session_id, winner.session_id);
568 assert_eq!(replay.snapshot().prompt, winner.prompt);
569 assert_eq!(store.list().await.len(), 1);
570 }
571
572 #[tokio::test]
573 async fn workspace_change_set_is_terminal_and_immutable() {
574 let store = InMemoryRunStore::new();
575 let run = store.create_run("session-1", "change the workspace").await;
576 let evidence = RunWorkspaceChangeSet {
577 base_tree: format!("git-tree:{}", "1".repeat(40)),
578 result_tree: format!("git-tree:{}", "2".repeat(40)),
579 patch_digest: format!("sha256:{}", "3".repeat(64)),
580 patch_bytes: 0,
581 patch_base64: String::new(),
582 observed_at_ms: 1,
583 };
584
585 assert!(matches!(
586 store
587 .record_workspace_change_set(&run.id, evidence.clone())
588 .await,
589 Err(RunWorkspaceChangeSetError::RunNotTerminal)
590 ));
591 store.mark_failed(&run.id, "fixture failure").await.unwrap();
592 assert_eq!(
593 store
594 .record_workspace_change_set(&run.id, evidence.clone())
595 .await
596 .unwrap()
597 .workspace_change_set,
598 Some(evidence.clone())
599 );
600 store
601 .record_workspace_change_set(&run.id, evidence.clone())
602 .await
603 .expect("exact evidence replay is idempotent");
604
605 let mut conflict = evidence;
606 conflict.result_tree = format!("git-tree:{}", "4".repeat(40));
607 assert!(matches!(
608 store.record_workspace_change_set(&run.id, conflict).await,
609 Err(RunWorkspaceChangeSetError::Conflict)
610 ));
611 }
612
613 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
614 async fn concurrent_create_and_record_under_cap_does_not_deadlock() {
615 let store = std::sync::Arc::new(InMemoryRunStore::with_retention(Some(10), None));
619 let mut handles = Vec::new();
620 for i in 0..100 {
621 let s = std::sync::Arc::clone(&store);
622 handles.push(tokio::spawn(async move {
623 let r = s.create_run("sess", &format!("p{i}")).await;
624 for _ in 0..5 {
625 s.record_event(
626 &r.id,
627 AgentEvent::TextDelta {
628 text: "x".to_string(),
629 },
630 )
631 .await;
632 }
633 }));
634 }
635 for h in handles {
636 h.await.unwrap();
637 }
638 assert!(store.list().await.len() <= 10);
641 }
642
643 #[tokio::test]
644 async fn replace_records_preserves_cumulative_event_count_after_trim() {
645 let src = InMemoryRunStore::with_retention(None, Some(3));
647 let run = src.create_run("s", "p").await;
648 for _ in 0..10 {
649 src.record_event(
650 &run.id,
651 AgentEvent::TextDelta {
652 text: "x".to_string(),
653 },
654 )
655 .await;
656 }
657 let records = src.records().await;
658 assert_eq!(records.len(), 1);
660 assert_eq!(records[0].events.len(), 3, "buffer trimmed to cap");
661 assert_eq!(records[0].snapshot.event_count, 10, "cumulative preserved");
662
663 let dst = InMemoryRunStore::new();
665 dst.replace_records(records).await;
666 let restored = dst.snapshot(&run.id).await.unwrap();
667 assert_eq!(
668 restored.event_count, 10,
669 "replace_records must NOT reset event_count to the trimmed buffer length"
670 );
671 assert_eq!(dst.events(&run.id).await.len(), 3);
673 }
674
675 #[tokio::test]
676 async fn replace_records_enforces_run_and_event_caps() {
677 let source = InMemoryRunStore::new();
678 for run_index in 0..4 {
679 let run = source
680 .create_run_with_id(
681 format!("run-{run_index}"),
682 "session-1",
683 &format!("prompt-{run_index}"),
684 )
685 .await;
686 for event_index in 0..5 {
687 source
688 .record_event(
689 &run.id,
690 AgentEvent::TextDelta {
691 text: format!("{run_index}:{event_index}"),
692 },
693 )
694 .await;
695 }
696 }
697
698 let restored = InMemoryRunStore::with_retention(Some(2), Some(2));
699 restored.replace_records(source.records().await).await;
700
701 let records = restored.records().await;
702 assert_eq!(
703 records
704 .iter()
705 .map(|record| record.snapshot.id.as_str())
706 .collect::<Vec<_>>(),
707 vec!["run-2", "run-3"],
708 "restore must keep the newest runs under the same FIFO policy as live writes"
709 );
710 for (run_index, record) in records.iter().enumerate() {
711 assert_eq!(record.snapshot.event_count, 5);
712 assert_eq!(record.events.len(), 2);
713 assert_eq!(record.events[0].sequence, 3);
714 assert_eq!(record.events[1].sequence, 4);
715 assert_eq!(record.snapshot.id, format!("run-{}", run_index + 2));
716 }
717 }
718
719 #[tokio::test]
720 async fn replace_records_honors_zero_caps() {
721 let source = InMemoryRunStore::new();
722 let run = source.create_run("session-1", "prompt").await;
723 source
724 .record_event(
725 &run.id,
726 AgentEvent::TextDelta {
727 text: "event".to_string(),
728 },
729 )
730 .await;
731
732 let no_runs = InMemoryRunStore::with_retention(Some(0), Some(0));
733 no_runs.replace_records(source.records().await).await;
734 assert!(no_runs.records().await.is_empty());
735
736 let no_events = InMemoryRunStore::with_retention(None, Some(0));
737 no_events.replace_records(source.records().await).await;
738 let records = no_events.records().await;
739 assert_eq!(records.len(), 1);
740 assert!(records[0].events.is_empty());
741 assert_eq!(records[0].snapshot.event_count, 1);
742 }
743
744 #[tokio::test]
745 async fn max_runs_evicts_oldest() {
746 let store = InMemoryRunStore::with_retention(Some(2), None);
747 let _ = store.create_run("session-1", "prompt-1").await;
748 let r2 = store.create_run("session-1", "prompt-2").await;
749 let r3 = store.create_run("session-1", "prompt-3").await;
750
751 assert_eq!(store.list().await.len(), 2);
753 let ids: Vec<String> = store.list().await.into_iter().map(|r| r.id).collect();
754 assert!(ids.contains(&r2.id));
755 assert!(ids.contains(&r3.id));
756 assert!(store.events(&r2.id).await.is_empty());
757 let surviving_event_count: usize =
759 store.events(&r2.id).await.len() + store.events(&r3.id).await.len();
760 assert_eq!(surviving_event_count, 0);
761 }
762
763 #[tokio::test]
764 async fn max_events_per_run_caps_event_buffer() {
765 let store = InMemoryRunStore::with_retention(None, Some(3));
766 let run = store.create_run("session-1", "prompt").await;
767 for _ in 0..10 {
768 store
769 .record_event(
770 &run.id,
771 AgentEvent::TextDelta {
772 text: "x".to_string(),
773 },
774 )
775 .await;
776 }
777 let events = store.events(&run.id).await;
778 assert_eq!(
779 events.len(),
780 3,
781 "buffer must be capped at max_events_per_run"
782 );
783 let snap = store.snapshot(&run.id).await.unwrap();
786 assert_eq!(snap.event_count, 10);
787 }
788
789 #[tokio::test]
790 async fn max_event_bytes_per_run_drops_oversized_live_event_but_advances_cursor() {
791 let store = InMemoryRunStore::with_retention_limits(None, None, Some(0));
792 let run = store.create_run("session-1", "prompt").await;
793
794 store
795 .record_event(
796 &run.id,
797 AgentEvent::TextDelta {
798 text: "oversized".to_string(),
799 },
800 )
801 .await;
802
803 assert!(store.events(&run.id).await.is_empty());
804 let snapshot = store.snapshot(&run.id).await.unwrap();
805 assert_eq!(snapshot.event_count, 1);
806 }
807
808 #[tokio::test]
809 async fn replace_records_enforces_serialized_event_byte_cap_fifo() {
810 let source = InMemoryRunStore::new();
811 let run = source.create_run("session-1", "prompt").await;
812 for text in ["old", "middle", "new"] {
813 source
814 .record_event(
815 &run.id,
816 AgentEvent::TextDelta {
817 text: text.to_string(),
818 },
819 )
820 .await;
821 }
822 let source_records = source.records().await;
823 let retained_bytes = source_records[0].events[1..]
824 .iter()
825 .map(serialized_event_record_len)
826 .sum();
827
828 let restored = InMemoryRunStore::with_retention_limits(None, None, Some(retained_bytes));
829 restored.replace_records(source_records).await;
830
831 let records = restored.records().await;
832 assert_eq!(records[0].snapshot.event_count, 3);
833 assert_eq!(
834 records[0]
835 .events
836 .iter()
837 .map(|event| event.sequence)
838 .collect::<Vec<_>>(),
839 vec![1, 2]
840 );
841 }
842
843 #[tokio::test]
844 async fn retained_event_sequences_remain_monotonic_after_fifo_trim() {
845 let store = InMemoryRunStore::with_retention(None, Some(3));
846 let run = store.create_run("session-1", "prompt").await;
847
848 for index in 0..10 {
849 store
850 .record_event(
851 &run.id,
852 AgentEvent::TextDelta {
853 text: index.to_string(),
854 },
855 )
856 .await;
857 }
858
859 let sequences = store
860 .events(&run.id)
861 .await
862 .into_iter()
863 .map(|record| record.sequence)
864 .collect::<Vec<_>>();
865 assert_eq!(sequences, vec![7, 8, 9]);
866 assert!(sequences.windows(2).all(|pair| pair[0] < pair[1]));
867 }
868
869 #[tokio::test]
870 async fn restored_run_continues_sequence_from_cumulative_event_count() {
871 let source = InMemoryRunStore::with_retention(None, Some(3));
872 let run = source.create_run("session-1", "prompt").await;
873 for index in 0..10 {
874 source
875 .record_event(
876 &run.id,
877 AgentEvent::TextDelta {
878 text: index.to_string(),
879 },
880 )
881 .await;
882 }
883
884 let restored = InMemoryRunStore::with_retention(None, Some(3));
885 restored.replace_records(source.records().await).await;
886 restored
887 .record_event(
888 &run.id,
889 AgentEvent::TextDelta {
890 text: "after restore".to_string(),
891 },
892 )
893 .await;
894
895 let sequences = restored
896 .events(&run.id)
897 .await
898 .into_iter()
899 .map(|record| record.sequence)
900 .collect::<Vec<_>>();
901 assert_eq!(sequences, vec![8, 9, 10]);
902 assert_eq!(restored.snapshot(&run.id).await.unwrap().event_count, 11);
903 }
904
905 #[tokio::test]
906 async fn event_page_reports_retention_gap_and_paginates_from_cursor() {
907 let store = InMemoryRunStore::with_retention(None, Some(3));
908 let run = store.create_run("session-1", "prompt").await;
909 for index in 0..6 {
910 store
911 .record_event(
912 &run.id,
913 AgentEvent::TextDelta {
914 text: index.to_string(),
915 },
916 )
917 .await;
918 }
919
920 let first = store.event_page(&run.id, None, 2).await.unwrap();
921 assert_eq!(first.first_available_sequence, Some(3));
922 assert_eq!(first.latest_sequence_exclusive, 6);
923 assert!(first.retention_gap);
924 assert!(first.has_more);
925 assert_eq!(first.next_after_sequence, Some(4));
926 assert_eq!(
927 first
928 .events
929 .iter()
930 .map(|event| event.sequence)
931 .collect::<Vec<_>>(),
932 vec![3, 4]
933 );
934
935 let second = store
936 .event_page(&run.id, first.next_after_sequence, 2)
937 .await
938 .unwrap();
939 assert!(!second.retention_gap);
940 assert!(!second.has_more);
941 assert_eq!(second.next_after_sequence, Some(5));
942 assert_eq!(second.events[0].sequence, 5);
943 assert!(store.event_page("missing", None, 10).await.is_none());
944 }
945
946 #[tokio::test]
947 async fn event_page_reports_gap_when_retention_keeps_no_events() {
948 let store = InMemoryRunStore::with_retention(None, Some(0));
949 let run = store.create_run("session-1", "prompt").await;
950 store
951 .record_event(
952 &run.id,
953 AgentEvent::TextDelta {
954 text: "gone".to_string(),
955 },
956 )
957 .await;
958
959 let page = store.event_page(&run.id, None, 10).await.unwrap();
960 assert!(page.events.is_empty());
961 assert_eq!(page.first_available_sequence, None);
962 assert_eq!(page.latest_sequence_exclusive, 1);
963 assert!(page.retention_gap);
964 assert!(!page.has_more);
965 }
966
967 #[tokio::test]
968 async fn unlimited_retention_is_the_default() {
969 let store = InMemoryRunStore::new();
970 for i in 0..50 {
971 let r = store.create_run("s", &format!("p{i}")).await;
972 for _ in 0..20 {
973 store
974 .record_event(
975 &r.id,
976 AgentEvent::TextDelta {
977 text: "y".to_string(),
978 },
979 )
980 .await;
981 }
982 }
983 assert_eq!(store.list().await.len(), 50);
984 }
985}
986
987#[derive(Clone)]
988pub struct RunHandle {
989 id: String,
990 session_id: String,
991 store: Arc<InMemoryRunStore>,
992 cancel_token: Arc<Mutex<Option<CancellationToken>>>,
993 current_run_id: Arc<Mutex<Option<String>>>,
994 hook_executor: Option<Arc<dyn crate::hooks::HookExecutor>>,
995}
996
997impl RunHandle {
998 pub(crate) fn new(
999 id: String,
1000 session_id: String,
1001 store: Arc<InMemoryRunStore>,
1002 cancel_token: Arc<Mutex<Option<CancellationToken>>>,
1003 current_run_id: Arc<Mutex<Option<String>>>,
1004 hook_executor: Option<Arc<dyn crate::hooks::HookExecutor>>,
1005 ) -> Self {
1006 Self {
1007 id,
1008 session_id,
1009 store,
1010 cancel_token,
1011 current_run_id,
1012 hook_executor,
1013 }
1014 }
1015
1016 pub fn id(&self) -> &str {
1017 &self.id
1018 }
1019
1020 pub fn session_id(&self) -> &str {
1021 &self.session_id
1022 }
1023
1024 pub async fn snapshot(&self) -> Option<RunSnapshot> {
1025 self.store.snapshot(&self.id).await
1026 }
1027
1028 pub async fn events(&self) -> Vec<RunEventRecord> {
1029 self.store.events(&self.id).await
1030 }
1031
1032 pub async fn status(&self) -> Option<RunStatus> {
1033 self.snapshot().await.map(|snapshot| snapshot.status)
1034 }
1035
1036 pub async fn cancel(&self) -> bool {
1037 let current_run_id = self.current_run_id.lock().await.clone();
1038 if current_run_id.as_deref() != Some(self.id.as_str()) {
1039 return false;
1040 }
1041
1042 let token = self.cancel_token.lock().await.clone();
1043 if let Some(token) = token {
1044 token.cancel();
1045 let _ = self.store.mark_cancelled(&self.id).await;
1046 if let Some(executor) = &self.hook_executor {
1047 executor
1048 .record_run_cancelled(&self.id, &self.session_id, Some("cancelled by host"))
1049 .await;
1050 }
1051 true
1052 } else {
1053 false
1054 }
1055 }
1056}
1057
1058fn apply_event_to_snapshot(run: &mut RunSnapshot, event: &AgentEvent) {
1059 if run.status.is_terminal() {
1063 return;
1064 }
1065
1066 match event {
1067 AgentEvent::Start { prompt } => {
1068 run.status = RunStatus::Executing;
1069 if run.prompt.is_empty() {
1070 run.prompt = prompt.clone();
1071 }
1072 }
1073 AgentEvent::PlanningStart { .. } => {
1074 run.status = RunStatus::Planning;
1075 }
1076 AgentEvent::StepStart { .. }
1077 | AgentEvent::ToolStart { .. }
1078 | AgentEvent::ToolExecutionStart { .. }
1079 | AgentEvent::TurnStart { .. }
1080 if !matches!(run.status, RunStatus::Planning) =>
1081 {
1082 run.status = RunStatus::Executing;
1083 }
1084 AgentEvent::End { text, .. } => {
1085 run.status = RunStatus::Completed;
1086 run.result_text = Some(text.clone());
1087 run.error = None;
1088 }
1089 AgentEvent::Error { message } => {
1090 run.status = RunStatus::Failed;
1091 run.error = Some(message.clone());
1092 }
1093 _ => {}
1094 }
1095}
1096
1097fn now_ms() -> u64 {
1098 std::time::SystemTime::now()
1099 .duration_since(std::time::UNIX_EPOCH)
1100 .map(|duration| duration.as_millis() as u64)
1101 .unwrap_or(0)
1102}
1103
1104#[cfg(test)]
1105mod tests {
1106 use super::*;
1107
1108 #[tokio::test]
1109 async fn run_store_tracks_status_and_events() {
1110 let store = InMemoryRunStore::new();
1111 let run = store.create_run("session-1", "fix tests").await;
1112
1113 store
1114 .record_event(
1115 &run.id,
1116 AgentEvent::Start {
1117 prompt: "fix tests".to_string(),
1118 },
1119 )
1120 .await;
1121 store
1122 .record_event(
1123 &run.id,
1124 AgentEvent::End {
1125 text: "done".to_string(),
1126 usage: Default::default(),
1127 verification_summary: Box::new(
1128 crate::verification::VerificationSummary::from_reports(&[]),
1129 ),
1130 meta: None,
1131 },
1132 )
1133 .await;
1134
1135 let snapshot = store.snapshot(&run.id).await.unwrap();
1136 assert_eq!(snapshot.status, RunStatus::Completed);
1137 assert_eq!(snapshot.result_text.as_deref(), Some("done"));
1138 assert_eq!(snapshot.event_count, 2);
1139 assert_eq!(store.events(&run.id).await.len(), 2);
1140 }
1141
1142 #[tokio::test]
1143 async fn run_store_replaces_persisted_records() {
1144 let source = InMemoryRunStore::new();
1145 let run = source.create_run("session-1", "persist").await;
1146 source
1147 .record_event(
1148 &run.id,
1149 AgentEvent::Start {
1150 prompt: "persist".to_string(),
1151 },
1152 )
1153 .await;
1154
1155 let target = InMemoryRunStore::new();
1156 target.replace_records(source.records().await).await;
1157
1158 assert_eq!(target.list().await.len(), 1);
1159 assert_eq!(target.events(&run.id).await.len(), 1);
1160 assert_eq!(target.snapshot(&run.id).await.unwrap().event_count, 1);
1161 }
1162
1163 #[tokio::test]
1164 async fn run_handle_only_cancels_current_run() {
1165 let store = Arc::new(InMemoryRunStore::new());
1166 let run = store.create_run("session-1", "fix tests").await;
1167 let cancel_token = Arc::new(Mutex::new(Some(CancellationToken::new())));
1168 let current_run_id = Arc::new(Mutex::new(Some(run.id.clone())));
1169 let handle = RunHandle::new(
1170 run.id.clone(),
1171 run.session_id.clone(),
1172 store.clone(),
1173 cancel_token,
1174 current_run_id.clone(),
1175 None,
1176 );
1177
1178 assert!(handle.cancel().await);
1179 assert_eq!(handle.status().await, Some(RunStatus::Cancelled));
1180
1181 *current_run_id.lock().await = Some("other-run".to_string());
1182 assert!(!handle.cancel().await);
1183 }
1184
1185 #[tokio::test]
1186 async fn late_events_cannot_regress_a_terminal_run_status() {
1187 let store = InMemoryRunStore::new();
1188 let cancelled = store.create_run("session-1", "cancelled").await;
1189 store.mark_cancelled(&cancelled.id).await;
1190 store
1191 .record_event(&cancelled.id, AgentEvent::TurnStart { turn: 2 })
1192 .await;
1193 assert_eq!(
1194 store.snapshot(&cancelled.id).await.unwrap().status,
1195 RunStatus::Cancelled
1196 );
1197
1198 let completed = store.create_run("session-1", "completed").await;
1199 store
1200 .record_event(
1201 &completed.id,
1202 AgentEvent::End {
1203 text: "done".to_string(),
1204 usage: Default::default(),
1205 verification_summary: Box::new(
1206 crate::verification::VerificationSummary::from_reports(&[]),
1207 ),
1208 meta: None,
1209 },
1210 )
1211 .await;
1212 store
1213 .record_event(
1214 &completed.id,
1215 AgentEvent::ToolExecutionStart {
1216 id: "late-tool".to_string(),
1217 name: "bash".to_string(),
1218 args: serde_json::json!({}),
1219 },
1220 )
1221 .await;
1222 assert_eq!(
1223 store.snapshot(&completed.id).await.unwrap().status,
1224 RunStatus::Completed
1225 );
1226 }
1227}