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