Skip to main content

everruns_core/
durability.rs

1//! Neutral contracts for durable tool results and stream recovery.
2
3use crate::error::Result;
4use crate::typed_id::{MessageId, SessionId};
5use async_trait::async_trait;
6
7/// Result of a claim attempt on the per-tool-call idempotency store.
8#[derive(Debug)]
9pub enum ToolCallClaimResult {
10    /// First claim for this (turn_id, tool_call_id); caller should execute the tool.
11    /// `claim_token` must be passed to `settle_tool_call` to verify ownership.
12    Claimed { claim_token: uuid::Uuid },
13    /// A prior run already settled this call; replay the stored result.
14    AlreadySettled {
15        result_json: serde_json::Value,
16        args_fingerprint: String,
17    },
18    /// A prior run started but never settled. For `AtMostOnce` tools the
19    /// caller should NOT re-execute; for `Pure`/`Idempotent` tools the caller
20    /// may re-execute and then try to settle (the settle CAS will be a no-op if
21    /// a different claimer wins first).
22    AlreadyRunning { args_fingerprint: String },
23    /// A settled row exists but its `args_fingerprint` does not match the
24    /// current call — this is a determinism violation (workflow replay with
25    /// different inputs). The workflow should be failed loudly.
26    DeterminismViolation {
27        stored_fingerprint: String,
28        current_fingerprint: String,
29    },
30}
31
32/// Read-only status of a tool call in durable storage (EVE-533).
33#[derive(Debug, Clone)]
34pub enum DurableToolCallStatus {
35    /// Tool completed successfully or with an error; result is stored.
36    Settled { result_json: serde_json::Value },
37    /// Tool was settled with `interrupted` status; result may contain error details.
38    Interrupted {
39        result_json: Option<serde_json::Value>,
40    },
41    /// A claim exists but the tool never finished.
42    Running,
43}
44
45/// Durable per-tool-call idempotency store (EVE-530).
46///
47/// Implements the claim/settle CAS that prevents double-execution of
48/// `AtMostOnce` tools on worker reclaim/replay.
49#[async_trait]
50pub trait DurableToolResultStore: Send + Sync + 'static {
51    /// Atomically claim `(turn_id, tool_call_id)` before tool dispatch.
52    ///
53    /// - Inserts a `running` row if none exists → `Claimed`.
54    /// - Finds an existing `settled` row → `AlreadySettled`.
55    /// - Finds an existing `running` row → `AlreadyRunning`.
56    /// - Finds a `settled` row with a mismatched `args_fingerprint`
57    ///   (determinism violation) → `DeterminismViolation`.
58    async fn try_claim_tool_call(
59        &self,
60        turn_id: &str,
61        tool_call_id: &str,
62        tool_name: &str,
63        args_fingerprint: &str,
64    ) -> Result<ToolCallClaimResult>;
65
66    /// Settle a previously claimed tool call with its result.
67    ///
68    /// `claim_token` must match the token returned by `try_claim_tool_call`.
69    /// Returns `Ok(true)` if the row was updated, `Ok(false)` if the claim
70    /// token no longer matches (ownership lost — treat as a warning).
71    async fn settle_tool_call(
72        &self,
73        turn_id: &str,
74        tool_call_id: &str,
75        result_json: serde_json::Value,
76        status: &str,
77        claim_token: uuid::Uuid,
78    ) -> Result<bool>;
79
80    /// Read-only lookup of a tool call's current status in durable storage (EVE-533).
81    ///
82    /// Used by transcript repair to decide whether to replay a stored result or
83    /// synthesize an interrupted placeholder. Returns `None` if no row exists.
84    async fn get_tool_call_status(
85        &self,
86        turn_id: &str,
87        tool_call_id: &str,
88    ) -> Result<Option<DurableToolCallStatus>>;
89}
90
91// ============================================================================
92// StreamHeartbeater — per-stream liveness signal for Reason activity (EVE-531)
93// ============================================================================
94
95/// Progress snapshot carried in each stream heartbeat.
96#[derive(Debug, Clone)]
97pub struct StreamProgress {
98    /// Accumulated text + thinking length (characters) at the time of heartbeat.
99    pub accumulated_len: usize,
100    /// Wall-clock time of the most recent received token (Unix seconds).
101    pub last_delta_at: u64,
102}
103
104/// Heartbeater the Reason streaming loop calls on delta batches and a keepalive
105/// timer, signalling that the provider connection is alive.
106///
107/// Implementations bridge to the durable-execution layer (e.g. gRPC).
108#[async_trait]
109pub trait StreamHeartbeater: Send + Sync {
110    /// Signal stream liveness with current progress.
111    ///
112    /// Must be best-effort: errors must not propagate to the caller.
113    /// Cancel-safety is critical — if the worker dies the heartbeat stops
114    /// and the existing task-level reclaim takes over.
115    async fn heartbeat(&self, progress: StreamProgress);
116}
117
118// ============================================================================
119// PartialStreamStore — partial-stream recovery for Reason activity (EVE-532)
120// ============================================================================
121
122/// State of a partially-streamed assistant message detected in the event log.
123#[derive(Debug, Clone)]
124pub struct PartialStreamState {
125    /// Prepared Astra effort recovered from the matching stream-start event.
126    pub reasoning_state: Option<everruns_provider::reasoning_updates::ReasoningState>,
127    /// Stable public id from the latest `output.message.started` event.
128    pub message_id: MessageId,
129
130    /// Accumulated text from the last `output.message.delta` for the turn.
131    /// Empty when `output.message.started` was emitted but no delta arrived.
132    pub accumulated: String,
133}
134
135/// Consults the persisted event log to detect whether a `reason` activity
136/// was interrupted after `output.message.started` but before
137/// `output.message.completed` or `output.message.replaced`.
138///
139/// Used by `ReasonAtom` on re-entry to apply the ContinuePartial recovery
140/// policy (EVE-532): finalize the partial text without a second provider call,
141/// or restart clean if the partial is unusable.
142#[async_trait]
143pub trait PartialStreamStore: Send + Sync {
144    /// Return the partial-stream state for `(session_id, turn_id)` if an
145    /// in-flight assistant message exists (started but not completed).
146    async fn get_partial_stream(
147        &self,
148        session_id: SessionId,
149        turn_id: &str,
150    ) -> Result<Option<PartialStreamState>>;
151}