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 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}