Skip to main content

agent_graph_mcp/
run_manager.rs

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/// The only budgets accepted by `graph_run_start`.
78#[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    /// Kept in the model so the public contract has one canonical shape. The
85    /// parser rejects it until a real LLM invocation hook is available.
86    #[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    /// Atomically reserves one of the bounded execution slots for an accepted run.
308    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    /// Reserve capacity before an irreversible durable transition. The caller
329    /// must either activate or release this reservation.
330    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                        // Wake a cancellable provider/node without marking the
514                        // user cancellation flag. Terminal cancellation must
515                        // retain precedence only for an actual cancel request.
516                        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        // derive a receipt from the summary
536        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            // The LLM node invokes llm-pipeline directly. There is no permitted
579            // invocation hook in this crate, so max_llm_calls is rejected and
580            // this observed counter remains zero for accepted runs.
581            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
917/// Return the greatest number of engine supersteps whose conservative frontier
918/// size stays within a node budget. The engine checks recursion before entering
919/// a superstep, so this prevents a later parallel frontier from starting after
920/// the budget is exhausted. Conditional edges are treated as possible edges;
921/// this can stop early, but never allows an over-budget frontier.
922fn 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}