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