Skip to main content

salvor_runtime/
runtime.rs

1//! [`Runtime`]: the batteries-included entry points over the built-in loop.
2//!
3//! Four verbs, one per way a run can need driving:
4//!
5//! - [`start`](Runtime::start) mints a run id and drives a fresh run.
6//! - [`recover`](Runtime::recover) re-drives an interrupted (crashed) run
7//!   over its recorded log: recorded steps replay, execution continues live
8//!   from the first unrecorded step. Driving an already-completed run this
9//!   way replays it end to end and is the cheapest full divergence check.
10//! - [`resume`](Runtime::resume) supplies input to a *parked* run (one whose
11//!   log ends at a `Suspended` or `BudgetExceeded` event). The input is
12//!   validated first: against the recorded suspension `input_schema` (see
13//!   [`crate::validate`] for what validation means in v0.1), or against the
14//!   budget-extension shape (see [`crate::budgets`]). Only then is it handed
15//!   to the loop, which records it as the `Resumed` event at the parked
16//!   position, through the cursor like every other event.
17//! - [`resolve`](Runtime::resolve) is the one human-driven override: it
18//!   records the completion of a dangling `Write` intent by hand, after a
19//!   human has verified externally what the write actually did. It executes
20//!   nothing and drives nothing; it appends exactly one `ToolCallCompleted`
21//!   so the run leaves `NeedsReconciliation` and a later `recover` can
22//!   continue it. Every other verb refuses a dangling write, by design; this
23//!   verb is the sanctioned way past it.
24//!
25//! A `Runtime` owns the store handle plus the injected clock and random
26//! source it builds each [`RunCtx`](crate::RunCtx) with. It holds no
27//! per-run state at all: dropping it mid-run loses nothing, because every
28//! event was persisted the moment it happened. That is the kill -9 story.
29
30use std::collections::BTreeMap;
31use std::sync::Arc;
32
33use salvor_core::{
34    Event, EventEnvelope, PendingCall, RunId, RunStatus, UnresolvedWrite, derive_state,
35};
36use salvor_store::{CallClaimant, EventStore};
37use serde_json::Value;
38
39// `ParkReason` is a plain value over the event vocabulary, so it lives in the
40// pure `salvor-replay` crate that `salvor-core` re-exports rather than at this
41// IO edge. Re-exported here so every `salvor_runtime::ParkReason` and
42// `crate::runtime::ParkReason` path keeps resolving.
43pub use salvor_core::ParkReason;
44
45use crate::agent::Agent;
46use crate::budgets::validate_extension_input;
47use crate::ctx::{ClockFn, RandomFn, RunCtx};
48use crate::driver::{self, LoopOutcome};
49use crate::error::RuntimeError;
50use crate::validate::validate_against_schema;
51
52/// How a drive of a run ended.
53#[derive(Debug, Clone)]
54pub enum RunOutcome {
55    /// The run completed with this output.
56    Completed {
57        /// The run that completed.
58        run_id: RunId,
59        /// The recorded final output.
60        output: Value,
61    },
62    /// The run is parked durably; it survives restarts and deploys, and
63    /// [`Runtime::resume`] continues it once input arrives.
64    Parked {
65        /// The parked run.
66        run_id: RunId,
67        /// Why it parked.
68        reason: ParkReason,
69    },
70}
71
72/// The batteries-included runtime. See the module docs for the three verbs.
73pub struct Runtime {
74    store: Arc<dyn EventStore>,
75    clock: ClockFn,
76    random: RandomFn,
77    /// Whether the [`RunCtx`](crate::RunCtx) this runtime builds records the
78    /// full model request body. Off unless
79    /// [`with_record_prompts`](Runtime::with_record_prompts) turns it on.
80    record_prompts: bool,
81    /// Correlation tags every [`RunCtx`](crate::RunCtx) this runtime builds
82    /// stamps on a genuinely fresh run. Unset unless
83    /// [`with_labels`](Runtime::with_labels) sets them.
84    labels: Option<BTreeMap<String, String>>,
85}
86
87impl Runtime {
88    /// A runtime over `store` with the default clock and OS randomness.
89    #[must_use]
90    pub fn new(store: Arc<dyn EventStore>) -> Self {
91        Self::with_hooks(
92            store,
93            Arc::new(time::OffsetDateTime::now_utc),
94            Arc::new(crate::ctx::os_random),
95        )
96    }
97
98    /// A runtime with an injected clock and random source, handed to every
99    /// [`RunCtx`](crate::RunCtx) it builds. Deterministic tests inject fixed
100    /// functions so full event logs compare equal across runs.
101    #[must_use]
102    pub fn with_hooks(store: Arc<dyn EventStore>, clock: ClockFn, random: RandomFn) -> Self {
103        Self {
104            store,
105            clock,
106            random,
107            record_prompts: false,
108            labels: None,
109        }
110    }
111
112    /// Turns on recording of the full model request body for every run this
113    /// runtime drives, passed through to each [`RunCtx`](crate::RunCtx) it
114    /// builds. Off by default. Chained builder style so
115    /// [`new`](Self::new)/[`with_hooks`](Self::with_hooks) keep their
116    /// signatures and every existing caller stays at off.
117    ///
118    /// This is PII-sensitive (the body may hold user data or secrets), which is
119    /// why it is off unless an operator opts in. See
120    /// [`RunCtx::with_record_prompts`](crate::RunCtx::with_record_prompts) for
121    /// what recording means and the guarantee that it does not affect replay.
122    #[must_use]
123    pub fn with_record_prompts(mut self, record_prompts: bool) -> Self {
124        self.record_prompts = record_prompts;
125        self
126    }
127
128    /// Sets the correlation tags every run this runtime drives stamps on its
129    /// `RunStarted`, passed through to each [`RunCtx`](crate::RunCtx) it
130    /// builds. Unset by default. Chained builder style, mirroring
131    /// [`with_record_prompts`](Self::with_record_prompts); see
132    /// [`RunCtx::with_labels`](crate::RunCtx::with_labels) for the bounds
133    /// that apply and when they are checked.
134    #[must_use]
135    pub fn with_labels(mut self, labels: BTreeMap<String, String>) -> Self {
136        self.labels = Some(labels);
137        self
138    }
139
140    /// Starts a fresh run of `agent` with `input`, under a newly minted
141    /// run id.
142    ///
143    /// # Errors
144    ///
145    /// Everything [`start_with_id`](Self::start_with_id) returns.
146    pub async fn start(&self, agent: &Agent, input: Value) -> Result<RunOutcome, RuntimeError> {
147        self.start_with_id(agent, RunId::new(), input).await
148    }
149
150    /// Starts a fresh run under a caller-chosen run id (tests use this to
151    /// make logs comparable across control and killed runs).
152    ///
153    /// # Errors
154    ///
155    /// [`RuntimeError::RunAlreadyStarted`] when the id already has history;
156    /// otherwise whatever the loop surfaces ([`RuntimeError::Store`],
157    /// [`RuntimeError::Model`], [`RuntimeError::Replay`], ...).
158    pub async fn start_with_id(
159        &self,
160        agent: &Agent,
161        run_id: RunId,
162        input: Value,
163    ) -> Result<RunOutcome, RuntimeError> {
164        let log = self.store.read_log(run_id).await?;
165        if !log.is_empty() {
166            return Err(RuntimeError::RunAlreadyStarted { run_id });
167        }
168        let mut ctx = self.ctx(run_id, log)?;
169        finish(run_id, driver::drive(&mut ctx, agent, &input).await?)
170    }
171
172    /// Re-drives an interrupted run: replays the recorded log, then
173    /// continues live from the first unrecorded step. This is the
174    /// post-crash verb; it supplies no new input.
175    ///
176    /// # Errors
177    ///
178    /// [`RuntimeError::UnknownRun`] when the id has no history;
179    /// `RuntimeError::Replay(ReplayError::NeedsReconciliation)` when the log
180    /// ends in a write intent with no completion (a human must resolve it);
181    /// [`RuntimeError::Replay`] on any divergence.
182    pub async fn recover(&self, agent: &Agent, run_id: RunId) -> Result<RunOutcome, RuntimeError> {
183        let log = self.read_existing(run_id).await?;
184        let mut ctx = self.ctx(run_id, log)?;
185        finish(run_id, driver::drive(&mut ctx, agent, &Value::Null).await?)
186    }
187
188    /// Resumes a parked run with `input`.
189    ///
190    /// The run must be parked: its derived status must be `Suspended` or
191    /// `BudgetExceeded`. The input is validated before anything is recorded:
192    /// a suspension validates against its recorded `input_schema`, a budget
193    /// crossing against the extension shape. On success, the loop re-drives
194    /// the run; the input is recorded as `Resumed` at the parked position
195    /// and becomes the pending tool's result (or the budget extension).
196    ///
197    /// # Errors
198    ///
199    /// [`RuntimeError::UnknownRun`], [`RuntimeError::NotParked`], or
200    /// [`RuntimeError::ResumeInputRejected`]; then whatever the loop
201    /// surfaces.
202    pub async fn resume(
203        &self,
204        agent: &Agent,
205        run_id: RunId,
206        input: Value,
207    ) -> Result<RunOutcome, RuntimeError> {
208        let log = self.read_existing(run_id).await?;
209        let state = derive_state(&log);
210        match &state.status {
211            RunStatus::Suspended { input_schema, .. } => {
212                validate_against_schema(&input, input_schema)
213                    .map_err(RuntimeError::ResumeInputRejected)?;
214            }
215            RunStatus::BudgetExceeded { .. } => {
216                validate_extension_input(&input).map_err(RuntimeError::ResumeInputRejected)?;
217            }
218            other => {
219                return Err(RuntimeError::NotParked {
220                    run_id,
221                    status: status_name(other).to_owned(),
222                });
223            }
224        }
225        let mut ctx = self.ctx(run_id, log)?;
226        ctx.set_resume_input(input);
227        finish(run_id, driver::drive(&mut ctx, agent, &Value::Null).await?)
228    }
229
230    /// Records, by hand, the completion of a dangling `Write` intent, after a
231    /// human has verified externally what the write did.
232    ///
233    /// This is the concrete form of human resolution. A crash
234    /// between a write's recorded intent and its completion derives to
235    /// [`RunStatus::NeedsReconciliation`], which every automatic verb refuses:
236    /// the write may or may not have reached its target, and the runtime will
237    /// not guess. Once a human has checked, `resolve` appends the completion
238    /// they observed (or the completion of the write they performed by hand),
239    /// so replay treats the call as done and never re-executes it. The run is
240    /// then recoverable through [`recover`](Self::recover) like any other.
241    ///
242    /// It takes the same care as [`resume`](Self::resume): the state is
243    /// validated *before* anything is written, and exactly one event is
244    /// appended. `output` is recorded verbatim as the tool's output; nothing
245    /// executes and nothing else is driven.
246    ///
247    /// When the resolved call held a cross-run commitment for its
248    /// `(tool, idempotency key)`, this completion settles it, in the same
249    /// atomic step. A human resolving a payment is the moment that payment
250    /// stops being in flight, and the store has to learn it from somewhere;
251    /// leaving the commitment open would refuse every later run under that key
252    /// forever, with nothing anywhere to say why.
253    ///
254    /// # Errors
255    ///
256    /// [`RuntimeError::UnknownRun`] when the id has no history;
257    /// [`RuntimeError::NotReconcilable`] when the run's log does not end at a
258    /// dangling write intent (so there is no completion to record);
259    /// [`RuntimeError::Store`] when the append fails.
260    pub async fn resolve(&self, run_id: RunId, output: Value) -> Result<RunId, RuntimeError> {
261        let log = self.read_existing(run_id).await?;
262        let state = derive_state(&log);
263        // Only a dangling write derives to NeedsReconciliation, and it always
264        // carries the pending tool intent whose completion is missing.
265        let (intent_seq, tool, idempotency_key) = match (&state.status, &state.pending_call) {
266            (
267                RunStatus::NeedsReconciliation,
268                Some(PendingCall::Tool {
269                    seq,
270                    tool,
271                    idempotency_key,
272                    ..
273                }),
274            ) => (*seq, tool.clone(), idempotency_key.clone()),
275            (other, _) => {
276                return Err(RuntimeError::NotReconcilable {
277                    run_id,
278                    status: status_name(other).to_owned(),
279                });
280            }
281        };
282        // The completion correlates to the intent's sequence number and takes
283        // the next contiguous log position, exactly as the cursor would have
284        // recorded it had the process not died in between.
285        let completion = Event::ToolCallCompleted {
286            seq: intent_seq,
287            output,
288            // A human reconciled this write by hand. Nothing was copied from
289            // another run, so there is no origin to name.
290            deduplicated_from: None,
291        };
292        let envelope = EventEnvelope::new(run_id, state.next_seq, (self.clock)(), completion);
293
294        // If this call held a cross-run commitment, the human's completion is
295        // the one that settles it. Skipping this would leave the key held by a
296        // run that is finished, and every future call under it refused forever:
297        // the human resolved the write and would have no way to tell that the
298        // key was still stuck.
299        let held = match &idempotency_key {
300            Some(key) => self.store.lookup_call(&tool, key).await?,
301            None => None,
302        };
303        let unsettled_here = held.is_some_and(|commitment| {
304            commitment.run_id == run_id
305                && commitment.intent_seq == intent_seq
306                && commitment.completion_seq.is_none()
307        });
308        if unsettled_here {
309            let key = idempotency_key.as_deref().expect("checked above");
310            self.store
311                .append_settling_call(
312                    &envelope,
313                    CallClaimant {
314                        tool: &tool,
315                        idempotency_key: key,
316                        run_id,
317                        intent_seq,
318                    },
319                )
320                .await?;
321        } else {
322            self.store.append(&envelope).await?;
323        }
324        crate::progress::emit_step(run_id, envelope.seq, &envelope.event);
325        Ok(run_id)
326    }
327
328    /// Abandons a run: appends a terminal [`Event::RunAbandoned`] by hand,
329    /// retiring a run deliberately without finishing or failing it.
330    ///
331    /// An operator action, not a driver action. It executes nothing, drives
332    /// nothing, and needs no lease: abandonment is the sanctioned "we do not
333    /// care about this run anymore" path, appended straight to the log the way
334    /// [`resolve`](Self::resolve) appends its one completion. It is allowed for
335    /// any non-terminal run, whatever state it parked or crashed in.
336    ///
337    /// When the run is parked at a dangling write (status
338    /// [`RunStatus::NeedsReconciliation`]), the outstanding intent's position
339    /// and tool ride on the event as
340    /// [`unresolved_write`](Event::RunAbandoned::unresolved_write). The
341    /// abandonment never claims the write question was answered: it records
342    /// exactly which write was left unsettled, so the honesty the reconciliation
343    /// refusal carried is preserved in the terminal record rather than erased.
344    ///
345    /// # Errors
346    ///
347    /// [`RuntimeError::UnknownRun`] when the id has no history;
348    /// [`RuntimeError::AlreadyTerminal`] when the run already reached a terminal
349    /// event (completed, failed, or previously abandoned), so there is nothing
350    /// left to retire; [`RuntimeError::Store`] when the append fails.
351    pub async fn abandon(
352        &self,
353        run_id: RunId,
354        reason: Option<String>,
355    ) -> Result<RunId, RuntimeError> {
356        let log = self.read_existing(run_id).await?;
357        let state = derive_state(&log);
358        // Refuse a run that already reached a terminal event: there is nothing
359        // left to abandon. Every non-terminal state is fair game.
360        if matches!(
361            state.status,
362            RunStatus::Completed { .. } | RunStatus::Failed { .. } | RunStatus::Abandoned { .. }
363        ) {
364            return Err(RuntimeError::AlreadyTerminal {
365                run_id,
366                status: status_name(&state.status).to_owned(),
367            });
368        }
369        // A run parked at a dangling write carries the outstanding intent
370        // forward as recorded honesty: name the write whose effect stays
371        // unknown rather than pretend it settled. Every other state abandons
372        // with no unresolved-write evidence.
373        let unresolved_write = match (&state.status, &state.pending_call) {
374            (RunStatus::NeedsReconciliation, Some(PendingCall::Tool { seq, tool, .. })) => {
375                Some(UnresolvedWrite {
376                    seq: *seq,
377                    tool: tool.clone(),
378                })
379            }
380            _ => None,
381        };
382        let event = Event::RunAbandoned {
383            reason,
384            unresolved_write,
385        };
386        let envelope = EventEnvelope::new(run_id, state.next_seq, (self.clock)(), event);
387        self.store.append(&envelope).await?;
388        crate::progress::emit_step(run_id, envelope.seq, &envelope.event);
389        Ok(run_id)
390    }
391
392    /// Reads a run's log, insisting it exists.
393    async fn read_existing(&self, run_id: RunId) -> Result<Vec<EventEnvelope>, RuntimeError> {
394        let log = self.store.read_log(run_id).await?;
395        if log.is_empty() {
396            return Err(RuntimeError::UnknownRun { run_id });
397        }
398        Ok(log)
399    }
400
401    /// Builds the per-run context with this runtime's hooks.
402    fn ctx(&self, run_id: RunId, log: Vec<EventEnvelope>) -> Result<RunCtx, RuntimeError> {
403        let mut ctx = RunCtx::with_hooks(
404            self.store.clone(),
405            run_id,
406            log,
407            self.clock.clone(),
408            self.random.clone(),
409        )?
410        .with_record_prompts(self.record_prompts);
411        if let Some(labels) = &self.labels {
412            ctx = ctx.with_labels(labels.clone());
413        }
414        Ok(ctx)
415    }
416}
417
418/// Attaches the run id to a loop outcome.
419#[allow(clippy::unnecessary_wraps)]
420fn finish(run_id: RunId, outcome: LoopOutcome) -> Result<RunOutcome, RuntimeError> {
421    Ok(match outcome {
422        LoopOutcome::Completed(output) => RunOutcome::Completed { run_id, output },
423        LoopOutcome::Parked(reason) => RunOutcome::Parked { run_id, reason },
424    })
425}
426
427/// A short status name for the not-parked error message.
428fn status_name(status: &RunStatus) -> &'static str {
429    match status {
430        RunStatus::NotStarted => "not started",
431        RunStatus::Running => "running",
432        RunStatus::AwaitingModel => "awaiting model (interrupted; use recover)",
433        RunStatus::AwaitingTool => "awaiting tool (interrupted; use recover)",
434        RunStatus::Suspended { .. } => "suspended",
435        RunStatus::BudgetExceeded { .. } => "budget exceeded",
436        RunStatus::NeedsReconciliation => "needs reconciliation",
437        RunStatus::Completed { .. } => "completed",
438        RunStatus::Failed { .. } => "failed",
439        RunStatus::Abandoned { .. } => "abandoned",
440    }
441}