1use std::collections::{HashMap, HashSet, VecDeque};
2use std::sync::{
3 atomic::{AtomicBool, AtomicU64, Ordering},
4 Arc, Mutex,
5};
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use ri_agent_graph::config::GraphConfig;
9use ri_agent_graph::event_sink::GraphEvent;
10use ri_agent_graph::state::{AgentState, StateLimits};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13use tokio::sync::Notify;
14
15use crate::compiler::{compile, CompileContext};
16use crate::evidence::{bundle, digest, redact, validate_witness_dependencies};
17use crate::spec::{ensure_size, GraphSpec, MAX_OUTPUT_BYTES, MAX_STATE_BYTES};
18use crate::store::PersistentStore;
19
20const MAX_RUNS: usize = 100;
21const MAX_ACTIVE_RUNS: usize = 8;
22
23fn is_terminal(status: &str) -> bool {
24 matches!(
25 status,
26 "completed" | "failed" | "cancelled" | "checkpointed"
27 )
28}
29
30#[derive(Clone)]
31pub struct RunRecord {
32 pub run_id: String,
33 pub trace: String,
34 pub graph_id: String,
35 pub graph_version: String,
36 pub status: String,
37 pub success: Option<bool>,
38 pub input: Value,
39 pub state: Value,
40 pub final_state: Value,
41 pub steps: Vec<Value>,
42 pub error: Option<String>,
43 pub events: VecDeque<Value>,
44 pub next_cursor: u64,
45 pub dropped_events: u64,
46 pub receipt: Value,
47 pub bundle: Value,
48 pub persistence_status: String,
49 pub persistence_error: Option<String>,
50 pub budgets: Option<RunBudgets>,
51 pub budget_counters: BudgetCounters,
52 pub budget_exhausted: Option<String>,
53 pub checkpoint_id: Option<String>,
54 pub checkpoint_digest: Option<String>,
55 pub approval: Option<Value>,
56 pub resumed: bool,
57 pub cancelled: Arc<AtomicBool>,
58 pub cancellation: Arc<Notify>,
59}
60
61impl RunRecord {
62 pub fn public(&self) -> Value {
63 serde_json::json!({
64 "run_id":self.run_id,"trace":self.trace,"graph_id":self.graph_id,"graph_version":self.graph_version,
65 "storage_class": if self.persistence_status == "durable_terminal" { "sqlite_terminal_projection" } else { "volatile" },
66 "persistence_status":self.persistence_status,"persistence_error":self.persistence_error,
67 "status":self.status,"success":self.success,"final_state":self.final_state,
68 "state":self.state,"steps":self.steps,"error":self.error,"receipt":self.receipt,
69 "budgets":self.budgets,"budget_counters":self.budget_counters,
70 "budget_exhausted":self.budget_exhausted,
71 "checkpoint": self.checkpoint_id.as_ref().zip(self.checkpoint_digest.as_ref()).map(|(id, digest)| serde_json::json!({"checkpoint_id":id,"checkpoint_digest":digest})),
72 "replay_capability":self.receipt.get("replay_capability").and_then(Value::as_str).unwrap_or("integrity_only")
73 })
74 }
75}
76
77#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
79pub struct RunBudgets {
80 #[serde(skip_serializing_if = "Option::is_none")]
81 pub max_wall_clock_ms: Option<u64>,
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub max_nodes: Option<u64>,
84 #[serde(skip_serializing_if = "Option::is_none")]
88 pub max_llm_calls: Option<u64>,
89}
90
91impl RunBudgets {
92 pub fn parse(value: Option<&Value>) -> Result<Option<Self>, String> {
93 let Some(value) = value.filter(|value| !value.is_null()) else {
94 return Ok(None);
95 };
96 let object = value
97 .as_object()
98 .ok_or_else(|| "budgets must be an object".to_owned())?;
99 if object.is_empty() {
100 return Err("budgets must contain at least one supported positive integer".into());
101 }
102
103 let mut budgets = Self {
104 max_wall_clock_ms: None,
105 max_nodes: None,
106 max_llm_calls: None,
107 };
108 for (key, raw) in object {
109 let slot = match key.as_str() {
110 "max_wall_clock_ms" => &mut budgets.max_wall_clock_ms,
111 "max_nodes" => &mut budgets.max_nodes,
112 "max_llm_calls" => &mut budgets.max_llm_calls,
113 _ => return Err(format!("unknown budget field '{key}'")),
114 };
115 let number = raw
116 .as_u64()
117 .filter(|number| *number > 0)
118 .ok_or_else(|| format!("budget '{key}' must be a positive integer"))?;
119 *slot = Some(number);
120 }
121 if budgets.max_wall_clock_ms.is_none()
122 && budgets.max_nodes.is_none()
123 && budgets.max_llm_calls.is_none()
124 {
125 return Err("budgets must contain an enforceable supported field".into());
126 }
127 Ok(Some(budgets))
128 }
129
130 pub fn requested_value(&self) -> Value {
131 serde_json::to_value(self).unwrap_or(Value::Null)
132 }
133}
134
135#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
136pub struct BudgetCounters {
137 pub nodes: u64,
138 pub llm_calls: u64,
139 pub wall_clock_ms: u64,
140}
141
142#[derive(Clone)]
143pub struct RunManager {
144 inner: Arc<Mutex<Inner>>,
145 counter: Arc<AtomicU64>,
146 api_key: Option<String>,
149}
150struct Inner {
151 runs: HashMap<String, RunRecord>,
152 order: VecDeque<String>,
153 reserved_async_slots: usize,
154}
155
156impl Default for RunManager {
157 fn default() -> Self {
158 Self {
159 inner: Arc::new(Mutex::new(Inner {
160 runs: HashMap::new(),
161 order: VecDeque::new(),
162 reserved_async_slots: 0,
163 })),
164 counter: Arc::new(AtomicU64::new(1)),
165 api_key: None,
166 }
167 }
168}
169
170impl RunManager {
171 pub fn with_api_key(mut self, api_key: Option<String>) -> Self {
172 self.api_key = api_key;
173 self
174 }
175 pub fn allocate(
176 &self,
177 graph_id: &str,
178 graph_version: &str,
179 input: Value,
180 ) -> Result<String, String> {
181 self.allocate_with_budgets(graph_id, graph_version, input, None)
182 }
183
184 pub fn allocate_with_budgets(
185 &self,
186 graph_id: &str,
187 graph_version: &str,
188 input: Value,
189 budgets: Option<RunBudgets>,
190 ) -> Result<String, String> {
191 let n = self.counter.fetch_add(1, Ordering::SeqCst);
192 let millis = SystemTime::now()
193 .duration_since(UNIX_EPOCH)
194 .unwrap_or_default()
195 .as_millis();
196 let run_id = format!("run-{millis:x}-{n:x}");
197 let record = RunRecord {
198 run_id: run_id.clone(),
199 trace: format!("trace-{millis:x}-{n:x}"),
200 graph_id: graph_id.into(),
201 graph_version: graph_version.into(),
202 status: "accepted".into(),
203 success: None,
204 input,
205 state: Value::Null,
206 final_state: Value::Null,
207 steps: vec![],
208 error: None,
209 events: VecDeque::new(),
210 next_cursor: 0,
211 dropped_events: 0,
212 receipt: Value::Null,
213 bundle: Value::Null,
214 persistence_status: "volatile_active".into(),
215 persistence_error: None,
216 budgets,
217 budget_counters: BudgetCounters::default(),
218 budget_exhausted: None,
219 checkpoint_id: None,
220 checkpoint_digest: None,
221 approval: None,
222 resumed: false,
223 cancelled: Arc::new(AtomicBool::new(false)),
224 cancellation: Arc::new(Notify::new()),
225 };
226 self.insert_record(record)
227 }
228
229 fn insert_record(&self, record: RunRecord) -> Result<String, String> {
230 let run_id = record.run_id.clone();
231 let mut inner = self.inner.lock().expect("run registry poisoned");
232 if inner.order.len() == MAX_RUNS {
233 let Some(old) = inner.order.iter().find_map(|id| {
234 inner
235 .runs
236 .get(id)
237 .filter(|run| is_terminal(&run.status))
238 .map(|_| id.clone())
239 }) else {
240 return Err(format!(
241 "run retention capacity reached: {MAX_RUNS} live runs cannot be evicted"
242 ));
243 };
244 inner.order.retain(|id| id != &old);
245 inner.runs.remove(&old);
246 }
247 inner.order.push_back(run_id.clone());
248 inner.runs.insert(run_id.clone(), record);
249 Ok(run_id)
250 }
251
252 pub fn allocate_resumed(
253 &self,
254 run_id: &str,
255 graph_id: &str,
256 graph_version: &str,
257 input: Value,
258 state: Value,
259 budgets: Option<RunBudgets>,
260 checkpoint_id: &str,
261 checkpoint_digest: &str,
262 approval: Option<Value>,
263 ) -> Result<String, String> {
264 let millis = SystemTime::now()
265 .duration_since(UNIX_EPOCH)
266 .unwrap_or_default()
267 .as_millis();
268 let record = RunRecord {
269 run_id: run_id.to_owned(),
270 trace: format!("trace-{millis:x}-resume"),
271 graph_id: graph_id.into(),
272 graph_version: graph_version.into(),
273 status: "accepted".into(),
274 success: None,
275 input,
276 state,
277 final_state: Value::Null,
278 steps: vec![],
279 error: None,
280 events: VecDeque::new(),
281 next_cursor: 0,
282 dropped_events: 0,
283 receipt: Value::Null,
284 bundle: Value::Null,
285 persistence_status: "volatile_active".into(),
286 persistence_error: None,
287 budgets,
288 budget_counters: BudgetCounters::default(),
289 budget_exhausted: None,
290 checkpoint_id: Some(checkpoint_id.to_owned()),
291 checkpoint_digest: Some(checkpoint_digest.to_owned()),
292 approval,
293 resumed: true,
294 cancelled: Arc::new(AtomicBool::new(false)),
295 cancellation: Arc::new(Notify::new()),
296 };
297 self.insert_record(record)
298 }
299
300 pub fn mark_checkpointed(
301 &self,
302 id: &str,
303 checkpoint_id: &str,
304 checkpoint_digest: &str,
305 ) -> Result<(), String> {
306 self.update(id, |run| {
307 run.status = "checkpointed".into();
308 run.checkpoint_id = Some(checkpoint_id.to_owned());
309 run.checkpoint_digest = Some(checkpoint_digest.to_owned());
310 })
311 }
312
313 pub fn admit_async(&self, id: &str) -> Result<(), String> {
315 let mut inner = self.inner.lock().map_err(|_| "run registry poisoned")?;
316 let active_runs = inner
317 .runs
318 .values()
319 .filter(|run| run.status == "running")
320 .count();
321 if active_runs + inner.reserved_async_slots >= MAX_ACTIVE_RUNS {
322 return Err(format!(
323 "active run capacity reached: {MAX_ACTIVE_RUNS} concurrent runs"
324 ));
325 }
326 let run = inner.runs.get_mut(id).ok_or("run not found")?;
327 if run.status != "accepted" {
328 return Err(format!("run '{id}' is not awaiting admission"));
329 }
330 run.status = "running".into();
331 Ok(())
332 }
333
334 pub fn reserve_async_slot(&self) -> Result<(), String> {
337 let mut inner = self.inner.lock().map_err(|_| "run registry poisoned")?;
338 let active_runs = inner
339 .runs
340 .values()
341 .filter(|run| run.status == "running")
342 .count();
343 if active_runs + inner.reserved_async_slots >= MAX_ACTIVE_RUNS {
344 return Err(format!(
345 "active run capacity reached: {MAX_ACTIVE_RUNS} concurrent runs"
346 ));
347 }
348 inner.reserved_async_slots += 1;
349 Ok(())
350 }
351
352 pub fn release_async_slot(&self) {
353 if let Ok(mut inner) = self.inner.lock() {
354 inner.reserved_async_slots = inner.reserved_async_slots.saturating_sub(1);
355 }
356 }
357
358 pub fn admit_reserved_async(&self, id: &str) -> Result<(), String> {
359 let mut inner = self.inner.lock().map_err(|_| "run registry poisoned")?;
360 if inner.reserved_async_slots == 0 {
361 return Err("no asynchronous execution slot was reserved".into());
362 }
363 let run = inner.runs.get_mut(id).ok_or("run not found")?;
364 if run.status != "accepted" {
365 return Err(format!("run '{id}' is not awaiting admission"));
366 }
367 run.status = "running".into();
368 inner.reserved_async_slots -= 1;
369 Ok(())
370 }
371
372 pub fn execute(
373 &self,
374 run_id: &str,
375 spec: GraphSpec,
376 base_url: String,
377 default_model: String,
378 ) -> Result<Value, String> {
379 self.execute_with_store(run_id, spec, base_url, default_model, None)
380 }
381
382 pub fn execute_with_store(
383 &self,
384 run_id: &str,
385 spec: GraphSpec,
386 base_url: String,
387 default_model: String,
388 store: Option<PersistentStore>,
389 ) -> Result<Value, String> {
390 self.execute_with_store_options(
391 run_id,
392 spec,
393 base_url,
394 default_model,
395 store,
396 None,
397 BudgetCounters::default(),
398 )
399 }
400
401 pub fn execute_with_store_options(
402 &self,
403 run_id: &str,
404 spec: GraphSpec,
405 base_url: String,
406 default_model: String,
407 store: Option<PersistentStore>,
408 initial_state: Option<Value>,
409 initial_counters: BudgetCounters,
410 ) -> Result<Value, String> {
411 self.update(run_id, |r| r.status = "running".into())?;
412 let (
413 input,
414 cancelled,
415 cancellation,
416 budgets,
417 record_state,
418 checkpoint_id,
419 checkpoint_digest,
420 approval,
421 resumed,
422 ) = {
423 let inner = self.inner.lock().map_err(|_| "run registry poisoned")?;
424 let r = inner.runs.get(run_id).ok_or("run not found")?;
425 (
426 r.input.clone(),
427 r.cancelled.clone(),
428 r.cancellation.clone(),
429 r.budgets.clone(),
430 r.state.clone(),
431 r.checkpoint_id.clone(),
432 r.checkpoint_digest.clone(),
433 r.approval.clone(),
434 r.resumed,
435 )
436 };
437 let started_at = Instant::now();
438 let provider = safe_provider_label(&base_url);
439 let configured_model = default_model.clone();
440 let events = Arc::new(Mutex::new(Vec::<GraphEvent>::new()));
441 let llm_calls = Arc::new(AtomicU64::new(0));
446 let llm_invocations = Arc::new(Mutex::new(Vec::<Value>::new()));
447 let graph = compile(
448 &spec,
449 CompileContext {
450 base_url,
451 default_model,
452 cancelled: cancelled.clone(),
453 cancellation: cancellation.clone(),
454 events: events.clone(),
455 llm_calls: llm_calls.clone(),
456 max_llm_calls: budgets.as_ref().and_then(|budget| budget.max_llm_calls),
457 llm_invocations: llm_invocations.clone(),
458 api_key: self.api_key.clone(),
459 },
460 )?;
461 let starting_state = initial_state
462 .or_else(|| resumed.then_some(record_state))
463 .unwrap_or_else(|| initial_state_for_input(&input));
464 let Value::Object(map) = starting_state else {
465 return Err("checkpoint state must be a JSON object".into());
466 };
467 let initial = map.into_iter().collect::<HashMap<_, _>>();
468 let state = AgentState::with_data_and_limits(
469 initial,
470 StateLimits {
471 max_keys: 1000,
472 max_value_bytes: 256 * 1024,
473 max_history_len: 100,
474 lock_timeout: std::time::Duration::from_secs(5),
475 },
476 );
477 let snapshot = state.clone();
478 let rt = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?;
479 let base_iteration_limit = spec.max_iterations.unwrap_or(64);
480 let budget_iteration_limit =
481 budgets
482 .as_ref()
483 .and_then(|budget| budget.max_nodes)
484 .map(|max_nodes| {
485 budget_iteration_limit(&spec, max_nodes.saturating_sub(initial_counters.nodes))
486 });
487 let config = GraphConfig::new()
488 .with_recursion_limit(
489 budget_iteration_limit
490 .unwrap_or(base_iteration_limit)
491 .min(base_iteration_limit),
492 )
493 .with_max_parallelism(spec.max_parallelism.unwrap_or(8));
494 let graph = Arc::new(graph);
495 let (handle, engine_cancel) =
496 rt.block_on(async { graph.execute_cancellable(&spec.entry, state, config) });
497 let timed_out = Arc::new(AtomicBool::new(false));
498 let timeout_notify = Arc::new(Notify::new());
499 let (timeout_complete, timeout_thread) = budgets
500 .as_ref()
501 .and_then(|budget| budget.max_wall_clock_ms)
502 .map(|limit| {
503 let (complete_tx, complete_rx) = std::sync::mpsc::channel::<()>();
504 let timed_out = timed_out.clone();
505 let timeout_notify = timeout_notify.clone();
506 let thread = std::thread::spawn(move || {
507 if matches!(
508 complete_rx.recv_timeout(Duration::from_millis(limit)),
509 Err(std::sync::mpsc::RecvTimeoutError::Timeout)
510 ) {
511 timed_out.store(true, Ordering::SeqCst);
512 timeout_notify.notify_waiters();
513 }
514 });
515 (complete_tx, thread)
516 })
517 .map_or((None, None), |(complete_tx, thread)| {
518 (Some(complete_tx), Some(thread))
519 });
520 let mut handle = handle;
521 let result = rt.block_on(async {
522 if timeout_thread.is_some() {
523 tokio::select! {
524 joined = &mut handle => joined
525 .map_err(|error| format!("execution task failed: {error}"))?
526 .map_err(|error| error.to_string()),
527 _ = timeout_notify.notified() => {
528 engine_cancel.store(true, Ordering::SeqCst);
529 cancellation.notify_waiters();
533 handle.abort();
534 let _ = handle.await;
535 Err("BUDGET_EXHAUSTED".to_owned())
536 }
537 }
538 } else {
539 handle
540 .await
541 .map_err(|error| format!("execution task failed: {error}"))?
542 .map_err(|error| error.to_string())
543 }
544 });
545 if let Some(complete) = timeout_complete {
546 let _ = complete.send(());
547 }
548 if let Some(thread) = timeout_thread {
549 let _ = thread.join();
550 }
551 let core_receipt = serde_json::json!({
553 "api": "execute_with_summary",
554 "note": "receipt not yet fully typed; upgrade to execute_with_config + receipt"
555 });
556 let exported = rt.block_on(snapshot.export());
557 let state_value = serde_json::to_value(exported).map_err(|e| e.to_string())?;
558 ensure_size(&state_value, MAX_STATE_BYTES, "total state")?;
559 let final_state = spec
560 .output_key
561 .as_deref()
562 .and_then(|key| state_value.get(key).cloned())
563 .or_else(|| state_value.get("__input__").cloned())
564 .unwrap_or(Value::Null);
565 ensure_size(&final_state, MAX_OUTPUT_BYTES, "execution output")?;
566 let graph_events = events
567 .lock()
568 .map_err(|_| "event registry poisoned")?
569 .clone();
570 let observed_llm_calls = llm_calls.load(Ordering::SeqCst);
571 let observed_invocations = llm_invocations
572 .lock()
573 .map_err(|_| "llm invocation registry poisoned")?
574 .clone();
575 let mut steps: Vec<Value> = Vec::new();
576 for event in &graph_events {
577 if let GraphEvent::StateUpdate {
578 node_id, updates, ..
579 } = event
580 {
581 let output = updates
582 .get("__input__")
583 .or_else(|| updates.get("__route__"))
584 .cloned()
585 .unwrap_or_else(|| serde_json::to_value(updates).unwrap_or(Value::Null));
586 steps.push(serde_json::json!({
587 "node_id": node_id,
588 "output": output,
589 }));
590 }
591 }
592 let budget_counters = BudgetCounters {
593 nodes: initial_counters.nodes.saturating_add(
594 graph_events
595 .iter()
596 .filter(|event| matches!(event, GraphEvent::NodeStart { .. }))
597 .count() as u64,
598 ),
599 llm_calls: initial_counters
603 .llm_calls
604 .saturating_add(observed_llm_calls),
605 wall_clock_ms: initial_counters
606 .wall_clock_ms
607 .saturating_add(started_at.elapsed().as_millis().min(u64::MAX as u128) as u64),
608 };
609 let wall_exhausted = budgets.as_ref().is_some_and(|budget| {
610 budget
611 .max_wall_clock_ms
612 .is_some_and(|limit| budget_counters.wall_clock_ms >= limit)
613 || timed_out.load(Ordering::SeqCst)
614 });
615 let node_exhausted = budgets.as_ref().and_then(|budget| {
616 let limit = budget.max_nodes?;
617 let iteration_error = result
618 .as_ref()
619 .err()
620 .is_some_and(|error| error.contains("iteration"));
621 let budget_limited_iterations =
622 budget_iteration_limit.is_some_and(|limit| limit <= base_iteration_limit);
623 (budget_counters.nodes > limit
624 || (budget_counters.nodes >= limit && iteration_error && budget_limited_iterations))
625 .then_some("max_nodes".into())
626 });
627 let llm_exhausted = budgets.as_ref().is_some_and(|budget| {
628 budget.max_llm_calls.is_some()
629 && result
630 .as_ref()
631 .err()
632 .is_some_and(|error| error.contains("BUDGET_EXHAUSTED"))
633 });
634 let budget_exhausted = if wall_exhausted {
635 Some("max_wall_clock_ms".to_owned())
636 } else if let Some(node) = node_exhausted {
637 Some(node)
638 } else if llm_exhausted {
639 Some("max_llm_calls".to_owned())
640 } else {
641 None
642 };
643 let mut dependency_envelopes = Value::Array(Vec::new());
644 let mut dependency_envelopes_complete = false;
645 let mut evidence_error = None;
646 if result.is_ok() && spec.nodes.iter().any(|node| node.evidence_required) {
647 match store.as_ref() {
648 Some(store) => {
649 let mut collected = Vec::new();
650 for node in spec.nodes.iter().filter(|node| node.evidence_required) {
651 let output_key = node
652 .config
653 .get("output_key")
654 .and_then(Value::as_str)
655 .unwrap_or("");
656 let Some(evidence) = state_value.get(output_key) else {
657 evidence_error = Some("WITNESS_EVIDENCE_MISSING".to_owned());
658 break;
659 };
660 match validate_witness_dependencies(evidence, store) {
661 Ok(Value::Array(dependencies)) => collected.extend(dependencies),
662 Ok(_) => {
663 evidence_error = Some("WITNESS_EVIDENCE_INVALID".to_owned());
664 break;
665 }
666 Err(error) => {
667 evidence_error = Some(error.code);
668 break;
669 }
670 }
671 }
672 if evidence_error.is_none() {
673 let mut unique = std::collections::BTreeMap::new();
674 for dependency in collected {
675 if let Some(id) = dependency.get("witness_id").and_then(Value::as_str) {
676 unique.insert(id.to_owned(), dependency);
677 }
678 }
679 dependency_envelopes = Value::Array(unique.into_values().collect());
680 dependency_envelopes_complete = true;
681 }
682 }
683 None => evidence_error = Some("WITNESS_STORE_REQUIRED".to_owned()),
684 }
685 }
686 let error = if budget_exhausted.is_some() {
687 Some("BUDGET_EXHAUSTED".to_owned())
688 } else if evidence_error.is_some() {
689 evidence_error
690 } else {
691 result.err().map(|e| e.to_string())
692 };
693 let trace = self.get(run_id).ok_or("run not found")?.trace;
694 let graph_version = self.get(run_id).ok_or("run not found")?.graph_version;
695 let models: Vec<Value> = spec.nodes.iter().filter(|node| matches!(node.node_type, crate::spec::NodeType::Llm)).map(|node| serde_json::json!({
696 "node_id":node.id,"model_alias":node.model.as_deref().unwrap_or("server_default"),"prompt_digest":digest(&Value::String(node.prompt.clone().unwrap_or_else(||"{input}".into())))
697 })).collect();
698 let model_labels: Vec<Value> = models
699 .iter()
700 .filter_map(|m| m.get("model_alias").cloned())
701 .collect();
702 let terminal_output_key = spec
703 .output_key
704 .clone()
705 .unwrap_or_else(|| "__input__".to_owned());
706 let terminal_output = serde_json::json!({
707 "state_key": terminal_output_key,
708 "provenance": if spec.output_key.is_some() { "declared_output_key" } else { "legacy_input_fallback" },
709 "value_digest": digest(&final_state),
710 });
711 let receipt = serde_json::json!({"schema":"agent-graph-mcp-receipt-v2","run_id":run_id,"trace":trace,"graph_version":graph_version,
712 "input_digest":digest(&input),"output_digest":digest(&state_value),"step_count":steps.len(),"models":models,
713 "provider":provider,"default_model":configured_model,"model_labels":model_labels,
714 "core":core_receipt,"terminal_output":terminal_output,"llm_invocations":observed_invocations,
715 "dependency_envelopes":dependency_envelopes,"dependency_envelopes_complete":dependency_envelopes_complete,"replay_capability":if resumed { "deterministic_local_resume" } else { "integrity_only" },
716 "resume_supported":resumed,
717 "checkpoint":checkpoint_id.as_ref().zip(checkpoint_digest.as_ref()).map(|(id, digest)| serde_json::json!({"checkpoint_id":id,"checkpoint_digest":digest})),
718 "approval":approval,
719 "evidence_authority":if dependency_envelopes_complete { "local_capture_receipt_only; source_authority_not_verified" } else { "structural_unverified" },"persistence_status":"pending",
720 "budgets":budgets.as_ref().map(RunBudgets::requested_value).unwrap_or(Value::Null),
721 "budget_counters":budget_counters,"budget_exhausted":budget_exhausted});
722 let artifact = bundle(run_id, &graph_version, &input, &state_value, &receipt);
723 self.update(run_id, |r| {
724 let cancellation_observed = r.cancelled.load(Ordering::SeqCst);
725 let terminal_error = (!cancellation_observed).then(|| error.clone()).flatten();
726 let terminal = terminal_outcome(cancellation_observed, terminal_error.as_deref());
727 r.status = terminal.status.into();
728 r.success = Some(terminal.success);
729 r.state = state_value.clone();
730 r.final_state = final_state.clone();
731 r.steps = steps.clone();
732 r.error = terminal_error;
733 r.budget_counters = budget_counters.clone();
734 r.budget_exhausted = budget_exhausted.clone();
735 r.receipt = receipt.clone();
736 r.bundle = artifact.clone();
737 r.persistence_status = "pending".into();
738 for event in graph_events {
739 push_event(r, serde_json::to_value(event).unwrap_or(Value::Null));
740 }
741 })?;
742 Ok(self.get(run_id).expect("updated run").public())
743 }
744
745 pub fn start(&self, run_id: String, spec: GraphSpec, base_url: String, model: String) {
746 self.start_with_completion(run_id, spec, base_url, model, |_| {});
747 }
748
749 pub fn start_with_completion<F>(
750 &self,
751 run_id: String,
752 spec: GraphSpec,
753 base_url: String,
754 model: String,
755 on_completion: F,
756 ) where
757 F: FnOnce(RunRecord) + Send + 'static,
758 {
759 self.start_with_completion_with_store(run_id, spec, base_url, model, None, on_completion);
760 }
761
762 pub fn start_with_completion_with_store<F>(
763 &self,
764 run_id: String,
765 spec: GraphSpec,
766 base_url: String,
767 model: String,
768 store: Option<PersistentStore>,
769 on_completion: F,
770 ) where
771 F: FnOnce(RunRecord) + Send + 'static,
772 {
773 let manager = self.clone();
774 std::thread::spawn(move || {
775 if let Err(error) = manager.execute_with_store(&run_id, spec, base_url, model, store) {
776 let _ = manager.update(&run_id, |r| {
777 r.status = "failed".into();
778 r.success = Some(false);
779 r.error = Some(error.clone());
780 });
781 }
782 if let Some(record) = manager.get(&run_id) {
783 on_completion(record);
784 }
785 });
786 }
787
788 pub fn start_resumed_with_completion<F>(
789 &self,
790 run_id: String,
791 spec: GraphSpec,
792 base_url: String,
793 model: String,
794 store: Option<PersistentStore>,
795 on_completion: F,
796 ) where
797 F: FnOnce(RunRecord) + Send + 'static,
798 {
799 let manager = self.clone();
800 std::thread::spawn(move || {
801 if let Err(error) = manager.execute_with_store_options(
802 &run_id,
803 spec,
804 base_url,
805 model,
806 store,
807 None,
808 BudgetCounters::default(),
809 ) {
810 let _ = manager.update(&run_id, |r| {
811 r.status = "failed".into();
812 r.success = Some(false);
813 r.error = Some(error.clone());
814 });
815 }
816 if let Some(record) = manager.get(&run_id) {
817 on_completion(record);
818 }
819 });
820 }
821 pub fn cancel(&self, id: &str) -> Result<Value, String> {
822 let cancellation = {
823 let mut inner = self.inner.lock().map_err(|_| "run registry poisoned")?;
824 let r = inner.runs.get_mut(id).ok_or("run not found")?;
825 if is_terminal(&r.status) {
826 return Err("RUN_NOT_CANCELLABLE".into());
827 }
828 r.cancelled.store(true, Ordering::SeqCst);
829 r.cancellation.clone()
830 };
831 cancellation.notify_waiters();
832 Ok(serde_json::json!({
833 "run_id":id,
834 "status":"cancellation_requested",
835 "cancellation_effect":"best_effort_drop_provider_future",
836 "provider_request_may_still_be_in_flight":true,
837 "effective_at":"provider_completion_or_cancellation_observation"
838 }))
839 }
840 pub fn get(&self, id: &str) -> Option<RunRecord> {
841 self.inner.lock().ok()?.runs.get(id).cloned()
842 }
843 pub fn mark_persistence(&self, id: &str, status: &str, error: Option<String>) {
844 let _ = self.update(id, |run| {
845 run.persistence_status = status.into();
846 run.persistence_error = error.clone();
847 if let Some(object) = run.receipt.as_object_mut() {
848 object.insert("persistence_status".into(), Value::String(status.into()));
849 if let Some(error) = error {
850 object.insert("persistence_error".into(), Value::String(error));
851 }
852 }
853 run.bundle = bundle(
854 &run.run_id,
855 &run.graph_version,
856 &run.input,
857 &run.state,
858 &run.receipt,
859 );
860 });
861 }
862 pub(crate) fn remove(&self, id: &str) -> Option<RunRecord> {
863 let mut inner = self.inner.lock().ok()?;
864 if inner
865 .runs
866 .get(id)
867 .is_some_and(|run| run.status == "running")
868 {
869 return None;
870 }
871 inner.order.retain(|run_id| run_id != id);
872 inner.runs.remove(id)
873 }
874 pub fn list(&self) -> Vec<Value> {
875 self.inner
876 .lock()
877 .map(|i| {
878 i.order
879 .iter()
880 .filter_map(|id| i.runs.get(id).map(RunRecord::public))
881 .collect()
882 })
883 .unwrap_or_default()
884 }
885 pub fn events(
886 &self,
887 store: Option<&PersistentStore>,
888 id: &str,
889 cursor: u64,
890 limit: usize,
891 ) -> Result<Value, String> {
892 let Some(r) = self.get(id) else {
893 if let Some(store) = store {
894 if let Some(events) = store.load_events(id, cursor, limit)? {
895 return Ok(events);
896 }
897 }
898 return Err("run not found".into());
899 };
900 let first = r
901 .events
902 .front()
903 .and_then(|v| v.get("cursor"))
904 .and_then(Value::as_u64)
905 .unwrap_or(r.next_cursor);
906 let events: Vec<_> = r
907 .events
908 .iter()
909 .filter(|v| v["cursor"].as_u64().unwrap_or(0) >= cursor)
910 .take(limit.min(200))
911 .cloned()
912 .collect();
913 Ok(
914 serde_json::json!({"run_id":id,"events":events,"next_cursor":r.next_cursor,"gap":cursor<first,"truncated":r.dropped_events>0,"dropped":r.dropped_events}),
915 )
916 }
917 pub fn set_state_value(&self, id: &str, key: &str, value: Value) -> Result<(), String> {
918 self.update(id, |run| {
919 if let Some(state) = run.state.as_object_mut() {
920 state.insert(key.to_owned(), value.clone());
921 } else {
922 let mut state = serde_json::Map::new();
923 state.insert(key.to_owned(), value);
924 run.state = Value::Object(state);
925 }
926 })
927 }
928 fn update(&self, id: &str, f: impl FnOnce(&mut RunRecord)) -> Result<(), String> {
929 let mut inner = self.inner.lock().map_err(|_| "run registry poisoned")?;
930 let r = inner.runs.get_mut(id).ok_or("run not found")?;
931 f(r);
932 Ok(())
933 }
934}
935
936#[derive(Debug, Clone, Copy, PartialEq, Eq)]
937struct TerminalOutcome {
938 status: &'static str,
939 success: bool,
940}
941
942fn terminal_outcome(cancelled: bool, error: Option<&str>) -> TerminalOutcome {
943 if cancelled {
944 TerminalOutcome {
945 status: "cancelled",
946 success: false,
947 }
948 } else if error.is_some() {
949 TerminalOutcome {
950 status: "failed",
951 success: false,
952 }
953 } else {
954 TerminalOutcome {
955 status: "completed",
956 success: true,
957 }
958 }
959}
960
961fn budget_iteration_limit(spec: &GraphSpec, max_nodes: u64) -> usize {
967 let graph_limit = spec.max_iterations.unwrap_or(64);
968 let mut frontier = vec![spec.entry.clone()];
969 let mut observed = 0u64;
970 let mut allowed = 0usize;
971
972 for _ in 0..graph_limit {
973 frontier.retain(|node| node != "END");
974 if frontier.is_empty() {
975 break;
976 }
977 let width = frontier.len() as u64;
978 if observed.saturating_add(width) > max_nodes {
979 break;
980 }
981 observed = observed.saturating_add(width);
982 allowed += 1;
983
984 let current: HashSet<&str> = frontier.iter().map(String::as_str).collect();
985 let mut next = Vec::new();
986 for edge in &spec.edges {
987 if current.contains(edge.from.as_str()) && edge.to != "END" {
988 next.push(edge.to.clone());
989 }
990 }
991 let mut seen = HashSet::new();
992 next.retain(|node| seen.insert(node.clone()));
993 frontier = next;
994 }
995
996 allowed.max(1)
997}
998
999fn push_event(run: &mut RunRecord, event: Value) {
1000 if run.events.len() == 512 {
1001 run.events.pop_front();
1002 run.dropped_events += 1;
1003 }
1004 let cursor = run.next_cursor;
1005 run.next_cursor += 1;
1006 run.events
1007 .push_back(serde_json::json!({"cursor":cursor,"event":redact(&event)}));
1008}
1009
1010fn safe_provider_label(url: &str) -> String {
1011 let without_fragment = url.split(['?', '#']).next().unwrap_or(url);
1012 if let Some((scheme, rest)) = without_fragment.split_once("://") {
1013 let authority_and_path = rest.rsplit_once('@').map(|(_, safe)| safe).unwrap_or(rest);
1014 format!("{scheme}://{authority_and_path}")
1015 } else {
1016 "server-configured".into()
1017 }
1018}
1019
1020pub fn initial_state_for_input(input: &Value) -> Value {
1021 let mut initial = serde_json::Map::new();
1022 initial.insert("__input__".into(), input.clone());
1023 if let Value::Object(map) = input {
1024 initial.extend(map.clone());
1025 }
1026 Value::Object(initial)
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032
1033 #[test]
1034 fn async_admission_is_bounded_atomically() {
1035 let manager = RunManager::default();
1036 for index in 0..MAX_ACTIVE_RUNS {
1037 let id = manager
1038 .allocate("graph", "version", serde_json::json!({"index": index}))
1039 .expect("allocate admitted run");
1040 manager.admit_async(&id).expect("admit within cap");
1041 }
1042 let overflow = manager
1043 .allocate("graph", "version", Value::Null)
1044 .expect("registry still has room");
1045 assert!(manager.admit_async(&overflow).is_err());
1046 manager.remove(&overflow);
1047 assert!(manager.get(&overflow).is_none());
1048 }
1049
1050 #[test]
1051 fn retention_never_evicts_live_runs() {
1052 let manager = RunManager::default();
1053 let mut ids = Vec::new();
1054 for _ in 0..MAX_RUNS {
1055 ids.push(
1056 manager
1057 .allocate("graph", "version", Value::Null)
1058 .expect("allocate retained run"),
1059 );
1060 }
1061 assert!(manager.allocate("graph", "version", Value::Null).is_err());
1062 manager
1063 .update(&ids[0], |record| record.status = "completed".into())
1064 .expect("mark terminal");
1065 let replacement = manager
1066 .allocate("graph", "version", Value::Null)
1067 .expect("terminal record may be evicted");
1068 assert!(manager.get(&ids[0]).is_none());
1069 assert!(manager.get(&replacement).is_some());
1070 }
1071
1072 #[test]
1073 fn cancelled_terminal_run_is_never_successful() {
1074 let outcome = terminal_outcome(true, None);
1075 assert_eq!(outcome.status, "cancelled");
1076 assert!(!outcome.success);
1077
1078 let completed = terminal_outcome(false, None);
1079 assert_eq!(completed.status, "completed");
1080 assert!(completed.success);
1081
1082 let failed = terminal_outcome(false, Some("provider error"));
1083 assert_eq!(failed.status, "failed");
1084 assert!(!failed.success);
1085 }
1086
1087 #[test]
1088 fn cancellation_is_only_a_request_until_a_node_boundary() {
1089 let manager = RunManager::default();
1090 let id = manager
1091 .allocate("graph", "version", Value::Null)
1092 .expect("allocate run");
1093 manager.admit_async(&id).expect("admit run");
1094 let response = manager.cancel(&id).expect("request cancellation");
1095 assert_eq!(response["status"], "cancellation_requested");
1096 assert_eq!(
1097 response["cancellation_effect"],
1098 "best_effort_drop_provider_future"
1099 );
1100 assert_eq!(manager.get(&id).expect("run").status, "running");
1101 }
1102
1103 #[test]
1104 fn persistence_failure_is_publicly_volatile_and_recorded() {
1105 let manager = RunManager::default();
1106 let id = manager
1107 .allocate("graph", "version", Value::Null)
1108 .expect("allocate run");
1109 manager.mark_persistence(
1110 &id,
1111 "volatile_persistence_failed",
1112 Some("database is unavailable".into()),
1113 );
1114 let public = manager.get(&id).expect("run").public();
1115 assert_eq!(public["storage_class"], "volatile");
1116 assert_eq!(public["persistence_error"], "database is unavailable");
1117 }
1118}