Skip to main content

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