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