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