Skip to main content

bamboo_engine/workflow_run/
executor.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::future::Future;
3use std::pin::Pin;
4use std::sync::{
5    atomic::{AtomicBool, Ordering},
6    Arc, Weak,
7};
8use std::time::Duration;
9
10use async_trait::async_trait;
11use bamboo_agent_core::tools::{
12    FunctionCall, ToolCall, ToolExecutionContext, ToolExecutionSessionFlags, ToolExecutor,
13    ToolOutcome, ToolResult,
14};
15use bamboo_domain::{
16    validate_schema, CompiledWorkflow, FailurePolicy, StartWorkflowRun, ValueRef,
17    WorkflowBudgetUsage, WorkflowBudgets, WorkflowCompileError, WorkflowDefinitionBundle,
18    WorkflowFailure, WorkflowFailureCode, WorkflowPlan, WorkflowProgress, WorkflowRunDefinition,
19    WorkflowRunEvent, WorkflowRunEventKind, WorkflowRunSnapshot, WorkflowRunStatus,
20    WorkflowStepDefinition, WorkflowStepKind, WorkflowStepSnapshot, WorkflowStepStatus,
21    WorkflowSuspensionContext,
22};
23use chrono::Utc;
24use dashmap::{mapref::entry::Entry, DashMap};
25use futures::{future::join_all, stream::FuturesUnordered, StreamExt};
26use serde_json::Value;
27use sha2::{Digest, Sha256};
28use thiserror::Error;
29use tokio::sync::{broadcast, Mutex, Semaphore};
30use tokio_util::sync::CancellationToken;
31use uuid::Uuid;
32
33use super::repository::WorkflowRunRepository;
34
35type SecretResolutionFuture<'a> =
36    Pin<Box<dyn Future<Output = Result<(Value, Vec<String>), WorkflowFailure>> + Send + 'a>>;
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct NamedAgentSpec {
40    pub name: String,
41    pub allowed_capabilities: BTreeSet<String>,
42    pub profile:
43        Option<Arc<crate::session_app::child_session::named_profile::ResolvedChildProfile>>,
44    pub cost_supported: bool,
45}
46
47#[derive(Debug, Clone)]
48pub struct AgentStepResult {
49    pub output: Value,
50    pub tokens: u64,
51    pub cost_micros: Option<u64>,
52    /// A completed attempt may fail after consuming tokens; its usage still counts.
53    pub failure: Option<WorkflowFailure>,
54    /// Process-local ownership handoff; absent for ports without retained results.
55    pub attempt_id: Option<String>,
56}
57
58#[async_trait]
59pub trait AgentStepPort: Send + Sync {
60    /// #563 seam. Unknown names must return `Ok(None)` and fail preflight.
61    async fn resolve(&self, name: &str, session_id: &str)
62        -> Result<Option<NamedAgentSpec>, String>;
63    async fn execute(
64        &self,
65        spec: &NamedAgentSpec,
66        prompt: Value,
67        model: Option<&str>,
68        effort: Option<&str>,
69        capabilities: &BTreeSet<String>,
70        session_id: &str,
71        root_run_id: &str,
72        cancellation: CancellationToken,
73    ) -> Result<AgentStepResult, String>;
74    /// Consume a retained result while the caller owns the usage ledger lock.
75    fn acknowledge_result(&self, _attempt_id: &str) -> bool {
76        true
77    }
78    /// Stop and drain cancelled executions owned by this root run.
79    async fn drain_cancelled(
80        &self,
81        _root_run_id: &str,
82    ) -> (Vec<Result<AgentStepResult, String>>, Option<String>) {
83        (Vec::new(), None)
84    }
85}
86
87#[async_trait]
88pub trait WorkflowDefinitionPort: Send + Sync {
89    /// Pin the root and every transitively referenced nested definition from one
90    /// immutable catalog publication. Implementations must never re-read a live
91    /// source while constructing the returned bundle.
92    async fn pin_bundle(
93        &self,
94        root: &WorkflowRunDefinition,
95    ) -> Result<WorkflowDefinitionBundle, String>;
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum PermissionDecision {
100    Allow,
101    Deny(String),
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum WorkflowPolicyTarget {
106    Tool(String),
107    Agent(String),
108    Workflow { id: String, revision: u64 },
109}
110
111#[async_trait]
112pub trait WorkflowPolicyPort: Send + Sync {
113    async fn authorize(
114        &self,
115        session_id: &str,
116        target: &WorkflowPolicyTarget,
117        requested: &BTreeSet<String>,
118        workspace_trusted: bool,
119    ) -> PermissionDecision;
120}
121
122#[async_trait]
123pub trait WorkflowSessionPermissionPort: Send + Sync {
124    async fn flags_for_session(
125        &self,
126        session_id: &str,
127    ) -> Result<ToolExecutionSessionFlags, String>;
128}
129
130/// Resolves a typed, persisted-safe capability handle to ephemeral secret
131/// material. Implementations own access control; raw values are never returned
132/// in snapshots/events/errors.
133pub struct WorkflowSecretMaterial(String);
134
135impl WorkflowSecretMaterial {
136    pub fn new(value: String) -> Self {
137        Self(value)
138    }
139
140    fn into_exposed(self) -> String {
141        self.0
142    }
143}
144
145#[async_trait]
146pub trait WorkflowSecretResolverPort: Send + Sync {
147    async fn resolve(
148        &self,
149        session_id: &str,
150        capability: &str,
151    ) -> Result<WorkflowSecretMaterial, String>;
152}
153
154#[derive(Debug, Error)]
155pub enum WorkflowRunError {
156    #[error(transparent)]
157    Compile(#[from] WorkflowCompileError),
158    #[error("invalid workflow input: {0}")]
159    InvalidInput(String),
160    #[error("workflow preflight failed: {0}")]
161    Preflight(String),
162    #[error("named agents without monetary measurement do not support a finite monetary budget")]
163    UnsupportedMonetaryBudget,
164    #[error("workflow storage failed: {0}")]
165    Storage(String),
166    #[error("workflow run not found")]
167    NotFound,
168    #[error("workflow run is already terminal")]
169    Terminal,
170}
171
172pub struct WorkflowRunEngine {
173    repository: Arc<dyn WorkflowRunRepository>,
174    tools: Arc<dyn ToolExecutor>,
175    agents: Arc<dyn AgentStepPort>,
176    definitions: Arc<dyn WorkflowDefinitionPort>,
177    policy: Arc<dyn WorkflowPolicyPort>,
178    secrets: Arc<dyn WorkflowSecretResolverPort>,
179    ceilings: WorkflowBudgets,
180    active: DashMap<String, Arc<ActiveRun>>,
181    events: DashMap<String, broadcast::Sender<WorkflowRunEvent>>,
182    // An owned commit can outlive ActiveRun. Weak entries preserve that exact
183    // fence without retaining completed runs or granting execution ownership.
184    commit_fences: DashMap<String, Weak<Mutex<()>>>,
185    session_permissions: std::sync::RwLock<Option<Arc<dyn WorkflowSessionPermissionPort>>>,
186}
187
188struct ActiveRun {
189    session_id: String,
190    cancellation: CancellationToken,
191    snapshot: Arc<Mutex<WorkflowRunSnapshot>>,
192    root_run_id: String,
193    ledger: Arc<Mutex<WorkflowBudgetUsage>>,
194    drain_lock: Arc<Mutex<()>>,
195    cleanup_pending: AtomicBool,
196    has_agents: bool,
197}
198
199struct RuntimeRegistration {
200    engine: Weak<WorkflowRunEngine>,
201    run_id: String,
202}
203
204impl Drop for RuntimeRegistration {
205    fn drop(&mut self) {
206        if let Some(engine) = self.engine.upgrade() {
207            let pending = engine
208                .active
209                .get(&self.run_id)
210                .is_some_and(|run| run.cleanup_pending.load(Ordering::Acquire));
211            if !pending {
212                engine.active.remove(&self.run_id);
213                engine.events.remove(&self.run_id);
214                // Never remove a live fence by run ID: an owned commit or a
215                // waiting finalizer can still hold it after this registration.
216                engine
217                    .commit_fences
218                    .retain(|_, fence| fence.strong_count() > 0);
219            }
220        }
221    }
222}
223
224struct RunContext {
225    engine: Arc<WorkflowRunEngine>,
226    compiled: Arc<CompiledWorkflow>,
227    bundle: Arc<WorkflowDefinitionBundle>,
228    pinned_agents: Arc<HashMap<String, NamedAgentSpec>>,
229    snapshot: Arc<Mutex<WorkflowRunSnapshot>>,
230    cancellation: CancellationToken,
231    branch_cancellation: CancellationToken,
232    allowed_capabilities: BTreeSet<String>,
233    workspace_trusted: bool,
234    semaphore: Arc<Semaphore>,
235    items: HashMap<String, Value>,
236    scope: String,
237    map_index_path: Vec<usize>,
238    depth: u32,
239    ledger: Arc<Mutex<WorkflowBudgetUsage>>,
240    root_limits: WorkflowBudgets,
241    root_run_id: String,
242    drain_lock: Arc<Mutex<()>>,
243}
244
245impl Clone for RunContext {
246    fn clone(&self) -> Self {
247        Self {
248            engine: self.engine.clone(),
249            compiled: self.compiled.clone(),
250            bundle: self.bundle.clone(),
251            pinned_agents: self.pinned_agents.clone(),
252            snapshot: self.snapshot.clone(),
253            cancellation: self.cancellation.clone(),
254            branch_cancellation: self.branch_cancellation.clone(),
255            allowed_capabilities: self.allowed_capabilities.clone(),
256            workspace_trusted: self.workspace_trusted,
257            semaphore: self.semaphore.clone(),
258            items: self.items.clone(),
259            scope: self.scope.clone(),
260            map_index_path: self.map_index_path.clone(),
261            depth: self.depth,
262            ledger: self.ledger.clone(),
263            root_limits: self.root_limits.clone(),
264            root_run_id: self.root_run_id.clone(),
265            drain_lock: self.drain_lock.clone(),
266        }
267    }
268}
269
270type NodeFuture<'a> = Pin<Box<dyn Future<Output = Result<Value, WorkflowFailure>> + Send + 'a>>;
271type StartSignal =
272    Arc<Mutex<Option<tokio::sync::oneshot::Sender<Result<WorkflowRunSnapshot, WorkflowRunError>>>>>;
273
274impl WorkflowRunEngine {
275    pub fn new(
276        repository: Arc<dyn WorkflowRunRepository>,
277        tools: Arc<dyn ToolExecutor>,
278        agents: Arc<dyn AgentStepPort>,
279        definitions: Arc<dyn WorkflowDefinitionPort>,
280        policy: Arc<dyn WorkflowPolicyPort>,
281        secrets: Arc<dyn WorkflowSecretResolverPort>,
282        ceilings: WorkflowBudgets,
283    ) -> Arc<Self> {
284        Arc::new(Self {
285            repository,
286            tools,
287            agents,
288            definitions,
289            policy,
290            secrets,
291            ceilings,
292            active: DashMap::new(),
293            events: DashMap::new(),
294            commit_fences: DashMap::new(),
295            session_permissions: std::sync::RwLock::new(None),
296        })
297    }
298
299    pub fn set_session_permission_port(&self, port: Arc<dyn WorkflowSessionPermissionPort>) {
300        *self
301            .session_permissions
302            .write()
303            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(port);
304    }
305
306    pub async fn run(
307        self: &Arc<Self>,
308        request: StartWorkflowRun,
309    ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
310        let bundle = self.pin_and_validate_bundle(&request.definition).await?;
311        self.run_pinned(request, bundle).await
312    }
313
314    pub async fn run_pinned(
315        self: &Arc<Self>,
316        request: StartWorkflowRun,
317        bundle: WorkflowDefinitionBundle,
318    ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
319        self.validate_bundle(&request.definition, &bundle)?;
320        let cancellation = CancellationToken::new();
321        let ledger = Arc::new(Mutex::new(WorkflowBudgetUsage::default()));
322        let limits = effective_limits(&request.definition.budgets, &self.ceilings);
323        let semaphore = Arc::new(Semaphore::new(limits.max_concurrency));
324        let pinned_agents = Arc::new(
325            self.preflight_bundle(
326                &bundle,
327                &request.session_id,
328                &request.allowed_capabilities.iter().cloned().collect(),
329                request.workspace_trusted,
330                &limits,
331            )
332            .await?,
333        );
334        self.run_internal(
335            request,
336            Arc::new(bundle),
337            pinned_agents,
338            None,
339            None,
340            0,
341            cancellation,
342            ledger,
343            limits,
344            semaphore,
345            None,
346            None,
347        )
348        .await
349    }
350
351    /// Start in the background and return only after the running snapshot is
352    /// durable. HTTP and tool adapters use this non-blocking entrypoint.
353    pub async fn start(
354        self: &Arc<Self>,
355        request: StartWorkflowRun,
356    ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
357        let bundle = self.pin_and_validate_bundle(&request.definition).await?;
358        self.start_pinned(request, bundle).await
359    }
360
361    pub async fn start_pinned(
362        self: &Arc<Self>,
363        request: StartWorkflowRun,
364        bundle: WorkflowDefinitionBundle,
365    ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
366        self.validate_bundle(&request.definition, &bundle)?;
367        let cancellation = CancellationToken::new();
368        let ledger = Arc::new(Mutex::new(WorkflowBudgetUsage::default()));
369        let limits = effective_limits(&request.definition.budgets, &self.ceilings);
370        let semaphore = Arc::new(Semaphore::new(limits.max_concurrency));
371        let pinned_agents = Arc::new(
372            self.preflight_bundle(
373                &bundle,
374                &request.session_id,
375                &request.allowed_capabilities.iter().cloned().collect(),
376                request.workspace_trusted,
377                &limits,
378            )
379            .await?,
380        );
381        let (tx, rx) = tokio::sync::oneshot::channel();
382        let signal = Arc::new(Mutex::new(Some(tx)));
383        let engine = self.clone();
384        tokio::spawn(async move {
385            let result = engine
386                .run_internal(
387                    request,
388                    Arc::new(bundle),
389                    pinned_agents,
390                    None,
391                    None,
392                    0,
393                    cancellation,
394                    ledger,
395                    limits,
396                    semaphore,
397                    Some(signal.clone()),
398                    None,
399                )
400                .await;
401            if let Err(error) = result {
402                if let Some(sender) = signal.lock().await.take() {
403                    let _ = sender.send(Err(error));
404                } else {
405                    tracing::error!("background workflow run failed after start");
406                }
407            } else if signal.lock().await.is_some() {
408                tracing::error!("workflow task completed without publishing a start snapshot");
409            }
410        });
411        rx.await.map_err(|_| {
412            WorkflowRunError::Storage("workflow task exited before durable start".to_string())
413        })?
414    }
415
416    /// Phase-1 safe restart starts a fresh run from the suspended run's pinned
417    /// definition snapshot. Entered-step replay and script resume remain out of scope.
418    pub async fn restart(
419        self: &Arc<Self>,
420        run_id: &str,
421        workspace_trusted: bool,
422        allowed_capabilities: Vec<String>,
423    ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
424        let previous = self
425            .repository
426            .load(run_id)
427            .await
428            .map_err(storage)?
429            .ok_or(WorkflowRunError::NotFound)?;
430        if previous.status != WorkflowRunStatus::Suspended {
431            return Err(if previous.status.is_terminal() {
432                WorkflowRunError::Terminal
433            } else {
434                WorkflowRunError::Preflight("only suspended workflows can restart".to_string())
435            });
436        }
437        if matches!(
438            previous.suspension,
439            Some(
440                WorkflowSuspensionContext::ToolApproval { .. }
441                    | WorkflowSuspensionContext::ToolRunning { .. }
442            )
443        ) {
444            return Err(WorkflowRunError::Preflight(
445                "workflow has durable suspension context and requires explicit resume handling"
446                    .to_string(),
447            ));
448        }
449        let bundle = previous.definition_bundle;
450        self.start_pinned(
451            StartWorkflowRun {
452                definition: previous.definition,
453                args: previous.validated_args,
454                session_id: previous.session_id,
455                workspace_trusted,
456                allowed_capabilities,
457            },
458            bundle,
459        )
460        .await
461    }
462
463    /// Validate an unentered suffix using only the durable checkpoint. This is
464    /// also the metadata predicate; invocation authority is checked separately.
465    pub fn completed_prefix(snapshot: &WorkflowRunSnapshot) -> Result<usize, WorkflowRunError> {
466        let refuse = || {
467            WorkflowRunError::Preflight("workflow has no safe completed-prefix checkpoint".into())
468        };
469        if snapshot.status != WorkflowRunStatus::Suspended
470            || !matches!(
471                snapshot.suspension,
472                Some(WorkflowSuspensionContext::Recovery { .. })
473            )
474            || snapshot.parent_run_id.is_some()
475            || snapshot.parent_step_id.is_some()
476            || snapshot.output.is_some()
477            || snapshot.failure.is_some()
478        {
479            return Err(refuse());
480        }
481        let bundle = &snapshot.definition_bundle;
482        if bundle.definitions.len() != 1
483            || bundle.root_id != snapshot.definition.id
484            || bundle.root_revision != snapshot.definition.revision
485            || bundle.root() != Some(&snapshot.definition)
486            || definition_bundle_hash(bundle)? != snapshot.definition_bundle_hash
487        {
488            return Err(refuse());
489        }
490        let compiled = CompiledWorkflow::compile(snapshot.definition.clone())?;
491        compiled
492            .validate_input(&snapshot.validated_args)
493            .map_err(WorkflowRunError::InvalidInput)?;
494        reject_secret_material(&snapshot.validated_args).map_err(WorkflowRunError::InvalidInput)?;
495        let serialized = serde_json::to_value(bundle)
496            .map_err(|error| WorkflowRunError::Preflight(error.to_string()))?;
497        reject_secret_material_in_definition(&serialized).map_err(WorkflowRunError::Preflight)?;
498        let WorkflowPlan::Sequence { nodes } = &snapshot.definition.plan else {
499            return Err(refuse());
500        };
501        let ids = nodes
502            .iter()
503            .map(|node| match node {
504                WorkflowPlan::Step { step } => Ok(step.as_str()),
505                _ => Err(refuse()),
506            })
507            .collect::<Result<Vec<_>, _>>()?;
508        if ids.is_empty()
509            || ids.len() != compiled.steps.len()
510            || ids.iter().copied().collect::<BTreeSet<_>>().len() != ids.len()
511        {
512            return Err(refuse());
513        }
514        let mut outputs = BTreeMap::new();
515        let mut prefix = 0;
516        let mut suffix = false;
517        for id in ids.iter().copied() {
518            let step = compiled.steps.get(id).ok_or_else(refuse)?;
519            let WorkflowStepKind::Tool {
520                args, capabilities, ..
521            } = &step.kind
522            else {
523                return Err(refuse());
524            };
525            if capabilities.iter().any(|capability| capability != "read")
526                || contains_secret_handle(args)
527            {
528                return Err(refuse());
529            }
530            let Some(state) = snapshot.steps.get(id) else {
531                suffix = true;
532                continue;
533            };
534            if suffix
535                || state.id != id
536                || state.status != WorkflowStepStatus::Succeeded
537                || state.attempts != 1
538                || state.failure.is_some()
539            {
540                return Err(refuse());
541            }
542            let input = resolve_checkpoint_template(args, &snapshot.validated_args, &outputs)?;
543            let hash = hex::encode(Sha256::digest(
544                serde_json::to_vec(&input)
545                    .map_err(|error| WorkflowRunError::InvalidInput(error.to_string()))?,
546            ));
547            let output = state.output.as_ref().ok_or_else(refuse)?;
548            if hash != state.input_hash {
549                return Err(refuse());
550            }
551            if let Some(schema) = &step.output_schema {
552                validate_schema(schema, output).map_err(|_| refuse())?;
553            }
554            reject_secret_material(output).map_err(|_| refuse())?;
555            outputs.insert(id.to_string(), output.clone());
556            prefix += 1;
557        }
558        let usage = &snapshot.usage;
559        let budget = &snapshot.definition.budgets;
560        if prefix == 0
561            || snapshot.steps.len() != prefix
562            || usage.steps as usize != prefix
563            || usage.agents != 0
564            || usage.retries != 0
565            || ids.len() > budget.max_steps as usize
566            || budget.max_tokens.is_some_and(|limit| usage.tokens > limit)
567            || budget
568                .max_cost_micros
569                .is_some_and(|limit| usage.cost_micros.is_none_or(|cost| cost > limit))
570            || remaining_wall_time(snapshot) == 0
571        {
572            return Err(refuse());
573        }
574        Ok(prefix)
575    }
576
577    /// Explicitly continue the same readonly run; no entered step is replayed.
578    /// The original created_at + wall_time_ms deadline includes time offline.
579    pub async fn continue_completed_prefix(
580        self: &Arc<Self>,
581        run_id: &str,
582        session_id: &str,
583        workspace_trusted: bool,
584        allowed_capabilities: Vec<String>,
585    ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
586        let previous = self
587            .repository
588            .load(run_id)
589            .await
590            .map_err(storage)?
591            .ok_or(WorkflowRunError::NotFound)?;
592        if previous.run_id != run_id || previous.session_id != session_id {
593            return Err(WorkflowRunError::NotFound);
594        }
595        Self::completed_prefix(&previous)?;
596        let snapshot = Arc::new(Mutex::new(previous.clone()));
597        let cancellation = CancellationToken::new();
598        let ledger = Arc::new(Mutex::new(previous.usage.clone()));
599        let drain_lock = Arc::new(Mutex::new(()));
600        let active = Arc::new(ActiveRun {
601            session_id: session_id.to_string(),
602            cancellation: cancellation.clone(),
603            snapshot: snapshot.clone(),
604            root_run_id: run_id.to_string(),
605            ledger: ledger.clone(),
606            drain_lock: drain_lock.clone(),
607            cleanup_pending: AtomicBool::new(false),
608            has_agents: false,
609        });
610        match self.active.entry(run_id.to_string()) {
611            Entry::Occupied(_) => {
612                return Err(WorkflowRunError::Preflight(
613                    "workflow continuation is already active".into(),
614                ))
615            }
616            Entry::Vacant(entry) => {
617                entry.insert(active);
618            }
619        }
620        let registration = RuntimeRegistration {
621            engine: Arc::downgrade(self),
622            run_id: run_id.to_string(),
623        };
624        // A concurrent cancellation may have committed while admission was
625        // loading the checkpoint. Re-read after acquiring the existing owner.
626        let current = self
627            .repository
628            .load(run_id)
629            .await
630            .map_err(storage)?
631            .ok_or(WorkflowRunError::NotFound)?;
632        let prefix = Self::completed_prefix(&current)?;
633        if current != previous {
634            return Err(WorkflowRunError::Preflight(
635                "workflow checkpoint changed during continuation admission".into(),
636            ));
637        }
638        self.validate_bundle(&current.definition, &current.definition_bundle)?;
639        self.enforce_ceilings(&current.definition.budgets)?;
640        let allowed_capabilities = allowed_capabilities.into_iter().collect::<BTreeSet<_>>();
641        let root_limits = effective_limits(&current.definition.budgets, &self.ceilings);
642        self.preflight_bundle(
643            &current.definition_bundle,
644            session_id,
645            &allowed_capabilities,
646            workspace_trusted,
647            &root_limits,
648        )
649        .await?;
650        let permission_port = self
651            .session_permissions
652            .read()
653            .unwrap_or_else(std::sync::PoisonError::into_inner)
654            .clone();
655        if let Some(port) = permission_port {
656            port.flags_for_session(session_id).await.map_err(|_| {
657                WorkflowRunError::Preflight(
658                    "workflow session permission posture is unavailable".into(),
659                )
660            })?;
661        }
662        Self::completed_prefix(&current)?;
663        let compiled = Arc::new(CompiledWorkflow::compile(current.definition.clone())?);
664        let context = RunContext {
665            engine: self.clone(),
666            compiled,
667            bundle: Arc::new(current.definition_bundle),
668            pinned_agents: Arc::new(HashMap::new()),
669            snapshot: snapshot.clone(),
670            cancellation: cancellation.clone(),
671            branch_cancellation: cancellation.child_token(),
672            allowed_capabilities,
673            workspace_trusted,
674            semaphore: Arc::new(Semaphore::new(root_limits.max_concurrency)),
675            items: HashMap::new(),
676            scope: "root".into(),
677            map_index_path: Vec::new(),
678            depth: 0,
679            ledger,
680            root_limits,
681            root_run_id: run_id.to_string(),
682            drain_lock,
683        };
684        let (sender, _) = broadcast::channel(256);
685        self.events.insert(run_id.to_string(), sender);
686        let (tx, rx) = tokio::sync::oneshot::channel();
687        let engine = self.clone();
688        tokio::spawn(async move {
689            let _registration = registration;
690            let start = {
691                let mut snapshot = snapshot.lock().await;
692                if cancellation.is_cancelled() {
693                    Err(WorkflowRunError::Preflight(
694                        "workflow continuation was cancelled during admission".into(),
695                    ))
696                } else {
697                    engine
698                        .transition(
699                            &mut snapshot,
700                            None,
701                            WorkflowRunEventKind::RunStarted,
702                            |snapshot| {
703                                snapshot.status = WorkflowRunStatus::Running;
704                                snapshot.suspension = None;
705                            },
706                        )
707                        .await
708                        .map(|()| snapshot.clone())
709                }
710            };
711            match start {
712                Ok(started) => {
713                    let timeout_ms = remaining_wall_time(&started);
714                    let _ = tx.send(Ok(started));
715                    if engine
716                        .execute_context(context, timeout_ms, prefix)
717                        .await
718                        .is_err()
719                    {
720                        tracing::error!(
721                            "background workflow continuation failed after durable start"
722                        );
723                    }
724                }
725                Err(error) => {
726                    let _ = tx.send(Err(error));
727                }
728            }
729        });
730        rx.await.map_err(|_| {
731            WorkflowRunError::Storage("workflow continuation exited before durable start".into())
732        })?
733    }
734
735    #[allow(clippy::too_many_arguments)]
736    async fn run_internal(
737        self: &Arc<Self>,
738        request: StartWorkflowRun,
739        bundle: Arc<WorkflowDefinitionBundle>,
740        pinned_agents: Arc<HashMap<String, NamedAgentSpec>>,
741        parent_run_id: Option<String>,
742        parent_step_id: Option<String>,
743        depth: u32,
744        cancellation: CancellationToken,
745        ledger: Arc<Mutex<WorkflowBudgetUsage>>,
746        root_limits: WorkflowBudgets,
747        semaphore: Arc<Semaphore>,
748        started: Option<StartSignal>,
749        root_run_id: Option<String>,
750    ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
751        if request.definition.steps.len() > self.ceilings.max_steps as usize {
752            return Err(WorkflowRunError::Preflight(
753                "workflow definition exceeds server step-count ceiling".to_string(),
754            ));
755        }
756        let compiled = Arc::new(CompiledWorkflow::compile(request.definition)?);
757        self.enforce_ceilings(&compiled.definition.budgets)?;
758        let definition_value = serde_json::to_value(&compiled.definition)
759            .map_err(|error| WorkflowRunError::Preflight(error.to_string()))?;
760        reject_secret_material_in_definition(&definition_value)
761            .map_err(WorkflowRunError::Preflight)?;
762        compiled
763            .validate_input(&request.args)
764            .map_err(WorkflowRunError::InvalidInput)?;
765        reject_secret_material(&request.args).map_err(WorkflowRunError::InvalidInput)?;
766        let allowed_capabilities = request
767            .allowed_capabilities
768            .into_iter()
769            .collect::<BTreeSet<_>>();
770        enforce_budget_within(&compiled.definition.budgets, &root_limits).map_err(|message| {
771            WorkflowRunError::Preflight(format!("nested workflow budget expands root: {message}"))
772        })?;
773
774        let run_id = Uuid::new_v4().to_string();
775        let root_run_id = root_run_id.unwrap_or_else(|| run_id.clone());
776        let now = Utc::now();
777        let snapshot = WorkflowRunSnapshot {
778            run_id: run_id.clone(),
779            parent_run_id,
780            parent_step_id,
781            session_id: request.session_id,
782            definition: compiled.definition.clone(),
783            definition_bundle: bundle.as_ref().clone(),
784            definition_bundle_hash: definition_bundle_hash(&bundle)?,
785            validated_args: request.args,
786            status: WorkflowRunStatus::Queued,
787            steps: BTreeMap::new(),
788            usage: WorkflowBudgetUsage::default(),
789            last_sequence: 1,
790            output: None,
791            failure: None,
792            suspension: None,
793            created_at: now,
794            updated_at: now,
795        };
796        let queued = event(&snapshot, None, WorkflowRunEventKind::RunQueued);
797        let session_id = snapshot.session_id.clone();
798        self.repository
799            .create(&snapshot, &queued)
800            .await
801            .map_err(storage)?;
802        let (sender, _) = broadcast::channel(256);
803        self.events.insert(run_id.clone(), sender);
804        self.publish(&queued);
805        let snapshot = Arc::new(Mutex::new(snapshot));
806        let drain_lock = Arc::new(Mutex::new(()));
807        self.active.insert(
808            run_id.clone(),
809            Arc::new(ActiveRun {
810                session_id,
811                cancellation: cancellation.clone(),
812                snapshot: snapshot.clone(),
813                root_run_id: root_run_id.clone(),
814                ledger: ledger.clone(),
815                drain_lock: drain_lock.clone(),
816                cleanup_pending: AtomicBool::new(false),
817                has_agents: !pinned_agents.is_empty(),
818            }),
819        );
820        let _registration = RuntimeRegistration {
821            engine: Arc::downgrade(self),
822            run_id: run_id.clone(),
823        };
824        {
825            let mut shared = snapshot.lock().await;
826            let start_result = if cancellation.is_cancelled() {
827                self.finish_cancelled(&mut shared).await
828            } else {
829                self.transition(
830                    &mut shared,
831                    None,
832                    WorkflowRunEventKind::RunStarted,
833                    |snapshot| {
834                        snapshot.status = WorkflowRunStatus::Running;
835                    },
836                )
837                .await
838            };
839            start_result?;
840            if let Some(started) = started {
841                if let Some(sender) = started.lock().await.take() {
842                    let _ = sender.send(Ok(shared.clone()));
843                }
844            }
845        }
846        let context = RunContext {
847            engine: self.clone(),
848            compiled: compiled.clone(),
849            bundle,
850            pinned_agents,
851            snapshot: snapshot.clone(),
852            cancellation: cancellation.clone(),
853            branch_cancellation: cancellation.child_token(),
854            allowed_capabilities,
855            workspace_trusted: request.workspace_trusted,
856            semaphore,
857            items: HashMap::new(),
858            scope: "root".to_string(),
859            map_index_path: Vec::new(),
860            depth,
861            ledger,
862            root_limits,
863            root_run_id: root_run_id.clone(),
864            drain_lock,
865        };
866        self.execute_context(context, compiled.definition.budgets.wall_time_ms, 0)
867            .await
868    }
869
870    async fn execute_context(
871        self: &Arc<Self>,
872        context: RunContext,
873        timeout_ms: u64,
874        completed_prefix: usize,
875    ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
876        let compiled = &context.compiled;
877        let snapshot = &context.snapshot;
878        let cancellation = &context.cancellation;
879        let run_id = snapshot.lock().await.run_id.clone();
880        let result = tokio::time::timeout(
881            Duration::from_millis(timeout_ms),
882            context.execute_root(completed_prefix),
883        )
884        .await;
885        if result.is_err() {
886            cancellation.cancel();
887        }
888        let _drain_guard = context.drain_lock.lock().await;
889        {
890            let snapshot = snapshot.lock().await;
891            if snapshot.status.is_terminal() {
892                return Ok(snapshot.clone());
893            }
894        }
895        if result.is_err() {
896            if let Some(durable) = self.load_committed_snapshot(&run_id).await? {
897                *snapshot.lock().await = durable;
898            }
899        }
900        let (usage_unavailable, drain_error) = context.drain_agents().await;
901        // A node can be dropped after its ledger handoff but before checkpoint.
902        if context.ledger.lock().await.agents > 0 {
903            context
904                .checkpoint_agent_usage("AgentDrained")
905                .await
906                .map_err(|e| WorkflowRunError::Storage(e.message))?;
907        }
908        if drain_error.is_some() {
909            if let Some(active) = self.active.get(&run_id) {
910                active.cleanup_pending.store(true, Ordering::Release);
911            }
912            return Err(WorkflowRunError::Preflight(
913                "Workflow children have not confirmed stop; cancellation can be retried".into(),
914            ));
915        }
916        let mut final_snapshot = snapshot.lock().await;
917        if usage_unavailable && !final_snapshot.status.is_terminal() {
918            self.finish_failed(&mut final_snapshot, failure(WorkflowFailureCode::ExecutionFailed,
919                "workflow Agent stopped but cumulative token observation unavailable; usage is not measured", false)).await?;
920            return Ok(final_snapshot.clone());
921        }
922        if final_snapshot.status.is_terminal() {
923            return Ok(final_snapshot.clone());
924        }
925        match result {
926            Ok(Ok(output)) if cancellation.is_cancelled() => {
927                let _ = output;
928                self.finish_cancelled(&mut final_snapshot).await?;
929            }
930            Ok(Ok(output)) => {
931                if let Some(schema) = &compiled.definition.output_schema {
932                    if let Err(message) = validate_schema(schema, &output) {
933                        let failure = failure(WorkflowFailureCode::InvalidOutput, message, false);
934                        self.finish_failed(&mut final_snapshot, failure).await?;
935                    } else {
936                        self.finish_succeeded(&mut final_snapshot, output).await?;
937                    }
938                } else {
939                    self.finish_succeeded(&mut final_snapshot, output).await?;
940                }
941            }
942            Ok(Err(error)) if error.code == WorkflowFailureCode::Cancelled => {
943                self.finish_cancelled(&mut final_snapshot).await?;
944            }
945            Ok(Err(error)) if error.code == WorkflowFailureCode::Suspended => {
946                self.finish_suspended(&mut final_snapshot, error.message)
947                    .await?;
948            }
949            Ok(Err(error)) => self.finish_failed(&mut final_snapshot, error).await?,
950            Err(_) => {
951                cancellation.cancel();
952                // `timeout` cancels the node future at an arbitrary await,
953                // including a repository commit. Reconcile the in-memory copy
954                // from the journal/temp recovery protocol before allocating the
955                // next sequence, so a partially committed StepStarted cannot
956                // make the timeout terminal transition skip a sequence.
957                if let Some(durable) = self.load_committed_snapshot(&run_id).await? {
958                    *final_snapshot = durable;
959                }
960                let step_failure = failure(
961                    WorkflowFailureCode::BudgetExceeded,
962                    "workflow wall-time budget exceeded",
963                    false,
964                );
965                self.fail_timeout_frontier(
966                    &mut final_snapshot,
967                    &compiled.definition.plan,
968                    step_failure.clone(),
969                )
970                .await?;
971                self.fail_active_steps(&mut final_snapshot, step_failure.clone())
972                    .await?;
973                self.finish_failed(&mut final_snapshot, step_failure)
974                    .await?;
975            }
976        }
977        Ok(final_snapshot.clone())
978    }
979
980    pub async fn progress(
981        &self,
982        run_id: &str,
983        since: u64,
984    ) -> Result<WorkflowProgress, WorkflowRunError> {
985        let snapshot = self
986            .repository
987            .load(run_id)
988            .await
989            .map_err(storage)?
990            .ok_or(WorkflowRunError::NotFound)?;
991        let events = self
992            .repository
993            .events_since(run_id, since)
994            .await
995            .map_err(storage)?;
996        Ok(WorkflowProgress { snapshot, events })
997    }
998
999    pub async fn list_run_ids(&self) -> Result<Vec<String>, WorkflowRunError> {
1000        self.repository.list_run_ids().await.map_err(storage)
1001    }
1002
1003    /// Whether the in-process worker for `run_id` is still executing.
1004    ///
1005    /// A terminal durable snapshot can become visible just before the worker's
1006    /// final registration guard is released. Shutdown/restart coordination can
1007    /// use this boundary to avoid opening a second repository owner while the
1008    /// old worker is still finishing its journal commit.
1009    pub fn is_run_active(&self, run_id: &str) -> bool {
1010        self.active.contains_key(run_id)
1011    }
1012
1013    /// Includes workers whose terminal snapshot is visible but whose cleanup
1014    /// has not drained. Adapters fence Session scope changes until this ends.
1015    pub fn is_session_active(&self, session_id: &str) -> bool {
1016        self.active.iter().any(|run| run.session_id == session_id)
1017    }
1018
1019    #[cfg(test)]
1020    pub(super) fn test_active_ledger(
1021        &self,
1022        run_id: &str,
1023    ) -> (Arc<Mutex<WorkflowBudgetUsage>>, CancellationToken) {
1024        let active = self.active.get(run_id).unwrap();
1025        (active.ledger.clone(), active.cancellation.clone())
1026    }
1027
1028    pub async fn cancel(
1029        self: &Arc<Self>,
1030        run_id: &str,
1031    ) -> Result<WorkflowRunSnapshot, WorkflowRunError> {
1032        let snapshot = self
1033            .load_committed_snapshot(run_id)
1034            .await?
1035            .ok_or(WorkflowRunError::NotFound)?;
1036        if snapshot.status == WorkflowRunStatus::Cancelled {
1037            return Ok(snapshot);
1038        }
1039        if snapshot.status.is_terminal() {
1040            return Err(WorkflowRunError::Terminal);
1041        }
1042        // Inactive cancellation must own the same entry as continuation.
1043        // Otherwise it can commit a terminal outcome after a new worker has
1044        // registered without cancelling that worker's token.
1045        let (active, _registration) = match self.active.entry(run_id.to_string()) {
1046            Entry::Occupied(entry) => (entry.get().clone(), None),
1047            Entry::Vacant(entry) => {
1048                let active = Arc::new(ActiveRun {
1049                    session_id: snapshot.session_id.clone(),
1050                    cancellation: CancellationToken::new(),
1051                    snapshot: Arc::new(Mutex::new(snapshot)),
1052                    root_run_id: run_id.to_string(),
1053                    ledger: Arc::new(Mutex::new(WorkflowBudgetUsage::default())),
1054                    drain_lock: Arc::new(Mutex::new(())),
1055                    cleanup_pending: AtomicBool::new(false),
1056                    has_agents: false,
1057                });
1058                entry.insert(active.clone());
1059                (
1060                    active,
1061                    Some(RuntimeRegistration {
1062                        engine: Arc::downgrade(self),
1063                        run_id: run_id.to_string(),
1064                    }),
1065                )
1066            }
1067        };
1068        {
1069            active.cancellation.cancel();
1070            // Tool-only runs keep their existing cancellation/repository path.
1071            if !active.has_agents {
1072                let mut shared = active.snapshot.lock().await;
1073                if !shared.status.is_terminal() {
1074                    self.finish_cancelled(&mut shared).await?;
1075                }
1076                return Ok(shared.clone());
1077            }
1078            let _drain_guard = active.drain_lock.lock().await;
1079            {
1080                let snapshot = active.snapshot.lock().await;
1081                if snapshot.status.is_terminal() {
1082                    return Ok(snapshot.clone());
1083                }
1084            }
1085            let mut usage_unavailable = false;
1086            let mut ledger = active.ledger.lock().await;
1087            let (drained, drain_error) = self.agents.drain_cancelled(&active.root_run_id).await;
1088            let usage = {
1089                let usage = &mut *ledger;
1090                for result in drained {
1091                    match result {
1092                        Ok(result) => {
1093                            usage.tokens = usage.tokens.saturating_add(result.tokens);
1094                            usage.cost_micros = usage
1095                                .cost_micros
1096                                .zip(result.cost_micros)
1097                                .map(|(a, b)| a.saturating_add(b));
1098                        }
1099                        Err(_) => {
1100                            usage_unavailable = true;
1101                            usage.cost_micros = None;
1102                        }
1103                    }
1104                }
1105                usage.clone()
1106            };
1107            drop(ledger);
1108            {
1109                let mut shared = active.snapshot.lock().await;
1110                self.transition(
1111                    &mut shared,
1112                    None,
1113                    WorkflowRunEventKind::Phase {
1114                        name: "AgentDrained".into(),
1115                    },
1116                    |snapshot| {
1117                        snapshot.usage.tokens = usage.tokens;
1118                        snapshot.usage.cost_micros = usage.cost_micros;
1119                    },
1120                )
1121                .await?;
1122            }
1123            if drain_error.is_some() {
1124                active.cleanup_pending.store(true, Ordering::Release);
1125                return Err(WorkflowRunError::Preflight(
1126                    "Workflow children have not confirmed stop; cancellation can be retried".into(),
1127                ));
1128            }
1129            let mut shared = active.snapshot.lock().await;
1130            if !shared.status.is_terminal() {
1131                if usage_unavailable {
1132                    self.finish_failed(&mut shared, failure(WorkflowFailureCode::ExecutionFailed,
1133                        "workflow Agent stopped but cumulative token observation unavailable; usage is not measured", false)).await?;
1134                } else {
1135                    self.finish_cancelled(&mut shared).await?;
1136                }
1137            }
1138            let result = shared.clone();
1139            if active.cleanup_pending.swap(false, Ordering::AcqRel) {
1140                self.active.remove(run_id);
1141                self.events.remove(run_id);
1142            }
1143            Ok(result)
1144        }
1145    }
1146
1147    pub async fn recover(&self) -> Result<Vec<WorkflowRunSnapshot>, WorkflowRunError> {
1148        let mut recovered = Vec::new();
1149        for run_id in self.repository.list_run_ids().await.map_err(storage)? {
1150            let Some(mut snapshot) = self.repository.load(&run_id).await.map_err(storage)? else {
1151                continue;
1152            };
1153            if matches!(
1154                snapshot.status,
1155                WorkflowRunStatus::Queued | WorkflowRunStatus::Running
1156            ) {
1157                let reason = "process restarted; explicit safe restart is required".to_string();
1158                let active_steps = snapshot
1159                    .steps
1160                    .iter()
1161                    .filter(|(_, step)| {
1162                        matches!(
1163                            step.status,
1164                            WorkflowStepStatus::Queued | WorkflowStepStatus::Running
1165                        )
1166                    })
1167                    .map(|(id, _)| id.clone())
1168                    .collect::<Vec<_>>();
1169                for step_id in active_steps {
1170                    let state_id = step_id.clone();
1171                    let step_reason = reason.clone();
1172                    self.transition(
1173                        &mut snapshot,
1174                        Some(step_id),
1175                        WorkflowRunEventKind::StepSuspended {
1176                            reason: reason.clone(),
1177                        },
1178                        move |snapshot| {
1179                            if let Some(step) = snapshot.steps.get_mut(&state_id) {
1180                                step.status = WorkflowStepStatus::Suspended;
1181                                step.failure = Some(failure(
1182                                    WorkflowFailureCode::RecoverySuspended,
1183                                    step_reason,
1184                                    true,
1185                                ));
1186                            }
1187                        },
1188                    )
1189                    .await?;
1190                }
1191                self.transition(
1192                    &mut snapshot,
1193                    None,
1194                    WorkflowRunEventKind::RunSuspended {
1195                        reason: reason.clone(),
1196                    },
1197                    move |snapshot| {
1198                        snapshot.status = WorkflowRunStatus::Suspended;
1199                        snapshot.suspension = Some(WorkflowSuspensionContext::Recovery {
1200                            reason: reason.clone(),
1201                        });
1202                    },
1203                )
1204                .await?;
1205                recovered.push(snapshot);
1206            }
1207        }
1208        Ok(recovered)
1209    }
1210
1211    pub fn subscribe(&self, run_id: &str) -> Option<broadcast::Receiver<WorkflowRunEvent>> {
1212        self.events.get(run_id).map(|sender| sender.subscribe())
1213    }
1214
1215    #[cfg(test)]
1216    pub(crate) fn runtime_resource_counts(&self) -> (usize, usize) {
1217        (self.active.len(), self.events.len())
1218    }
1219
1220    async fn pin_and_validate_bundle(
1221        &self,
1222        root: &WorkflowRunDefinition,
1223    ) -> Result<WorkflowDefinitionBundle, WorkflowRunError> {
1224        let bundle =
1225            self.definitions.pin_bundle(root).await.map_err(|_| {
1226                WorkflowRunError::Preflight("workflow bundle pin failed".to_string())
1227            })?;
1228        self.validate_bundle(root, &bundle)?;
1229        Ok(bundle)
1230    }
1231
1232    fn validate_bundle(
1233        &self,
1234        root: &WorkflowRunDefinition,
1235        bundle: &WorkflowDefinitionBundle,
1236    ) -> Result<(), WorkflowRunError> {
1237        if bundle.root_id != root.id
1238            || bundle.root_revision != root.revision
1239            || bundle.root() != Some(root)
1240        {
1241            return Err(WorkflowRunError::Preflight(
1242                "pinned bundle root identity/content mismatch".to_string(),
1243            ));
1244        }
1245        let serialized = serde_json::to_value(bundle).map_err(|_| {
1246            WorkflowRunError::Preflight("workflow bundle is not serializable".into())
1247        })?;
1248        reject_secret_material_in_definition(&serialized).map_err(WorkflowRunError::Preflight)?;
1249        let mut stack = vec![(root.id.clone(), root.revision, Vec::<String>::new())];
1250        let mut visited = BTreeSet::new();
1251        while let Some((id, revision, path)) = stack.pop() {
1252            let key = WorkflowDefinitionBundle::key(&id, revision);
1253            if path.contains(&key) {
1254                return Err(WorkflowRunError::Preflight(format!(
1255                    "nested workflow cycle includes {key}"
1256                )));
1257            }
1258            if !visited.insert(key.clone()) {
1259                continue;
1260            }
1261            let definition = bundle.get(&id, revision).ok_or_else(|| {
1262                WorkflowRunError::Preflight(format!("pinned bundle is missing {key}"))
1263            })?;
1264            if definition.id != id || definition.revision != revision {
1265                return Err(WorkflowRunError::Preflight(
1266                    "pinned bundle definition identity mismatch".to_string(),
1267                ));
1268            }
1269            let compiled = CompiledWorkflow::compile(definition.clone())?;
1270            let mut nested_path = path;
1271            nested_path.push(key);
1272            for step in compiled.steps.values() {
1273                if let WorkflowStepKind::Workflow {
1274                    workflow_id,
1275                    revision,
1276                    args,
1277                } = &step.kind
1278                {
1279                    let nested = bundle.get(workflow_id, *revision).ok_or_else(|| {
1280                        WorkflowRunError::Preflight(format!(
1281                            "pinned bundle is missing {workflow_id}@{revision}"
1282                        ))
1283                    })?;
1284                    validate_nested_input_contract(args, &nested.input_schema, &compiled)
1285                        .map_err(WorkflowRunError::Preflight)?;
1286                    stack.push((workflow_id.clone(), *revision, nested_path.clone()));
1287                }
1288            }
1289        }
1290        Ok(())
1291    }
1292
1293    async fn preflight_bundle(
1294        &self,
1295        bundle: &WorkflowDefinitionBundle,
1296        session_id: &str,
1297        allowed: &BTreeSet<String>,
1298        trusted: bool,
1299        root_limits: &WorkflowBudgets,
1300    ) -> Result<HashMap<String, NamedAgentSpec>, WorkflowRunError> {
1301        let mut pinned_agents = HashMap::<String, NamedAgentSpec>::new();
1302        let mut stack = vec![(bundle.root_id.clone(), bundle.root_revision, 0_u32)];
1303        let mut visited = BTreeSet::new();
1304        while let Some((id, revision, depth)) = stack.pop() {
1305            if depth >= root_limits.max_nesting_depth {
1306                return Err(WorkflowRunError::Preflight(
1307                    "nested workflow depth exceeded shared root limit".to_string(),
1308                ));
1309            }
1310            if !visited.insert(WorkflowDefinitionBundle::key(&id, revision)) {
1311                continue;
1312            }
1313            let definition = bundle.get(&id, revision).ok_or_else(|| {
1314                WorkflowRunError::Preflight("pinned workflow definition missing".to_string())
1315            })?;
1316            enforce_budget_within(&definition.budgets, root_limits).map_err(|message| {
1317                WorkflowRunError::Preflight(format!(
1318                    "nested workflow budget expands root: {message}"
1319                ))
1320            })?;
1321            let compiled = CompiledWorkflow::compile(definition.clone())?;
1322            for step in compiled.steps.values() {
1323                let (target, capabilities) = match &step.kind {
1324                    WorkflowStepKind::Tool {
1325                        tool, capabilities, ..
1326                    } => (WorkflowPolicyTarget::Tool(tool.clone()), capabilities),
1327                    WorkflowStepKind::Agent {
1328                        agent,
1329                        capabilities,
1330                        ..
1331                    } => {
1332                        let spec = if let Some(spec) = pinned_agents.get(agent) {
1333                            spec.clone()
1334                        } else {
1335                            let spec = self
1336                                .agents
1337                                .resolve(agent, session_id)
1338                                .await
1339                                .map_err(|_| {
1340                                    WorkflowRunError::Preflight(
1341                                        "named agent resolution failed".to_string(),
1342                                    )
1343                                })?
1344                                .ok_or_else(|| {
1345                                    WorkflowRunError::Preflight(format!(
1346                                        "unknown named agent '{agent}'"
1347                                    ))
1348                                })?;
1349                            if spec.name != *agent {
1350                                return Err(WorkflowRunError::Preflight(
1351                                    "named agent resolver returned mismatched identity".to_string(),
1352                                ));
1353                            }
1354                            pinned_agents.insert(agent.clone(), spec.clone());
1355                            spec
1356                        };
1357                        if (root_limits.max_cost_micros.is_some()
1358                            || compiled.definition.budgets.max_cost_micros.is_some())
1359                            && !spec.cost_supported
1360                        {
1361                            return Err(WorkflowRunError::UnsupportedMonetaryBudget);
1362                        }
1363                        if !capabilities
1364                            .iter()
1365                            .all(|capability| spec.allowed_capabilities.contains(capability))
1366                        {
1367                            return Err(WorkflowRunError::Preflight(format!(
1368                                "agent '{agent}' capability expansion denied"
1369                            )));
1370                        }
1371                        (WorkflowPolicyTarget::Agent(agent.clone()), capabilities)
1372                    }
1373                    WorkflowStepKind::Workflow {
1374                        workflow_id,
1375                        revision,
1376                        args,
1377                    } => {
1378                        let nested = bundle.get(workflow_id, *revision).ok_or_else(|| {
1379                            WorkflowRunError::Preflight(format!(
1380                                "missing pinned workflow {workflow_id}@{revision}"
1381                            ))
1382                        })?;
1383                        validate_nested_input_contract(args, &nested.input_schema, &compiled)
1384                            .map_err(WorkflowRunError::Preflight)?;
1385                        stack.push((workflow_id.clone(), *revision, depth + 1));
1386                        (
1387                            WorkflowPolicyTarget::Workflow {
1388                                id: workflow_id.clone(),
1389                                revision: *revision,
1390                            },
1391                            &Vec::new(),
1392                        )
1393                    }
1394                };
1395                let requested = capabilities.iter().cloned().collect::<BTreeSet<_>>();
1396                if !requested.is_subset(allowed) {
1397                    return Err(WorkflowRunError::Preflight(format!(
1398                        "step '{}' exceeds root capabilities",
1399                        step.id
1400                    )));
1401                }
1402                if let PermissionDecision::Deny(_reason) = self
1403                    .policy
1404                    .authorize(session_id, &target, &requested, trusted)
1405                    .await
1406                {
1407                    return Err(WorkflowRunError::Preflight(
1408                        "workflow policy denied this step".to_string(),
1409                    ));
1410                }
1411            }
1412        }
1413        Ok(pinned_agents)
1414    }
1415
1416    fn enforce_ceilings(&self, budget: &WorkflowBudgets) -> Result<(), WorkflowRunError> {
1417        if budget.max_concurrency > self.ceilings.max_concurrency
1418            || budget.max_agents > self.ceilings.max_agents
1419            || budget.max_steps > self.ceilings.max_steps
1420            || budget.max_retries > self.ceilings.max_retries
1421            || budget.max_nesting_depth > self.ceilings.max_nesting_depth
1422            || budget.wall_time_ms > self.ceilings.wall_time_ms
1423            || exceeds_optional(budget.max_tokens, self.ceilings.max_tokens)
1424            || exceeds_optional(budget.max_cost_micros, self.ceilings.max_cost_micros)
1425        {
1426            return Err(WorkflowRunError::Preflight(
1427                "definition exceeds server workflow budget ceilings".to_string(),
1428            ));
1429        }
1430        Ok(())
1431    }
1432
1433    fn commit_fence(&self, run_id: &str) -> Arc<Mutex<()>> {
1434        // Use the same weak-lock lifetime pattern as the File repository. Each
1435        // acquisition also reaps abandoned/failed callers' inactive entries.
1436        self.commit_fences
1437            .retain(|_, fence| fence.strong_count() > 0);
1438        let mut entry = self.commit_fences.entry(run_id.to_owned()).or_default();
1439        if let Some(fence) = entry.upgrade() {
1440            return fence;
1441        }
1442        let fence = Arc::new(Mutex::new(()));
1443        *entry = Arc::downgrade(&fence);
1444        fence
1445    }
1446
1447    async fn load_committed_snapshot(
1448        &self,
1449        run_id: &str,
1450    ) -> Result<Option<WorkflowRunSnapshot>, WorkflowRunError> {
1451        let _commit_guard = self.commit_fence(run_id).lock_owned().await;
1452        self.repository.load(run_id).await.map_err(storage)
1453    }
1454
1455    async fn transition(
1456        &self,
1457        snapshot: &mut WorkflowRunSnapshot,
1458        step_id: Option<String>,
1459        kind: WorkflowRunEventKind,
1460        mutate: impl FnOnce(&mut WorkflowRunSnapshot),
1461    ) -> Result<(), WorkflowRunError> {
1462        // Acquire before deriving the next sequence, then transfer the fence
1463        // to the owned commit. Dropping this caller cannot let a finalizer read
1464        // or allocate against the prior sequence while that commit is pending.
1465        let commit_guard = self.commit_fence(&snapshot.run_id).lock_owned().await;
1466        // In-memory state advances only after the task confirms durability.
1467        let mut candidate = self
1468            .repository
1469            .load(&snapshot.run_id)
1470            .await
1471            .map_err(storage)?
1472            .ok_or_else(|| WorkflowRunError::Storage("workflow snapshot missing".to_string()))?;
1473        mutate(&mut candidate);
1474        candidate.last_sequence += 1;
1475        candidate.updated_at = Utc::now();
1476        let event = event(&candidate, step_id, kind);
1477        let repository = self.repository.clone();
1478        let durable_candidate = candidate.clone();
1479        let durable_event = event.clone();
1480        let commit = tokio::spawn(async move {
1481            let _commit_guard = commit_guard;
1482            repository.commit(&durable_candidate, &durable_event).await
1483        });
1484        commit
1485            .await
1486            .map_err(|error| {
1487                WorkflowRunError::Storage(format!("workflow commit task failed: {error}"))
1488            })?
1489            .map_err(storage)?;
1490        *snapshot = candidate;
1491        self.publish(&event);
1492        Ok(())
1493    }
1494
1495    fn publish(&self, event: &WorkflowRunEvent) {
1496        if let Some(sender) = self.events.get(&event.run_id) {
1497            let _ = sender.send(event.clone());
1498        }
1499    }
1500
1501    async fn finish_succeeded(
1502        &self,
1503        snapshot: &mut WorkflowRunSnapshot,
1504        output: Value,
1505    ) -> Result<(), WorkflowRunError> {
1506        if snapshot.status.is_terminal() {
1507            return Ok(());
1508        }
1509        let copy = output.clone();
1510        self.transition(
1511            snapshot,
1512            None,
1513            WorkflowRunEventKind::RunSucceeded { output },
1514            move |snapshot| {
1515                snapshot.status = WorkflowRunStatus::Succeeded;
1516                snapshot.output = Some(copy);
1517            },
1518        )
1519        .await
1520    }
1521    async fn finish_failed(
1522        &self,
1523        snapshot: &mut WorkflowRunSnapshot,
1524        error: WorkflowFailure,
1525    ) -> Result<(), WorkflowRunError> {
1526        if snapshot.status.is_terminal() {
1527            return Ok(());
1528        }
1529        let copy = error.clone();
1530        self.transition(
1531            snapshot,
1532            None,
1533            WorkflowRunEventKind::RunFailed { failure: error },
1534            move |snapshot| {
1535                snapshot.status = WorkflowRunStatus::Failed;
1536                snapshot.failure = Some(copy);
1537            },
1538        )
1539        .await
1540    }
1541    async fn finish_cancelled(
1542        &self,
1543        snapshot: &mut WorkflowRunSnapshot,
1544    ) -> Result<(), WorkflowRunError> {
1545        if snapshot.status == WorkflowRunStatus::Cancelled {
1546            return Ok(());
1547        }
1548        let active_steps = snapshot
1549            .steps
1550            .iter()
1551            .filter(|(_, step)| {
1552                matches!(
1553                    step.status,
1554                    WorkflowStepStatus::Queued | WorkflowStepStatus::Running
1555                )
1556            })
1557            .map(|(id, _)| id.clone())
1558            .collect::<Vec<_>>();
1559        for step_id in active_steps {
1560            let state_id = step_id.clone();
1561            self.transition(
1562                snapshot,
1563                Some(step_id),
1564                WorkflowRunEventKind::StepCancelled,
1565                move |snapshot| {
1566                    if let Some(step) = snapshot.steps.get_mut(&state_id) {
1567                        step.status = WorkflowStepStatus::Cancelled;
1568                        step.failure = Some(failure(
1569                            WorkflowFailureCode::Cancelled,
1570                            "workflow cancelled",
1571                            false,
1572                        ));
1573                    }
1574                },
1575            )
1576            .await?;
1577        }
1578        self.transition(
1579            snapshot,
1580            None,
1581            WorkflowRunEventKind::RunCancelled,
1582            |snapshot| {
1583                snapshot.status = WorkflowRunStatus::Cancelled;
1584                snapshot.failure = Some(failure(
1585                    WorkflowFailureCode::Cancelled,
1586                    "workflow cancelled",
1587                    false,
1588                ));
1589            },
1590        )
1591        .await
1592    }
1593
1594    async fn finish_suspended(
1595        &self,
1596        snapshot: &mut WorkflowRunSnapshot,
1597        reason: String,
1598    ) -> Result<(), WorkflowRunError> {
1599        if snapshot.status.is_terminal() {
1600            return Ok(());
1601        }
1602        self.transition(
1603            snapshot,
1604            None,
1605            WorkflowRunEventKind::RunSuspended { reason },
1606            |snapshot| {
1607                snapshot.status = WorkflowRunStatus::Suspended;
1608            },
1609        )
1610        .await
1611    }
1612
1613    async fn fail_active_steps(
1614        &self,
1615        snapshot: &mut WorkflowRunSnapshot,
1616        error: WorkflowFailure,
1617    ) -> Result<(), WorkflowRunError> {
1618        let active_steps = snapshot
1619            .steps
1620            .iter()
1621            .filter(|(_, step)| {
1622                matches!(
1623                    step.status,
1624                    WorkflowStepStatus::Queued | WorkflowStepStatus::Running
1625                )
1626            })
1627            .map(|(id, _)| id.clone())
1628            .collect::<Vec<_>>();
1629        for step_id in active_steps {
1630            let state_id = step_id.clone();
1631            let copy = error.clone();
1632            self.transition(
1633                snapshot,
1634                Some(step_id),
1635                WorkflowRunEventKind::StepFailed {
1636                    failure: error.clone(),
1637                },
1638                move |snapshot| {
1639                    if let Some(step) = snapshot.steps.get_mut(&state_id) {
1640                        step.status = WorkflowStepStatus::Failed;
1641                        step.failure = Some(copy);
1642                    }
1643                },
1644            )
1645            .await?;
1646        }
1647        Ok(())
1648    }
1649
1650    async fn fail_timeout_frontier(
1651        &self,
1652        snapshot: &mut WorkflowRunSnapshot,
1653        plan: &WorkflowPlan,
1654        error: WorkflowFailure,
1655    ) -> Result<(), WorkflowRunError> {
1656        if snapshot.steps.values().any(|step| {
1657            matches!(
1658                step.status,
1659                WorkflowStepStatus::Queued | WorkflowStepStatus::Running
1660            )
1661        }) {
1662            return Ok(());
1663        }
1664        for step_id in plan_frontier(plan) {
1665            if snapshot.steps.contains_key(&step_id) {
1666                continue;
1667            }
1668            let state_id = step_id.clone();
1669            let state_error = error.clone();
1670            let topology = plan.step_topology(&step_id, Vec::new());
1671            self.transition(
1672                snapshot,
1673                Some(step_id),
1674                WorkflowRunEventKind::StepFailed {
1675                    failure: error.clone(),
1676                },
1677                move |snapshot| {
1678                    snapshot.steps.insert(
1679                        state_id.clone(),
1680                        WorkflowStepSnapshot {
1681                            id: state_id,
1682                            topology,
1683                            status: WorkflowStepStatus::Failed,
1684                            input_hash: String::new(),
1685                            output: None,
1686                            failure: Some(state_error),
1687                            attempts: 0,
1688                        },
1689                    );
1690                },
1691            )
1692            .await?;
1693        }
1694        Ok(())
1695    }
1696}
1697
1698impl RunContext {
1699    async fn execute_root(&self, completed_prefix: usize) -> Result<Value, WorkflowFailure> {
1700        if completed_prefix == 0 {
1701            return self
1702                .execute_node(&self.compiled.definition.plan, "root")
1703                .await;
1704        }
1705        let WorkflowPlan::Sequence { nodes } = &self.compiled.definition.plan else {
1706            unreachable!("validated flat sequence");
1707        };
1708        let WorkflowPlan::Step { step } = &nodes[completed_prefix - 1] else {
1709            unreachable!("validated tool leaf");
1710        };
1711        let mut result = self.snapshot.lock().await.steps[step]
1712            .output
1713            .clone()
1714            .expect("validated completed output");
1715        for (index, node) in nodes.iter().enumerate().skip(completed_prefix) {
1716            result = self.execute_node(node, &format!("root.{index}")).await?;
1717        }
1718        Ok(result)
1719    }
1720
1721    fn execute_node<'a>(&'a self, plan: &'a WorkflowPlan, path: &'a str) -> NodeFuture<'a> {
1722        Box::pin(async move {
1723            self.check_cancelled()?;
1724            match plan {
1725                WorkflowPlan::Step { step } => self.execute_step(step, path).await,
1726                WorkflowPlan::Sequence { nodes } => {
1727                    let mut result = Value::Null;
1728                    for (index, node) in nodes.iter().enumerate() {
1729                        match self.execute_node(node, &format!("{path}.{index}")).await {
1730                            Ok(value) => result = value,
1731                            Err(error) => {
1732                                if error.code == WorkflowFailureCode::DependencySkipped {
1733                                    for remaining in &nodes[index + 1..] {
1734                                        self.skip_plan(
1735                                            remaining,
1736                                            "dependency requested skip_dependents",
1737                                        )
1738                                        .await?;
1739                                    }
1740                                }
1741                                return Err(error);
1742                            }
1743                        }
1744                    }
1745                    Ok(result)
1746                }
1747                WorkflowPlan::Parallel { nodes } => {
1748                    let parallel_cancellation = self.branch_cancellation.child_token();
1749                    let mut futures = FuturesUnordered::new();
1750                    for (index, node) in nodes.iter().enumerate() {
1751                        let mut child = self.clone();
1752                        child.branch_cancellation = parallel_cancellation.clone();
1753                        futures.push(async move {
1754                            (
1755                                index,
1756                                child.execute_node(node, &format!("{path}.{index}")).await,
1757                            )
1758                        });
1759                    }
1760                    let mut output = vec![Value::Null; nodes.len()];
1761                    while let Some((index, result)) = futures.next().await {
1762                        match result {
1763                            Ok(value) => output[index] = value,
1764                            Err(mut error) => {
1765                                parallel_cancellation.cancel();
1766                                // Drop sibling futures before awaiting durable
1767                                // cancellation transitions. A sibling may hold
1768                                // the snapshot mutex across its shielded commit;
1769                                // leaving it parked inside FuturesUnordered would
1770                                // deadlock this reconciliation.
1771                                drop(futures);
1772                                if !self.pinned_agents.is_empty() {
1773                                    let _drain_guard = self.drain_lock.lock().await;
1774                                    let (usage_unavailable, drain_error) =
1775                                        self.drain_agents().await;
1776                                    self.checkpoint_agent_usage("AgentDrained").await?;
1777                                    if usage_unavailable {
1778                                        return Err(failure(WorkflowFailureCode::ExecutionFailed, "workflow Agent cumulative token observation unavailable; usage is not measured", false));
1779                                    }
1780                                    if drain_error.is_some() {
1781                                        return Err(failure(
1782                                            WorkflowFailureCode::ExecutionFailed,
1783                                            "Workflow children have not confirmed stop",
1784                                            false,
1785                                        ));
1786                                    }
1787                                }
1788                                self.cancel_active_parallel_steps(nodes).await?;
1789                                error.message =
1790                                    format!("parallel branch[{index}] failed: {}", error.message);
1791                                return Err(error);
1792                            }
1793                        }
1794                    }
1795                    Ok(Value::Array(output))
1796                }
1797                WorkflowPlan::Choice {
1798                    condition,
1799                    then_branch,
1800                    else_branch,
1801                } => {
1802                    let condition = self.resolve_ref(condition).await?;
1803                    let selected = condition.as_bool().ok_or_else(|| {
1804                        failure(
1805                            WorkflowFailureCode::InvalidInput,
1806                            "choice condition must be a boolean",
1807                            false,
1808                        )
1809                    })?;
1810                    let (chosen, unchosen, branch) = if selected {
1811                        (then_branch, else_branch, "then")
1812                    } else {
1813                        (else_branch, then_branch, "else")
1814                    };
1815                    self.skip_plan(unchosen, "conditional branch not selected")
1816                        .await?;
1817                    self.execute_node(chosen, &format!("{path}.{branch}")).await
1818                }
1819                WorkflowPlan::Map { source, item, body } => {
1820                    let source = self.resolve_ref(source).await?;
1821                    let values = source.as_array().ok_or_else(|| {
1822                        failure(
1823                            WorkflowFailureCode::InvalidInput,
1824                            "map source must be an array",
1825                            false,
1826                        )
1827                    })?;
1828                    let used = self.ledger.lock().await.steps as usize;
1829                    let remaining = (self.root_limits.max_steps as usize).saturating_sub(used);
1830                    let per_item = plan_leaf_count(body).max(1);
1831                    if values
1832                        .len()
1833                        .checked_mul(per_item)
1834                        .is_none_or(|required| required > remaining)
1835                    {
1836                        return Err(failure(
1837                            WorkflowFailureCode::BudgetExceeded,
1838                            "map cardinality exceeds remaining workflow step budget",
1839                            false,
1840                        ));
1841                    }
1842                    let futures = values.iter().cloned().enumerate().map(|(index, value)| {
1843                        let mut child = self.clone();
1844                        child.items.insert(item.clone(), value);
1845                        // Scope identifies the logical map item, not a retry
1846                        // attempt's diagnostic path. This keeps durable attempts
1847                        // cumulative when Retry wraps Map and gives nested
1848                        // Parallel invocations an item-local cancellation domain.
1849                        child.scope = format!("{}[{index}]", self.scope);
1850                        child.map_index_path.push(index);
1851                        async move { child.execute_node(body, &format!("{path}[{index}]")).await }
1852                    });
1853                    let results = join_all(futures).await;
1854                    let mut values = Vec::with_capacity(results.len());
1855                    let mut failures = Vec::new();
1856                    for (index, result) in results.into_iter().enumerate() {
1857                        match result {
1858                            Ok(value) => values.push(value),
1859                            Err(error) => failures.push((index, error)),
1860                        }
1861                    }
1862                    if failures.is_empty() {
1863                        Ok(Value::Array(values))
1864                    } else {
1865                        let retryable = failures.iter().any(|(_, error)| error.retryable);
1866                        let first_code = failures[0].1.code;
1867                        let code = if failures
1868                            .iter()
1869                            .any(|(_, error)| error.code == WorkflowFailureCode::DependencySkipped)
1870                        {
1871                            WorkflowFailureCode::DependencySkipped
1872                        } else if failures.iter().all(|(_, error)| error.code == first_code) {
1873                            first_code
1874                        } else {
1875                            WorkflowFailureCode::ExecutionFailed
1876                        };
1877                        let diagnostics = failures
1878                            .into_iter()
1879                            .map(|(index, error)| format!("item[{index}]: {}", error.message))
1880                            .collect::<Vec<_>>()
1881                            .join("; ");
1882                        Err(failure(
1883                            code,
1884                            format!("map items failed: {diagnostics}"),
1885                            retryable,
1886                        ))
1887                    }
1888                }
1889                WorkflowPlan::Retry {
1890                    node,
1891                    max_attempts,
1892                    delay_ms,
1893                } => {
1894                    let limit =
1895                        (*max_attempts).min(self.compiled.definition.budgets.max_retries + 1);
1896                    let mut last = None;
1897                    for attempt in 0..limit {
1898                        match self
1899                            .execute_node(node, &format!("{path}.retry{attempt}"))
1900                            .await
1901                        {
1902                            Ok(value) => return Ok(value),
1903                            Err(error) if error.retryable => {
1904                                last = Some(error);
1905                                if attempt + 1 < limit {
1906                                    self.reserve_retry().await?;
1907                                    self.checkpoint_usage("retry_reserved").await?;
1908                                    tokio::select! {
1909                                        _ = self.cancellation.cancelled() => return Err(failure(WorkflowFailureCode::Cancelled, "workflow cancelled", false)),
1910                                        _ = self.branch_cancellation.cancelled() => return Err(failure(WorkflowFailureCode::Cancelled, "workflow branch cancelled", false)),
1911                                        _ = tokio::time::sleep(Duration::from_millis(*delay_ms)) => {}
1912                                    }
1913                                } else {
1914                                    break;
1915                                }
1916                            }
1917                            Err(error) => return Err(error),
1918                        }
1919                    }
1920                    Err(failure(
1921                        WorkflowFailureCode::RetryExhausted,
1922                        last.map_or_else(|| "retry exhausted".to_string(), |error| error.message),
1923                        false,
1924                    ))
1925                }
1926            }
1927        })
1928    }
1929
1930    async fn execute_step(&self, step_id: &str, _path: &str) -> Result<Value, WorkflowFailure> {
1931        self.check_cancelled()?;
1932        let step = self.compiled.steps.get(step_id).cloned().ok_or_else(|| {
1933            failure(
1934                WorkflowFailureCode::UnknownReference,
1935                format!("unknown step {step_id}"),
1936                false,
1937            )
1938        })?;
1939        let instance_id = self.instance_id(step_id);
1940        let input = match &step.kind {
1941            WorkflowStepKind::Tool { args, .. } | WorkflowStepKind::Workflow { args, .. } => {
1942                self.resolve_template(args).await?
1943            }
1944            WorkflowStepKind::Agent { prompt, .. } => self.resolve_template(prompt).await?,
1945        };
1946        let input_hash = hex::encode(Sha256::digest(
1947            serde_json::to_vec(&input).unwrap_or_default(),
1948        ));
1949        self.reserve_step().await?;
1950        self.checkpoint_usage("step_reserved").await?;
1951        self.step_transition(&instance_id, WorkflowRunEventKind::StepQueued, |snapshot| {
1952            let state = snapshot
1953                .steps
1954                .entry(instance_id.clone())
1955                .or_insert_with(|| WorkflowStepSnapshot {
1956                    id: instance_id.clone(),
1957                    topology: self
1958                        .compiled
1959                        .definition
1960                        .plan
1961                        .step_topology(step_id, self.map_index_path.clone()),
1962                    status: WorkflowStepStatus::Queued,
1963                    input_hash: input_hash.clone(),
1964                    output: None,
1965                    failure: None,
1966                    attempts: 0,
1967                });
1968            state.status = WorkflowStepStatus::Queued;
1969            state.input_hash = input_hash;
1970            state.output = None;
1971            state.failure = None;
1972        })
1973        .await?;
1974        let _permit = if matches!(&step.kind, WorkflowStepKind::Workflow { .. }) {
1975            None
1976        } else {
1977            Some(tokio::select! {
1978                _ = self.cancellation.cancelled() => {
1979                    let cancelled_id = instance_id.clone();
1980                    self.step_transition(&instance_id, WorkflowRunEventKind::StepCancelled, move |snapshot| {
1981                        if let Some(state) = snapshot.steps.get_mut(&cancelled_id) { state.status = WorkflowStepStatus::Cancelled; }
1982                    }).await?;
1983                    return Err(failure(WorkflowFailureCode::Cancelled, "workflow cancelled", false));
1984                }
1985                _ = self.branch_cancellation.cancelled() => {
1986                    let cancelled_id = instance_id.clone();
1987                    self.step_transition(&instance_id, WorkflowRunEventKind::StepCancelled, move |snapshot| {
1988                        if let Some(state) = snapshot.steps.get_mut(&cancelled_id) {
1989                            state.status = WorkflowStepStatus::Cancelled;
1990                        }
1991                    }).await?;
1992                    return Err(failure(WorkflowFailureCode::Cancelled, "workflow branch cancelled", false));
1993                }
1994                permit = self.semaphore.acquire() => permit.map_err(|_| failure(WorkflowFailureCode::ExecutionFailed, "workflow semaphore closed", false))?,
1995            })
1996        };
1997        let started_id = instance_id.clone();
1998        self.step_transition(
1999            &instance_id,
2000            WorkflowRunEventKind::StepStarted,
2001            move |snapshot| {
2002                if let Some(state) = snapshot.steps.get_mut(&started_id) {
2003                    state.status = WorkflowStepStatus::Running;
2004                    state.attempts += 1;
2005                }
2006            },
2007        )
2008        .await?;
2009        let result = self
2010            .dispatch(&step, input, &instance_id)
2011            .await
2012            .and_then(|output| {
2013                self.check_suspension_restriction()?;
2014                Ok(output)
2015            });
2016        let result = match result {
2017            Ok(output) => {
2018                if let Some(schema) = &step.output_schema {
2019                    validate_schema(schema, &output)
2020                        .map(|()| output)
2021                        .map_err(|message| {
2022                            failure(WorkflowFailureCode::InvalidOutput, message, false)
2023                        })
2024                } else {
2025                    Ok(output)
2026                }
2027            }
2028            Err(error) => Err(error),
2029        };
2030        let result = result.and_then(|output| {
2031            reject_secret_material(&output)
2032                .map(|()| output)
2033                .map_err(|message| failure(WorkflowFailureCode::InvalidOutput, message, false))
2034        });
2035        match result {
2036            Ok(output) => {
2037                let copy = output.clone();
2038                let completed_id = instance_id.clone();
2039                self.step_transition(
2040                    &instance_id,
2041                    WorkflowRunEventKind::StepCompleted {
2042                        output: output.clone(),
2043                    },
2044                    move |snapshot| {
2045                        if let Some(state) = snapshot.steps.get_mut(&completed_id) {
2046                            state.status = WorkflowStepStatus::Succeeded;
2047                            state.output = Some(copy);
2048                        }
2049                    },
2050                )
2051                .await?;
2052                Ok(output)
2053            }
2054            Err(error) => {
2055                if error.code == WorkflowFailureCode::Cancelled {
2056                    let cancelled_id = instance_id.clone();
2057                    self.step_transition(
2058                        &instance_id,
2059                        WorkflowRunEventKind::StepCancelled,
2060                        move |snapshot| {
2061                            if let Some(state) = snapshot.steps.get_mut(&cancelled_id) {
2062                                state.status = WorkflowStepStatus::Cancelled;
2063                                state.failure = Some(failure(
2064                                    WorkflowFailureCode::Cancelled,
2065                                    "workflow branch cancelled",
2066                                    false,
2067                                ));
2068                            }
2069                        },
2070                    )
2071                    .await?;
2072                    return Err(error);
2073                }
2074                if error.code == WorkflowFailureCode::Suspended {
2075                    let reason = error.message.clone();
2076                    let suspended_id = instance_id.clone();
2077                    self.step_transition(
2078                        &instance_id,
2079                        WorkflowRunEventKind::StepSuspended {
2080                            reason: reason.clone(),
2081                        },
2082                        move |snapshot| {
2083                            if let Some(state) = snapshot.steps.get_mut(&suspended_id) {
2084                                state.status = WorkflowStepStatus::Suspended;
2085                                state.failure =
2086                                    Some(failure(WorkflowFailureCode::Suspended, reason, true));
2087                            }
2088                        },
2089                    )
2090                    .await?;
2091                    return Err(error);
2092                }
2093                let copy = error.clone();
2094                let failed_id = instance_id.clone();
2095                self.step_transition(
2096                    &instance_id,
2097                    WorkflowRunEventKind::StepFailed {
2098                        failure: error.clone(),
2099                    },
2100                    move |snapshot| {
2101                        if let Some(state) = snapshot.steps.get_mut(&failed_id) {
2102                            state.status = WorkflowStepStatus::Failed;
2103                            state.failure = Some(copy);
2104                        }
2105                    },
2106                )
2107                .await?;
2108                // A host-owned Running refusal is terminal even when the
2109                // definition normally continues after execution errors.
2110                self.check_suspension_restriction()?;
2111                match step.failure {
2112                    FailurePolicy::ContinueWithError => Ok(serde_json::json!({"error": error})),
2113                    FailurePolicy::SkipDependents => Err(failure(
2114                        WorkflowFailureCode::DependencySkipped,
2115                        format!("{} (dependents skipped)", error.message),
2116                        false,
2117                    )),
2118                    FailurePolicy::FailFast => Err(error),
2119                }
2120            }
2121        }
2122    }
2123
2124    fn check_suspension_restriction(&self) -> Result<(), WorkflowFailure> {
2125        if bamboo_agent_core::tools::suspension_running_violated() {
2126            return Err(failure(
2127                WorkflowFailureCode::ExecutionFailed,
2128                "workflow cannot continue after a forbidden Running outcome; cleanup requested, stop unconfirmed",
2129                false,
2130            ));
2131        }
2132        Ok(())
2133    }
2134
2135    async fn dispatch(
2136        &self,
2137        step: &WorkflowStepDefinition,
2138        input: Value,
2139        instance_id: &str,
2140    ) -> Result<Value, WorkflowFailure> {
2141        self.check_suspension_restriction()?;
2142        let session_id = { self.snapshot.lock().await.session_id.clone() };
2143        match &step.kind {
2144            WorkflowStepKind::Tool {
2145                tool, capabilities, ..
2146            } => {
2147                self.authorize(
2148                    &session_id,
2149                    WorkflowPolicyTarget::Tool(tool.clone()),
2150                    capabilities,
2151                )
2152                .await?;
2153                let (resolved_input, resolved_secrets) =
2154                    self.resolve_secret_handles(&input, &session_id).await?;
2155                let arguments = serde_json::to_string(&resolved_input).map_err(|error| {
2156                    failure(WorkflowFailureCode::InvalidInput, error.to_string(), false)
2157                })?;
2158                let call = ToolCall {
2159                    id: format!("workflow-{}", Uuid::new_v4()),
2160                    tool_type: "function".to_string(),
2161                    function: FunctionCall {
2162                        name: tool.clone(),
2163                        arguments,
2164                    },
2165                };
2166                let permission_port = self
2167                    .engine
2168                    .session_permissions
2169                    .read()
2170                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2171                    .clone();
2172                let session_flags = match permission_port {
2173                    Some(port) => port.flags_for_session(&session_id).await.map_err(|error| {
2174                        failure(
2175                            WorkflowFailureCode::ExecutionFailed,
2176                            format!("workflow session permission posture is unavailable: {error}"),
2177                            true,
2178                        )
2179                    })?,
2180                    None => ToolExecutionSessionFlags::default(),
2181                };
2182                let context = ToolExecutionContext {
2183                    executing_supervisor: None,
2184                    session_id: Some(&session_id),
2185                    // WorkflowRunSnapshot does not persist root-session identity.
2186                    // Fail closed for tool events instead of treating a child
2187                    // session id as authoritative root metadata.
2188                    root_session_id: None,
2189                    tool_call_id: &call.id,
2190                    event_tx: None,
2191                    available_tool_schemas: None,
2192                    bypass_permissions: session_flags.bypass_permissions,
2193                    auto_approve_permissions: session_flags.auto_approve_permissions,
2194                    plan_read_only: session_flags.plan_read_only,
2195                    can_async_resume: false,
2196                    bash_completion_sink: None,
2197                    pre_parsed_args: Some(&resolved_input),
2198                };
2199                if session_flags.plan_read_only
2200                    && !bamboo_tools::orchestrator::plan_mode_allows_tool(&call.function.name)
2201                {
2202                    return Err(failure(
2203                        WorkflowFailureCode::ExecutionFailed,
2204                        format!("Plan mode: {} operation blocked", call.function.name),
2205                        false,
2206                    ));
2207                }
2208                // A parallel sibling may have set the shared host flag
2209                // while authorization, secret or session flags were awaited.
2210                self.check_suspension_restriction()?;
2211                let outcome = self
2212                    .engine
2213                    .tools
2214                    .execute_with_context_outcome(&call, context)
2215                    .await
2216                    .map(ToolOutcome::enforce_suspension_restriction)
2217                    .map_err(|error| {
2218                        let (code, message, retryable) = match error {
2219                            bamboo_agent_core::tools::ToolError::NotFound(_) => (
2220                                WorkflowFailureCode::UnknownReference,
2221                                "workflow tool is not available",
2222                                false,
2223                            ),
2224                            bamboo_agent_core::tools::ToolError::InvalidArguments(_) => (
2225                                WorkflowFailureCode::InvalidInput,
2226                                "workflow tool arguments were rejected",
2227                                false,
2228                            ),
2229                            bamboo_agent_core::tools::ToolError::Execution(_) => (
2230                                WorkflowFailureCode::ExecutionFailed,
2231                                "workflow tool execution was denied or failed",
2232                                true,
2233                            ),
2234                        };
2235                        failure(code, message, retryable)
2236                    })?;
2237                self.check_suspension_restriction()?;
2238                match outcome {
2239                    ToolOutcome::Completed(result) => {
2240                        let output = parse_tool_result(result)?;
2241                        if contains_any_secret_material(&output, &resolved_secrets) {
2242                            return Err(failure(
2243                                WorkflowFailureCode::InvalidOutput,
2244                                "workflow tool output contained resolved secret material",
2245                                false,
2246                            ));
2247                        }
2248                        Ok(output)
2249                    }
2250                    ToolOutcome::NeedsHuman { question, .. } => {
2251                        self.persist_suspension(WorkflowSuspensionContext::ToolApproval {
2252                            step_id: instance_id.to_string(),
2253                            tool: tool.clone(),
2254                            tool_call_id: question.tool_call_id,
2255                        })
2256                        .await?;
2257                        Err(failure(
2258                            WorkflowFailureCode::Suspended,
2259                            "workflow tool requires human approval",
2260                            true,
2261                        ))
2262                    }
2263                    ToolOutcome::Running(handle) => {
2264                        let tool_call_id = handle.tool_call_id.clone();
2265                        (handle.kill)();
2266                        self.persist_suspension(WorkflowSuspensionContext::ToolRunning {
2267                            step_id: instance_id.to_string(),
2268                            tool: tool.clone(),
2269                            tool_call_id,
2270                            killed: true,
2271                        })
2272                        .await?;
2273                        Err(failure(
2274                            WorkflowFailureCode::Suspended,
2275                            "workflow tool is running without a durable workflow resume handle",
2276                            true,
2277                        ))
2278                    }
2279                }
2280            }
2281            WorkflowStepKind::Agent {
2282                agent,
2283                model,
2284                effort,
2285                capabilities,
2286                structured_output_attempts,
2287                ..
2288            } => {
2289                if contains_secret_handle(&input) {
2290                    return Err(failure(
2291                        WorkflowFailureCode::PermissionDenied,
2292                        "secret capability handles are supported only for tool arguments",
2293                        false,
2294                    ));
2295                }
2296                self.authorize(
2297                    &session_id,
2298                    WorkflowPolicyTarget::Agent(agent.clone()),
2299                    capabilities,
2300                )
2301                .await?;
2302                let spec = self.pinned_agents.get(agent).cloned().ok_or_else(|| {
2303                    failure(
2304                        WorkflowFailureCode::PermissionDenied,
2305                        "named agent was not pinned during preflight",
2306                        false,
2307                    )
2308                })?;
2309                let requested = capabilities.iter().cloned().collect::<BTreeSet<_>>();
2310                if !requested.is_subset(&spec.allowed_capabilities) {
2311                    return Err(failure(
2312                        WorkflowFailureCode::PermissionDenied,
2313                        "named agent capability intersection changed",
2314                        false,
2315                    ));
2316                }
2317                let mut last_error = None;
2318                for _ in 0..*structured_output_attempts {
2319                    self.ensure_agent_usage_budget_available().await?;
2320                    self.reserve_agent().await?;
2321                    if !spec.cost_supported {
2322                        self.ledger.lock().await.cost_micros = None;
2323                    }
2324                    self.checkpoint_usage("agent_reserved").await?;
2325                    self.check_suspension_restriction()?;
2326                    match self
2327                        .engine
2328                        .agents
2329                        .execute(
2330                            &spec,
2331                            input.clone(),
2332                            model.as_deref(),
2333                            effort.as_deref(),
2334                            &requested,
2335                            &session_id,
2336                            &self.root_run_id,
2337                            self.branch_cancellation.child_token(),
2338                        )
2339                        .await
2340                    {
2341                        Ok(result) => {
2342                            let _drain_guard = self.drain_lock.lock().await;
2343                            let mut usage = self.ledger.lock().await;
2344                            if result
2345                                .attempt_id
2346                                .as_ref()
2347                                .is_some_and(|id| !self.engine.agents.acknowledge_result(id))
2348                            {
2349                                self.check_cancelled()?;
2350                                return Err(failure(
2351                                    WorkflowFailureCode::Cancelled,
2352                                    "workflow Agent result already drained",
2353                                    false,
2354                                ));
2355                            }
2356                            // No await between ownership transfer and accounting.
2357                            let exceeded =
2358                                self.record_usage(&mut usage, result.tokens, result.cost_micros);
2359                            drop(usage);
2360                            self.checkpoint_usage("agent_usage_recorded").await?;
2361                            self.check_cancelled()?;
2362                            if let Some(error) = result.failure {
2363                                return Err(error);
2364                            }
2365                            if let Some(error) = exceeded {
2366                                return Err(error);
2367                            }
2368                            if let Some(schema) = &step.output_schema {
2369                                if let Err(error) = validate_schema(schema, &result.output) {
2370                                    last_error = Some(error);
2371                                    continue;
2372                                }
2373                            }
2374                            return Ok(result.output);
2375                        }
2376                        Err(_error) => {
2377                            self.check_cancelled()?;
2378                            return Err(failure(
2379                                WorkflowFailureCode::ExecutionFailed,
2380                                "named agent execution or usage observation unavailable",
2381                                false,
2382                            ));
2383                        }
2384                    }
2385                }
2386                Err(failure(
2387                    WorkflowFailureCode::InvalidOutput,
2388                    last_error.unwrap_or_else(|| "agent structured output exhausted".to_string()),
2389                    false,
2390                ))
2391            }
2392            WorkflowStepKind::Workflow {
2393                workflow_id,
2394                revision,
2395                ..
2396            } => {
2397                if self.depth + 1 >= self.root_limits.max_nesting_depth {
2398                    return Err(failure(
2399                        WorkflowFailureCode::BudgetExceeded,
2400                        "nested workflow depth exceeded",
2401                        false,
2402                    ));
2403                }
2404                let definition = self
2405                    .bundle
2406                    .get(workflow_id, *revision)
2407                    .cloned()
2408                    .ok_or_else(|| {
2409                        failure(
2410                            WorkflowFailureCode::UnknownReference,
2411                            format!("persisted bundle missing workflow {workflow_id}@{revision}"),
2412                            false,
2413                        )
2414                    })?;
2415                let nested = StartWorkflowRun {
2416                    definition,
2417                    args: input,
2418                    session_id,
2419                    workspace_trusted: self.workspace_trusted,
2420                    allowed_capabilities: self.allowed_capabilities.iter().cloned().collect(),
2421                };
2422                let parent_run_id = self.snapshot.lock().await.run_id.clone();
2423                self.check_suspension_restriction()?;
2424                let result = Box::pin(self.engine.run_internal(
2425                    nested,
2426                    self.bundle.clone(),
2427                    self.pinned_agents.clone(),
2428                    Some(parent_run_id),
2429                    Some(instance_id.to_string()),
2430                    self.depth + 1,
2431                    self.branch_cancellation.clone(),
2432                    self.ledger.clone(),
2433                    self.root_limits.clone(),
2434                    self.semaphore.clone(),
2435                    None,
2436                    Some(self.root_run_id.clone()),
2437                ))
2438                .await
2439                .map_err(|_error| {
2440                    failure(
2441                        WorkflowFailureCode::ExecutionFailed,
2442                        "nested workflow execution failed",
2443                        false,
2444                    )
2445                })?;
2446                result.output.ok_or_else(|| {
2447                    result.failure.unwrap_or_else(|| {
2448                        failure(
2449                            WorkflowFailureCode::ExecutionFailed,
2450                            "nested workflow returned no output",
2451                            false,
2452                        )
2453                    })
2454                })
2455            }
2456        }
2457    }
2458
2459    async fn authorize(
2460        &self,
2461        session_id: &str,
2462        target: WorkflowPolicyTarget,
2463        capabilities: &[String],
2464    ) -> Result<(), WorkflowFailure> {
2465        let requested = capabilities.iter().cloned().collect::<BTreeSet<_>>();
2466        if !requested.is_subset(&self.allowed_capabilities) {
2467            return Err(failure(
2468                WorkflowFailureCode::PermissionDenied,
2469                "step capability exceeds root policy",
2470                false,
2471            ));
2472        }
2473        match self
2474            .engine
2475            .policy
2476            .authorize(session_id, &target, &requested, self.workspace_trusted)
2477            .await
2478        {
2479            PermissionDecision::Allow => Ok(()),
2480            PermissionDecision::Deny(_reason) => Err(failure(
2481                if self.workspace_trusted {
2482                    WorkflowFailureCode::PermissionDenied
2483                } else {
2484                    WorkflowFailureCode::UntrustedWorkspace
2485                },
2486                "workflow policy denied this step",
2487                false,
2488            )),
2489        }
2490    }
2491
2492    async fn step_transition(
2493        &self,
2494        step_id: &str,
2495        kind: WorkflowRunEventKind,
2496        mutate: impl FnOnce(&mut WorkflowRunSnapshot),
2497    ) -> Result<(), WorkflowFailure> {
2498        let usage = self.ledger.lock().await.clone();
2499        let mut snapshot = self.snapshot.lock().await;
2500        snapshot.usage = usage;
2501        self.engine
2502            .transition(&mut snapshot, Some(step_id.to_string()), kind, mutate)
2503            .await
2504            .map_err(|error| failure(WorkflowFailureCode::Storage, error.to_string(), false))
2505    }
2506
2507    async fn reserve_step(&self) -> Result<(), WorkflowFailure> {
2508        let mut usage = self.ledger.lock().await;
2509        if usage.steps >= self.root_limits.max_steps {
2510            return Err(failure(
2511                WorkflowFailureCode::BudgetExceeded,
2512                "workflow step budget exceeded",
2513                false,
2514            ));
2515        }
2516        usage.steps += 1;
2517        Ok(())
2518    }
2519
2520    async fn reserve_retry(&self) -> Result<(), WorkflowFailure> {
2521        let mut usage = self.ledger.lock().await;
2522        if usage.retries >= self.root_limits.max_retries {
2523            return Err(failure(
2524                WorkflowFailureCode::BudgetExceeded,
2525                "workflow retry budget exceeded",
2526                false,
2527            ));
2528        }
2529        usage.retries += 1;
2530        Ok(())
2531    }
2532
2533    async fn reserve_agent(&self) -> Result<(), WorkflowFailure> {
2534        let mut usage = self.ledger.lock().await;
2535        if usage.agents >= self.root_limits.max_agents {
2536            return Err(failure(
2537                WorkflowFailureCode::BudgetExceeded,
2538                "workflow agent budget exceeded",
2539                false,
2540            ));
2541        }
2542        usage.agents += 1;
2543        Ok(())
2544    }
2545
2546    fn record_usage(
2547        &self,
2548        usage: &mut WorkflowBudgetUsage,
2549        tokens: u64,
2550        cost_micros: Option<u64>,
2551    ) -> Option<WorkflowFailure> {
2552        let next_tokens = usage.tokens.saturating_add(tokens);
2553        let next_cost = usage
2554            .cost_micros
2555            .zip(cost_micros)
2556            .map(|(a, b)| a.saturating_add(b));
2557        usage.tokens = next_tokens;
2558        usage.cost_micros = next_cost;
2559        if self
2560            .root_limits
2561            .max_tokens
2562            .is_some_and(|limit| next_tokens > limit)
2563            || self
2564                .root_limits
2565                .max_cost_micros
2566                .is_some_and(|limit| next_cost.is_none_or(|cost| cost > limit))
2567        {
2568            return Some(failure(
2569                WorkflowFailureCode::BudgetExceeded,
2570                "workflow token/cost budget exceeded",
2571                false,
2572            ));
2573        }
2574        None
2575    }
2576
2577    async fn drain_agents(&self) -> (bool, Option<String>) {
2578        // Acquire before the port transfers results; after it returns there is
2579        // no await until all rows are accounted. Provider execution stays parallel.
2580        let mut ledger = self.ledger.lock().await;
2581        let (results, error) = self.engine.agents.drain_cancelled(&self.root_run_id).await;
2582        let mut unavailable = false;
2583        for result in results {
2584            match result {
2585                Ok(result) => {
2586                    self.record_usage(&mut ledger, result.tokens, result.cost_micros);
2587                }
2588                Err(_) => {
2589                    unavailable = true;
2590                    ledger.cost_micros = None;
2591                }
2592            }
2593        }
2594        (unavailable, error)
2595    }
2596
2597    async fn ensure_agent_usage_budget_available(&self) -> Result<(), WorkflowFailure> {
2598        let usage = self.ledger.lock().await;
2599        if self
2600            .root_limits
2601            .max_tokens
2602            .is_some_and(|limit| usage.tokens >= limit)
2603            || self
2604                .root_limits
2605                .max_cost_micros
2606                .is_some_and(|limit| usage.cost_micros.is_none_or(|cost| cost >= limit))
2607        {
2608            return Err(failure(
2609                WorkflowFailureCode::BudgetExceeded,
2610                "workflow token/cost budget exhausted before agent dispatch",
2611                false,
2612            ));
2613        }
2614        Ok(())
2615    }
2616
2617    async fn checkpoint_agent_usage(&self, name: &str) -> Result<(), WorkflowFailure> {
2618        let usage = self.ledger.lock().await.clone();
2619        let mut snapshot = self.snapshot.lock().await;
2620        self.engine
2621            .transition(
2622                &mut snapshot,
2623                None,
2624                WorkflowRunEventKind::Phase {
2625                    name: name.to_string(),
2626                },
2627                move |snapshot| {
2628                    // Only completed Agent usage is reconciled here. Other counters
2629                    // retain their existing durable reservation/checkpoint semantics.
2630                    snapshot.usage.tokens = usage.tokens;
2631                    snapshot.usage.cost_micros = usage.cost_micros;
2632                },
2633            )
2634            .await
2635            .map_err(|e| failure(WorkflowFailureCode::Storage, e.to_string(), false))
2636    }
2637
2638    async fn checkpoint_usage(&self, name: &str) -> Result<(), WorkflowFailure> {
2639        let usage = self.ledger.lock().await.clone();
2640        let mut snapshot = self.snapshot.lock().await;
2641        self.engine
2642            .transition(
2643                &mut snapshot,
2644                None,
2645                WorkflowRunEventKind::Phase {
2646                    name: name.to_string(),
2647                },
2648                move |snapshot| snapshot.usage = usage,
2649            )
2650            .await
2651            .map_err(|error| failure(WorkflowFailureCode::Storage, error.to_string(), false))
2652    }
2653
2654    async fn persist_suspension(
2655        &self,
2656        context: WorkflowSuspensionContext,
2657    ) -> Result<(), WorkflowFailure> {
2658        let mut snapshot = self.snapshot.lock().await;
2659        self.engine
2660            .transition(
2661                &mut snapshot,
2662                None,
2663                WorkflowRunEventKind::Phase {
2664                    name: "suspension_context_persisted".to_string(),
2665                },
2666                move |snapshot| snapshot.suspension = Some(context),
2667            )
2668            .await
2669            .map_err(|error| failure(WorkflowFailureCode::Storage, error.to_string(), false))
2670    }
2671
2672    async fn cancel_active_parallel_steps(
2673        &self,
2674        nodes: &[WorkflowPlan],
2675    ) -> Result<(), WorkflowFailure> {
2676        let sibling_steps = nodes
2677            .iter()
2678            .flat_map(plan_step_ids)
2679            .collect::<BTreeSet<_>>();
2680        let active = {
2681            let snapshot = self.snapshot.lock().await;
2682            snapshot
2683                .steps
2684                .iter()
2685                .filter(|(_, step)| {
2686                    matches!(
2687                        step.status,
2688                        WorkflowStepStatus::Queued | WorkflowStepStatus::Running
2689                    ) && sibling_steps
2690                        .iter()
2691                        .any(|step_id| self.step_is_in_scope(step, step_id))
2692                })
2693                .map(|(id, _)| id.clone())
2694                .collect::<Vec<_>>()
2695        };
2696        for step_id in active {
2697            let state_id = step_id.clone();
2698            self.step_transition(
2699                &step_id,
2700                WorkflowRunEventKind::StepCancelled,
2701                move |snapshot| {
2702                    if let Some(step) = snapshot.steps.get_mut(&state_id) {
2703                        step.status = WorkflowStepStatus::Cancelled;
2704                        step.failure = Some(failure(
2705                            WorkflowFailureCode::Cancelled,
2706                            "parallel sibling cancelled by fail_fast",
2707                            false,
2708                        ));
2709                    }
2710                },
2711            )
2712            .await?;
2713        }
2714        Ok(())
2715    }
2716
2717    async fn resolve_secret_handles(
2718        &self,
2719        value: &Value,
2720        session_id: &str,
2721    ) -> Result<(Value, Vec<String>), WorkflowFailure> {
2722        fn walk<'a>(
2723            context: &'a RunContext,
2724            value: &'a Value,
2725            session_id: &'a str,
2726        ) -> SecretResolutionFuture<'a> {
2727            Box::pin(async move {
2728                match value {
2729                    Value::Object(object) if object.contains_key("$secret") => {
2730                        let handle: bamboo_domain::WorkflowSecretHandle =
2731                            serde_json::from_value(value.clone()).map_err(|_| {
2732                                failure(
2733                                    WorkflowFailureCode::InvalidInput,
2734                                    "malformed secret capability handle",
2735                                    false,
2736                                )
2737                            })?;
2738                        let material = context
2739                            .engine
2740                            .secrets
2741                            .resolve(session_id, &handle.capability)
2742                            .await
2743                            .map_err(|_| {
2744                                failure(
2745                                    WorkflowFailureCode::PermissionDenied,
2746                                    "secret capability resolution denied",
2747                                    false,
2748                                )
2749                            })?;
2750                        let material = material.into_exposed();
2751                        Ok((Value::String(material.clone()), vec![material]))
2752                    }
2753                    Value::Object(object) => {
2754                        let mut resolved = serde_json::Map::new();
2755                        let mut secrets = Vec::new();
2756                        for (key, child) in object {
2757                            let (child, mut child_secrets) =
2758                                walk(context, child, session_id).await?;
2759                            resolved.insert(key.clone(), child);
2760                            secrets.append(&mut child_secrets);
2761                        }
2762                        Ok((Value::Object(resolved), secrets))
2763                    }
2764                    Value::Array(array) => {
2765                        let mut resolved = Vec::with_capacity(array.len());
2766                        let mut secrets = Vec::new();
2767                        for child in array {
2768                            let (child, mut child_secrets) =
2769                                walk(context, child, session_id).await?;
2770                            resolved.push(child);
2771                            secrets.append(&mut child_secrets);
2772                        }
2773                        Ok((Value::Array(resolved), secrets))
2774                    }
2775                    value => Ok((value.clone(), Vec::new())),
2776                }
2777            })
2778        }
2779        walk(self, value, session_id).await
2780    }
2781
2782    fn skip_plan<'a>(&'a self, plan: &'a WorkflowPlan, reason: &'a str) -> NodeFuture<'a> {
2783        Box::pin(async move {
2784            match plan {
2785                WorkflowPlan::Step { step } => {
2786                    let instance_id = self.instance_id(step);
2787                    // A previous Retry attempt may have materialized Map items
2788                    // in this branch. Close only instances in the current scope.
2789                    let mut instances = {
2790                        let snapshot = self.snapshot.lock().await;
2791                        snapshot
2792                            .steps
2793                            .iter()
2794                            .filter(|(_, state)| self.step_is_in_scope(state, step))
2795                            .map(|(id, _)| id.clone())
2796                            .collect::<Vec<_>>()
2797                    };
2798                    if instances.is_empty() {
2799                        instances.push(instance_id);
2800                    }
2801                    for instance_id in instances {
2802                        let reason_owned = reason.to_string();
2803                        let state_id = instance_id.clone();
2804                        self.step_transition(
2805                            &instance_id,
2806                            WorkflowRunEventKind::StepSkipped {
2807                                reason: reason.to_string(),
2808                            },
2809                            move |snapshot| {
2810                                let state = snapshot.steps.entry(state_id.clone()).or_insert(
2811                                    WorkflowStepSnapshot {
2812                                        id: state_id,
2813                                        topology: self
2814                                            .compiled
2815                                            .definition
2816                                            .plan
2817                                            .step_topology(step, self.map_index_path.clone()),
2818                                        status: WorkflowStepStatus::Skipped,
2819                                        input_hash: String::new(),
2820                                        output: None,
2821                                        failure: None,
2822                                        attempts: 0,
2823                                    },
2824                                );
2825                                state.status = WorkflowStepStatus::Skipped;
2826                                state.output = None;
2827                                state.failure = Some(failure(
2828                                    WorkflowFailureCode::DependencySkipped,
2829                                    reason_owned,
2830                                    false,
2831                                ));
2832                            },
2833                        )
2834                        .await?;
2835                    }
2836                }
2837                WorkflowPlan::Sequence { nodes } | WorkflowPlan::Parallel { nodes } => {
2838                    for node in nodes {
2839                        self.skip_plan(node, reason).await?;
2840                    }
2841                }
2842                WorkflowPlan::Choice {
2843                    then_branch,
2844                    else_branch,
2845                    ..
2846                } => {
2847                    self.skip_plan(then_branch, reason).await?;
2848                    self.skip_plan(else_branch, reason).await?;
2849                }
2850                WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
2851                    self.skip_plan(body, reason).await?;
2852                }
2853            }
2854            Ok(Value::Null)
2855        })
2856    }
2857
2858    async fn resolve_template(&self, value: &Value) -> Result<Value, WorkflowFailure> {
2859        Box::pin(self.resolve_template_inner(value)).await
2860    }
2861
2862    fn resolve_template_inner<'a>(
2863        &'a self,
2864        value: &'a Value,
2865    ) -> Pin<Box<dyn Future<Output = Result<Value, WorkflowFailure>> + Send + 'a>> {
2866        Box::pin(async move {
2867            match value {
2868                Value::Object(object) if object.get("from").is_some() => {
2869                    let reference: ValueRef =
2870                        serde_json::from_value(value.clone()).map_err(|error| {
2871                            failure(
2872                                WorkflowFailureCode::InvalidInput,
2873                                format!("malformed value reference: {error}"),
2874                                false,
2875                            )
2876                        })?;
2877                    self.resolve_ref(&reference).await
2878                }
2879                Value::Object(object) => {
2880                    let mut resolved = serde_json::Map::new();
2881                    for (key, child) in object {
2882                        resolved.insert(key.clone(), self.resolve_template_inner(child).await?);
2883                    }
2884                    Ok(Value::Object(resolved))
2885                }
2886                Value::Array(array) => {
2887                    let mut resolved = Vec::with_capacity(array.len());
2888                    for child in array {
2889                        resolved.push(self.resolve_template_inner(child).await?);
2890                    }
2891                    Ok(Value::Array(resolved))
2892                }
2893                value => Ok(value.clone()),
2894            }
2895        })
2896    }
2897
2898    async fn resolve_ref(&self, reference: &ValueRef) -> Result<Value, WorkflowFailure> {
2899        let (root, pointer) = match reference {
2900            ValueRef::Args { pointer } => (
2901                self.snapshot.lock().await.validated_args.clone(),
2902                pointer.as_str(),
2903            ),
2904            ValueRef::Step { step, pointer } => {
2905                let snapshot = self.snapshot.lock().await;
2906                let exact = self.instance_id(step);
2907                let output = snapshot
2908                    .steps
2909                    .get(&exact)
2910                    .or_else(|| snapshot.steps.get(step))
2911                    .and_then(|state| state.output.clone())
2912                    .ok_or_else(|| {
2913                        failure(
2914                            WorkflowFailureCode::UnknownReference,
2915                            format!("step output '{step}' unavailable in execution scope"),
2916                            false,
2917                        )
2918                    })?;
2919                (output, pointer.as_str())
2920            }
2921            ValueRef::Item { name, pointer } => (
2922                self.items.get(name).cloned().ok_or_else(|| {
2923                    failure(
2924                        WorkflowFailureCode::UnknownReference,
2925                        format!("map item '{name}' unavailable"),
2926                        false,
2927                    )
2928                })?,
2929                pointer.as_str(),
2930            ),
2931            ValueRef::Literal { value } => return Ok(value.clone()),
2932        };
2933        if pointer.is_empty() {
2934            Ok(root)
2935        } else {
2936            root.pointer(pointer).cloned().ok_or_else(|| {
2937                failure(
2938                    WorkflowFailureCode::UnknownReference,
2939                    format!("JSON pointer '{pointer}' not found"),
2940                    false,
2941                )
2942            })
2943        }
2944    }
2945
2946    // Keep existing opaque IDs unless a declared step would occupy the same
2947    // key. Scoped IDs end in a numeric scope plus optional '@' suffixes, so
2948    // distinct scoped instances cannot alias each other or a declared step.
2949    fn instance_id(&self, step_id: &str) -> String {
2950        if self.scope == "root" {
2951            return step_id.to_owned();
2952        }
2953        let mut id = format!("{step_id}@{}", self.scope);
2954        while self.compiled.steps.contains_key(&id) {
2955            id.push('@');
2956        }
2957        id
2958    }
2959
2960    fn step_is_in_scope(&self, state: &WorkflowStepSnapshot, step_id: &str) -> bool {
2961        state.topology.as_ref().map_or_else(
2962            || instance_is_in_scope(&state.id, step_id, &self.scope),
2963            |topology| {
2964                topology.planned_step_id == step_id
2965                    && topology.map_index_path.starts_with(&self.map_index_path)
2966            },
2967        )
2968    }
2969
2970    fn check_cancelled(&self) -> Result<(), WorkflowFailure> {
2971        if self.cancellation.is_cancelled() || self.branch_cancellation.is_cancelled() {
2972            Err(failure(
2973                WorkflowFailureCode::Cancelled,
2974                "workflow cancelled",
2975                false,
2976            ))
2977        } else {
2978            Ok(())
2979        }
2980    }
2981}
2982
2983fn event(
2984    snapshot: &WorkflowRunSnapshot,
2985    step_id: Option<String>,
2986    kind: WorkflowRunEventKind,
2987) -> WorkflowRunEvent {
2988    WorkflowRunEvent {
2989        run_id: snapshot.run_id.clone(),
2990        sequence: snapshot.last_sequence,
2991        at: Utc::now(),
2992        step_id,
2993        kind,
2994    }
2995}
2996fn failure(
2997    code: WorkflowFailureCode,
2998    message: impl Into<String>,
2999    retryable: bool,
3000) -> WorkflowFailure {
3001    WorkflowFailure {
3002        code,
3003        message: message.into(),
3004        retryable,
3005    }
3006}
3007fn storage(error: std::io::Error) -> WorkflowRunError {
3008    WorkflowRunError::Storage(error.to_string())
3009}
3010fn exceeds_optional(requested: Option<u64>, ceiling: Option<u64>) -> bool {
3011    match (requested, ceiling) {
3012        (Some(requested), Some(ceiling)) => requested > ceiling,
3013        _ => false,
3014    }
3015}
3016
3017fn enforce_budget_within(
3018    requested: &WorkflowBudgets,
3019    ceiling: &WorkflowBudgets,
3020) -> Result<(), &'static str> {
3021    if requested.max_concurrency > ceiling.max_concurrency {
3022        Err("max_concurrency")
3023    } else if requested.max_agents > ceiling.max_agents {
3024        Err("max_agents")
3025    } else if requested.max_steps > ceiling.max_steps {
3026        Err("max_steps")
3027    } else if requested.max_retries > ceiling.max_retries {
3028        Err("max_retries")
3029    } else if requested.max_nesting_depth > ceiling.max_nesting_depth {
3030        Err("max_nesting_depth")
3031    } else if requested.wall_time_ms > ceiling.wall_time_ms {
3032        Err("wall_time_ms")
3033    } else if exceeds_optional(requested.max_tokens, ceiling.max_tokens) {
3034        Err("max_tokens")
3035    } else if exceeds_optional(requested.max_cost_micros, ceiling.max_cost_micros) {
3036        Err("max_cost_micros")
3037    } else {
3038        Ok(())
3039    }
3040}
3041
3042fn remaining_wall_time(snapshot: &WorkflowRunSnapshot) -> u64 {
3043    let elapsed = Utc::now()
3044        .signed_duration_since(snapshot.created_at)
3045        .num_milliseconds()
3046        .max(0) as u64;
3047    snapshot
3048        .definition
3049        .budgets
3050        .wall_time_ms
3051        .saturating_sub(elapsed)
3052}
3053
3054fn resolve_checkpoint_template(
3055    value: &Value,
3056    args: &Value,
3057    outputs: &BTreeMap<String, Value>,
3058) -> Result<Value, WorkflowRunError> {
3059    match value {
3060        Value::Object(object) if object.contains_key("from") => {
3061            let reference: ValueRef = serde_json::from_value(value.clone())
3062                .map_err(|_| WorkflowRunError::Preflight("invalid checkpoint reference".into()))?;
3063            let (root, pointer) = match &reference {
3064                ValueRef::Args { pointer } => (args, pointer),
3065                ValueRef::Step { step, pointer } => (
3066                    outputs.get(step).ok_or_else(|| {
3067                        WorkflowRunError::Preflight(
3068                            "checkpoint reference is not in the completed prefix".into(),
3069                        )
3070                    })?,
3071                    pointer,
3072                ),
3073                ValueRef::Literal { value } => return Ok(value.clone()),
3074                ValueRef::Item { .. } => {
3075                    return Err(WorkflowRunError::Preflight(
3076                        "map references are unsupported for continuation".into(),
3077                    ))
3078                }
3079            };
3080            if pointer.is_empty() {
3081                Ok(root.clone())
3082            } else {
3083                root.pointer(pointer).cloned().ok_or_else(|| {
3084                    WorkflowRunError::Preflight("checkpoint reference pointer is missing".into())
3085                })
3086            }
3087        }
3088        Value::Object(object) => object
3089            .iter()
3090            .map(|(key, value)| {
3091                Ok((
3092                    key.clone(),
3093                    resolve_checkpoint_template(value, args, outputs)?,
3094                ))
3095            })
3096            .collect::<Result<serde_json::Map<_, _>, _>>()
3097            .map(Value::Object),
3098        Value::Array(values) => values
3099            .iter()
3100            .map(|value| resolve_checkpoint_template(value, args, outputs))
3101            .collect::<Result<Vec<_>, _>>()
3102            .map(Value::Array),
3103        value => Ok(value.clone()),
3104    }
3105}
3106
3107fn definition_bundle_hash(bundle: &WorkflowDefinitionBundle) -> Result<String, WorkflowRunError> {
3108    let bytes = serde_json::to_vec(bundle)
3109        .map_err(|_| WorkflowRunError::Preflight("workflow bundle hashing failed".to_string()))?;
3110    Ok(hex::encode(Sha256::digest(bytes)))
3111}
3112fn parse_tool_result(result: ToolResult) -> Result<Value, WorkflowFailure> {
3113    if !result.success {
3114        return Err(failure(
3115            WorkflowFailureCode::ExecutionFailed,
3116            "workflow tool reported failure",
3117            true,
3118        ));
3119    }
3120    Ok(serde_json::from_str(&result.result).unwrap_or(Value::String(result.result)))
3121}
3122fn reject_secret_material(value: &Value) -> Result<(), String> {
3123    reject_secret_material_inner(value, false)
3124}
3125
3126fn reject_secret_material_in_definition(value: &Value) -> Result<(), String> {
3127    reject_secret_material_inner(value, true)
3128}
3129
3130fn reject_secret_material_inner(value: &Value, allow_bindings: bool) -> Result<(), String> {
3131    fn walk(value: &Value, key: Option<&str>, allow_bindings: bool) -> Result<(), String> {
3132        if value.as_object().is_some_and(|object| {
3133            object.len() == 1
3134                && object
3135                    .get("$secret")
3136                    .and_then(Value::as_str)
3137                    .is_some_and(|handle| !handle.trim().is_empty())
3138        }) {
3139            return Ok(());
3140        }
3141        let safe_binding = allow_bindings
3142            && serde_json::from_value::<ValueRef>(value.clone())
3143                .is_ok_and(|reference| !matches!(reference, ValueRef::Literal { .. }));
3144        if key.is_some_and(|key| {
3145            let normalized = key
3146                .chars()
3147                .filter(|character| character.is_ascii_alphanumeric())
3148                .flat_map(char::to_lowercase)
3149                .collect::<String>();
3150            matches!(
3151                normalized.as_str(),
3152                "secret"
3153                    | "token"
3154                    | "password"
3155                    | "credential"
3156                    | "credentials"
3157                    | "apikey"
3158                    | "accesskey"
3159                    | "accesstoken"
3160                    | "secretkey"
3161                    | "privatekey"
3162            )
3163        }) && !safe_binding
3164        {
3165            return Err("secret-bearing fields are not accepted by workflow runs".to_string());
3166        }
3167        if value.as_str().is_some_and(|value| {
3168            let trimmed = value.trim();
3169            trimmed.starts_with("capability://")
3170                || trimmed.starts_with("Bearer ")
3171                || trimmed.starts_with("sk-")
3172                || trimmed.starts_with("ghp_")
3173                || trimmed.starts_with("github_pat_")
3174        }) {
3175            // No production capability resolver is part of #578. Treating an
3176            // arbitrary caller string as an opaque handle would be an injection
3177            // channel, so handles and common raw credential forms fail closed.
3178            return Err("opaque credential handles are not enabled for workflows".to_string());
3179        }
3180        match value {
3181            Value::Object(object) => {
3182                for (key, value) in object {
3183                    if key == "properties" {
3184                        let properties = value.as_object().ok_or_else(|| {
3185                            "workflow schema properties must be an object".to_string()
3186                        })?;
3187                        for schema in properties.values() {
3188                            walk(schema, None, allow_bindings)?;
3189                        }
3190                    } else {
3191                        walk(value, Some(key), allow_bindings)?;
3192                    }
3193                }
3194            }
3195            Value::Array(array) => {
3196                for value in array {
3197                    walk(value, None, allow_bindings)?;
3198                }
3199            }
3200            _ => {}
3201        }
3202        Ok(())
3203    }
3204    walk(value, None, allow_bindings)
3205}
3206
3207fn contains_secret_handle(value: &Value) -> bool {
3208    match value {
3209        Value::Object(object) => {
3210            object.contains_key("$secret") || object.values().any(contains_secret_handle)
3211        }
3212        Value::Array(array) => array.iter().any(contains_secret_handle),
3213        _ => false,
3214    }
3215}
3216
3217fn contains_any_secret_material(value: &Value, secrets: &[String]) -> bool {
3218    let matches = |candidate: &str| {
3219        secrets
3220            .iter()
3221            .any(|secret| !secret.is_empty() && candidate.contains(secret))
3222    };
3223    fn walk(value: &Value, matches: &impl Fn(&str) -> bool) -> bool {
3224        match value {
3225            Value::String(value) => matches(value),
3226            Value::Object(object) => object
3227                .iter()
3228                .any(|(key, value)| matches(key) || walk(value, matches)),
3229            Value::Array(array) => array.iter().any(|value| walk(value, matches)),
3230            _ => false,
3231        }
3232    }
3233    walk(value, &matches)
3234}
3235
3236fn plan_leaf_count(plan: &WorkflowPlan) -> usize {
3237    match plan {
3238        WorkflowPlan::Step { .. } => 1,
3239        WorkflowPlan::Sequence { nodes } | WorkflowPlan::Parallel { nodes } => {
3240            nodes.iter().fold(0usize, |total, node| {
3241                total.saturating_add(plan_leaf_count(node))
3242            })
3243        }
3244        WorkflowPlan::Choice {
3245            then_branch,
3246            else_branch,
3247            ..
3248        } => plan_leaf_count(then_branch).max(plan_leaf_count(else_branch)),
3249        WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
3250            plan_leaf_count(body)
3251        }
3252    }
3253}
3254
3255fn plan_step_ids(plan: &WorkflowPlan) -> Vec<String> {
3256    match plan {
3257        WorkflowPlan::Step { step } => vec![step.clone()],
3258        WorkflowPlan::Sequence { nodes } | WorkflowPlan::Parallel { nodes } => {
3259            nodes.iter().flat_map(plan_step_ids).collect()
3260        }
3261        WorkflowPlan::Choice {
3262            then_branch,
3263            else_branch,
3264            ..
3265        } => {
3266            let mut result = plan_step_ids(then_branch);
3267            result.extend(plan_step_ids(else_branch));
3268            result
3269        }
3270        WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
3271            plan_step_ids(body)
3272        }
3273    }
3274}
3275
3276fn instance_is_in_scope(instance_id: &str, step_id: &str, scope: &str) -> bool {
3277    if scope == "root" {
3278        instance_id == step_id
3279            || instance_id
3280                .strip_prefix(&format!("{step_id}@root"))
3281                .is_some_and(|suffix| suffix.starts_with('['))
3282    } else {
3283        let exact = format!("{step_id}@{scope}");
3284        instance_id == exact
3285            || instance_id
3286                .strip_prefix(&exact)
3287                .is_some_and(|suffix| suffix.starts_with('['))
3288    }
3289}
3290
3291fn plan_frontier(plan: &WorkflowPlan) -> Vec<String> {
3292    match plan {
3293        WorkflowPlan::Step { step } => vec![step.clone()],
3294        WorkflowPlan::Sequence { nodes } => nodes.first().map_or_else(Vec::new, plan_frontier),
3295        WorkflowPlan::Parallel { nodes } => nodes.iter().flat_map(plan_frontier).collect(),
3296        WorkflowPlan::Choice {
3297            then_branch,
3298            else_branch,
3299            ..
3300        } => {
3301            let mut result = plan_frontier(then_branch);
3302            result.extend(plan_frontier(else_branch));
3303            result
3304        }
3305        WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
3306            plan_frontier(body)
3307        }
3308    }
3309}
3310
3311fn validate_nested_input_contract(
3312    template: &Value,
3313    target_schema: &Value,
3314    compiled: &CompiledWorkflow,
3315) -> Result<(), String> {
3316    fn contains_ref(value: &Value) -> bool {
3317        match value {
3318            Value::Object(object) => {
3319                object.contains_key("from") || object.values().any(contains_ref)
3320            }
3321            Value::Array(array) => array.iter().any(contains_ref),
3322            _ => false,
3323        }
3324    }
3325    if !contains_ref(template) {
3326        return validate_schema(target_schema, template)
3327            .map_err(|error| format!("nested workflow input is incompatible: {error}"));
3328    }
3329    let reference: ValueRef = serde_json::from_value(template.clone()).map_err(|_| {
3330        "nested dynamic input schema cannot be proven compatible in phase 1".to_string()
3331    })?;
3332    let source_schema = match reference {
3333        ValueRef::Args { pointer } => {
3334            schema_at_pointer(&compiled.definition.input_schema, &pointer)
3335        }
3336        ValueRef::Step { step, pointer } => compiled
3337            .steps
3338            .get(&step)
3339            .and_then(|step| step.output_schema.as_ref())
3340            .and_then(|schema| schema_at_pointer(schema, &pointer)),
3341        ValueRef::Literal { value } => {
3342            return validate_schema(target_schema, &value)
3343                .map_err(|error| format!("nested workflow input is incompatible: {error}"));
3344        }
3345        ValueRef::Item { .. } => None,
3346    }
3347    .ok_or_else(|| {
3348        "nested dynamic input source schema is missing or pointer is invalid".to_string()
3349    })?;
3350    if schema_compatible(source_schema, target_schema) {
3351        Ok(())
3352    } else {
3353        Err("nested workflow input schema is not compatible with its pinned target".to_string())
3354    }
3355}
3356
3357fn schema_at_pointer<'a>(schema: &'a Value, pointer: &str) -> Option<&'a Value> {
3358    if pointer.is_empty() {
3359        return Some(schema);
3360    }
3361    let mut current = schema;
3362    for token in pointer.strip_prefix('/')?.split('/') {
3363        let token = token.replace("~1", "/").replace("~0", "~");
3364        current = if token.parse::<usize>().is_ok() {
3365            current.get("items")?
3366        } else {
3367            current.get("properties")?.get(&token)?
3368        };
3369    }
3370    Some(current)
3371}
3372
3373fn schema_compatible(source: &Value, target: &Value) -> bool {
3374    if source == target {
3375        return true;
3376    }
3377    let source_type = source.get("type").and_then(Value::as_str);
3378    let target_type = target.get("type").and_then(Value::as_str);
3379    source_type.is_some() && source_type == target_type && target_type != Some("object")
3380}
3381
3382fn effective_limits(requested: &WorkflowBudgets, ceilings: &WorkflowBudgets) -> WorkflowBudgets {
3383    WorkflowBudgets {
3384        max_concurrency: requested.max_concurrency.min(ceilings.max_concurrency),
3385        max_agents: requested.max_agents.min(ceilings.max_agents),
3386        max_steps: requested.max_steps.min(ceilings.max_steps),
3387        max_retries: requested.max_retries.min(ceilings.max_retries),
3388        max_nesting_depth: requested.max_nesting_depth.min(ceilings.max_nesting_depth),
3389        wall_time_ms: requested.wall_time_ms.min(ceilings.wall_time_ms),
3390        max_tokens: match (requested.max_tokens, ceilings.max_tokens) {
3391            (Some(requested), Some(ceiling)) => Some(requested.min(ceiling)),
3392            (Some(requested), None) => Some(requested),
3393            (None, ceiling) => ceiling,
3394        },
3395        max_cost_micros: match (requested.max_cost_micros, ceilings.max_cost_micros) {
3396            (Some(requested), Some(ceiling)) => Some(requested.min(ceiling)),
3397            (Some(requested), None) => Some(requested),
3398            (None, ceiling) => ceiling,
3399        },
3400    }
3401}