salvor_runtime/ctx.rs
1//! [`RunCtx`]: the public durability substrate. One recorded run, one
2//! context; every operation is answered from history or executed live and
3//! persisted immediately.
4//!
5//! This is the library-first tier: a Rust team
6//! that wants to own its control flow writes an ordinary async function
7//! against this type and gets the same durability, replay, and budget
8//! guarantees as the built-in loop, which is itself written against exactly
9//! this surface.
10//!
11//! # What it owns, and what it wraps
12//!
13//! The pure replay cursor in `salvor-core` refuses to own three things: a
14//! store, executors, and the ambient clock/RNG. `RunCtx` owns all three and
15//! wraps each cursor request one to one:
16//!
17//! | `RunCtx` method | cursor request | live side effect |
18//! |---|---|---|
19//! | [`begin`](RunCtx::begin) | `begin` | persist `RunStarted` |
20//! | [`now`](RunCtx::now) | `now` | read the injected clock, persist |
21//! | [`random`](RunCtx::random) | `random` | draw from the injected RNG, persist |
22//! | [`model_call`](RunCtx::model_call) | `model_call` | persist intent, call provider, persist completion |
23//! | [`tool_call`](RunCtx::tool_call) | `tool_call` | persist intent **before executing**, execute, persist completion |
24//! | [`suspend`](RunCtx::suspend) | `suspend` | persist `Suspended` |
25//! | [`suspend_for_signal`](RunCtx::suspend_for_signal) | `suspend_for_signal` | persist `Suspended` marked as a signal wait |
26//! | [`await_resume`](RunCtx::await_resume) | `await_resume` | persist `Resumed` when input was provided |
27//! | [`sleep_until`](RunCtx::sleep_until) | `sleep_started` | persist `SleepStarted` |
28//! | [`await_wake`](RunCtx::await_wake) | `sleep_completed` | read the injected clock; persist `SleepCompleted` once the instant has passed |
29//! | [`budget_exceeded`](RunCtx::budget_exceeded) | `budget_exceeded` | persist `BudgetExceeded` |
30//! | [`complete_run`](RunCtx::complete_run) / [`fail_run`](RunCtx::fail_run) | same | persist the terminal event |
31//!
32//! Every live permit redemption persists its event *immediately*, with a
33//! timestamp read from the injected clock at this IO edge. Nothing is
34//! buffered: when a method returns `Ok`, the event is durable.
35//!
36//! # Injected clock and randomness
37//!
38//! The constructor takes the clock and RNG as functions. The defaults read
39//! the real clock and the operating system's randomness; tests inject
40//! deterministic ones, which makes whole event logs (envelopes included)
41//! comparable across runs. Note the injection covers the *envelope
42//! timestamps and observations*, not replay: replayed values always come
43//! from the log, whatever functions are installed.
44//!
45//! # Write-ahead ordering
46//!
47//! [`tool_call`](RunCtx::tool_call) persists the intent event and only then
48//! executes the tool. For a `Write`-effect tool this ordering is the whole
49//! reconciliation story: a crash between intent and completion leaves
50//! evidence, and resume refuses to guess. Model intents persist before the
51//! provider call for the same reason (though a dangling model intent is
52//! safely re-issued rather than reconciled).
53//!
54//! # Nothing happens twice, across runs as well as within one
55//!
56//! Replay is what keeps a resumed run from repeating itself: a recorded
57//! completion is read back, never re-executed. Two *independent* runs share no
58//! log, so replay has nothing to say about them, and something else has to
59//! hold the line. That something is the idempotency key, arbitrated by the
60//! store.
61//!
62//! The whole decision happens in [`tool_call`](RunCtx::tool_call), live,
63//! before the intent is written and before the tool runs; see that method for
64//! the mechanism and its boundaries. What matters here is the boundary it does
65//! not cross: **replay never consults the store about another run.** A
66//! recorded log is a complete description of its run, and folding it back
67//! produces the same result on a machine that has never seen the store the run
68//! was recorded against.
69//!
70//! # Retries inside one tool call
71//!
72//! One `tool_call` is one intent/completion pair, so retries of a failed
73//! live execution happen *inside* the call, between the two events, honoring
74//! `RetryPolicy`: `Read` and `Idempotent` handler failures re-execute up to
75//! [`MAX_TOOL_ATTEMPTS`] total attempts (idempotent retries reuse the same
76//! key, carried on `ToolCtx`), `Write` failures never re-execute, and input
77//! validation, an unreadable tool result, or output serialization never
78//! retries because it would fail identically again. Whatever the final result,
79//! the completion is recorded: an output, a suspension sentinel, or a failure
80//! object (see [`crate::wire`]).
81//!
82//! # Sleeping belongs between calls, never inside one
83//!
84//! [`sleep_until`](RunCtx::sleep_until) and [`sleep_for`](RunCtx::sleep_for)
85//! must not be called between a claimed tool call's intent and its
86//! completion. A claim is held for the whole span between the two, so every
87//! other run presenting that idempotency key gets `CallInFlight` for as long
88//! as the sleep lasts, and a durable timer lasts hours or weeks where a call
89//! lasts seconds. A process death inside such a sleep is worse: it leaves a
90//! dangling `Write` intent, which derives to
91//! [`RunStatus::NeedsReconciliation`](salvor_core::RunStatus::NeedsReconciliation)
92//! and stops the run until a human answers for the write by hand.
93//!
94//! Nothing in `RunCtx` can enforce the ordering, because the caller owns it
95//! and this type sees one request at a time. Sleep between completed calls.
96//!
97//! A tool that asks for the sleep itself is not an exception to that rule, it
98//! is the rule mechanized. A tool returning `ToolOutcome::Sleep` has its
99//! request encoded into its own `ToolCallCompleted` (see [`crate::wire`]), so
100//! the call settles, the claim releases, and only then does the driver call
101//! [`sleep_until`](RunCtx::sleep_until). The recorded order is intent,
102//! completion, `SleepStarted`, and a sleeping run therefore holds no claim.
103
104use std::collections::BTreeMap;
105use std::sync::Arc;
106
107use salvor_core::{
108 Budget, DedupOrigin, Effect, Emitted, Event, EventEnvelope, ModelReply, Outcome, PendingCall,
109 ReplayCursor, RunId, SequenceNumber, SuspensionKind, TokenUsage,
110};
111use salvor_llm::{Client, MessageAccumulator, MessageRequest, MessageResponse, StreamEvent};
112use salvor_store::{CallClaim, CallClaimant, CallCommitment, EventStore};
113use salvor_tools::{DynTool, RetryPolicy, Sleep, Suspension, ToolCtx, ToolError, ToolOutcome};
114use serde_json::Value;
115use time::{Duration, OffsetDateTime, PrimitiveDateTime};
116use uuid::Uuid;
117
118use crate::error::RuntimeError;
119use crate::hash::hash_value;
120use crate::labels::validate_labels;
121use crate::model::{response_value, usage_of};
122use crate::wire::{
123 ToolFailure, decode_failure, decode_sleep, decode_suspension, encode_failure, encode_sleep,
124 encode_suspension,
125};
126
127/// The injected clock: called once per persisted event (for the envelope
128/// timestamp) and once per live [`RunCtx::now`] observation.
129pub type ClockFn = Arc<dyn Fn() -> OffsetDateTime + Send + Sync>;
130
131/// The injected random source: called once per live [`RunCtx::random`]
132/// observation, returning 64 raw bits.
133pub type RandomFn = Arc<dyn Fn() -> u64 + Send + Sync>;
134
135/// The cap on total executions of one live tool call, counting the first
136/// attempt. Applies only where `RetryPolicy` allows retrying at all.
137pub const MAX_TOOL_ATTEMPTS: u32 = 3;
138
139/// A model call's result: the typed response plus the token usage recorded
140/// for it. Identical whether the call was executed live or replayed.
141#[derive(Debug, Clone)]
142pub struct ModelTurn {
143 /// The model's response.
144 pub response: MessageResponse,
145 /// The recorded token usage for this call.
146 pub usage: TokenUsage,
147}
148
149/// A tool call's result, decoded from the recorded completion output (the
150/// decoding is identical live and on replay, which is what keeps a resumed
151/// orchestration on the recorded path).
152#[derive(Debug, Clone)]
153pub enum ToolCallResult {
154 /// The tool produced this output.
155 Output(Value),
156 /// The tool failed after exhausting its retry policy; the full error is
157 /// recorded in the completion. See [`crate::wire`] for the shape.
158 Failed(ToolFailure),
159 /// The tool asked to park the run. Follow with [`RunCtx::suspend`] and
160 /// [`RunCtx::await_resume`].
161 Suspended(Suspension),
162 /// The tool asked to park the run until an instant. Follow with
163 /// [`RunCtx::sleep_until`] and [`RunCtx::await_wake`].
164 ///
165 /// The call itself is finished when this is returned: its completion is
166 /// recorded and any idempotency claim is settled, so the sleep that
167 /// follows holds nothing. See [`crate::wire`] for why the request travels
168 /// in the completion rather than as an event of its own.
169 Sleeping(Sleep),
170}
171
172/// What [`RunCtx::await_resume`] produced.
173#[derive(Debug, Clone)]
174pub enum Resumption {
175 /// The resume input, recorded or just persisted. Continue the run.
176 Resumed(Value),
177 /// No resume input exists yet. The run is parked durably; the log
178 /// already holds everything, so the process may simply stop driving it.
179 Parked,
180}
181
182/// What [`RunCtx::await_wake`] produced: the timer counterpart of
183/// [`Resumption`], and separate from it because the two park for different
184/// reasons and end differently. A suspension ends when someone supplies an
185/// input; a sleep ends when an instant arrives and carries no input at all.
186#[derive(Debug, Clone, Copy)]
187pub enum Waking {
188 /// The wake is recorded, whether it was replayed from the log or just
189 /// persisted. Continue the run.
190 Woken,
191 /// The wake instant has not arrived. The run is parked durably; the
192 /// recorded `SleepStarted` already holds the deadline, so the process may
193 /// simply stop driving it and come back at `wake_at` or later.
194 ///
195 /// Named for the state the run is in rather than for the non-event that
196 /// left it there, exactly as [`Resumption::Parked`] is.
197 Asleep {
198 /// The recorded instant the run may continue at, so a caller deciding
199 /// when to come back does not have to re-derive the log.
200 wake_at: OffsetDateTime,
201 },
202}
203
204/// The public durability substrate for one run. See the module docs.
205pub struct RunCtx {
206 cursor: ReplayCursor,
207 store: Arc<dyn EventStore>,
208 run_id: RunId,
209 clock: ClockFn,
210 random: RandomFn,
211 resume_input: Option<Value>,
212 /// The wake instant of the sleep this drive last recorded or replayed,
213 /// set by [`sleep_until`](Self::sleep_until) and read by
214 /// [`await_wake`](Self::await_wake) to decide whether the deadline has
215 /// arrived. Not state about the run (the log holds that); state about
216 /// where this drive is, which is why it is not persisted and why a fresh
217 /// context starts without it.
218 sleeping_until: Option<OffsetDateTime>,
219 /// Whether to record the full model request body on each
220 /// `ModelCallRequested`. Off unless [`with_record_prompts`](Self::with_record_prompts)
221 /// turns it on. See that method for the PII rationale.
222 record_prompts: bool,
223 /// Correlation tags to stamp on a genuinely fresh `RunStarted`. Unset
224 /// unless [`with_labels`](Self::with_labels) sets them. See that method.
225 labels: Option<BTreeMap<String, String>>,
226}
227
228impl RunCtx {
229 /// Builds a context over a run's recorded log (empty for a fresh run),
230 /// with the default clock (the real UTC clock) and the default random
231 /// source (operating-system randomness).
232 ///
233 /// # Errors
234 ///
235 /// Returns [`RuntimeError::Replay`] when the log is not a well-formed
236 /// run history.
237 pub fn new(
238 store: Arc<dyn EventStore>,
239 run_id: RunId,
240 log: Vec<EventEnvelope>,
241 ) -> Result<Self, RuntimeError> {
242 Self::with_hooks(
243 store,
244 run_id,
245 log,
246 Arc::new(OffsetDateTime::now_utc),
247 Arc::new(os_random),
248 )
249 }
250
251 /// Builds a context with an injected clock and random source.
252 ///
253 /// The clock stamps every persisted envelope and answers live
254 /// [`now`](Self::now) observations; the random source answers live
255 /// [`random`](Self::random) observations. Injecting deterministic
256 /// functions makes complete event logs comparable across runs, which is
257 /// how the kill/resume tests prove byte-identical recovery.
258 ///
259 /// # Errors
260 ///
261 /// Returns [`RuntimeError::Replay`] when the log is not a well-formed
262 /// run history.
263 pub fn with_hooks(
264 store: Arc<dyn EventStore>,
265 run_id: RunId,
266 log: Vec<EventEnvelope>,
267 clock: ClockFn,
268 random: RandomFn,
269 ) -> Result<Self, RuntimeError> {
270 let cursor = ReplayCursor::new(log)?;
271 Ok(Self {
272 cursor,
273 store,
274 run_id,
275 clock,
276 random,
277 resume_input: None,
278 sleeping_until: None,
279 record_prompts: false,
280 labels: None,
281 })
282 }
283
284 /// Turns on recording of the full model request body into the durable log.
285 ///
286 /// Additive and off by default: the existing [`new`](Self::new) and
287 /// [`with_hooks`](Self::with_hooks) constructors leave it off, so no
288 /// caller that predates this method changes behavior. Chained builder
289 /// style keeps those signatures intact, which is why the flag arrives this
290 /// way rather than as a new constructor argument.
291 ///
292 /// When on, each live [`model_call`](Self::model_call) records the exact
293 /// request it sent on the `ModelCallRequested` event, so the v0.3 dashboard
294 /// inspector can show the prompt. This is PII-sensitive: the body can hold
295 /// user data and secrets, which is why the default is off and turning it on
296 /// is a deliberate per-agent or operator choice. The recorded body lands
297 /// only in the event log; it never reaches the progress stream or any
298 /// console output. It does not affect replay: the request hash is computed
299 /// the same either way, and replay ignores the body.
300 #[must_use]
301 pub fn with_record_prompts(mut self, record_prompts: bool) -> Self {
302 self.record_prompts = record_prompts;
303 self
304 }
305
306 /// Sets the correlation tags to stamp on a genuinely fresh `RunStarted`.
307 ///
308 /// Additive and unset by default: the existing [`new`](Self::new) and
309 /// [`with_hooks`](Self::with_hooks) constructors leave it unset, so no
310 /// caller that predates this method changes behavior. Chained builder
311 /// style, mirroring [`with_record_prompts`](Self::with_record_prompts).
312 ///
313 /// Labels are checked against the sanity bounds (see
314 /// [`crate::validate_labels`]) only on [`begin`](Self::begin)'s live path,
315 /// the moment a `RunStarted` is actually about to be created;
316 /// [`RuntimeError::InvalidLabels`] surfaces there, not here, so this
317 /// setter itself is infallible. A replayed `begin` never re-checks them:
318 /// whatever the log already holds is trusted and returned as recorded.
319 /// Labels never enter `agent_def_hash` or any request hash; they are a
320 /// tag on the run, not part of its identity.
321 #[must_use]
322 pub fn with_labels(mut self, labels: BTreeMap<String, String>) -> Self {
323 self.labels = Some(labels);
324 self
325 }
326
327 /// Provides the input a parked run is being resumed with. The next
328 /// [`await_resume`](Self::await_resume) that reaches live mode records
329 /// it as the `Resumed` event and returns it; without one, a live
330 /// `await_resume` reports [`Resumption::Parked`].
331 pub fn set_resume_input(&mut self, input: Value) {
332 self.resume_input = Some(input);
333 }
334
335 /// The resume input staged by [`set_resume_input`](Self::set_resume_input)
336 /// and not yet consumed, without consuming it.
337 ///
338 /// This is the read-only half of the accept edge. A driver that needs to
339 /// vet a resume input against something only it knows (the graph engine
340 /// checks a gate's declared `approval_schema`) has to see the value BEFORE
341 /// [`await_resume`](Self::await_resume) turns it into a `Resumed` event,
342 /// because after that it is history and refusing it would mean an appended
343 /// event the run has to live with. Peeking here and refusing leaves the log
344 /// untouched and the run parked exactly as it was.
345 ///
346 /// `None` once `await_resume` has taken the value, or when none was staged.
347 #[must_use]
348 pub fn staged_resume_input(&self) -> Option<&Value> {
349 self.resume_input.as_ref()
350 }
351
352 /// The run this context drives.
353 #[must_use]
354 pub fn run_id(&self) -> RunId {
355 self.run_id
356 }
357
358 /// Whether recorded history remains to be consumed.
359 #[must_use]
360 pub fn is_replaying(&self) -> bool {
361 self.cursor.is_replaying()
362 }
363
364 /// The log position the next consumed or emitted event occupies.
365 #[must_use]
366 pub fn next_seq(&self) -> SequenceNumber {
367 self.cursor.next_seq()
368 }
369
370 /// Starts (or replays the start of) the run.
371 ///
372 /// Live: records `RunStarted` with `input` and the labels set through
373 /// [`with_labels`](Self::with_labels) (if any), and returns `input`.
374 /// Replayed: verifies `agent_def_hash` against the recorded event and
375 /// returns the *recorded* input, which always wins; the `input` argument
376 /// is only used when the log is empty, exactly like `labels`.
377 ///
378 /// # Errors
379 ///
380 /// [`RuntimeError::Replay`] on a definition-hash mismatch or any other
381 /// divergence; [`RuntimeError::InvalidLabels`] when the labels set
382 /// through [`with_labels`](Self::with_labels) violate the sanity bounds
383 /// (only checked on the live path; see that method); [`RuntimeError::Store`]
384 /// when persistence fails.
385 pub async fn begin(
386 &mut self,
387 agent_def_hash: &str,
388 input: &Value,
389 ) -> Result<Value, RuntimeError> {
390 match self.cursor.begin(agent_def_hash, self.labels.clone())? {
391 Outcome::Replayed(recorded) => Ok(recorded),
392 Outcome::Live(permit) => {
393 if let Some(labels) = &self.labels {
394 validate_labels(labels).map_err(RuntimeError::InvalidLabels)?;
395 }
396 let emitted = permit.record(input.clone());
397 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
398 Ok(input.clone())
399 }
400 }
401 }
402
403 /// Starts (or replays the start of) a graph run: the graph-document
404 /// counterpart of [`begin`](Self::begin).
405 ///
406 /// Live: records [`salvor_core::Event::GraphRunStarted`] with `input`, the
407 /// labels set through [`with_labels`](Self::with_labels) (if any), and no
408 /// fork origin, then returns `input`. Replayed: verifies `graph_hash`
409 /// against the recorded head (a changed graph document must not silently
410 /// resume an old run) and returns the *recorded* input, which always wins.
411 ///
412 /// A graph run's log opens with this event rather than `RunStarted` because
413 /// a graph coordinates many agent hashes and has none at its head. The graph
414 /// engine calls this once, then frames each node with
415 /// [`node_entered`](Self::node_entered) / [`node_exited`](Self::node_exited)
416 /// and records the single terminal itself after the last node.
417 ///
418 /// # Errors
419 ///
420 /// [`RuntimeError::Replay`] on a graph-hash mismatch or any other
421 /// divergence; [`RuntimeError::InvalidLabels`] when the labels set through
422 /// [`with_labels`](Self::with_labels) violate the sanity bounds (only
423 /// checked on the live path, exactly as [`begin`](Self::begin) does);
424 /// [`RuntimeError::Store`] when persistence fails.
425 pub async fn begin_graph(
426 &mut self,
427 graph_hash: &str,
428 input: &Value,
429 ) -> Result<Value, RuntimeError> {
430 match self
431 .cursor
432 .begin_graph(graph_hash, self.labels.clone(), None)?
433 {
434 Outcome::Replayed(recorded) => Ok(recorded),
435 Outcome::Live(permit) => {
436 if let Some(labels) = &self.labels {
437 validate_labels(labels).map_err(RuntimeError::InvalidLabels)?;
438 }
439 let emitted = permit.record(input.clone());
440 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
441 Ok(input.clone())
442 }
443 }
444 }
445
446 /// Records (or replays) entry into a graph node. A graph node's own events
447 /// (an agent loop's model calls, a tool call) are recorded between this and
448 /// the matching [`node_exited`](Self::node_exited).
449 ///
450 /// # Errors
451 ///
452 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
453 /// persistence fails.
454 pub async fn node_entered(&mut self, node: &str) -> Result<(), RuntimeError> {
455 match self.cursor.node_entered(node)? {
456 Outcome::Replayed(()) => Ok(()),
457 Outcome::Live(emitted) => {
458 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
459 }
460 }
461 }
462
463 /// Records (or replays) exit from a graph node, having produced its output.
464 /// The counterpart of [`node_entered`](Self::node_entered).
465 ///
466 /// # Errors
467 ///
468 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
469 /// persistence fails.
470 pub async fn node_exited(&mut self, node: &str) -> Result<(), RuntimeError> {
471 match self.cursor.node_exited(node)? {
472 Outcome::Replayed(()) => Ok(()),
473 Outcome::Live(emitted) => {
474 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
475 }
476 }
477 }
478
479 /// Records (or replays) that a graph node was skipped: reached on the walk
480 /// but deliberately not run (a branch routed past it). Unlike an executed
481 /// node there is no [`node_entered`](Self::node_entered)/[`node_exited`](Self::node_exited)
482 /// pair; the skip is the node's sole marker, which is what lets a projection
483 /// tell "skipped" apart from "never reached". `reason` must be a pure
484 /// function of the document and recorded values so it reproduces on replay.
485 ///
486 /// # Errors
487 ///
488 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
489 /// persistence fails.
490 pub async fn node_skipped(&mut self, node: &str, reason: &str) -> Result<(), RuntimeError> {
491 match self.cursor.node_skipped(node, reason)? {
492 Outcome::Replayed(()) => Ok(()),
493 Outcome::Live(emitted) => {
494 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
495 }
496 }
497 }
498
499 /// Records (or replays) that a branch node routed: the named `case` fired.
500 /// Recorded between the branch's [`node_entered`](Self::node_entered) and
501 /// [`node_exited`](Self::node_exited), it is the sole authority for which way
502 /// the branch went. The chosen `case` must be a deterministic function of
503 /// recorded values (a pure expression over the routed value, or a decision
504 /// recomputed from a replayed model reply) so replay reproduces the route.
505 ///
506 /// # Errors
507 ///
508 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
509 /// persistence fails.
510 pub async fn branch_taken(&mut self, node: &str, case: &str) -> Result<(), RuntimeError> {
511 match self.cursor.branch_taken(node, case)? {
512 Outcome::Replayed(()) => Ok(()),
513 Outcome::Live(emitted) => {
514 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
515 }
516 }
517 }
518
519 /// Records (or replays) that a map node fanned out over a resolved item list.
520 ///
521 /// Recorded between the map node's [`node_entered`](Self::node_entered) and its
522 /// per-iteration markers. The `items` must be a deterministic function of
523 /// recorded values (the map's `over` reference resolved against the recorded
524 /// routed value), so replay reproduces the identical fan-out, which is what
525 /// makes the derived per-iteration child ids reproducible.
526 ///
527 /// # Errors
528 ///
529 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
530 /// persistence fails.
531 pub async fn map_fanned_out(&mut self, node: &str, items: &Value) -> Result<(), RuntimeError> {
532 match self.cursor.map_fanned_out(node, items)? {
533 Outcome::Replayed(()) => Ok(()),
534 Outcome::Live(emitted) => {
535 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
536 }
537 }
538 }
539
540 /// Records (or replays) that one iteration of a map fan-out started, as a child
541 /// run with the derived id `child_run`. The `child_run` is derived from the
542 /// parent run id, the node id, and the index. On replay the RECORDED id wins
543 /// and the match is on `node` + `index` alone, so a fork (which replays the
544 /// origin's prefix under a new run id and thus re-derives a different id)
545 /// still replays its inherited map markers cleanly.
546 ///
547 /// # Errors
548 ///
549 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
550 /// persistence fails.
551 pub async fn map_iteration_started(
552 &mut self,
553 node: &str,
554 index: u64,
555 child_run: &str,
556 ) -> Result<(), RuntimeError> {
557 match self.cursor.map_iteration_started(node, index, child_run)? {
558 Outcome::Replayed(()) => Ok(()),
559 Outcome::Live(emitted) => {
560 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
561 }
562 }
563 }
564
565 /// Records (or replays) that one iteration of a map fan-out joined back into
566 /// the map node's output. Joins must be recorded in index order, never
567 /// completion order, so the concurrency of the fan-out never influences the
568 /// parent log's byte sequence.
569 ///
570 /// # Errors
571 ///
572 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
573 /// persistence fails.
574 pub async fn map_iteration_joined(
575 &mut self,
576 node: &str,
577 index: u64,
578 ) -> Result<(), RuntimeError> {
579 match self.cursor.map_iteration_joined(node, index)? {
580 Outcome::Replayed(()) => Ok(()),
581 Outcome::Live(emitted) => {
582 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
583 }
584 }
585 }
586
587 /// Records (or replays) that a fold node began one bounded pass of its
588 /// accumulate-and-refine loop. A fold's passes run inline in this log rather
589 /// than as child runs, so `index` is both the pass position and its recorded
590 /// order, and replay matches it exactly: a replayed pass returns without
591 /// re-recording anything.
592 ///
593 /// # Errors
594 ///
595 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
596 /// persistence fails.
597 pub async fn fold_iteration_started(
598 &mut self,
599 node: &str,
600 index: u64,
601 ) -> Result<(), RuntimeError> {
602 match self.cursor.fold_iteration_started(node, index)? {
603 Outcome::Replayed(()) => Ok(()),
604 Outcome::Live(emitted) => {
605 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
606 }
607 }
608 }
609
610 /// Records (or replays) that one fold pass joined back into the fold node's
611 /// accumulated value. Recorded in index order, which for a fold is already
612 /// completion order because its passes are sequential. A replayed join
613 /// returns without re-recording anything.
614 ///
615 /// # Errors
616 ///
617 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
618 /// persistence fails.
619 pub async fn fold_iteration_joined(
620 &mut self,
621 node: &str,
622 index: u64,
623 ) -> Result<(), RuntimeError> {
624 match self.cursor.fold_iteration_joined(node, index)? {
625 Outcome::Replayed(()) => Ok(()),
626 Outcome::Live(emitted) => {
627 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
628 }
629 }
630 }
631
632 /// Records (or replays) that a fold node settled: its loop stopped and its
633 /// `join` rule selected the pass at `winner_index`, for the recorded
634 /// `reason`. This is the sole authority for which pass the fold's output
635 /// came from, as [`branch_taken`](Self::branch_taken) is for a branch's
636 /// route. Both the winner and the reason must be deterministic functions of
637 /// the recorded pass values, because replay matches all three fields and a
638 /// replayed convergence returns without re-recording anything.
639 ///
640 /// # Errors
641 ///
642 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
643 /// persistence fails.
644 pub async fn fold_converged(
645 &mut self,
646 node: &str,
647 winner_index: u64,
648 reason: &str,
649 ) -> Result<(), RuntimeError> {
650 match self.cursor.fold_converged(node, winner_index, reason)? {
651 Outcome::Replayed(()) => Ok(()),
652 Outcome::Live(emitted) => {
653 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
654 }
655 }
656 }
657
658 /// The recorded clock: reads the injected clock once, live, and replays
659 /// the identical instant forever after.
660 ///
661 /// # Errors
662 ///
663 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
664 /// persistence fails.
665 pub async fn now(&mut self) -> Result<OffsetDateTime, RuntimeError> {
666 match self.cursor.now()? {
667 Outcome::Replayed(instant) => Ok(instant),
668 Outcome::Live(permit) => {
669 let instant = (self.clock)();
670 let emitted = permit.record(instant);
671 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
672 Ok(instant)
673 }
674 }
675 }
676
677 /// The recorded random source: draws 64 bits from the injected source
678 /// once, live, and replays the identical bits forever after. Richer
679 /// random values must be derived from these bits deterministically.
680 ///
681 /// # Errors
682 ///
683 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
684 /// persistence fails.
685 pub async fn random(&mut self) -> Result<u64, RuntimeError> {
686 match self.cursor.random()? {
687 Outcome::Replayed(bits) => Ok(bits),
688 Outcome::Live(permit) => {
689 let bits = (self.random)();
690 let emitted = permit.record(bits);
691 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
692 Ok(bits)
693 }
694 }
695 }
696
697 /// A recorded model call.
698 ///
699 /// The request is identified by its content hash
700 /// (`sha256:` over the canonical serialization; see [`crate::hash`]).
701 /// Replayed: the recorded response is decoded and returned; the provider
702 /// is never contacted. Live: the intent event is persisted, the provider
703 /// is called through `client`, and the completion (response plus usage)
704 /// is persisted. A recorded intent with no completion (a call the
705 /// process died inside) is re-issued safely: the fresh completion
706 /// correlates to the recorded intent.
707 ///
708 /// When [`with_record_prompts`](Self::with_record_prompts) is on, the exact
709 /// request body is recorded alongside the hash on the fresh live intent.
710 /// It is the same value the hash was computed over, it never feeds into the
711 /// hash, and replay ignores it, so recording it changes nothing about how
712 /// the run replays.
713 ///
714 /// # Errors
715 ///
716 /// [`RuntimeError::Replay`] on divergence, [`RuntimeError::Store`] when
717 /// persistence fails, [`RuntimeError::Model`] when the live provider
718 /// call fails (the log stays intact and the run is recoverable),
719 /// [`RuntimeError::RequestEncode`] / [`RuntimeError::RecordedResponseDecode`]
720 /// on the JSON edges.
721 pub async fn model_call(
722 &mut self,
723 client: &Client,
724 request: &MessageRequest,
725 ) -> Result<ModelTurn, RuntimeError> {
726 let request_value = serde_json::to_value(request).map_err(RuntimeError::RequestEncode)?;
727 let request_hash = hash_value(&request_value);
728 // The hash is computed above from `request_value` and is unaffected by
729 // what follows. When prompt recording is on, the body handed to the
730 // cursor is that same `request_value`, so the recorded body is exactly
731 // what was hashed; when off it is `None` and nothing is recorded.
732 let request_body = if self.record_prompts {
733 Some(request_value)
734 } else {
735 None
736 };
737 match self.cursor.model_call(&request_hash, request_body)? {
738 Outcome::Replayed(ModelReply { response, usage }) => {
739 let response = serde_json::from_value(response)
740 .map_err(RuntimeError::RecordedResponseDecode)?;
741 Ok(ModelTurn { response, usage })
742 }
743 Outcome::Live(permit) => {
744 if let Some(intent) = permit.intent().cloned() {
745 persist(self.store.as_ref(), self.run_id, &self.clock, &intent).await?;
746 }
747 let response = client.send_message(request).await?;
748 let usage = usage_of(&response);
749 let completion = permit.record(response_value(&response), usage);
750 persist(self.store.as_ref(), self.run_id, &self.clock, &completion).await?;
751 Ok(ModelTurn { response, usage })
752 }
753 }
754 }
755
756 /// A recorded model call that streams live events to `on_event` while it
757 /// runs, recording the identical completion [`model_call`](Self::model_call)
758 /// would record.
759 ///
760 /// This is a live-progress affordance layered on top of the durable record,
761 /// not a different kind of call. The recorded log is byte-for-byte what
762 /// [`model_call`](Self::model_call) writes for the same underlying response:
763 /// the request is hashed the same way (see [`crate::hash`]), the intent is
764 /// the same `ModelCallRequested`, and the completion carries the same
765 /// `response` value and `usage`. A run does not care which path recorded it,
766 /// and replay is deterministic either way.
767 ///
768 /// Replayed: the recorded response is decoded and returned, exactly as
769 /// [`model_call`](Self::model_call) does. The provider is never contacted and
770 /// `on_event` never fires, because there are no live tokens to report; the
771 /// caller gets the final result at once.
772 ///
773 /// Live: the intent event is persisted first (write-ahead, the same ordering
774 /// [`model_call`](Self::model_call) uses), then the provider stream is opened
775 /// through `client`. Each [`StreamEvent`] is handed to `on_event` for a live
776 /// ticker (text deltas ride [`StreamEvent::ContentBlockDelta`], token counts
777 /// ride [`StreamEvent::MessageDelta`]) and, in the same pass, applied to a
778 /// [`MessageAccumulator`]. When the stream ends, the assembled
779 /// [`MessageResponse`] is converted with the same `response_value` and usage
780 /// logic [`model_call`](Self::model_call) uses, the completion is persisted,
781 /// and the [`ModelTurn`] is returned.
782 ///
783 /// All persistence lives inside this method, so a caller cannot record a
784 /// partial or wrong completion: the completion is written only after the
785 /// stream is fully assembled. A caller that drops the returned future before
786 /// the stream completes leaves a dangling model intent (the write-ahead
787 /// intent with no completion), exactly like a live [`model_call`](Self::model_call)
788 /// the process died inside. That intent is re-issued safely on resume: the
789 /// fresh completion correlates to the recorded intent. `on_event` firing is
790 /// not part of the durable record, so a ticker that saw partial tokens before
791 /// the drop has no effect on what replay produces.
792 ///
793 /// When [`with_record_prompts`](Self::with_record_prompts) is on, the exact
794 /// request body is recorded on the fresh live intent, identically to
795 /// [`model_call`](Self::model_call).
796 ///
797 /// # Errors
798 ///
799 /// [`RuntimeError::Replay`] on divergence, [`RuntimeError::Store`] when
800 /// persistence fails, [`RuntimeError::Model`] when the live stream fails
801 /// (opening it, an error event or transport fault mid-stream, or a
802 /// tool-call fragment that does not parse) surfaced as the same error type
803 /// [`model_call`](Self::model_call) returns, with the log left intact and the
804 /// run recoverable, and [`RuntimeError::RequestEncode`] /
805 /// [`RuntimeError::RecordedResponseDecode`] on the JSON edges.
806 pub async fn model_call_streaming(
807 &mut self,
808 client: &Client,
809 request: &MessageRequest,
810 mut on_event: impl FnMut(&StreamEvent),
811 ) -> Result<ModelTurn, RuntimeError> {
812 let request_value = serde_json::to_value(request).map_err(RuntimeError::RequestEncode)?;
813 let request_hash = hash_value(&request_value);
814 // Hashing and body recording are identical to `model_call`: the hash is
815 // computed from `request_value` above, and the body handed to the cursor
816 // is that same value when recording is on, `None` when off. Streaming
817 // changes nothing here, which is half of why the recorded intent matches.
818 let request_body = if self.record_prompts {
819 Some(request_value)
820 } else {
821 None
822 };
823 match self.cursor.model_call(&request_hash, request_body)? {
824 Outcome::Replayed(ModelReply { response, usage }) => {
825 // No live call, so `on_event` never fires: replay has no tokens.
826 let response = serde_json::from_value(response)
827 .map_err(RuntimeError::RecordedResponseDecode)?;
828 Ok(ModelTurn { response, usage })
829 }
830 Outcome::Live(permit) => {
831 if let Some(intent) = permit.intent().cloned() {
832 persist(self.store.as_ref(), self.run_id, &self.clock, &intent).await?;
833 }
834 // Pump the stream once: every event feeds the ticker and the
835 // accumulator in the same pass. The accumulator assembles the
836 // exact `MessageResponse` `send_message` would have returned
837 // (salvor-llm guarantees this), so the recorded completion below
838 // is byte-identical to the non-streaming path.
839 let mut stream = client.stream_message(request).await?;
840 let mut accumulator = MessageAccumulator::new();
841 while let Some(event) = stream.next_event().await {
842 let event = event?;
843 on_event(&event);
844 accumulator.apply(&event)?;
845 }
846 let response = accumulator.into_message()?;
847 let usage = usage_of(&response);
848 let completion = permit.record(response_value(&response), usage);
849 persist(self.store.as_ref(), self.run_id, &self.clock, &completion).await?;
850 Ok(ModelTurn { response, usage })
851 }
852 }
853 }
854
855 /// A recorded tool call: one intent/completion pair, whatever happens in
856 /// between.
857 ///
858 /// Replayed: the recorded completion output is decoded (an output, a
859 /// failure object, or a suspension sentinel; see [`crate::wire`]) and
860 /// the tool is never executed. Live: the intent is persisted **before**
861 /// the tool executes (write-ahead), the tool runs with retries per its
862 /// effect's `RetryPolicy` (see [`MAX_TOOL_ATTEMPTS`]), and the
863 /// completion is persisted. A recorded `Read`/`Idempotent` intent with
864 /// no completion re-executes here under its recorded idempotency key; a
865 /// dangling `Write` intent fails with
866 /// `ReplayError::NeedsReconciliation` before anything runs.
867 ///
868 /// `idempotency_key` is the key for a *fresh* call; the built-in loop
869 /// derives it from [`random`](Self::random) for `Idempotent` tools so it
870 /// reproduces on replay. A key the tool declares for itself
871 /// ([`DynTool::idempotency_key`]) takes precedence over the one passed
872 /// here, because only the tool can say what effect a call *is*. For a
873 /// re-executed recorded intent the recorded key wins, and whatever is
874 /// presented must match it (the cursor checks).
875 ///
876 /// # Cross-run deduplication
877 ///
878 /// Within one run, nothing happens twice because a recorded completion is
879 /// replayed rather than re-executed. Across independent runs there is no
880 /// log to replay, so something else has to hold the line, and that
881 /// something is the idempotency key.
882 ///
883 /// ## Which keys count
884 ///
885 /// Only a key the tool **declares** for itself, through
886 /// [`DynTool::idempotency_key`], is an identity to deduplicate on. A key
887 /// the runtime derives on a tool's behalf is not, and the difference is not
888 /// a technicality.
889 ///
890 /// A hand-written tool makes that declaration in Rust. An MCP or wasm tool
891 /// has no code here to make it in, so its operator does, by naming the
892 /// input field that identifies a call in the agent file
893 /// (`idempotency_keys`); the tool derives the key from that field on every
894 /// call and answers through the same trait method. Nothing below this
895 /// distinguishes the two, because there is no distinction to make: both are
896 /// a statement about what the call does in the world, from someone in a
897 /// position to know.
898 ///
899 /// A declared key is a statement about the world: `"pay_claim:wreck-9931"`
900 /// means *this is the payout for claim 9931*, and two calls carrying it are
901 /// the same payment no matter which run asked for them. A derived key says
902 /// something much weaker. The built-in loop draws one from recorded
903 /// randomness so a retry within a run reuses it; the graph engine derives
904 /// one from a node's position so a fork re-executing a node reuses it.
905 /// Both are *attempt* identifiers, scoped to one run or one lineage, and
906 /// two unrelated runs can hold the same derived key over completely
907 /// different arguments. Treating one as an effect identity would let a
908 /// second run collect the first run's output for a call it never made,
909 /// which is a worse failure than the duplicate execution this is meant to
910 /// stop.
911 ///
912 /// So a derived key keeps doing exactly what it always did, at the provider
913 /// and inside its own run, and is recorded exactly as before. Cross-run
914 /// deduplication waits for a tool to say what its calls *are*.
915 ///
916 /// A declared key is also what a call records, in preference to a derived
917 /// one, since only the tool can name its own effect.
918 ///
919 /// ## The mechanism
920 ///
921 /// **The decision is made here, live, before the intent is recorded and
922 /// before the tool runs.** For a [`Effect::Write`] or
923 /// [`Effect::Idempotent`] call carrying a declared key, this method claims
924 /// the identity `(tool name, idempotency key)` in the store
925 /// ([`EventStore::claim_call`](salvor_store::EventStore::claim_call)),
926 /// which is the arbiter:
927 ///
928 /// - **Claimed.** This run is the one execution. The intent is recorded,
929 /// the tool runs, and the completion is appended *and* settles the
930 /// commitment as one atomic step, so no crash can leave a committed
931 /// completion the store still calls unfinished.
932 /// - **Held, and settled.** An equal call is already committed. The origin
933 /// run's log is read back (through
934 /// [`read_log`](salvor_store::EventStore::read_log), so its hash chain is
935 /// verified before a single byte is copied), its recorded input is
936 /// checked against this call's, and its output becomes this call's
937 /// output. The intent is still recorded, because an intent that resolves
938 /// as a duplicate is an honest thing to have recorded, and the completion
939 /// carries a [`DedupOrigin`] naming what it copied. **The tool does not
940 /// run.**
941 /// - **Held, and unfinished.** Refused with
942 /// [`RuntimeError::CallInFlight`], before anything is recorded. See that
943 /// variant for why refusing beats guessing.
944 ///
945 /// A call with no declared key is untouched by any of this: there is no
946 /// identity to deduplicate on, so a keyless write behaves exactly as it
947 /// always has, and so does a write carrying only a derived key. So does
948 /// every [`Effect::Read`], which has no effect worth naming.
949 ///
950 /// **Replay never participates.** A recorded completion replays from the
951 /// log, whether it was witnessed or copied, with no store lookup of any
952 /// kind; the [`DedupOrigin`] on it is read by humans and audits, never by
953 /// the cursor. That is what keeps a recorded log a self-contained
954 /// description of a run.
955 ///
956 /// The one place resume consults the store is the gap a crash can leave
957 /// between a deduplicated intent and its copied completion. That intent
958 /// executed nothing (this run never held the identity, so it never held the
959 /// right to execute), and the store can prove it, so the call is finished
960 /// as the duplicate it was rather than parked for a human. Every other
961 /// dangling write still parks: see [`recover_deduplicated_intent`](Self::recover_deduplicated_intent).
962 ///
963 /// # Errors
964 ///
965 /// [`RuntimeError::Replay`] on divergence or a dangling write intent;
966 /// [`RuntimeError::Store`] when persistence fails;
967 /// [`RuntimeError::CallInFlight`] when another run holds this call's
968 /// identity and has not finished with it;
969 /// [`RuntimeError::IdempotencyKeyCollision`] when one key names two
970 /// different calls; [`RuntimeError::CommitmentUnreadable`] when the store
971 /// points at a completion its own log does not hold. A failing *tool* is
972 /// not an `Err`: it returns [`ToolCallResult::Failed`], because the
973 /// failure is a recorded outcome the orchestration must handle
974 /// deterministically.
975 pub async fn tool_call(
976 &mut self,
977 tool: &dyn DynTool,
978 input: &Value,
979 idempotency_key: Option<&str>,
980 ) -> Result<ToolCallResult, RuntimeError> {
981 let effect = tool.effect();
982 let declared = tool.idempotency_key(input);
983 // What is recorded on the wire: the tool's own declaration when it
984 // makes one, otherwise the attempt key the caller derived.
985 let recorded = declared
986 .clone()
987 .or_else(|| idempotency_key.map(ToOwned::to_owned));
988 let key = recorded.as_deref();
989 // What deduplication is arbitrated on: a declared key only. See the
990 // method docs for why an attempt key is not an identity.
991 let identity = declared.as_deref().filter(|_| deduplicates(effect));
992
993 // Before the cursor is asked to take a step, because `tool_call` either
994 // advances it or fails, with nothing in between where a store lookup
995 // could go.
996 if let Some(resolved) = self
997 .recover_deduplicated_intent(tool, input, effect, identity)
998 .await?
999 {
1000 return Ok(resolved);
1001 }
1002
1003 match self.cursor.tool_call(tool.name(), input, effect, key)? {
1004 Outcome::Replayed(output) => Ok(decode_tool_output(output)),
1005 Outcome::Live(permit) => {
1006 // THE DECISION POINT. Live, before the write-ahead intent is
1007 // persisted and before the tool is touched. Nothing below this
1008 // block consults the store about other runs, and replay never
1009 // reaches it at all.
1010 let claimant = identity.map(|key| CallClaimant {
1011 tool: tool.name(),
1012 idempotency_key: key,
1013 run_id: self.run_id,
1014 intent_seq: permit.seq(),
1015 });
1016 let mut copied = None;
1017 if let Some(claimant) = claimant {
1018 match self.store.claim_call(claimant).await? {
1019 // This run is the one execution.
1020 CallClaim::Claimed => {}
1021 CallClaim::Held(commitment) if commitment.completion_seq.is_some() => {
1022 copied = Some(
1023 committed_call(
1024 self.store.as_ref(),
1025 tool.name(),
1026 claimant.idempotency_key,
1027 commitment,
1028 input,
1029 )
1030 .await?,
1031 );
1032 }
1033 // Held by a run that has not finished. Nothing is
1034 // recorded, so this run can simply be run again once
1035 // the holder is resolved.
1036 CallClaim::Held(commitment) => {
1037 return Err(RuntimeError::CallInFlight {
1038 tool: tool.name().to_owned(),
1039 idempotency_key: claimant.idempotency_key.to_owned(),
1040 holder: commitment.run_id,
1041 holder_seq: commitment.intent_seq.get(),
1042 });
1043 }
1044 }
1045 }
1046
1047 // Write-ahead, on both paths. An intent that resolves as a
1048 // duplicate is still an honest record of what this run asked
1049 // for.
1050 if let Some(intent) = permit.intent().cloned() {
1051 persist(self.store.as_ref(), self.run_id, &self.clock, &intent).await?;
1052 }
1053
1054 if let Some((output, origin)) = copied {
1055 let completion = permit.record_deduplicated(output.clone(), origin);
1056 persist(self.store.as_ref(), self.run_id, &self.clock, &completion).await?;
1057 return Ok(decode_tool_output(output));
1058 }
1059
1060 let key = permit.idempotency_key().map(ToOwned::to_owned);
1061 let tool_ctx = ToolCtx::new(key);
1062 let policy = RetryPolicy::for_effect(effect);
1063 let mut attempts: u32 = 0;
1064 let outcome = loop {
1065 attempts += 1;
1066 match tool.call_json(&tool_ctx, input.clone()).await {
1067 Ok(outcome) => break Ok(outcome),
1068 Err(error) => {
1069 // Only a handler failure is retryable, and only
1070 // when the effect's policy allows a re-attempt.
1071 // Every other variant is a fault in the arguments
1072 // or in the tool itself, and a second attempt
1073 // would reach the same verdict against a slower
1074 // clock.
1075 let may_retry = matches!(error, ToolError::Handler { .. })
1076 && policy.allows_retry()
1077 && attempts < MAX_TOOL_ATTEMPTS;
1078 if may_retry {
1079 continue;
1080 }
1081 break Err(error);
1082 }
1083 }
1084 };
1085 let (output, result) = match outcome {
1086 Ok(ToolOutcome::Output(value)) => {
1087 (value.clone(), ToolCallResult::Output(value))
1088 }
1089 Ok(ToolOutcome::Suspend(suspension)) => (
1090 encode_suspension(&suspension),
1091 ToolCallResult::Suspended(suspension),
1092 ),
1093 Ok(ToolOutcome::Sleep(sleep)) => {
1094 // The instant is normalized on the way into the
1095 // completion, so what the caller sleeps on is what the
1096 // log holds and what every later drive decodes.
1097 let output = encode_sleep(&sleep);
1098 let recorded = decode_sleep(&output).unwrap_or(sleep);
1099 (output, ToolCallResult::Sleeping(recorded))
1100 }
1101 Err(error) => {
1102 let failure = ToolFailure::from_error(&error, attempts);
1103 (encode_failure(&failure), ToolCallResult::Failed(failure))
1104 }
1105 };
1106 let completion = permit.record(output);
1107 match claimant {
1108 // The completion and the settlement land together, so the
1109 // store never believes an identity is still in flight when
1110 // its result is already recorded.
1111 Some(claimant) => {
1112 persist_settling(
1113 self.store.as_ref(),
1114 self.run_id,
1115 &self.clock,
1116 &completion,
1117 claimant,
1118 )
1119 .await?;
1120 }
1121 None => {
1122 persist(self.store.as_ref(), self.run_id, &self.clock, &completion).await?;
1123 }
1124 }
1125 Ok(result)
1126 }
1127 }
1128 }
1129
1130 /// Finishes a deduplicated call whose process died between recording the
1131 /// intent and recording the copied completion, or reports that this is not
1132 /// that situation.
1133 ///
1134 /// This is the only place a resume consults the store about another run,
1135 /// and it turns on a fact the store can settle: a call executes only under
1136 /// a claim, so an identity held by a **different** run is proof that this
1137 /// run never executed. The intent it left behind is then not a dangling
1138 /// write at all, it is an unfinished copy, and parking it would ask a human
1139 /// to reconcile an effect that provably never happened.
1140 ///
1141 /// Every other reading falls through to [`tool_call`](Self::tool_call)'s
1142 /// normal path and its normal consequences. In particular an identity held
1143 /// by *this* run is exactly the reconciliation hazard it has always been:
1144 /// this run did hold the right to execute, so nobody can say from the
1145 /// outside whether it did, and the run parks with
1146 /// [`ReplayError::NeedsReconciliation`](salvor_core::ReplayError::NeedsReconciliation).
1147 ///
1148 /// Returns `Ok(None)` when the situation does not apply, which is the
1149 /// overwhelmingly common case.
1150 async fn recover_deduplicated_intent(
1151 &mut self,
1152 tool: &dyn DynTool,
1153 input: &Value,
1154 effect: Effect,
1155 key: Option<&str>,
1156 ) -> Result<Option<ToolCallResult>, RuntimeError> {
1157 let Some(key) = key.filter(|_| deduplicates(effect)) else {
1158 return Ok(None);
1159 };
1160 let Some(PendingCall::Tool {
1161 tool: recorded_tool,
1162 input: recorded_input,
1163 effect: recorded_effect,
1164 idempotency_key: Some(recorded_key),
1165 ..
1166 }) = self.cursor.dangling_intent()
1167 else {
1168 return Ok(None);
1169 };
1170 // Only the call the orchestration is asking for right now. Anything
1171 // else is a divergence for the cursor to report, not ours to smooth
1172 // over.
1173 if recorded_tool != tool.name()
1174 || recorded_input != *input
1175 || recorded_effect != effect
1176 || recorded_key != key
1177 {
1178 return Ok(None);
1179 }
1180
1181 let Some(commitment) = self.store.lookup_call(tool.name(), key).await? else {
1182 return Ok(None);
1183 };
1184 // The proof, and the whole reason this is safe: the identity belongs to
1185 // some other run, and that run finished. This run never held it, so it
1186 // never had the right to execute, so it did not.
1187 if commitment.run_id == self.run_id || commitment.completion_seq.is_none() {
1188 return Ok(None);
1189 }
1190
1191 let (output, origin) =
1192 committed_call(self.store.as_ref(), tool.name(), key, commitment, input).await?;
1193 let permit = self
1194 .cursor
1195 .resume_unexecuted_tool_call(tool.name(), input, effect, key)?;
1196 let completion = permit.record_deduplicated(output.clone(), origin);
1197 persist(self.store.as_ref(), self.run_id, &self.clock, &completion).await?;
1198 Ok(Some(decode_tool_output(output)))
1199 }
1200
1201 /// Parks the run on a human gate: records `Suspended { reason,
1202 /// input_schema }`, with no discriminator, which is what every suspension
1203 /// recorded before signals existed means. Follow with
1204 /// [`await_resume`](Self::await_resume).
1205 ///
1206 /// # Errors
1207 ///
1208 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
1209 /// persistence fails.
1210 pub async fn suspend(
1211 &mut self,
1212 reason: &str,
1213 input_schema: &Value,
1214 ) -> Result<(), RuntimeError> {
1215 self.suspend_with_kind(reason, input_schema, None).await
1216 }
1217
1218 /// Parks the run on an external signal: records `Suspended` with
1219 /// [`SuspensionKind::Signal`](salvor_core::SuspensionKind::Signal), for a
1220 /// wait a webhook or callback answers rather than a person. Follow with
1221 /// [`await_resume`](Self::await_resume), exactly as a gate does.
1222 ///
1223 /// The run parks, validates, and resumes identically either way. The
1224 /// recorded discriminator exists so a surface can route: a signal wait is
1225 /// nobody's task, and listing it in an approval inbox invents work for an
1226 /// operator who cannot do it.
1227 ///
1228 /// # Errors
1229 ///
1230 /// [`RuntimeError::Replay`] on divergence (a replayed suspension whose
1231 /// discriminator differs included); [`RuntimeError::Store`] when
1232 /// persistence fails.
1233 pub async fn suspend_for_signal(
1234 &mut self,
1235 reason: &str,
1236 input_schema: &Value,
1237 ) -> Result<(), RuntimeError> {
1238 self.suspend_with_kind(reason, input_schema, Some(SuspensionKind::Signal))
1239 .await
1240 }
1241
1242 /// Parks the run on a suspension whose discriminator is already a value:
1243 /// records `Suspended { reason, input_schema, kind }`. Follow with
1244 /// [`await_resume`](Self::await_resume).
1245 ///
1246 /// This exists for the drivers, which read the kind off a
1247 /// [`Suspension`](salvor_tools::Suspension) a tool returned and cannot
1248 /// choose between the two named methods without matching on it. Hand-written
1249 /// orchestration should say [`suspend`](Self::suspend) or
1250 /// [`suspend_for_signal`](Self::suspend_for_signal) instead, so the call
1251 /// site reads as what it is.
1252 ///
1253 /// # Errors
1254 ///
1255 /// [`RuntimeError::Replay`] on divergence (a replayed suspension whose
1256 /// discriminator differs included); [`RuntimeError::Store`] when
1257 /// persistence fails.
1258 pub async fn suspend_with_kind(
1259 &mut self,
1260 reason: &str,
1261 input_schema: &Value,
1262 kind: Option<SuspensionKind>,
1263 ) -> Result<(), RuntimeError> {
1264 let requested = match kind {
1265 None => self.cursor.suspend(reason, input_schema)?,
1266 Some(SuspensionKind::Signal) => self.cursor.suspend_for_signal(reason, input_schema)?,
1267 };
1268 match requested {
1269 Outcome::Replayed(()) => Ok(()),
1270 Outcome::Live(emitted) => {
1271 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
1272 }
1273 }
1274 }
1275
1276 /// Obtains the input a parked run was resumed with.
1277 ///
1278 /// Replayed: the recorded `Resumed` input. Live: when a resume input was
1279 /// provided through [`set_resume_input`](Self::set_resume_input), it is
1280 /// recorded and returned; otherwise the run stays parked and
1281 /// [`Resumption::Parked`] tells the caller to stop driving.
1282 ///
1283 /// # Errors
1284 ///
1285 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
1286 /// persistence fails.
1287 pub async fn await_resume(&mut self) -> Result<Resumption, RuntimeError> {
1288 match self.cursor.await_resume()? {
1289 Outcome::Replayed(input) => Ok(Resumption::Resumed(input)),
1290 Outcome::Live(parked) => match self.resume_input.take() {
1291 Some(input) => {
1292 let emitted = parked.resume(input.clone());
1293 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
1294 Ok(Resumption::Resumed(input))
1295 }
1296 None => Ok(Resumption::Parked),
1297 },
1298 }
1299 }
1300
1301 /// Parks the run on a durable timer: records `SleepStarted { wake_at }`.
1302 /// Follow with [`await_wake`](Self::await_wake).
1303 ///
1304 /// `wake_at` must be derived from recorded data, because replay presents
1305 /// it again and the cursor matches it exactly: derive it from an observed
1306 /// [`now`](Self::now) (which [`sleep_for`](Self::sleep_for) does for you),
1307 /// never from a clock read outside the log. An instant recomputed from an
1308 /// ambient clock differs on every drive and diverges on the first one.
1309 ///
1310 /// # Never inside a claimed tool call
1311 ///
1312 /// A sleep must not be recorded between a claimed call's intent and its
1313 /// completion. The claim is held for the whole span, so every other run
1314 /// under that idempotency key gets `CallInFlight` for as long as the run
1315 /// sleeps, which for a durable timer is hours or weeks rather than the
1316 /// seconds a call takes. Worse, a process death mid-sleep strands a
1317 /// dangling `Write` intent, which derives to
1318 /// [`RunStatus::NeedsReconciliation`](salvor_core::RunStatus::NeedsReconciliation)
1319 /// and needs a human before the run moves again. Sleeping belongs between
1320 /// completed calls. Nothing here can enforce that (the caller owns the
1321 /// ordering, and this context sees one request at a time), so this
1322 /// paragraph is the guardrail.
1323 ///
1324 /// # Errors
1325 ///
1326 /// [`RuntimeError::Replay`] on divergence, including a `wake_at` that
1327 /// differs from the recorded one; [`RuntimeError::Store`] when
1328 /// persistence fails.
1329 pub async fn sleep_until(&mut self, wake_at: OffsetDateTime) -> Result<(), RuntimeError> {
1330 match self.cursor.sleep_started(wake_at)? {
1331 Outcome::Replayed(()) => {}
1332 Outcome::Live(emitted) => {
1333 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
1334 }
1335 }
1336 self.sleeping_until = Some(wake_at);
1337 Ok(())
1338 }
1339
1340 /// Sleeps for `duration` from a recorded reading of the clock, returning
1341 /// the wake instant it recorded.
1342 ///
1343 /// Exactly `now() + duration`, recorded: the reading goes into the log as
1344 /// a `NowObserved` before the sleep is derived from it, so every later
1345 /// drive replays the identical reading and derives the identical instant.
1346 /// A duration alone means nothing to a replay, which has no clock to
1347 /// interpret it against; this is the composition that turns one into an
1348 /// instant without leaving determinism behind.
1349 ///
1350 /// Carries every constraint [`sleep_until`](Self::sleep_until) does,
1351 /// including the never-inside-a-claimed-call rule. Follow it with
1352 /// [`await_wake`](Self::await_wake).
1353 ///
1354 /// # Errors
1355 ///
1356 /// [`RuntimeError::SleepOverflow`] when the wake instant would fall
1357 /// outside the representable range; [`RuntimeError::Replay`] on
1358 /// divergence; [`RuntimeError::Store`] when persistence fails.
1359 pub async fn sleep_for(&mut self, duration: Duration) -> Result<OffsetDateTime, RuntimeError> {
1360 let now = self.now().await?;
1361 let wake_at = now
1362 .checked_add(duration)
1363 .ok_or(RuntimeError::SleepOverflow { now, duration })?;
1364 self.sleep_until(wake_at).await?;
1365 Ok(wake_at)
1366 }
1367
1368 /// Asks whether the sleep is over.
1369 ///
1370 /// Replayed: the log holds the `SleepCompleted`, so the sleep already
1371 /// ended and the run carries on. Live: the injected clock decides. At or
1372 /// past the recorded wake instant the completion is recorded and the run
1373 /// continues; before it, the run stays asleep and [`Waking::Asleep`] tells
1374 /// the caller to stop driving.
1375 ///
1376 /// The clock read belongs here and not in the cursor, which reads none;
1377 /// it is the same category of live-only decision as "was a resume input
1378 /// provided", and like that one it is never recorded as an observation,
1379 /// because what the log needs is the fact that the sleep ended, not the
1380 /// instant something noticed. Enforcing the deadline here also means no
1381 /// caller can wake a run early by driving it early: a driver that comes
1382 /// back too soon simply finds it still asleep.
1383 ///
1384 /// Call it after [`sleep_until`](Self::sleep_until) or
1385 /// [`sleep_for`](Self::sleep_for) in the same drive, so the deadline to
1386 /// compare against is in hand. Without a sleep before it, there is no
1387 /// deadline that could have arrived and the run stays asleep.
1388 ///
1389 /// # Errors
1390 ///
1391 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
1392 /// persistence fails.
1393 pub async fn await_wake(&mut self) -> Result<Waking, RuntimeError> {
1394 match self.cursor.sleep_completed()? {
1395 Outcome::Replayed(()) => {
1396 self.sleeping_until = None;
1397 Ok(Waking::Woken)
1398 }
1399 Outcome::Live(asleep) => {
1400 // The last representable instant stands in for a deadline
1401 // this drive never set: a wake nobody asked for has not
1402 // arrived, and no clock reading will make it so.
1403 let wake_at = self
1404 .sleeping_until
1405 .unwrap_or_else(|| PrimitiveDateTime::MAX.assume_utc());
1406 if (self.clock)() < wake_at {
1407 return Ok(Waking::Asleep { wake_at });
1408 }
1409 let emitted = asleep.wake();
1410 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await?;
1411 self.sleeping_until = None;
1412 Ok(Waking::Woken)
1413 }
1414 }
1415 }
1416
1417 /// Records a budget crossing. The check that led here must be computed
1418 /// from replayed data (recorded usage, recorded `now` observations) so
1419 /// it re-fires identically on replay. Follow with
1420 /// [`await_resume`](Self::await_resume), exactly like a suspension.
1421 ///
1422 /// # Errors
1423 ///
1424 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
1425 /// persistence fails.
1426 pub async fn budget_exceeded(
1427 &mut self,
1428 budget: Budget,
1429 observed: f64,
1430 ) -> Result<(), RuntimeError> {
1431 match self.cursor.budget_exceeded(budget, observed)? {
1432 Outcome::Replayed(()) => Ok(()),
1433 Outcome::Live(emitted) => {
1434 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
1435 }
1436 }
1437 }
1438
1439 /// Completes the run with `output`. Every request after this is a
1440 /// divergence.
1441 ///
1442 /// # Errors
1443 ///
1444 /// [`RuntimeError::Replay`] on divergence (including an output that does
1445 /// not match the recorded one); [`RuntimeError::Store`] when persistence
1446 /// fails.
1447 pub async fn complete_run(&mut self, output: &Value) -> Result<(), RuntimeError> {
1448 match self.cursor.complete_run(output)? {
1449 Outcome::Replayed(()) => Ok(()),
1450 Outcome::Live(emitted) => {
1451 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
1452 }
1453 }
1454 }
1455
1456 /// Fails the run with `error`. Every request after this is a divergence.
1457 ///
1458 /// # Errors
1459 ///
1460 /// [`RuntimeError::Replay`] on divergence; [`RuntimeError::Store`] when
1461 /// persistence fails.
1462 pub async fn fail_run(&mut self, error: &str) -> Result<(), RuntimeError> {
1463 match self.cursor.fail_run(error)? {
1464 Outcome::Replayed(()) => Ok(()),
1465 Outcome::Live(emitted) => {
1466 persist(self.store.as_ref(), self.run_id, &self.clock, &emitted).await
1467 }
1468 }
1469 }
1470}
1471
1472/// Wraps an emitted event in an envelope (timestamp from the injected clock,
1473/// at this IO edge) and appends it durably. When this returns `Ok`, the
1474/// event is in the store.
1475async fn persist(
1476 store: &dyn EventStore,
1477 run_id: RunId,
1478 clock: &ClockFn,
1479 emitted: &Emitted,
1480) -> Result<(), RuntimeError> {
1481 let envelope = EventEnvelope::new(run_id, emitted.seq, (clock)(), emitted.event.clone());
1482 store.append(&envelope).await?;
1483 // The event is durable now, so this is the honest moment to report it.
1484 // Live progress streams from here as the run drives; replayed events take
1485 // the cursor's early return above and never reach this edge, so they never
1486 // re-emit. The detail is truncated (see `crate::progress`), so no full
1487 // payload rides the progress stream.
1488 crate::progress::emit_step(run_id, envelope.seq, &envelope.event);
1489 Ok(())
1490}
1491
1492/// Whether an effect class participates in cross-run deduplication at all.
1493///
1494/// A [`Effect::Read`] performs nothing worth naming, so there is nothing for a
1495/// second run to avoid repeating. The other two classes both do something to
1496/// the world, and a key on them is a claim about which something.
1497fn deduplicates(effect: Effect) -> bool {
1498 matches!(effect, Effect::Write | Effect::Idempotent)
1499}
1500
1501/// Reads back the call a commitment points at, and checks it really is the same
1502/// call.
1503///
1504/// The read goes through [`EventStore::read_log`], never around it, so the
1505/// origin run's hash chain is verified before any of its recorded bytes are
1506/// copied into this run's log. A commitment is a pointer and nothing else,
1507/// which is what makes that unavoidable rather than merely encouraged.
1508///
1509/// The input check is what keeps a key honest. If the origin's recorded input
1510/// differs from this call's, one key is naming two different calls, and both
1511/// available answers are wrong: copying would return an output computed from
1512/// somebody else's arguments, executing would repeat an effect the key says has
1513/// already happened. So neither happens.
1514async fn committed_call(
1515 store: &dyn EventStore,
1516 tool: &str,
1517 idempotency_key: &str,
1518 commitment: CallCommitment,
1519 input: &Value,
1520) -> Result<(Value, DedupOrigin), RuntimeError> {
1521 let log = store.read_log(commitment.run_id).await?;
1522 let correlation = commitment.intent_seq;
1523 let unreadable = || RuntimeError::CommitmentUnreadable {
1524 tool: tool.to_owned(),
1525 idempotency_key: idempotency_key.to_owned(),
1526 origin: commitment.run_id,
1527 origin_seq: correlation.get(),
1528 };
1529
1530 let recorded_input = log
1531 .iter()
1532 .find_map(|envelope| match &envelope.event {
1533 Event::ToolCallRequested {
1534 seq,
1535 tool: recorded_tool,
1536 input,
1537 ..
1538 } if *seq == correlation && recorded_tool == tool => Some(input),
1539 _ => None,
1540 })
1541 .ok_or_else(unreadable)?;
1542 if recorded_input != input {
1543 return Err(RuntimeError::IdempotencyKeyCollision {
1544 tool: tool.to_owned(),
1545 idempotency_key: idempotency_key.to_owned(),
1546 origin: commitment.run_id,
1547 origin_seq: correlation.get(),
1548 });
1549 }
1550
1551 let output = log
1552 .iter()
1553 .find_map(|envelope| match &envelope.event {
1554 Event::ToolCallCompleted { seq, output, .. } if *seq == correlation => Some(output),
1555 _ => None,
1556 })
1557 .ok_or_else(unreadable)?
1558 .clone();
1559
1560 Ok((
1561 output,
1562 DedupOrigin {
1563 run_id: commitment.run_id,
1564 seq: correlation,
1565 },
1566 ))
1567}
1568
1569/// Persists a completion and settles its call commitment in one indivisible
1570/// store operation. The settling counterpart of [`persist`].
1571async fn persist_settling(
1572 store: &dyn EventStore,
1573 run_id: RunId,
1574 clock: &ClockFn,
1575 emitted: &Emitted,
1576 claimant: CallClaimant<'_>,
1577) -> Result<(), RuntimeError> {
1578 let envelope = EventEnvelope::new(run_id, emitted.seq, (clock)(), emitted.event.clone());
1579 store.append_settling_call(&envelope, claimant).await?;
1580 crate::progress::emit_step(run_id, envelope.seq, &envelope.event);
1581 Ok(())
1582}
1583
1584/// Decodes a recorded completion output into the same [`ToolCallResult`] the
1585/// live path produced, so replayed orchestration takes the identical branch.
1586fn decode_tool_output(output: Value) -> ToolCallResult {
1587 if let Some(suspension) = decode_suspension(&output) {
1588 return ToolCallResult::Suspended(suspension);
1589 }
1590 if let Some(sleep) = decode_sleep(&output) {
1591 return ToolCallResult::Sleeping(sleep);
1592 }
1593 if let Some(failure) = decode_failure(&output) {
1594 return ToolCallResult::Failed(failure);
1595 }
1596 ToolCallResult::Output(output)
1597}
1598
1599/// The default random source: 64 bits folded from a freshly drawn version 4
1600/// UUID, which the `uuid` crate fills from operating-system randomness. Non
1601/// cryptographic by design; recorded bits only ever seed idempotency keys
1602/// and user-level derivations.
1603pub(crate) fn os_random() -> u64 {
1604 let bits = Uuid::new_v4().as_u128();
1605 (bits as u64) ^ ((bits >> 64) as u64)
1606}