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