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