Skip to main content

vv_agent/runtime/backends/distributed/
driver.rs

1use std::sync::Arc;
2
3use crate::budget::RunBudgetLimits;
4use crate::checkpoint::{CheckpointStatus, ClaimMode};
5use crate::runtime::token_usage::summarize_task_token_usage;
6use crate::runtime::CheckpointStore;
7use crate::types::{last_assistant_output, AgentResult, AgentStatus, AgentTask, CompletionReason};
8
9use super::super::RuntimeRecipe;
10use super::backend::DistributedBackend;
11use super::capabilities::DistributedCapabilityRegistry;
12use super::contract::{now_unix_ms, DistributedCheckpointConfig, DistributedRunEnvelope};
13use super::dispatch::CycleDispatchResult;
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub struct DistributedRunHandle {
17    pub checkpoint_key: String,
18    pub run_id: String,
19    pub trace_id: String,
20}
21
22impl DistributedRunHandle {
23    fn from_checkpoint(checkpoint: &crate::runtime::Checkpoint) -> Self {
24        Self {
25            checkpoint_key: checkpoint.checkpoint_key.clone(),
26            run_id: checkpoint.root_run_id.clone(),
27            trace_id: checkpoint.trace_id.clone(),
28        }
29    }
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub enum DistributedDeliveryOutcome {
34    Worker(Box<CycleDispatchResult>),
35    TransportFailure(String),
36}
37
38impl DistributedDeliveryOutcome {
39    pub fn worker(response: CycleDispatchResult) -> Self {
40        Self::Worker(Box::new(response))
41    }
42
43    pub fn transport_failure(error: impl Into<String>) -> Result<Self, String> {
44        let error = error.into();
45        if error.trim().is_empty() {
46            return Err("distributed transport error must be a non-empty string".to_string());
47        }
48        Ok(Self::TransportFailure(error))
49    }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum DistributedWaitReason {
54    ActiveClaim,
55    ReconciliationRequired,
56    HostInteraction,
57    SupersededDelivery,
58}
59
60#[derive(Debug, Clone, PartialEq)]
61pub enum DistributedAdvanceDecision {
62    Dispatch {
63        handle: DistributedRunHandle,
64        envelope: DistributedRunEnvelope,
65    },
66    RetryAt {
67        handle: DistributedRunHandle,
68        envelope: DistributedRunEnvelope,
69        not_before_unix_ms: u64,
70    },
71    Wait {
72        handle: DistributedRunHandle,
73        reason: DistributedWaitReason,
74    },
75    FinalizeRequired {
76        handle: DistributedRunHandle,
77        checkpoint_revision: u64,
78        result: AgentResult,
79    },
80    TerminalReplay {
81        handle: DistributedRunHandle,
82        checkpoint_revision: u64,
83        result: AgentResult,
84    },
85}
86
87pub trait CycleEnqueuer: Send + Sync {
88    fn enqueue_envelope(
89        &self,
90        envelope: &DistributedRunEnvelope,
91        not_before_unix_ms: Option<u64>,
92    ) -> Result<(), String>;
93}
94
95type NonblockingComponents<'a> = (
96    &'a RuntimeRecipe,
97    Arc<dyn CheckpointStore>,
98    &'a Arc<dyn CycleEnqueuer>,
99);
100
101impl DistributedBackend {
102    pub fn nonblocking(
103        runtime_recipe: RuntimeRecipe,
104        capability_registry: DistributedCapabilityRegistry,
105        cycle_enqueuer: Arc<dyn CycleEnqueuer>,
106    ) -> Self {
107        Self::inline_fallback().with_nonblocking_driver(
108            runtime_recipe,
109            capability_registry,
110            cycle_enqueuer,
111        )
112    }
113
114    pub fn with_nonblocking_driver(
115        mut self,
116        runtime_recipe: RuntimeRecipe,
117        capability_registry: DistributedCapabilityRegistry,
118        cycle_enqueuer: Arc<dyn CycleEnqueuer>,
119    ) -> Self {
120        self.runtime_recipe = Some(runtime_recipe);
121        self.capability_registry = Some(capability_registry);
122        self.cycle_enqueuer = Some(cycle_enqueuer);
123        self
124    }
125
126    pub fn start(
127        &self,
128        task: AgentTask,
129        checkpoint_config: DistributedCheckpointConfig,
130        budget_limits: Option<RunBudgetLimits>,
131    ) -> Result<DistributedRunHandle, String> {
132        let (recipe, store, enqueuer) = self.nonblocking_components()?;
133        checkpoint_config.validate()?;
134        let checkpoint = load_checkpoint_once(store.as_ref(), &checkpoint_config.key)?;
135        let handle = DistributedRunHandle::from_checkpoint(&checkpoint);
136        if checkpoint.terminal_result.is_some() {
137            return Ok(handle);
138        }
139        if checkpoint.status != CheckpointStatus::Running
140            || checkpoint.claim_token.is_some()
141            || checkpoint.cycle_index != 0
142        {
143            return Err(
144                "distributed start requires an unclaimed running checkpoint before cycle 1"
145                    .to_string(),
146            );
147        }
148        if checkpoint.task_id != task.task_id {
149            return Err(
150                "distributed start task does not match the authoritative checkpoint".to_string(),
151            );
152        }
153        let envelope = self.envelope_from_checkpoint(
154            task,
155            recipe.clone(),
156            &checkpoint,
157            checkpoint_config,
158            1,
159            ClaimMode::Continue,
160            budget_limits,
161            None,
162        )?;
163        enqueuer.enqueue_envelope(&envelope, None)?;
164        Ok(handle)
165    }
166
167    pub fn advance(
168        &self,
169        previous_envelope: &DistributedRunEnvelope,
170        outcome: DistributedDeliveryOutcome,
171    ) -> Result<DistributedAdvanceDecision, String> {
172        self.validate_nonblocking_recipe()?;
173        previous_envelope.validate()?;
174        if previous_envelope
175            .recipe
176            .capabilities
177            .approval_provider_ref
178            .is_some()
179            || previous_envelope
180                .recipe
181                .capabilities
182                .approval_broker_ref
183                .is_some()
184        {
185            return Err(
186                "nonblocking distributed runs do not support brokered approval waits".to_string(),
187            );
188        }
189        if matches!(
190            &outcome,
191            DistributedDeliveryOutcome::TransportFailure(error) if error.trim().is_empty()
192        ) {
193            return Err("distributed transport error must be a non-empty string".to_string());
194        }
195        if let DistributedDeliveryOutcome::Worker(response) = &outcome {
196            response.validate()?;
197        }
198        let registry = self.capability_registry.as_ref().ok_or_else(|| {
199            "nonblocking DistributedBackend requires a DistributedCapabilityRegistry".to_string()
200        })?;
201        let store_reference = previous_envelope
202            .recipe
203            .capabilities
204            .checkpoint_store_ref
205            .as_ref()
206            .ok_or_else(|| "distributed run requires checkpoint_store_ref".to_string())?;
207        let store = registry
208            .resolve_checkpoint_store_required(store_reference)
209            .map_err(|error| error.to_string())?;
210
211        // This is the one authoritative read performed by an advance invocation.
212        let checkpoint =
213            load_checkpoint_once(store.as_ref(), &previous_envelope.checkpoint_config.key)?;
214        validate_checkpoint_identity(previous_envelope, &checkpoint)?;
215        let handle = DistributedRunHandle::from_checkpoint(&checkpoint);
216        let response = match &outcome {
217            DistributedDeliveryOutcome::Worker(response) => Some(response.as_ref()),
218            DistributedDeliveryOutcome::TransportFailure(_) => None,
219        };
220
221        if let Some(terminal_result) = checkpoint.terminal_result.as_ref() {
222            let result = AgentResult::from_dict(terminal_result)
223                .map_err(|error| format!("invalid durable terminal result: {error}"))?;
224            if let Some(CycleDispatchResult::TerminalReplay {
225                checkpoint_revision,
226                result: observed,
227            }) = response
228            {
229                if *checkpoint_revision != checkpoint.revision || observed != &result {
230                    return Err(
231                        "distributed terminal replay does not match the durable checkpoint"
232                            .to_string(),
233                    );
234                }
235            }
236            return Ok(DistributedAdvanceDecision::TerminalReplay {
237                handle,
238                checkpoint_revision: checkpoint.revision,
239                result,
240            });
241        }
242
243        if matches!(response, Some(CycleDispatchResult::TerminalReplay { .. })) {
244            return Err("distributed terminal replay has no matching durable terminal".to_string());
245        }
246
247        if let Some(CycleDispatchResult::TerminalCandidate {
248            checkpoint_revision,
249            result,
250        }) = response
251        {
252            if *checkpoint_revision != checkpoint.revision {
253                return Err(
254                    "distributed terminal candidate revision does not match the checkpoint"
255                        .to_string(),
256                );
257            }
258            if result.status == AgentStatus::ReconciliationRequired {
259                if checkpoint.status != CheckpointStatus::ReconciliationRequired
260                    || checkpoint.claim_token.is_some()
261                {
262                    return Err(
263                        "distributed reconciliation candidate does not match durable state"
264                            .to_string(),
265                    );
266                }
267                return Ok(DistributedAdvanceDecision::Wait {
268                    handle,
269                    reason: DistributedWaitReason::ReconciliationRequired,
270                });
271            }
272            if checkpoint.claim_token.is_none()
273                || checkpoint.claimed_cycle != Some(u64::from(previous_envelope.cycle_index))
274            {
275                return Err(
276                    "distributed terminal candidate does not retain the dispatched cycle claim"
277                        .to_string(),
278                );
279            }
280            if result
281                .cycles
282                .last()
283                .is_some_and(|cycle| cycle.index != previous_envelope.cycle_index)
284            {
285                return Err(
286                    "distributed terminal candidate does not contain the dispatched cycle"
287                        .to_string(),
288                );
289            }
290            return Ok(DistributedAdvanceDecision::FinalizeRequired {
291                handle,
292                checkpoint_revision: checkpoint.revision,
293                result: result.clone(),
294            });
295        }
296
297        if checkpoint.status == CheckpointStatus::ReconciliationRequired {
298            return Ok(DistributedAdvanceDecision::Wait {
299                handle,
300                reason: DistributedWaitReason::ReconciliationRequired,
301            });
302        }
303
304        if let Some(CycleDispatchResult::Committed {
305            checkpoint_revision: _,
306            committed_cycle,
307        }) = response
308        {
309            if *committed_cycle != u64::from(previous_envelope.cycle_index) {
310                return Err(
311                    "distributed committed response does not match the dispatched cycle"
312                        .to_string(),
313                );
314            }
315            if checkpoint.cycle_index < *committed_cycle {
316                return Err("distributed committed response is ahead of the checkpoint".to_string());
317            }
318        }
319
320        let previous_cycle = u64::from(previous_envelope.cycle_index);
321        if checkpoint.cycle_index > previous_cycle
322            || checkpoint
323                .claimed_cycle
324                .is_some_and(|claimed_cycle| claimed_cycle > previous_cycle)
325        {
326            return Ok(DistributedAdvanceDecision::Wait {
327                handle,
328                reason: DistributedWaitReason::SupersededDelivery,
329            });
330        }
331        if let Some(CycleDispatchResult::Committed {
332            checkpoint_revision,
333            ..
334        }) = response
335        {
336            if checkpoint.claim_token.is_none() && *checkpoint_revision != checkpoint.revision {
337                return Err(
338                    "distributed committed response revision does not match the checkpoint"
339                        .to_string(),
340                );
341            }
342        }
343
344        let now_ms = now_unix_ms()?;
345        let (cycle_index, claim_mode, not_before_unix_ms) = if checkpoint.claim_token.is_some() {
346            let cycle_index = checkpoint
347                .claimed_cycle
348                .ok_or_else(|| "distributed checkpoint has a partial claim".to_string())?;
349            let not_before = checkpoint
350                .lease_expires_at_ms
351                .filter(|lease_expires_at_ms| *lease_expires_at_ms > now_ms);
352            (cycle_index, ClaimMode::Recovery, not_before)
353        } else if checkpoint.cycle_index == previous_cycle {
354            (
355                checkpoint
356                    .cycle_index
357                    .checked_add(1)
358                    .ok_or_else(|| "distributed cycle index overflow".to_string())?,
359                ClaimMode::Continue,
360                None,
361            )
362        } else if checkpoint.cycle_index.checked_add(1) == Some(previous_cycle) {
363            (previous_cycle, ClaimMode::Recovery, None)
364        } else {
365            return Err("distributed delivery is out of order with the checkpoint".to_string());
366        };
367
368        if cycle_index > u64::from(previous_envelope.task.max_cycles) {
369            let result = AgentResult {
370                status: AgentStatus::MaxCycles,
371                completion_reason: Some(CompletionReason::MaxCycles),
372                partial_output: last_assistant_output(&checkpoint.cycles),
373                token_usage: summarize_task_token_usage(&checkpoint.model_calls),
374                messages: checkpoint.messages.clone(),
375                cycles: checkpoint.cycles.clone(),
376                budget_usage: checkpoint.budget_usage.clone(),
377                final_answer: Some("Reached max cycles without finish signal.".to_string()),
378                shared_state: checkpoint.shared_state.clone(),
379                ..AgentResult::default()
380            };
381            return Ok(DistributedAdvanceDecision::FinalizeRequired {
382                handle,
383                checkpoint_revision: checkpoint.revision,
384                result,
385            });
386        }
387
388        let cycle_index = u32::try_from(cycle_index)
389            .map_err(|_| "distributed cycle index exceeds u32".to_string())?;
390        let envelope = self.envelope_from_checkpoint(
391            previous_envelope.task.clone(),
392            previous_envelope.recipe.clone(),
393            &checkpoint,
394            previous_envelope.checkpoint_config.clone(),
395            cycle_index,
396            claim_mode,
397            previous_envelope.budget_limits.clone(),
398            not_before_unix_ms,
399        )?;
400        let enqueuer = self
401            .cycle_enqueuer
402            .as_ref()
403            .ok_or_else(|| "nonblocking DistributedBackend requires a CycleEnqueuer".to_string())?;
404        if let Some(not_before_unix_ms) = not_before_unix_ms {
405            enqueuer.enqueue_envelope(&envelope, Some(not_before_unix_ms))?;
406            Ok(DistributedAdvanceDecision::RetryAt {
407                handle,
408                envelope,
409                not_before_unix_ms,
410            })
411        } else {
412            enqueuer.enqueue_envelope(&envelope, None)?;
413            Ok(DistributedAdvanceDecision::Dispatch { handle, envelope })
414        }
415    }
416
417    fn validate_nonblocking_recipe(&self) -> Result<(), String> {
418        let recipe = self
419            .runtime_recipe
420            .as_ref()
421            .ok_or_else(|| "nonblocking DistributedBackend requires a RuntimeRecipe".to_string())?;
422        recipe.validate()?;
423        if recipe.capabilities.approval_provider_ref.is_some()
424            || recipe.capabilities.approval_broker_ref.is_some()
425        {
426            return Err(
427                "nonblocking distributed runs do not support brokered approval waits".to_string(),
428            );
429        }
430        Ok(())
431    }
432
433    fn nonblocking_components(&self) -> Result<NonblockingComponents<'_>, String> {
434        self.validate_nonblocking_recipe()?;
435        let recipe = self.runtime_recipe.as_ref().expect("validated above");
436        let registry = self.capability_registry.as_ref().ok_or_else(|| {
437            "nonblocking DistributedBackend requires a DistributedCapabilityRegistry".to_string()
438        })?;
439        registry
440            .resolve(&recipe.capabilities)
441            .map_err(|error| error.to_string())?;
442        let reference = recipe
443            .capabilities
444            .checkpoint_store_ref
445            .as_ref()
446            .ok_or_else(|| "distributed run requires checkpoint_store_ref".to_string())?;
447        let store = registry
448            .resolve_checkpoint_store_required(reference)
449            .map_err(|error| error.to_string())?;
450        let enqueuer = self
451            .cycle_enqueuer
452            .as_ref()
453            .ok_or_else(|| "nonblocking DistributedBackend requires a CycleEnqueuer".to_string())?;
454        Ok((recipe, store, enqueuer))
455    }
456
457    #[allow(clippy::too_many_arguments)]
458    fn envelope_from_checkpoint(
459        &self,
460        task: AgentTask,
461        mut recipe: RuntimeRecipe,
462        checkpoint: &crate::runtime::Checkpoint,
463        checkpoint_config: DistributedCheckpointConfig,
464        cycle_index: u32,
465        claim_mode: ClaimMode,
466        budget_limits: Option<RunBudgetLimits>,
467        not_before_unix_ms: Option<u64>,
468    ) -> Result<DistributedRunEnvelope, String> {
469        let metadata_denials = crate::runtime::tool_planner::projected_metadata_denials(&task)?;
470        recipe
471            .capabilities
472            .tool_policy
473            .set_metadata_denials(&metadata_denials);
474        let timeout_ms = u64::try_from(self.dispatch_timeout.as_millis())
475            .map_err(|_| "distributed dispatch timeout exceeds u64 milliseconds".to_string())?;
476        let deadline_base_ms = now_unix_ms()?.max(not_before_unix_ms.unwrap_or(0));
477        let deadline_unix_ms = deadline_base_ms
478            .checked_add(timeout_ms)
479            .ok_or_else(|| "distributed dispatch deadline overflow".to_string())?;
480        DistributedRunEnvelope::for_cycle(
481            task,
482            recipe,
483            cycle_index,
484            self.cycle_name.clone(),
485            Some(checkpoint.root_run_id.clone()),
486            Some(deadline_unix_ms),
487            self.lease_duration_ms,
488            budget_limits,
489            checkpoint.root_run_id.clone(),
490            checkpoint.trace_id.clone(),
491            checkpoint.run_definition_digest.clone(),
492            claim_mode,
493            checkpoint.resume_attempt,
494            checkpoint_config,
495        )
496    }
497}
498
499fn load_checkpoint_once(
500    store: &dyn CheckpointStore,
501    checkpoint_key: &str,
502) -> Result<crate::runtime::Checkpoint, String> {
503    store
504        .load_checkpoint(checkpoint_key)
505        .map_err(|error| error.to_string())?
506        .ok_or_else(|| "checkpoint disappeared before distributed driver invocation".to_string())
507}
508
509fn validate_checkpoint_identity(
510    envelope: &DistributedRunEnvelope,
511    checkpoint: &crate::runtime::Checkpoint,
512) -> Result<(), String> {
513    if checkpoint.checkpoint_key != envelope.checkpoint_config.key
514        || checkpoint.task_id != envelope.task.task_id
515        || checkpoint.root_run_id != envelope.root_run_id
516        || checkpoint.trace_id != envelope.trace_id
517        || checkpoint.run_definition_digest != envelope.run_definition_digest
518    {
519        return Err(
520            "distributed advance envelope does not match the authoritative checkpoint".to_string(),
521        );
522    }
523    Ok(())
524}