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