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