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