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