Skip to main content

a3s_code_core/
run.rs

1//! Durable run primitives for agent executions.
2//!
3//! This module is intentionally small: it records runtime events and maintains a
4//! stable run status snapshot that can be persisted by session stores.
5
6use 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
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct RunEventRecord {
27    pub sequence: usize,
28    pub timestamp_ms: u64,
29    pub event: AgentEvent,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct ActiveToolSnapshot {
34    pub id: String,
35    pub name: String,
36    pub started_at_ms: u64,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct RunSnapshot {
41    pub id: String,
42    pub session_id: String,
43    pub status: RunStatus,
44    pub prompt: String,
45    pub created_at_ms: u64,
46    pub updated_at_ms: u64,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub result_text: Option<String>,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub error: Option<String>,
51    pub event_count: usize,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct RunRecord {
56    pub snapshot: RunSnapshot,
57    pub events: Vec<RunEventRecord>,
58}
59
60/// Cursor-based view over the retained event window for one run.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct RunEventPage {
63    pub events: Vec<RunEventRecord>,
64    /// Oldest sequence still available, or `None` when no events are retained.
65    pub first_available_sequence: Option<usize>,
66    /// Exclusive upper bound for every event ever recorded by this run.
67    pub latest_sequence_exclusive: usize,
68    /// Cursor to pass as `after_sequence` for the next page.
69    pub next_after_sequence: Option<usize>,
70    /// True when events requested by the cursor have already been evicted.
71    pub retention_gap: bool,
72    pub has_more: bool,
73}
74
75#[derive(Debug, Default)]
76struct RetainedRunEvents {
77    records: Vec<RunEventRecord>,
78    serialized_bytes: usize,
79}
80
81impl RunSnapshot {
82    fn new(id: String, session_id: String, prompt: String) -> Self {
83        let now = now_ms();
84        Self {
85            id,
86            session_id,
87            status: RunStatus::Created,
88            prompt,
89            created_at_ms: now,
90            updated_at_ms: now,
91            result_text: None,
92            error: None,
93            event_count: 0,
94        }
95    }
96}
97
98#[derive(Debug, Default)]
99pub struct InMemoryRunStore {
100    runs: RwLock<HashMap<String, RunSnapshot>>,
101    events: RwLock<HashMap<String, RetainedRunEvents>>,
102    /// Insertion order of run ids — used to FIFO-evict the oldest run
103    /// when `max_runs` is set and exceeded.
104    insertion_order: RwLock<VecDeque<String>>,
105    /// Maximum number of runs retained. When exceeded, oldest run is
106    /// dropped along with its events. `None` = unlimited (default).
107    max_runs: Option<usize>,
108    /// Maximum number of events retained per run. When exceeded, the
109    /// oldest events are FIFO-dropped from that run's buffer. The
110    /// run's `event_count` field is **not** decremented — it stays as
111    /// the cumulative total ever recorded. `None` = unlimited.
112    max_events_per_run: Option<usize>,
113    /// Maximum serialized size of retained event records per run. This is
114    /// independent of the count cap and uses the same FIFO policy.
115    max_event_bytes_per_run: Option<usize>,
116}
117
118impl InMemoryRunStore {
119    pub fn new() -> Self {
120        Self::default()
121    }
122
123    /// Construct a store with optional FIFO retention caps. `None`
124    /// fields keep the unbounded default.
125    pub fn with_retention(max_runs: Option<usize>, max_events_per_run: Option<usize>) -> Self {
126        Self::with_retention_limits(max_runs, max_events_per_run, None)
127    }
128
129    /// Construct a store with count and serialized-byte FIFO retention caps.
130    pub fn with_retention_limits(
131        max_runs: Option<usize>,
132        max_events_per_run: Option<usize>,
133        max_event_bytes_per_run: Option<usize>,
134    ) -> Self {
135        Self {
136            runs: RwLock::new(HashMap::new()),
137            events: RwLock::new(HashMap::new()),
138            insertion_order: RwLock::new(VecDeque::new()),
139            max_runs,
140            max_events_per_run,
141            max_event_bytes_per_run,
142        }
143    }
144
145    pub async fn create_run(&self, session_id: &str, prompt: &str) -> RunSnapshot {
146        // Default ID generation when the caller has no host_env handy.
147        // Production callers reach `create_run_with_id` via
148        // `RunControlState::start_run` so the host's IdGenerator is honored.
149        let id = format!("run-{}", uuid::Uuid::new_v4());
150        self.create_run_with_id(id, session_id, prompt).await
151    }
152
153    /// Create a run with a caller-supplied id. Used by the session
154    /// orchestration layer so the parent session's host-provided
155    /// [`IdGenerator`](crate::host_env::IdGenerator) governs run ids.
156    pub async fn create_run_with_id(
157        &self,
158        id: String,
159        session_id: &str,
160        prompt: &str,
161    ) -> RunSnapshot {
162        let snapshot = RunSnapshot::new(id.clone(), session_id.to_string(), prompt.to_string());
163        // Hold all three structures together for the insert + FIFO-evict so
164        // `runs`, `events`, and `insertion_order` never diverge under
165        // concurrent access (previously the maps were locked separately,
166        // leaving a window where a run existed in one map but not the
167        // other). Canonical acquisition order: order -> events -> runs.
168        // `record_event` uses the same events -> runs order. Other methods
169        // hold at most one of those locks, so holding both here cannot
170        // ABBA-deadlock against them.
171        {
172            let mut order = self.insertion_order.write().await;
173            let mut events = self.events.write().await;
174            let mut runs = self.runs.write().await;
175            runs.insert(id.clone(), snapshot.clone());
176            events.insert(id.clone(), RetainedRunEvents::default());
177            order.push_back(id);
178            if let Some(cap) = self.max_runs {
179                while order.len() > cap {
180                    if let Some(victim) = order.pop_front() {
181                        runs.remove(&victim);
182                        events.remove(&victim);
183                    }
184                }
185            }
186        }
187        snapshot
188    }
189
190    pub async fn record_event(&self, run_id: &str, event: AgentEvent) -> Option<RunSnapshot> {
191        let mut events = self.events.write().await;
192        let mut runs = self.runs.write().await;
193        let run_events = events.get_mut(run_id)?;
194        let run = runs.get_mut(run_id)?;
195
196        // `event_count` is cumulative and survives FIFO retention and
197        // persisted snapshot restoration, so it is the stable cursor for
198        // event sequencing. The retained buffer length is not: once a
199        // capped buffer is full it remains constant and would reuse the
200        // same sequence for every subsequent event.
201        let sequence = run.event_count;
202        let record = RunEventRecord {
203            sequence,
204            timestamp_ms: now_ms(),
205            event: event.clone(),
206        };
207        run_events.serialized_bytes = run_events
208            .serialized_bytes
209            .saturating_add(serialized_event_record_len(&record));
210        run_events.records.push(record);
211        trim_retained_events(
212            run_events,
213            self.max_events_per_run,
214            self.max_event_bytes_per_run,
215        );
216        apply_event_to_snapshot(run, &event);
217        run.event_count += 1;
218        run.updated_at_ms = now_ms();
219        Some(run.clone())
220    }
221
222    pub async fn mark_failed(&self, run_id: &str, error: impl Into<String>) -> Option<RunSnapshot> {
223        let mut runs = self.runs.write().await;
224        let run = runs.get_mut(run_id)?;
225        if run.status == RunStatus::Cancelled {
226            return Some(run.clone());
227        }
228        run.status = RunStatus::Failed;
229        run.error = Some(error.into());
230        run.updated_at_ms = now_ms();
231        Some(run.clone())
232    }
233
234    pub async fn mark_cancelled(&self, run_id: &str) -> Option<RunSnapshot> {
235        let mut runs = self.runs.write().await;
236        let run = runs.get_mut(run_id)?;
237        run.status = RunStatus::Cancelled;
238        run.updated_at_ms = now_ms();
239        Some(run.clone())
240    }
241
242    pub async fn snapshot(&self, run_id: &str) -> Option<RunSnapshot> {
243        self.runs.read().await.get(run_id).cloned()
244    }
245
246    pub async fn events(&self, run_id: &str) -> Vec<RunEventRecord> {
247        self.events
248            .read()
249            .await
250            .get(run_id)
251            .map(|events| events.records.clone())
252            .unwrap_or_default()
253    }
254
255    /// Return retained events strictly after `after_sequence`, bounded by
256    /// `limit`. The page reports when the requested cursor predates the
257    /// retained FIFO window. `None` distinguishes an unknown run from a known
258    /// run whose event window is empty.
259    pub async fn event_page(
260        &self,
261        run_id: &str,
262        after_sequence: Option<usize>,
263        limit: usize,
264    ) -> Option<RunEventPage> {
265        // Match the canonical events -> runs lock order used by record_event.
266        let events = self.events.read().await;
267        let runs = self.runs.read().await;
268        let retained = events.get(run_id)?;
269        let run = runs.get(run_id)?;
270        let first_available_sequence = retained.records.first().map(|event| event.sequence);
271        let requested_start = match after_sequence {
272            Some(sequence) => sequence.saturating_add(1),
273            None => 0,
274        };
275        let retention_gap = if requested_start >= run.event_count {
276            false
277        } else {
278            first_available_sequence
279                .map(|first| requested_start < first)
280                .unwrap_or(true)
281        };
282        let mut matching = retained
283            .records
284            .iter()
285            .filter(|event| after_sequence.is_none_or(|cursor| event.sequence > cursor));
286        let page_events = matching.by_ref().take(limit).cloned().collect::<Vec<_>>();
287        let has_more = matching.next().is_some();
288        let next_after_sequence = page_events
289            .last()
290            .map(|event| event.sequence)
291            .or(after_sequence);
292        Some(RunEventPage {
293            events: page_events,
294            first_available_sequence,
295            latest_sequence_exclusive: run.event_count,
296            next_after_sequence,
297            retention_gap,
298            has_more,
299        })
300    }
301
302    pub async fn list(&self) -> Vec<RunSnapshot> {
303        let order = self.insertion_order.read().await;
304        let runs = self.runs.read().await;
305        order
306            .iter()
307            .filter_map(|run_id| runs.get(run_id).cloned())
308            .collect()
309    }
310
311    pub async fn records(&self) -> Vec<RunRecord> {
312        // Preserve insertion order explicitly. Millisecond timestamps can tie,
313        // and sorting snapshots from a HashMap would then make FIFO restore
314        // nondeterministic. `create_run_with_id` uses the same
315        // order -> events -> runs acquisition order; `record_event` never
316        // acquires `insertion_order`, so this cannot form an ABBA cycle.
317        let order = self.insertion_order.read().await;
318        let events = self.events.read().await;
319        let runs = self.runs.read().await;
320        order
321            .iter()
322            .filter_map(|run_id| {
323                let snapshot = runs.get(run_id)?.clone();
324                Some(RunRecord {
325                    events: events
326                        .get(run_id)
327                        .map(|events| events.records.clone())
328                        .unwrap_or_default(),
329                    snapshot,
330                })
331            })
332            .collect()
333    }
334
335    pub async fn replace_records(&self, records: Vec<RunRecord>) {
336        // Preserve creation-order in the FIFO eviction queue so a
337        // restored session honours its `max_runs` cap consistently
338        // with newly-created runs.
339        let mut sorted = records;
340        sorted.sort_by_key(|r| r.snapshot.created_at_ms);
341        if let Some(cap) = self.max_runs {
342            let excess = sorted.len().saturating_sub(cap);
343            if excess > 0 {
344                sorted.drain(..excess);
345            }
346        }
347        let mut run_map = HashMap::new();
348        let mut event_map = HashMap::new();
349        let mut order = VecDeque::with_capacity(sorted.len());
350        for record in sorted {
351            let id = record.snapshot.id.clone();
352            // Trust the persisted `event_count` — it is the CUMULATIVE total
353            // ever recorded and is deliberately not decremented when the
354            // per-run event buffer is FIFO-trimmed by `max_events_per_run`.
355            // Overwriting it with `record.events.len()` here would corrupt
356            // the cumulative count for any restored run whose buffer was
357            // trimmed (restoring a 100-event run with a 50-cap buffer as
358            // event_count=50).
359            let mut retained = RetainedRunEvents {
360                serialized_bytes: record
361                    .events
362                    .iter()
363                    .map(serialized_event_record_len)
364                    .fold(0usize, usize::saturating_add),
365                records: record.events,
366            };
367            trim_retained_events(
368                &mut retained,
369                self.max_events_per_run,
370                self.max_event_bytes_per_run,
371            );
372            event_map.insert(id.clone(), retained);
373            run_map.insert(id.clone(), record.snapshot);
374            order.push_back(id);
375        }
376        // Publish the restored generation under the same canonical lock order
377        // used by create/read paths so concurrent observers cannot see a run
378        // map from one generation and event/order state from another.
379        let mut stored_order = self.insertion_order.write().await;
380        let mut stored_events = self.events.write().await;
381        let mut stored_runs = self.runs.write().await;
382        *stored_runs = run_map;
383        *stored_events = event_map;
384        *stored_order = order;
385    }
386}
387
388fn serialized_event_record_len(record: &RunEventRecord) -> usize {
389    serde_json::to_vec(record)
390        .map(|encoded| encoded.len())
391        .unwrap_or(usize::MAX)
392}
393
394fn trim_retained_events(
395    events: &mut RetainedRunEvents,
396    max_events: Option<usize>,
397    max_bytes: Option<usize>,
398) {
399    let count_excess = max_events
400        .map(|cap| events.records.len().saturating_sub(cap))
401        .unwrap_or(0);
402    let mut remove_count = count_excess;
403    let mut remaining_bytes = events.serialized_bytes;
404    for record in events.records.iter().take(remove_count) {
405        remaining_bytes = remaining_bytes.saturating_sub(serialized_event_record_len(record));
406    }
407    while max_bytes.is_some_and(|cap| remaining_bytes > cap) && remove_count < events.records.len()
408    {
409        remaining_bytes = remaining_bytes
410            .saturating_sub(serialized_event_record_len(&events.records[remove_count]));
411        remove_count += 1;
412    }
413    for record in events.records.iter().take(remove_count) {
414        events.serialized_bytes = events
415            .serialized_bytes
416            .saturating_sub(serialized_event_record_len(record));
417    }
418    if remove_count > 0 {
419        events.records.drain(..remove_count);
420    }
421}
422
423#[cfg(test)]
424mod retention_tests {
425    use super::*;
426
427    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
428    async fn concurrent_create_and_record_under_cap_does_not_deadlock() {
429        // Guards the canonical lock-ordering change in create_run_with_id
430        // (order -> events -> runs held together). A bad ordering would
431        // ABBA-deadlock against concurrent record_event and hang this test.
432        let store = std::sync::Arc::new(InMemoryRunStore::with_retention(Some(10), None));
433        let mut handles = Vec::new();
434        for i in 0..100 {
435            let s = std::sync::Arc::clone(&store);
436            handles.push(tokio::spawn(async move {
437                let r = s.create_run("sess", &format!("p{i}")).await;
438                for _ in 0..5 {
439                    s.record_event(
440                        &r.id,
441                        AgentEvent::TextDelta {
442                            text: "x".to_string(),
443                        },
444                    )
445                    .await;
446                }
447            }));
448        }
449        for h in handles {
450            h.await.unwrap();
451        }
452        // Cap honored under concurrent load, and the store is still usable
453        // (no deadlock, no poisoned locks).
454        assert!(store.list().await.len() <= 10);
455    }
456
457    #[tokio::test]
458    async fn replace_records_preserves_cumulative_event_count_after_trim() {
459        // Source store with a small per-run event cap.
460        let src = InMemoryRunStore::with_retention(None, Some(3));
461        let run = src.create_run("s", "p").await;
462        for _ in 0..10 {
463            src.record_event(
464                &run.id,
465                AgentEvent::TextDelta {
466                    text: "x".to_string(),
467                },
468            )
469            .await;
470        }
471        let records = src.records().await;
472        // Buffer trimmed to cap, but cumulative event_count is the total.
473        assert_eq!(records.len(), 1);
474        assert_eq!(records[0].events.len(), 3, "buffer trimmed to cap");
475        assert_eq!(records[0].snapshot.event_count, 10, "cumulative preserved");
476
477        // Round-trip into a fresh store via replace_records.
478        let dst = InMemoryRunStore::new();
479        dst.replace_records(records).await;
480        let restored = dst.snapshot(&run.id).await.unwrap();
481        assert_eq!(
482            restored.event_count, 10,
483            "replace_records must NOT reset event_count to the trimmed buffer length"
484        );
485        // The (trimmed) event buffer still round-trips at cap size.
486        assert_eq!(dst.events(&run.id).await.len(), 3);
487    }
488
489    #[tokio::test]
490    async fn replace_records_enforces_run_and_event_caps() {
491        let source = InMemoryRunStore::new();
492        for run_index in 0..4 {
493            let run = source
494                .create_run_with_id(
495                    format!("run-{run_index}"),
496                    "session-1",
497                    &format!("prompt-{run_index}"),
498                )
499                .await;
500            for event_index in 0..5 {
501                source
502                    .record_event(
503                        &run.id,
504                        AgentEvent::TextDelta {
505                            text: format!("{run_index}:{event_index}"),
506                        },
507                    )
508                    .await;
509            }
510        }
511
512        let restored = InMemoryRunStore::with_retention(Some(2), Some(2));
513        restored.replace_records(source.records().await).await;
514
515        let records = restored.records().await;
516        assert_eq!(
517            records
518                .iter()
519                .map(|record| record.snapshot.id.as_str())
520                .collect::<Vec<_>>(),
521            vec!["run-2", "run-3"],
522            "restore must keep the newest runs under the same FIFO policy as live writes"
523        );
524        for (run_index, record) in records.iter().enumerate() {
525            assert_eq!(record.snapshot.event_count, 5);
526            assert_eq!(record.events.len(), 2);
527            assert_eq!(record.events[0].sequence, 3);
528            assert_eq!(record.events[1].sequence, 4);
529            assert_eq!(record.snapshot.id, format!("run-{}", run_index + 2));
530        }
531    }
532
533    #[tokio::test]
534    async fn replace_records_honors_zero_caps() {
535        let source = InMemoryRunStore::new();
536        let run = source.create_run("session-1", "prompt").await;
537        source
538            .record_event(
539                &run.id,
540                AgentEvent::TextDelta {
541                    text: "event".to_string(),
542                },
543            )
544            .await;
545
546        let no_runs = InMemoryRunStore::with_retention(Some(0), Some(0));
547        no_runs.replace_records(source.records().await).await;
548        assert!(no_runs.records().await.is_empty());
549
550        let no_events = InMemoryRunStore::with_retention(None, Some(0));
551        no_events.replace_records(source.records().await).await;
552        let records = no_events.records().await;
553        assert_eq!(records.len(), 1);
554        assert!(records[0].events.is_empty());
555        assert_eq!(records[0].snapshot.event_count, 1);
556    }
557
558    #[tokio::test]
559    async fn max_runs_evicts_oldest() {
560        let store = InMemoryRunStore::with_retention(Some(2), None);
561        let _ = store.create_run("session-1", "prompt-1").await;
562        let r2 = store.create_run("session-1", "prompt-2").await;
563        let r3 = store.create_run("session-1", "prompt-3").await;
564
565        // Oldest run (prompt-1) must have been evicted.
566        assert_eq!(store.list().await.len(), 2);
567        let ids: Vec<String> = store.list().await.into_iter().map(|r| r.id).collect();
568        assert!(ids.contains(&r2.id));
569        assert!(ids.contains(&r3.id));
570        assert!(store.events(&r2.id).await.is_empty());
571        // The evicted run's events are gone too.
572        let surviving_event_count: usize =
573            store.events(&r2.id).await.len() + store.events(&r3.id).await.len();
574        assert_eq!(surviving_event_count, 0);
575    }
576
577    #[tokio::test]
578    async fn max_events_per_run_caps_event_buffer() {
579        let store = InMemoryRunStore::with_retention(None, Some(3));
580        let run = store.create_run("session-1", "prompt").await;
581        for _ in 0..10 {
582            store
583                .record_event(
584                    &run.id,
585                    AgentEvent::TextDelta {
586                        text: "x".to_string(),
587                    },
588                )
589                .await;
590        }
591        let events = store.events(&run.id).await;
592        assert_eq!(
593            events.len(),
594            3,
595            "buffer must be capped at max_events_per_run"
596        );
597        // Snapshot `event_count` reflects the cumulative total, not the
598        // surviving buffer length.
599        let snap = store.snapshot(&run.id).await.unwrap();
600        assert_eq!(snap.event_count, 10);
601    }
602
603    #[tokio::test]
604    async fn max_event_bytes_per_run_drops_oversized_live_event_but_advances_cursor() {
605        let store = InMemoryRunStore::with_retention_limits(None, None, Some(0));
606        let run = store.create_run("session-1", "prompt").await;
607
608        store
609            .record_event(
610                &run.id,
611                AgentEvent::TextDelta {
612                    text: "oversized".to_string(),
613                },
614            )
615            .await;
616
617        assert!(store.events(&run.id).await.is_empty());
618        let snapshot = store.snapshot(&run.id).await.unwrap();
619        assert_eq!(snapshot.event_count, 1);
620    }
621
622    #[tokio::test]
623    async fn replace_records_enforces_serialized_event_byte_cap_fifo() {
624        let source = InMemoryRunStore::new();
625        let run = source.create_run("session-1", "prompt").await;
626        for text in ["old", "middle", "new"] {
627            source
628                .record_event(
629                    &run.id,
630                    AgentEvent::TextDelta {
631                        text: text.to_string(),
632                    },
633                )
634                .await;
635        }
636        let source_records = source.records().await;
637        let retained_bytes = source_records[0].events[1..]
638            .iter()
639            .map(serialized_event_record_len)
640            .sum();
641
642        let restored = InMemoryRunStore::with_retention_limits(None, None, Some(retained_bytes));
643        restored.replace_records(source_records).await;
644
645        let records = restored.records().await;
646        assert_eq!(records[0].snapshot.event_count, 3);
647        assert_eq!(
648            records[0]
649                .events
650                .iter()
651                .map(|event| event.sequence)
652                .collect::<Vec<_>>(),
653            vec![1, 2]
654        );
655    }
656
657    #[tokio::test]
658    async fn retained_event_sequences_remain_monotonic_after_fifo_trim() {
659        let store = InMemoryRunStore::with_retention(None, Some(3));
660        let run = store.create_run("session-1", "prompt").await;
661
662        for index in 0..10 {
663            store
664                .record_event(
665                    &run.id,
666                    AgentEvent::TextDelta {
667                        text: index.to_string(),
668                    },
669                )
670                .await;
671        }
672
673        let sequences = store
674            .events(&run.id)
675            .await
676            .into_iter()
677            .map(|record| record.sequence)
678            .collect::<Vec<_>>();
679        assert_eq!(sequences, vec![7, 8, 9]);
680        assert!(sequences.windows(2).all(|pair| pair[0] < pair[1]));
681    }
682
683    #[tokio::test]
684    async fn restored_run_continues_sequence_from_cumulative_event_count() {
685        let source = InMemoryRunStore::with_retention(None, Some(3));
686        let run = source.create_run("session-1", "prompt").await;
687        for index in 0..10 {
688            source
689                .record_event(
690                    &run.id,
691                    AgentEvent::TextDelta {
692                        text: index.to_string(),
693                    },
694                )
695                .await;
696        }
697
698        let restored = InMemoryRunStore::with_retention(None, Some(3));
699        restored.replace_records(source.records().await).await;
700        restored
701            .record_event(
702                &run.id,
703                AgentEvent::TextDelta {
704                    text: "after restore".to_string(),
705                },
706            )
707            .await;
708
709        let sequences = restored
710            .events(&run.id)
711            .await
712            .into_iter()
713            .map(|record| record.sequence)
714            .collect::<Vec<_>>();
715        assert_eq!(sequences, vec![8, 9, 10]);
716        assert_eq!(restored.snapshot(&run.id).await.unwrap().event_count, 11);
717    }
718
719    #[tokio::test]
720    async fn event_page_reports_retention_gap_and_paginates_from_cursor() {
721        let store = InMemoryRunStore::with_retention(None, Some(3));
722        let run = store.create_run("session-1", "prompt").await;
723        for index in 0..6 {
724            store
725                .record_event(
726                    &run.id,
727                    AgentEvent::TextDelta {
728                        text: index.to_string(),
729                    },
730                )
731                .await;
732        }
733
734        let first = store.event_page(&run.id, None, 2).await.unwrap();
735        assert_eq!(first.first_available_sequence, Some(3));
736        assert_eq!(first.latest_sequence_exclusive, 6);
737        assert!(first.retention_gap);
738        assert!(first.has_more);
739        assert_eq!(first.next_after_sequence, Some(4));
740        assert_eq!(
741            first
742                .events
743                .iter()
744                .map(|event| event.sequence)
745                .collect::<Vec<_>>(),
746            vec![3, 4]
747        );
748
749        let second = store
750            .event_page(&run.id, first.next_after_sequence, 2)
751            .await
752            .unwrap();
753        assert!(!second.retention_gap);
754        assert!(!second.has_more);
755        assert_eq!(second.next_after_sequence, Some(5));
756        assert_eq!(second.events[0].sequence, 5);
757        assert!(store.event_page("missing", None, 10).await.is_none());
758    }
759
760    #[tokio::test]
761    async fn event_page_reports_gap_when_retention_keeps_no_events() {
762        let store = InMemoryRunStore::with_retention(None, Some(0));
763        let run = store.create_run("session-1", "prompt").await;
764        store
765            .record_event(
766                &run.id,
767                AgentEvent::TextDelta {
768                    text: "gone".to_string(),
769                },
770            )
771            .await;
772
773        let page = store.event_page(&run.id, None, 10).await.unwrap();
774        assert!(page.events.is_empty());
775        assert_eq!(page.first_available_sequence, None);
776        assert_eq!(page.latest_sequence_exclusive, 1);
777        assert!(page.retention_gap);
778        assert!(!page.has_more);
779    }
780
781    #[tokio::test]
782    async fn unlimited_retention_is_the_default() {
783        let store = InMemoryRunStore::new();
784        for i in 0..50 {
785            let r = store.create_run("s", &format!("p{i}")).await;
786            for _ in 0..20 {
787                store
788                    .record_event(
789                        &r.id,
790                        AgentEvent::TextDelta {
791                            text: "y".to_string(),
792                        },
793                    )
794                    .await;
795            }
796        }
797        assert_eq!(store.list().await.len(), 50);
798    }
799}
800
801#[derive(Clone)]
802pub struct RunHandle {
803    id: String,
804    session_id: String,
805    store: Arc<InMemoryRunStore>,
806    cancel_token: Arc<Mutex<Option<CancellationToken>>>,
807    current_run_id: Arc<Mutex<Option<String>>>,
808    hook_executor: Option<Arc<dyn crate::hooks::HookExecutor>>,
809}
810
811impl RunHandle {
812    pub(crate) fn new(
813        id: String,
814        session_id: String,
815        store: Arc<InMemoryRunStore>,
816        cancel_token: Arc<Mutex<Option<CancellationToken>>>,
817        current_run_id: Arc<Mutex<Option<String>>>,
818        hook_executor: Option<Arc<dyn crate::hooks::HookExecutor>>,
819    ) -> Self {
820        Self {
821            id,
822            session_id,
823            store,
824            cancel_token,
825            current_run_id,
826            hook_executor,
827        }
828    }
829
830    pub fn id(&self) -> &str {
831        &self.id
832    }
833
834    pub fn session_id(&self) -> &str {
835        &self.session_id
836    }
837
838    pub async fn snapshot(&self) -> Option<RunSnapshot> {
839        self.store.snapshot(&self.id).await
840    }
841
842    pub async fn events(&self) -> Vec<RunEventRecord> {
843        self.store.events(&self.id).await
844    }
845
846    pub async fn status(&self) -> Option<RunStatus> {
847        self.snapshot().await.map(|snapshot| snapshot.status)
848    }
849
850    pub async fn cancel(&self) -> bool {
851        let current_run_id = self.current_run_id.lock().await.clone();
852        if current_run_id.as_deref() != Some(self.id.as_str()) {
853            return false;
854        }
855
856        let token = self.cancel_token.lock().await.clone();
857        if let Some(token) = token {
858            token.cancel();
859            let _ = self.store.mark_cancelled(&self.id).await;
860            if let Some(executor) = &self.hook_executor {
861                executor
862                    .record_run_cancelled(&self.id, &self.session_id, Some("cancelled by host"))
863                    .await;
864            }
865            true
866        } else {
867            false
868        }
869    }
870}
871
872fn apply_event_to_snapshot(run: &mut RunSnapshot, event: &AgentEvent) {
873    match event {
874        AgentEvent::Start { prompt } => {
875            run.status = RunStatus::Executing;
876            if run.prompt.is_empty() {
877                run.prompt = prompt.clone();
878            }
879        }
880        AgentEvent::PlanningStart { .. } => {
881            run.status = RunStatus::Planning;
882        }
883        AgentEvent::StepStart { .. }
884        | AgentEvent::ToolStart { .. }
885        | AgentEvent::ToolExecutionStart { .. }
886        | AgentEvent::TurnStart { .. }
887            if !matches!(run.status, RunStatus::Planning) =>
888        {
889            run.status = RunStatus::Executing;
890        }
891        AgentEvent::End { text, .. } => {
892            if run.status == RunStatus::Cancelled {
893                return;
894            }
895            run.status = RunStatus::Completed;
896            run.result_text = Some(text.clone());
897            run.error = None;
898        }
899        AgentEvent::Error { message } => {
900            if run.status == RunStatus::Cancelled {
901                return;
902            }
903            run.status = RunStatus::Failed;
904            run.error = Some(message.clone());
905        }
906        _ => {}
907    }
908}
909
910fn now_ms() -> u64 {
911    std::time::SystemTime::now()
912        .duration_since(std::time::UNIX_EPOCH)
913        .map(|duration| duration.as_millis() as u64)
914        .unwrap_or(0)
915}
916
917#[cfg(test)]
918mod tests {
919    use super::*;
920
921    #[tokio::test]
922    async fn run_store_tracks_status_and_events() {
923        let store = InMemoryRunStore::new();
924        let run = store.create_run("session-1", "fix tests").await;
925
926        store
927            .record_event(
928                &run.id,
929                AgentEvent::Start {
930                    prompt: "fix tests".to_string(),
931                },
932            )
933            .await;
934        store
935            .record_event(
936                &run.id,
937                AgentEvent::End {
938                    text: "done".to_string(),
939                    usage: Default::default(),
940                    verification_summary: Box::new(
941                        crate::verification::VerificationSummary::from_reports(&[]),
942                    ),
943                    meta: None,
944                },
945            )
946            .await;
947
948        let snapshot = store.snapshot(&run.id).await.unwrap();
949        assert_eq!(snapshot.status, RunStatus::Completed);
950        assert_eq!(snapshot.result_text.as_deref(), Some("done"));
951        assert_eq!(snapshot.event_count, 2);
952        assert_eq!(store.events(&run.id).await.len(), 2);
953    }
954
955    #[tokio::test]
956    async fn run_store_replaces_persisted_records() {
957        let source = InMemoryRunStore::new();
958        let run = source.create_run("session-1", "persist").await;
959        source
960            .record_event(
961                &run.id,
962                AgentEvent::Start {
963                    prompt: "persist".to_string(),
964                },
965            )
966            .await;
967
968        let target = InMemoryRunStore::new();
969        target.replace_records(source.records().await).await;
970
971        assert_eq!(target.list().await.len(), 1);
972        assert_eq!(target.events(&run.id).await.len(), 1);
973        assert_eq!(target.snapshot(&run.id).await.unwrap().event_count, 1);
974    }
975
976    #[tokio::test]
977    async fn run_handle_only_cancels_current_run() {
978        let store = Arc::new(InMemoryRunStore::new());
979        let run = store.create_run("session-1", "fix tests").await;
980        let cancel_token = Arc::new(Mutex::new(Some(CancellationToken::new())));
981        let current_run_id = Arc::new(Mutex::new(Some(run.id.clone())));
982        let handle = RunHandle::new(
983            run.id.clone(),
984            run.session_id.clone(),
985            store.clone(),
986            cancel_token,
987            current_run_id.clone(),
988            None,
989        );
990
991        assert!(handle.cancel().await);
992        assert_eq!(handle.status().await, Some(RunStatus::Cancelled));
993
994        *current_run_id.lock().await = Some("other-run".to_string());
995        assert!(!handle.cancel().await);
996    }
997}