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