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.dispatch(&step, input, &instance_id).await;
1969        let result = match result {
1970            Ok(output) => {
1971                if let Some(schema) = &step.output_schema {
1972                    validate_schema(schema, &output)
1973                        .map(|()| output)
1974                        .map_err(|message| {
1975                            failure(WorkflowFailureCode::InvalidOutput, message, false)
1976                        })
1977                } else {
1978                    Ok(output)
1979                }
1980            }
1981            Err(error) => Err(error),
1982        };
1983        let result = result.and_then(|output| {
1984            reject_secret_material(&output)
1985                .map(|()| output)
1986                .map_err(|message| failure(WorkflowFailureCode::InvalidOutput, message, false))
1987        });
1988        match result {
1989            Ok(output) => {
1990                let copy = output.clone();
1991                let completed_id = instance_id.clone();
1992                self.step_transition(
1993                    &instance_id,
1994                    WorkflowRunEventKind::StepCompleted {
1995                        output: output.clone(),
1996                    },
1997                    move |snapshot| {
1998                        if let Some(state) = snapshot.steps.get_mut(&completed_id) {
1999                            state.status = WorkflowStepStatus::Succeeded;
2000                            state.output = Some(copy);
2001                        }
2002                    },
2003                )
2004                .await?;
2005                Ok(output)
2006            }
2007            Err(error) => {
2008                if error.code == WorkflowFailureCode::Cancelled {
2009                    let cancelled_id = instance_id.clone();
2010                    self.step_transition(
2011                        &instance_id,
2012                        WorkflowRunEventKind::StepCancelled,
2013                        move |snapshot| {
2014                            if let Some(state) = snapshot.steps.get_mut(&cancelled_id) {
2015                                state.status = WorkflowStepStatus::Cancelled;
2016                                state.failure = Some(failure(
2017                                    WorkflowFailureCode::Cancelled,
2018                                    "workflow branch cancelled",
2019                                    false,
2020                                ));
2021                            }
2022                        },
2023                    )
2024                    .await?;
2025                    return Err(error);
2026                }
2027                if error.code == WorkflowFailureCode::Suspended {
2028                    let reason = error.message.clone();
2029                    let suspended_id = instance_id.clone();
2030                    self.step_transition(
2031                        &instance_id,
2032                        WorkflowRunEventKind::StepSuspended {
2033                            reason: reason.clone(),
2034                        },
2035                        move |snapshot| {
2036                            if let Some(state) = snapshot.steps.get_mut(&suspended_id) {
2037                                state.status = WorkflowStepStatus::Suspended;
2038                                state.failure =
2039                                    Some(failure(WorkflowFailureCode::Suspended, reason, true));
2040                            }
2041                        },
2042                    )
2043                    .await?;
2044                    return Err(error);
2045                }
2046                let copy = error.clone();
2047                let failed_id = instance_id.clone();
2048                self.step_transition(
2049                    &instance_id,
2050                    WorkflowRunEventKind::StepFailed {
2051                        failure: error.clone(),
2052                    },
2053                    move |snapshot| {
2054                        if let Some(state) = snapshot.steps.get_mut(&failed_id) {
2055                            state.status = WorkflowStepStatus::Failed;
2056                            state.failure = Some(copy);
2057                        }
2058                    },
2059                )
2060                .await?;
2061                match step.failure {
2062                    FailurePolicy::ContinueWithError => Ok(serde_json::json!({"error": error})),
2063                    FailurePolicy::SkipDependents => Err(failure(
2064                        WorkflowFailureCode::DependencySkipped,
2065                        format!("{} (dependents skipped)", error.message),
2066                        false,
2067                    )),
2068                    FailurePolicy::FailFast => Err(error),
2069                }
2070            }
2071        }
2072    }
2073
2074    async fn dispatch(
2075        &self,
2076        step: &WorkflowStepDefinition,
2077        input: Value,
2078        instance_id: &str,
2079    ) -> Result<Value, WorkflowFailure> {
2080        let session_id = { self.snapshot.lock().await.session_id.clone() };
2081        match &step.kind {
2082            WorkflowStepKind::Tool {
2083                tool, capabilities, ..
2084            } => {
2085                self.authorize(
2086                    &session_id,
2087                    WorkflowPolicyTarget::Tool(tool.clone()),
2088                    capabilities,
2089                )
2090                .await?;
2091                let (resolved_input, resolved_secrets) =
2092                    self.resolve_secret_handles(&input, &session_id).await?;
2093                let arguments = serde_json::to_string(&resolved_input).map_err(|error| {
2094                    failure(WorkflowFailureCode::InvalidInput, error.to_string(), false)
2095                })?;
2096                let call = ToolCall {
2097                    id: format!("workflow-{}", Uuid::new_v4()),
2098                    tool_type: "function".to_string(),
2099                    function: FunctionCall {
2100                        name: tool.clone(),
2101                        arguments,
2102                    },
2103                };
2104                let permission_port = self
2105                    .engine
2106                    .session_permissions
2107                    .read()
2108                    .unwrap_or_else(std::sync::PoisonError::into_inner)
2109                    .clone();
2110                let session_flags = match permission_port {
2111                    Some(port) => port.flags_for_session(&session_id).await.map_err(|error| {
2112                        failure(
2113                            WorkflowFailureCode::ExecutionFailed,
2114                            format!("workflow session permission posture is unavailable: {error}"),
2115                            true,
2116                        )
2117                    })?,
2118                    None => ToolExecutionSessionFlags::default(),
2119                };
2120                let context = ToolExecutionContext {
2121                    executing_supervisor: None,
2122                    session_id: Some(&session_id),
2123                    // WorkflowRunSnapshot does not persist root-session identity.
2124                    // Fail closed for tool events instead of treating a child
2125                    // session id as authoritative root metadata.
2126                    root_session_id: None,
2127                    tool_call_id: &call.id,
2128                    event_tx: None,
2129                    available_tool_schemas: None,
2130                    bypass_permissions: session_flags.bypass_permissions,
2131                    auto_approve_permissions: session_flags.auto_approve_permissions,
2132                    plan_read_only: session_flags.plan_read_only,
2133                    can_async_resume: false,
2134                    bash_completion_sink: None,
2135                    pre_parsed_args: Some(&resolved_input),
2136                };
2137                if session_flags.plan_read_only
2138                    && !bamboo_tools::orchestrator::plan_mode_allows_tool(&call.function.name)
2139                {
2140                    return Err(failure(
2141                        WorkflowFailureCode::ExecutionFailed,
2142                        format!("Plan mode: {} operation blocked", call.function.name),
2143                        false,
2144                    ));
2145                }
2146                let outcome = self
2147                    .engine
2148                    .tools
2149                    .execute_with_context_outcome(&call, context)
2150                    .await
2151                    .map_err(|error| {
2152                        let (code, message, retryable) = match error {
2153                            bamboo_agent_core::tools::ToolError::NotFound(_) => (
2154                                WorkflowFailureCode::UnknownReference,
2155                                "workflow tool is not available",
2156                                false,
2157                            ),
2158                            bamboo_agent_core::tools::ToolError::InvalidArguments(_) => (
2159                                WorkflowFailureCode::InvalidInput,
2160                                "workflow tool arguments were rejected",
2161                                false,
2162                            ),
2163                            bamboo_agent_core::tools::ToolError::Execution(_) => (
2164                                WorkflowFailureCode::ExecutionFailed,
2165                                "workflow tool execution was denied or failed",
2166                                true,
2167                            ),
2168                        };
2169                        failure(code, message, retryable)
2170                    })?;
2171                match outcome {
2172                    ToolOutcome::Completed(result) => {
2173                        let output = parse_tool_result(result)?;
2174                        if contains_any_secret_material(&output, &resolved_secrets) {
2175                            return Err(failure(
2176                                WorkflowFailureCode::InvalidOutput,
2177                                "workflow tool output contained resolved secret material",
2178                                false,
2179                            ));
2180                        }
2181                        Ok(output)
2182                    }
2183                    ToolOutcome::NeedsHuman { question, .. } => {
2184                        self.persist_suspension(WorkflowSuspensionContext::ToolApproval {
2185                            step_id: instance_id.to_string(),
2186                            tool: tool.clone(),
2187                            tool_call_id: question.tool_call_id,
2188                        })
2189                        .await?;
2190                        Err(failure(
2191                            WorkflowFailureCode::Suspended,
2192                            "workflow tool requires human approval",
2193                            true,
2194                        ))
2195                    }
2196                    ToolOutcome::Running(handle) => {
2197                        let tool_call_id = handle.tool_call_id.clone();
2198                        (handle.kill)();
2199                        self.persist_suspension(WorkflowSuspensionContext::ToolRunning {
2200                            step_id: instance_id.to_string(),
2201                            tool: tool.clone(),
2202                            tool_call_id,
2203                            killed: true,
2204                        })
2205                        .await?;
2206                        Err(failure(
2207                            WorkflowFailureCode::Suspended,
2208                            "workflow tool is running without a durable workflow resume handle",
2209                            true,
2210                        ))
2211                    }
2212                }
2213            }
2214            WorkflowStepKind::Agent {
2215                agent,
2216                model,
2217                effort,
2218                capabilities,
2219                structured_output_attempts,
2220                ..
2221            } => {
2222                if contains_secret_handle(&input) {
2223                    return Err(failure(
2224                        WorkflowFailureCode::PermissionDenied,
2225                        "secret capability handles are supported only for tool arguments",
2226                        false,
2227                    ));
2228                }
2229                self.authorize(
2230                    &session_id,
2231                    WorkflowPolicyTarget::Agent(agent.clone()),
2232                    capabilities,
2233                )
2234                .await?;
2235                let spec = self.pinned_agents.get(agent).cloned().ok_or_else(|| {
2236                    failure(
2237                        WorkflowFailureCode::PermissionDenied,
2238                        "named agent was not pinned during preflight",
2239                        false,
2240                    )
2241                })?;
2242                let requested = capabilities.iter().cloned().collect::<BTreeSet<_>>();
2243                if !requested.is_subset(&spec.allowed_capabilities) {
2244                    return Err(failure(
2245                        WorkflowFailureCode::PermissionDenied,
2246                        "named agent capability intersection changed",
2247                        false,
2248                    ));
2249                }
2250                let mut last_error = None;
2251                for _ in 0..*structured_output_attempts {
2252                    self.ensure_agent_usage_budget_available().await?;
2253                    self.reserve_agent().await?;
2254                    if !spec.cost_supported {
2255                        self.ledger.lock().await.cost_micros = None;
2256                    }
2257                    self.checkpoint_usage("agent_reserved").await?;
2258                    match self
2259                        .engine
2260                        .agents
2261                        .execute(
2262                            &spec,
2263                            input.clone(),
2264                            model.as_deref(),
2265                            effort.as_deref(),
2266                            &requested,
2267                            &session_id,
2268                            &self.root_run_id,
2269                            self.branch_cancellation.child_token(),
2270                        )
2271                        .await
2272                    {
2273                        Ok(result) => {
2274                            let _drain_guard = self.drain_lock.lock().await;
2275                            let mut usage = self.ledger.lock().await;
2276                            if result
2277                                .attempt_id
2278                                .as_ref()
2279                                .is_some_and(|id| !self.engine.agents.acknowledge_result(id))
2280                            {
2281                                self.check_cancelled()?;
2282                                return Err(failure(
2283                                    WorkflowFailureCode::Cancelled,
2284                                    "workflow Agent result already drained",
2285                                    false,
2286                                ));
2287                            }
2288                            // No await between ownership transfer and accounting.
2289                            let exceeded =
2290                                self.record_usage(&mut usage, result.tokens, result.cost_micros);
2291                            drop(usage);
2292                            self.checkpoint_usage("agent_usage_recorded").await?;
2293                            self.check_cancelled()?;
2294                            if let Some(error) = result.failure {
2295                                return Err(error);
2296                            }
2297                            if let Some(error) = exceeded {
2298                                return Err(error);
2299                            }
2300                            if let Some(schema) = &step.output_schema {
2301                                if let Err(error) = validate_schema(schema, &result.output) {
2302                                    last_error = Some(error);
2303                                    continue;
2304                                }
2305                            }
2306                            return Ok(result.output);
2307                        }
2308                        Err(_error) => {
2309                            self.check_cancelled()?;
2310                            return Err(failure(
2311                                WorkflowFailureCode::ExecutionFailed,
2312                                "named agent execution or usage observation unavailable",
2313                                false,
2314                            ));
2315                        }
2316                    }
2317                }
2318                Err(failure(
2319                    WorkflowFailureCode::InvalidOutput,
2320                    last_error.unwrap_or_else(|| "agent structured output exhausted".to_string()),
2321                    false,
2322                ))
2323            }
2324            WorkflowStepKind::Workflow {
2325                workflow_id,
2326                revision,
2327                ..
2328            } => {
2329                if self.depth + 1 >= self.root_limits.max_nesting_depth {
2330                    return Err(failure(
2331                        WorkflowFailureCode::BudgetExceeded,
2332                        "nested workflow depth exceeded",
2333                        false,
2334                    ));
2335                }
2336                let definition = self
2337                    .bundle
2338                    .get(workflow_id, *revision)
2339                    .cloned()
2340                    .ok_or_else(|| {
2341                        failure(
2342                            WorkflowFailureCode::UnknownReference,
2343                            format!("persisted bundle missing workflow {workflow_id}@{revision}"),
2344                            false,
2345                        )
2346                    })?;
2347                let nested = StartWorkflowRun {
2348                    definition,
2349                    args: input,
2350                    session_id,
2351                    workspace_trusted: self.workspace_trusted,
2352                    allowed_capabilities: self.allowed_capabilities.iter().cloned().collect(),
2353                };
2354                let parent_run_id = self.snapshot.lock().await.run_id.clone();
2355                let result = Box::pin(self.engine.run_internal(
2356                    nested,
2357                    self.bundle.clone(),
2358                    self.pinned_agents.clone(),
2359                    Some(parent_run_id),
2360                    Some(instance_id.to_string()),
2361                    self.depth + 1,
2362                    self.branch_cancellation.clone(),
2363                    self.ledger.clone(),
2364                    self.root_limits.clone(),
2365                    self.semaphore.clone(),
2366                    None,
2367                    Some(self.root_run_id.clone()),
2368                ))
2369                .await
2370                .map_err(|_error| {
2371                    failure(
2372                        WorkflowFailureCode::ExecutionFailed,
2373                        "nested workflow execution failed",
2374                        false,
2375                    )
2376                })?;
2377                result.output.ok_or_else(|| {
2378                    result.failure.unwrap_or_else(|| {
2379                        failure(
2380                            WorkflowFailureCode::ExecutionFailed,
2381                            "nested workflow returned no output",
2382                            false,
2383                        )
2384                    })
2385                })
2386            }
2387        }
2388    }
2389
2390    async fn authorize(
2391        &self,
2392        session_id: &str,
2393        target: WorkflowPolicyTarget,
2394        capabilities: &[String],
2395    ) -> Result<(), WorkflowFailure> {
2396        let requested = capabilities.iter().cloned().collect::<BTreeSet<_>>();
2397        if !requested.is_subset(&self.allowed_capabilities) {
2398            return Err(failure(
2399                WorkflowFailureCode::PermissionDenied,
2400                "step capability exceeds root policy",
2401                false,
2402            ));
2403        }
2404        match self
2405            .engine
2406            .policy
2407            .authorize(session_id, &target, &requested, self.workspace_trusted)
2408            .await
2409        {
2410            PermissionDecision::Allow => Ok(()),
2411            PermissionDecision::Deny(_reason) => Err(failure(
2412                if self.workspace_trusted {
2413                    WorkflowFailureCode::PermissionDenied
2414                } else {
2415                    WorkflowFailureCode::UntrustedWorkspace
2416                },
2417                "workflow policy denied this step",
2418                false,
2419            )),
2420        }
2421    }
2422
2423    async fn step_transition(
2424        &self,
2425        step_id: &str,
2426        kind: WorkflowRunEventKind,
2427        mutate: impl FnOnce(&mut WorkflowRunSnapshot),
2428    ) -> Result<(), WorkflowFailure> {
2429        let usage = self.ledger.lock().await.clone();
2430        let mut snapshot = self.snapshot.lock().await;
2431        snapshot.usage = usage;
2432        self.engine
2433            .transition(&mut snapshot, Some(step_id.to_string()), kind, mutate)
2434            .await
2435            .map_err(|error| failure(WorkflowFailureCode::Storage, error.to_string(), false))
2436    }
2437
2438    async fn reserve_step(&self) -> Result<(), WorkflowFailure> {
2439        let mut usage = self.ledger.lock().await;
2440        if usage.steps >= self.root_limits.max_steps {
2441            return Err(failure(
2442                WorkflowFailureCode::BudgetExceeded,
2443                "workflow step budget exceeded",
2444                false,
2445            ));
2446        }
2447        usage.steps += 1;
2448        Ok(())
2449    }
2450
2451    async fn reserve_retry(&self) -> Result<(), WorkflowFailure> {
2452        let mut usage = self.ledger.lock().await;
2453        if usage.retries >= self.root_limits.max_retries {
2454            return Err(failure(
2455                WorkflowFailureCode::BudgetExceeded,
2456                "workflow retry budget exceeded",
2457                false,
2458            ));
2459        }
2460        usage.retries += 1;
2461        Ok(())
2462    }
2463
2464    async fn reserve_agent(&self) -> Result<(), WorkflowFailure> {
2465        let mut usage = self.ledger.lock().await;
2466        if usage.agents >= self.root_limits.max_agents {
2467            return Err(failure(
2468                WorkflowFailureCode::BudgetExceeded,
2469                "workflow agent budget exceeded",
2470                false,
2471            ));
2472        }
2473        usage.agents += 1;
2474        Ok(())
2475    }
2476
2477    fn record_usage(
2478        &self,
2479        usage: &mut WorkflowBudgetUsage,
2480        tokens: u64,
2481        cost_micros: Option<u64>,
2482    ) -> Option<WorkflowFailure> {
2483        let next_tokens = usage.tokens.saturating_add(tokens);
2484        let next_cost = usage
2485            .cost_micros
2486            .zip(cost_micros)
2487            .map(|(a, b)| a.saturating_add(b));
2488        usage.tokens = next_tokens;
2489        usage.cost_micros = next_cost;
2490        if self
2491            .root_limits
2492            .max_tokens
2493            .is_some_and(|limit| next_tokens > limit)
2494            || self
2495                .root_limits
2496                .max_cost_micros
2497                .is_some_and(|limit| next_cost.is_none_or(|cost| cost > limit))
2498        {
2499            return Some(failure(
2500                WorkflowFailureCode::BudgetExceeded,
2501                "workflow token/cost budget exceeded",
2502                false,
2503            ));
2504        }
2505        None
2506    }
2507
2508    async fn drain_agents(&self) -> (bool, Option<String>) {
2509        // Acquire before the port transfers results; after it returns there is
2510        // no await until all rows are accounted. Provider execution stays parallel.
2511        let mut ledger = self.ledger.lock().await;
2512        let (results, error) = self.engine.agents.drain_cancelled(&self.root_run_id).await;
2513        let mut unavailable = false;
2514        for result in results {
2515            match result {
2516                Ok(result) => {
2517                    self.record_usage(&mut ledger, result.tokens, result.cost_micros);
2518                }
2519                Err(_) => {
2520                    unavailable = true;
2521                    ledger.cost_micros = None;
2522                }
2523            }
2524        }
2525        (unavailable, error)
2526    }
2527
2528    async fn ensure_agent_usage_budget_available(&self) -> Result<(), WorkflowFailure> {
2529        let usage = self.ledger.lock().await;
2530        if self
2531            .root_limits
2532            .max_tokens
2533            .is_some_and(|limit| usage.tokens >= limit)
2534            || self
2535                .root_limits
2536                .max_cost_micros
2537                .is_some_and(|limit| usage.cost_micros.is_none_or(|cost| cost >= limit))
2538        {
2539            return Err(failure(
2540                WorkflowFailureCode::BudgetExceeded,
2541                "workflow token/cost budget exhausted before agent dispatch",
2542                false,
2543            ));
2544        }
2545        Ok(())
2546    }
2547
2548    async fn checkpoint_agent_usage(&self, name: &str) -> Result<(), WorkflowFailure> {
2549        let usage = self.ledger.lock().await.clone();
2550        let mut snapshot = self.snapshot.lock().await;
2551        self.engine
2552            .transition(
2553                &mut snapshot,
2554                None,
2555                WorkflowRunEventKind::Phase {
2556                    name: name.to_string(),
2557                },
2558                move |snapshot| {
2559                    // Only completed Agent usage is reconciled here. Other counters
2560                    // retain their existing durable reservation/checkpoint semantics.
2561                    snapshot.usage.tokens = usage.tokens;
2562                    snapshot.usage.cost_micros = usage.cost_micros;
2563                },
2564            )
2565            .await
2566            .map_err(|e| failure(WorkflowFailureCode::Storage, e.to_string(), false))
2567    }
2568
2569    async fn checkpoint_usage(&self, name: &str) -> Result<(), WorkflowFailure> {
2570        let usage = self.ledger.lock().await.clone();
2571        let mut snapshot = self.snapshot.lock().await;
2572        self.engine
2573            .transition(
2574                &mut snapshot,
2575                None,
2576                WorkflowRunEventKind::Phase {
2577                    name: name.to_string(),
2578                },
2579                move |snapshot| snapshot.usage = usage,
2580            )
2581            .await
2582            .map_err(|error| failure(WorkflowFailureCode::Storage, error.to_string(), false))
2583    }
2584
2585    async fn persist_suspension(
2586        &self,
2587        context: WorkflowSuspensionContext,
2588    ) -> Result<(), WorkflowFailure> {
2589        let mut snapshot = self.snapshot.lock().await;
2590        self.engine
2591            .transition(
2592                &mut snapshot,
2593                None,
2594                WorkflowRunEventKind::Phase {
2595                    name: "suspension_context_persisted".to_string(),
2596                },
2597                move |snapshot| snapshot.suspension = Some(context),
2598            )
2599            .await
2600            .map_err(|error| failure(WorkflowFailureCode::Storage, error.to_string(), false))
2601    }
2602
2603    async fn cancel_active_parallel_steps(
2604        &self,
2605        nodes: &[WorkflowPlan],
2606    ) -> Result<(), WorkflowFailure> {
2607        let sibling_steps = nodes
2608            .iter()
2609            .flat_map(plan_step_ids)
2610            .collect::<BTreeSet<_>>();
2611        let active = {
2612            let snapshot = self.snapshot.lock().await;
2613            snapshot
2614                .steps
2615                .iter()
2616                .filter(|(_, step)| {
2617                    matches!(
2618                        step.status,
2619                        WorkflowStepStatus::Queued | WorkflowStepStatus::Running
2620                    ) && sibling_steps
2621                        .iter()
2622                        .any(|step_id| self.step_is_in_scope(step, step_id))
2623                })
2624                .map(|(id, _)| id.clone())
2625                .collect::<Vec<_>>()
2626        };
2627        for step_id in active {
2628            let state_id = step_id.clone();
2629            self.step_transition(
2630                &step_id,
2631                WorkflowRunEventKind::StepCancelled,
2632                move |snapshot| {
2633                    if let Some(step) = snapshot.steps.get_mut(&state_id) {
2634                        step.status = WorkflowStepStatus::Cancelled;
2635                        step.failure = Some(failure(
2636                            WorkflowFailureCode::Cancelled,
2637                            "parallel sibling cancelled by fail_fast",
2638                            false,
2639                        ));
2640                    }
2641                },
2642            )
2643            .await?;
2644        }
2645        Ok(())
2646    }
2647
2648    async fn resolve_secret_handles(
2649        &self,
2650        value: &Value,
2651        session_id: &str,
2652    ) -> Result<(Value, Vec<String>), WorkflowFailure> {
2653        fn walk<'a>(
2654            context: &'a RunContext,
2655            value: &'a Value,
2656            session_id: &'a str,
2657        ) -> SecretResolutionFuture<'a> {
2658            Box::pin(async move {
2659                match value {
2660                    Value::Object(object) if object.contains_key("$secret") => {
2661                        let handle: bamboo_domain::WorkflowSecretHandle =
2662                            serde_json::from_value(value.clone()).map_err(|_| {
2663                                failure(
2664                                    WorkflowFailureCode::InvalidInput,
2665                                    "malformed secret capability handle",
2666                                    false,
2667                                )
2668                            })?;
2669                        let material = context
2670                            .engine
2671                            .secrets
2672                            .resolve(session_id, &handle.capability)
2673                            .await
2674                            .map_err(|_| {
2675                                failure(
2676                                    WorkflowFailureCode::PermissionDenied,
2677                                    "secret capability resolution denied",
2678                                    false,
2679                                )
2680                            })?;
2681                        let material = material.into_exposed();
2682                        Ok((Value::String(material.clone()), vec![material]))
2683                    }
2684                    Value::Object(object) => {
2685                        let mut resolved = serde_json::Map::new();
2686                        let mut secrets = Vec::new();
2687                        for (key, child) in object {
2688                            let (child, mut child_secrets) =
2689                                walk(context, child, session_id).await?;
2690                            resolved.insert(key.clone(), child);
2691                            secrets.append(&mut child_secrets);
2692                        }
2693                        Ok((Value::Object(resolved), secrets))
2694                    }
2695                    Value::Array(array) => {
2696                        let mut resolved = Vec::with_capacity(array.len());
2697                        let mut secrets = Vec::new();
2698                        for child in array {
2699                            let (child, mut child_secrets) =
2700                                walk(context, child, session_id).await?;
2701                            resolved.push(child);
2702                            secrets.append(&mut child_secrets);
2703                        }
2704                        Ok((Value::Array(resolved), secrets))
2705                    }
2706                    value => Ok((value.clone(), Vec::new())),
2707                }
2708            })
2709        }
2710        walk(self, value, session_id).await
2711    }
2712
2713    fn skip_plan<'a>(&'a self, plan: &'a WorkflowPlan, reason: &'a str) -> NodeFuture<'a> {
2714        Box::pin(async move {
2715            match plan {
2716                WorkflowPlan::Step { step } => {
2717                    let instance_id = self.instance_id(step);
2718                    // A previous Retry attempt may have materialized Map items
2719                    // in this branch. Close only instances in the current scope.
2720                    let mut instances = {
2721                        let snapshot = self.snapshot.lock().await;
2722                        snapshot
2723                            .steps
2724                            .iter()
2725                            .filter(|(_, state)| self.step_is_in_scope(state, step))
2726                            .map(|(id, _)| id.clone())
2727                            .collect::<Vec<_>>()
2728                    };
2729                    if instances.is_empty() {
2730                        instances.push(instance_id);
2731                    }
2732                    for instance_id in instances {
2733                        let reason_owned = reason.to_string();
2734                        let state_id = instance_id.clone();
2735                        self.step_transition(
2736                            &instance_id,
2737                            WorkflowRunEventKind::StepSkipped {
2738                                reason: reason.to_string(),
2739                            },
2740                            move |snapshot| {
2741                                let state = snapshot.steps.entry(state_id.clone()).or_insert(
2742                                    WorkflowStepSnapshot {
2743                                        id: state_id,
2744                                        topology: self
2745                                            .compiled
2746                                            .definition
2747                                            .plan
2748                                            .step_topology(step, self.map_index_path.clone()),
2749                                        status: WorkflowStepStatus::Skipped,
2750                                        input_hash: String::new(),
2751                                        output: None,
2752                                        failure: None,
2753                                        attempts: 0,
2754                                    },
2755                                );
2756                                state.status = WorkflowStepStatus::Skipped;
2757                                state.output = None;
2758                                state.failure = Some(failure(
2759                                    WorkflowFailureCode::DependencySkipped,
2760                                    reason_owned,
2761                                    false,
2762                                ));
2763                            },
2764                        )
2765                        .await?;
2766                    }
2767                }
2768                WorkflowPlan::Sequence { nodes } | WorkflowPlan::Parallel { nodes } => {
2769                    for node in nodes {
2770                        self.skip_plan(node, reason).await?;
2771                    }
2772                }
2773                WorkflowPlan::Choice {
2774                    then_branch,
2775                    else_branch,
2776                    ..
2777                } => {
2778                    self.skip_plan(then_branch, reason).await?;
2779                    self.skip_plan(else_branch, reason).await?;
2780                }
2781                WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
2782                    self.skip_plan(body, reason).await?;
2783                }
2784            }
2785            Ok(Value::Null)
2786        })
2787    }
2788
2789    async fn resolve_template(&self, value: &Value) -> Result<Value, WorkflowFailure> {
2790        Box::pin(self.resolve_template_inner(value)).await
2791    }
2792
2793    fn resolve_template_inner<'a>(
2794        &'a self,
2795        value: &'a Value,
2796    ) -> Pin<Box<dyn Future<Output = Result<Value, WorkflowFailure>> + Send + 'a>> {
2797        Box::pin(async move {
2798            match value {
2799                Value::Object(object) if object.get("from").is_some() => {
2800                    let reference: ValueRef =
2801                        serde_json::from_value(value.clone()).map_err(|error| {
2802                            failure(
2803                                WorkflowFailureCode::InvalidInput,
2804                                format!("malformed value reference: {error}"),
2805                                false,
2806                            )
2807                        })?;
2808                    self.resolve_ref(&reference).await
2809                }
2810                Value::Object(object) => {
2811                    let mut resolved = serde_json::Map::new();
2812                    for (key, child) in object {
2813                        resolved.insert(key.clone(), self.resolve_template_inner(child).await?);
2814                    }
2815                    Ok(Value::Object(resolved))
2816                }
2817                Value::Array(array) => {
2818                    let mut resolved = Vec::with_capacity(array.len());
2819                    for child in array {
2820                        resolved.push(self.resolve_template_inner(child).await?);
2821                    }
2822                    Ok(Value::Array(resolved))
2823                }
2824                value => Ok(value.clone()),
2825            }
2826        })
2827    }
2828
2829    async fn resolve_ref(&self, reference: &ValueRef) -> Result<Value, WorkflowFailure> {
2830        let (root, pointer) = match reference {
2831            ValueRef::Args { pointer } => (
2832                self.snapshot.lock().await.validated_args.clone(),
2833                pointer.as_str(),
2834            ),
2835            ValueRef::Step { step, pointer } => {
2836                let snapshot = self.snapshot.lock().await;
2837                let exact = self.instance_id(step);
2838                let output = snapshot
2839                    .steps
2840                    .get(&exact)
2841                    .or_else(|| snapshot.steps.get(step))
2842                    .and_then(|state| state.output.clone())
2843                    .ok_or_else(|| {
2844                        failure(
2845                            WorkflowFailureCode::UnknownReference,
2846                            format!("step output '{step}' unavailable in execution scope"),
2847                            false,
2848                        )
2849                    })?;
2850                (output, pointer.as_str())
2851            }
2852            ValueRef::Item { name, pointer } => (
2853                self.items.get(name).cloned().ok_or_else(|| {
2854                    failure(
2855                        WorkflowFailureCode::UnknownReference,
2856                        format!("map item '{name}' unavailable"),
2857                        false,
2858                    )
2859                })?,
2860                pointer.as_str(),
2861            ),
2862            ValueRef::Literal { value } => return Ok(value.clone()),
2863        };
2864        if pointer.is_empty() {
2865            Ok(root)
2866        } else {
2867            root.pointer(pointer).cloned().ok_or_else(|| {
2868                failure(
2869                    WorkflowFailureCode::UnknownReference,
2870                    format!("JSON pointer '{pointer}' not found"),
2871                    false,
2872                )
2873            })
2874        }
2875    }
2876
2877    // Keep existing opaque IDs unless a declared step would occupy the same
2878    // key. Scoped IDs end in a numeric scope plus optional '@' suffixes, so
2879    // distinct scoped instances cannot alias each other or a declared step.
2880    fn instance_id(&self, step_id: &str) -> String {
2881        if self.scope == "root" {
2882            return step_id.to_owned();
2883        }
2884        let mut id = format!("{step_id}@{}", self.scope);
2885        while self.compiled.steps.contains_key(&id) {
2886            id.push('@');
2887        }
2888        id
2889    }
2890
2891    fn step_is_in_scope(&self, state: &WorkflowStepSnapshot, step_id: &str) -> bool {
2892        state.topology.as_ref().map_or_else(
2893            || instance_is_in_scope(&state.id, step_id, &self.scope),
2894            |topology| {
2895                topology.planned_step_id == step_id
2896                    && topology.map_index_path.starts_with(&self.map_index_path)
2897            },
2898        )
2899    }
2900
2901    fn check_cancelled(&self) -> Result<(), WorkflowFailure> {
2902        if self.cancellation.is_cancelled() || self.branch_cancellation.is_cancelled() {
2903            Err(failure(
2904                WorkflowFailureCode::Cancelled,
2905                "workflow cancelled",
2906                false,
2907            ))
2908        } else {
2909            Ok(())
2910        }
2911    }
2912}
2913
2914fn event(
2915    snapshot: &WorkflowRunSnapshot,
2916    step_id: Option<String>,
2917    kind: WorkflowRunEventKind,
2918) -> WorkflowRunEvent {
2919    WorkflowRunEvent {
2920        run_id: snapshot.run_id.clone(),
2921        sequence: snapshot.last_sequence,
2922        at: Utc::now(),
2923        step_id,
2924        kind,
2925    }
2926}
2927fn failure(
2928    code: WorkflowFailureCode,
2929    message: impl Into<String>,
2930    retryable: bool,
2931) -> WorkflowFailure {
2932    WorkflowFailure {
2933        code,
2934        message: message.into(),
2935        retryable,
2936    }
2937}
2938fn storage(error: std::io::Error) -> WorkflowRunError {
2939    WorkflowRunError::Storage(error.to_string())
2940}
2941fn exceeds_optional(requested: Option<u64>, ceiling: Option<u64>) -> bool {
2942    match (requested, ceiling) {
2943        (Some(requested), Some(ceiling)) => requested > ceiling,
2944        _ => false,
2945    }
2946}
2947
2948fn enforce_budget_within(
2949    requested: &WorkflowBudgets,
2950    ceiling: &WorkflowBudgets,
2951) -> Result<(), &'static str> {
2952    if requested.max_concurrency > ceiling.max_concurrency {
2953        Err("max_concurrency")
2954    } else if requested.max_agents > ceiling.max_agents {
2955        Err("max_agents")
2956    } else if requested.max_steps > ceiling.max_steps {
2957        Err("max_steps")
2958    } else if requested.max_retries > ceiling.max_retries {
2959        Err("max_retries")
2960    } else if requested.max_nesting_depth > ceiling.max_nesting_depth {
2961        Err("max_nesting_depth")
2962    } else if requested.wall_time_ms > ceiling.wall_time_ms {
2963        Err("wall_time_ms")
2964    } else if exceeds_optional(requested.max_tokens, ceiling.max_tokens) {
2965        Err("max_tokens")
2966    } else if exceeds_optional(requested.max_cost_micros, ceiling.max_cost_micros) {
2967        Err("max_cost_micros")
2968    } else {
2969        Ok(())
2970    }
2971}
2972
2973fn remaining_wall_time(snapshot: &WorkflowRunSnapshot) -> u64 {
2974    let elapsed = Utc::now()
2975        .signed_duration_since(snapshot.created_at)
2976        .num_milliseconds()
2977        .max(0) as u64;
2978    snapshot
2979        .definition
2980        .budgets
2981        .wall_time_ms
2982        .saturating_sub(elapsed)
2983}
2984
2985fn resolve_checkpoint_template(
2986    value: &Value,
2987    args: &Value,
2988    outputs: &BTreeMap<String, Value>,
2989) -> Result<Value, WorkflowRunError> {
2990    match value {
2991        Value::Object(object) if object.contains_key("from") => {
2992            let reference: ValueRef = serde_json::from_value(value.clone())
2993                .map_err(|_| WorkflowRunError::Preflight("invalid checkpoint reference".into()))?;
2994            let (root, pointer) = match &reference {
2995                ValueRef::Args { pointer } => (args, pointer),
2996                ValueRef::Step { step, pointer } => (
2997                    outputs.get(step).ok_or_else(|| {
2998                        WorkflowRunError::Preflight(
2999                            "checkpoint reference is not in the completed prefix".into(),
3000                        )
3001                    })?,
3002                    pointer,
3003                ),
3004                ValueRef::Literal { value } => return Ok(value.clone()),
3005                ValueRef::Item { .. } => {
3006                    return Err(WorkflowRunError::Preflight(
3007                        "map references are unsupported for continuation".into(),
3008                    ))
3009                }
3010            };
3011            if pointer.is_empty() {
3012                Ok(root.clone())
3013            } else {
3014                root.pointer(pointer).cloned().ok_or_else(|| {
3015                    WorkflowRunError::Preflight("checkpoint reference pointer is missing".into())
3016                })
3017            }
3018        }
3019        Value::Object(object) => object
3020            .iter()
3021            .map(|(key, value)| {
3022                Ok((
3023                    key.clone(),
3024                    resolve_checkpoint_template(value, args, outputs)?,
3025                ))
3026            })
3027            .collect::<Result<serde_json::Map<_, _>, _>>()
3028            .map(Value::Object),
3029        Value::Array(values) => values
3030            .iter()
3031            .map(|value| resolve_checkpoint_template(value, args, outputs))
3032            .collect::<Result<Vec<_>, _>>()
3033            .map(Value::Array),
3034        value => Ok(value.clone()),
3035    }
3036}
3037
3038fn definition_bundle_hash(bundle: &WorkflowDefinitionBundle) -> Result<String, WorkflowRunError> {
3039    let bytes = serde_json::to_vec(bundle)
3040        .map_err(|_| WorkflowRunError::Preflight("workflow bundle hashing failed".to_string()))?;
3041    Ok(hex::encode(Sha256::digest(bytes)))
3042}
3043fn parse_tool_result(result: ToolResult) -> Result<Value, WorkflowFailure> {
3044    if !result.success {
3045        return Err(failure(
3046            WorkflowFailureCode::ExecutionFailed,
3047            "workflow tool reported failure",
3048            true,
3049        ));
3050    }
3051    Ok(serde_json::from_str(&result.result).unwrap_or(Value::String(result.result)))
3052}
3053fn reject_secret_material(value: &Value) -> Result<(), String> {
3054    reject_secret_material_inner(value, false)
3055}
3056
3057fn reject_secret_material_in_definition(value: &Value) -> Result<(), String> {
3058    reject_secret_material_inner(value, true)
3059}
3060
3061fn reject_secret_material_inner(value: &Value, allow_bindings: bool) -> Result<(), String> {
3062    fn walk(value: &Value, key: Option<&str>, allow_bindings: bool) -> Result<(), String> {
3063        if value.as_object().is_some_and(|object| {
3064            object.len() == 1
3065                && object
3066                    .get("$secret")
3067                    .and_then(Value::as_str)
3068                    .is_some_and(|handle| !handle.trim().is_empty())
3069        }) {
3070            return Ok(());
3071        }
3072        let safe_binding = allow_bindings
3073            && serde_json::from_value::<ValueRef>(value.clone())
3074                .is_ok_and(|reference| !matches!(reference, ValueRef::Literal { .. }));
3075        if key.is_some_and(|key| {
3076            let normalized = key
3077                .chars()
3078                .filter(|character| character.is_ascii_alphanumeric())
3079                .flat_map(char::to_lowercase)
3080                .collect::<String>();
3081            matches!(
3082                normalized.as_str(),
3083                "secret"
3084                    | "token"
3085                    | "password"
3086                    | "credential"
3087                    | "credentials"
3088                    | "apikey"
3089                    | "accesskey"
3090                    | "accesstoken"
3091                    | "secretkey"
3092                    | "privatekey"
3093            )
3094        }) && !safe_binding
3095        {
3096            return Err("secret-bearing fields are not accepted by workflow runs".to_string());
3097        }
3098        if value.as_str().is_some_and(|value| {
3099            let trimmed = value.trim();
3100            trimmed.starts_with("capability://")
3101                || trimmed.starts_with("Bearer ")
3102                || trimmed.starts_with("sk-")
3103                || trimmed.starts_with("ghp_")
3104                || trimmed.starts_with("github_pat_")
3105        }) {
3106            // No production capability resolver is part of #578. Treating an
3107            // arbitrary caller string as an opaque handle would be an injection
3108            // channel, so handles and common raw credential forms fail closed.
3109            return Err("opaque credential handles are not enabled for workflows".to_string());
3110        }
3111        match value {
3112            Value::Object(object) => {
3113                for (key, value) in object {
3114                    if key == "properties" {
3115                        let properties = value.as_object().ok_or_else(|| {
3116                            "workflow schema properties must be an object".to_string()
3117                        })?;
3118                        for schema in properties.values() {
3119                            walk(schema, None, allow_bindings)?;
3120                        }
3121                    } else {
3122                        walk(value, Some(key), allow_bindings)?;
3123                    }
3124                }
3125            }
3126            Value::Array(array) => {
3127                for value in array {
3128                    walk(value, None, allow_bindings)?;
3129                }
3130            }
3131            _ => {}
3132        }
3133        Ok(())
3134    }
3135    walk(value, None, allow_bindings)
3136}
3137
3138fn contains_secret_handle(value: &Value) -> bool {
3139    match value {
3140        Value::Object(object) => {
3141            object.contains_key("$secret") || object.values().any(contains_secret_handle)
3142        }
3143        Value::Array(array) => array.iter().any(contains_secret_handle),
3144        _ => false,
3145    }
3146}
3147
3148fn contains_any_secret_material(value: &Value, secrets: &[String]) -> bool {
3149    let matches = |candidate: &str| {
3150        secrets
3151            .iter()
3152            .any(|secret| !secret.is_empty() && candidate.contains(secret))
3153    };
3154    fn walk(value: &Value, matches: &impl Fn(&str) -> bool) -> bool {
3155        match value {
3156            Value::String(value) => matches(value),
3157            Value::Object(object) => object
3158                .iter()
3159                .any(|(key, value)| matches(key) || walk(value, matches)),
3160            Value::Array(array) => array.iter().any(|value| walk(value, matches)),
3161            _ => false,
3162        }
3163    }
3164    walk(value, &matches)
3165}
3166
3167fn plan_leaf_count(plan: &WorkflowPlan) -> usize {
3168    match plan {
3169        WorkflowPlan::Step { .. } => 1,
3170        WorkflowPlan::Sequence { nodes } | WorkflowPlan::Parallel { nodes } => {
3171            nodes.iter().fold(0usize, |total, node| {
3172                total.saturating_add(plan_leaf_count(node))
3173            })
3174        }
3175        WorkflowPlan::Choice {
3176            then_branch,
3177            else_branch,
3178            ..
3179        } => plan_leaf_count(then_branch).max(plan_leaf_count(else_branch)),
3180        WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
3181            plan_leaf_count(body)
3182        }
3183    }
3184}
3185
3186fn plan_step_ids(plan: &WorkflowPlan) -> Vec<String> {
3187    match plan {
3188        WorkflowPlan::Step { step } => vec![step.clone()],
3189        WorkflowPlan::Sequence { nodes } | WorkflowPlan::Parallel { nodes } => {
3190            nodes.iter().flat_map(plan_step_ids).collect()
3191        }
3192        WorkflowPlan::Choice {
3193            then_branch,
3194            else_branch,
3195            ..
3196        } => {
3197            let mut result = plan_step_ids(then_branch);
3198            result.extend(plan_step_ids(else_branch));
3199            result
3200        }
3201        WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
3202            plan_step_ids(body)
3203        }
3204    }
3205}
3206
3207fn instance_is_in_scope(instance_id: &str, step_id: &str, scope: &str) -> bool {
3208    if scope == "root" {
3209        instance_id == step_id
3210            || instance_id
3211                .strip_prefix(&format!("{step_id}@root"))
3212                .is_some_and(|suffix| suffix.starts_with('['))
3213    } else {
3214        let exact = format!("{step_id}@{scope}");
3215        instance_id == exact
3216            || instance_id
3217                .strip_prefix(&exact)
3218                .is_some_and(|suffix| suffix.starts_with('['))
3219    }
3220}
3221
3222fn plan_frontier(plan: &WorkflowPlan) -> Vec<String> {
3223    match plan {
3224        WorkflowPlan::Step { step } => vec![step.clone()],
3225        WorkflowPlan::Sequence { nodes } => nodes.first().map_or_else(Vec::new, plan_frontier),
3226        WorkflowPlan::Parallel { nodes } => nodes.iter().flat_map(plan_frontier).collect(),
3227        WorkflowPlan::Choice {
3228            then_branch,
3229            else_branch,
3230            ..
3231        } => {
3232            let mut result = plan_frontier(then_branch);
3233            result.extend(plan_frontier(else_branch));
3234            result
3235        }
3236        WorkflowPlan::Map { body, .. } | WorkflowPlan::Retry { node: body, .. } => {
3237            plan_frontier(body)
3238        }
3239    }
3240}
3241
3242fn validate_nested_input_contract(
3243    template: &Value,
3244    target_schema: &Value,
3245    compiled: &CompiledWorkflow,
3246) -> Result<(), String> {
3247    fn contains_ref(value: &Value) -> bool {
3248        match value {
3249            Value::Object(object) => {
3250                object.contains_key("from") || object.values().any(contains_ref)
3251            }
3252            Value::Array(array) => array.iter().any(contains_ref),
3253            _ => false,
3254        }
3255    }
3256    if !contains_ref(template) {
3257        return validate_schema(target_schema, template)
3258            .map_err(|error| format!("nested workflow input is incompatible: {error}"));
3259    }
3260    let reference: ValueRef = serde_json::from_value(template.clone()).map_err(|_| {
3261        "nested dynamic input schema cannot be proven compatible in phase 1".to_string()
3262    })?;
3263    let source_schema = match reference {
3264        ValueRef::Args { pointer } => {
3265            schema_at_pointer(&compiled.definition.input_schema, &pointer)
3266        }
3267        ValueRef::Step { step, pointer } => compiled
3268            .steps
3269            .get(&step)
3270            .and_then(|step| step.output_schema.as_ref())
3271            .and_then(|schema| schema_at_pointer(schema, &pointer)),
3272        ValueRef::Literal { value } => {
3273            return validate_schema(target_schema, &value)
3274                .map_err(|error| format!("nested workflow input is incompatible: {error}"));
3275        }
3276        ValueRef::Item { .. } => None,
3277    }
3278    .ok_or_else(|| {
3279        "nested dynamic input source schema is missing or pointer is invalid".to_string()
3280    })?;
3281    if schema_compatible(source_schema, target_schema) {
3282        Ok(())
3283    } else {
3284        Err("nested workflow input schema is not compatible with its pinned target".to_string())
3285    }
3286}
3287
3288fn schema_at_pointer<'a>(schema: &'a Value, pointer: &str) -> Option<&'a Value> {
3289    if pointer.is_empty() {
3290        return Some(schema);
3291    }
3292    let mut current = schema;
3293    for token in pointer.strip_prefix('/')?.split('/') {
3294        let token = token.replace("~1", "/").replace("~0", "~");
3295        current = if token.parse::<usize>().is_ok() {
3296            current.get("items")?
3297        } else {
3298            current.get("properties")?.get(&token)?
3299        };
3300    }
3301    Some(current)
3302}
3303
3304fn schema_compatible(source: &Value, target: &Value) -> bool {
3305    if source == target {
3306        return true;
3307    }
3308    let source_type = source.get("type").and_then(Value::as_str);
3309    let target_type = target.get("type").and_then(Value::as_str);
3310    source_type.is_some() && source_type == target_type && target_type != Some("object")
3311}
3312
3313fn effective_limits(requested: &WorkflowBudgets, ceilings: &WorkflowBudgets) -> WorkflowBudgets {
3314    WorkflowBudgets {
3315        max_concurrency: requested.max_concurrency.min(ceilings.max_concurrency),
3316        max_agents: requested.max_agents.min(ceilings.max_agents),
3317        max_steps: requested.max_steps.min(ceilings.max_steps),
3318        max_retries: requested.max_retries.min(ceilings.max_retries),
3319        max_nesting_depth: requested.max_nesting_depth.min(ceilings.max_nesting_depth),
3320        wall_time_ms: requested.wall_time_ms.min(ceilings.wall_time_ms),
3321        max_tokens: match (requested.max_tokens, ceilings.max_tokens) {
3322            (Some(requested), Some(ceiling)) => Some(requested.min(ceiling)),
3323            (Some(requested), None) => Some(requested),
3324            (None, ceiling) => ceiling,
3325        },
3326        max_cost_micros: match (requested.max_cost_micros, ceilings.max_cost_micros) {
3327            (Some(requested), Some(ceiling)) => Some(requested.min(ceiling)),
3328            (Some(requested), None) => Some(requested),
3329            (None, ceiling) => ceiling,
3330        },
3331    }
3332}