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    /// Enforced atomically before each provider invocation: attempts are
85    /// reserved pre-invocation, so failed or timed-out calls still count and
86    /// concurrent nodes cannot race past the limit.
87    #[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    /// Provider API key attached to http(s) llm-pipeline calls as a Bearer
147    /// header. Never serialized; codex-app-server:// carries no auth.
148    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    /// Atomically reserves one of the bounded execution slots for an accepted run.
314    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    /// Reserve capacity before an irreversible durable transition. The caller
335    /// must either activate or release this reservation.
336    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        // Shared per-run LLM-attempt accounting: one atomic counter and one
442        // typed invocation ledger are created here, handed into the compiled
443        // nodes, and read back at the terminal boundary so counters and
444        // receipts reflect the same observed provider attempts.
445        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                        // Wake a cancellable provider/node without marking the
530                        // user cancellation flag. Terminal cancellation must
531                        // retain precedence only for an actual cancel request.
532                        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        // derive a receipt from the summary
552        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            // Observed from the shared pre-invocation reservation counter, so
600            // the terminal record matches actual provider attempts (including
601            // failed or timed-out attempts).
602            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
961/// Return the greatest number of engine supersteps whose conservative frontier
962/// size stays within a node budget. The engine checks recursion before entering
963/// a superstep, so this prevents a later parallel frontier from starting after
964/// the budget is exhausted. Conditional edges are treated as possible edges;
965/// this can stop early, but never allows an over-budget frontier.
966fn 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}