Skip to main content

a3s_code_core/
dynamic_workflow.rs

1//! A3S Flow-backed dynamic workflow runtime.
2//!
3//! `DynamicWorkflowRuntime` lets hosts run a sandboxed PTC script as an A3S
4//! Flow runtime. Flow owns durable replay and step lifecycle; A3S Code's
5//! existing `program` tool remains the sandbox and tool-call boundary.
6
7use crate::execution_identity::{
8    ExecutionIdentityV1, DYNAMIC_WORKFLOW_CLAIM_IDENTITY_DOMAIN_V1,
9    DYNAMIC_WORKFLOW_CONTINUATION_IDENTITY_DOMAIN_V1, DYNAMIC_WORKFLOW_INPUT_IDENTITY_DOMAIN_V1,
10    FLOW_STEP_IDENTITY_DOMAIN_V1,
11};
12use crate::llm::{ModelGenerationAdmission, ModelGenerationConcurrency};
13use crate::task_scheduler::{
14    TaskLease, TaskPriority as SchedulerTaskPriority, TaskScheduler, TaskSchedulerQuota,
15};
16use crate::tools::{
17    registry_tool_invoker, Tool, ToolContext, ToolInvoker, ToolOutput, ToolRegistry, ToolResult,
18};
19use crate::{
20    agent::AgentEvent,
21    flow_graph::{
22        FileFlowDecisionLedger, FlowDecisionClaimOutcome, FlowDecisionClaimState,
23        FlowDecisionLedger, FlowGraphObserver, MemoryFlowDecisionLedger,
24    },
25    planning::{Complexity, ExecutionPlan, Task, TaskStatus},
26};
27use a3s_flow::{
28    CancellationRequest, FanoutFlowEventObserver, FlowEngine, FlowEvent, FlowEventEnvelope,
29    FlowEventObserver, FlowEventStore, FlowRuntime, InMemoryEventStore, LocalFileEventStore,
30    RuntimeBuildCompatibility, RuntimeBuildId, RuntimeCommand, StepInvocation, StepStatus,
31    WorkflowInvocation, WorkflowRunSnapshot, WorkflowRunStatus, WorkflowSpec,
32};
33use anyhow::{Context, Result};
34use async_trait::async_trait;
35use chrono::Utc;
36use serde::{Deserialize, Serialize};
37use serde_json::{json, Map, Value};
38use std::collections::{BTreeMap, BTreeSet};
39use std::fs::OpenOptions;
40use std::future::Future;
41use std::num::NonZeroUsize;
42use std::path::{Path, PathBuf};
43use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
44use std::sync::{Arc, Weak};
45use std::time::{Duration, SystemTime, UNIX_EPOCH};
46use tokio::sync::{broadcast, Mutex, OwnedSemaphorePermit, Semaphore};
47use tokio_util::sync::CancellationToken;
48
49const DYNAMIC_WORKFLOW_TOOL: &str = "dynamic_workflow";
50const GENERATE_OBJECT_TOOL: &str = "generate_object";
51const PROGRAM_TOOL: &str = "program";
52const TASK_TOOL: &str = "task";
53const PARALLEL_TASK_TOOL: &str = "parallel_task";
54const MAX_INLINE_RETRY_RESUMES: usize = 8;
55const MAX_INLINE_RETRY_DELAY: Duration = Duration::from_secs(5);
56const DEFAULT_MAX_CONCURRENT_STEPS: usize = 4;
57const MAX_MAX_CONCURRENT_STEPS: usize = 32;
58const MAX_FLOW_STEP_ID_BYTES: usize = 256;
59const MAX_FLOW_STEP_INPUT_BYTES: usize = 64 * 1024;
60const MAX_DYNAMIC_WORKFLOW_INPUT_BYTES: usize = 128 * 1024;
61const DEFAULT_DYNAMIC_WORKFLOW_LEASE_MS: u64 = 30_000;
62const MAX_DYNAMIC_WORKFLOW_SETTLE: Duration = Duration::from_secs(5);
63const MAX_DYNAMIC_WORKFLOW_CONTROL_REASON_BYTES: usize = 4 * 1024;
64const DYNAMIC_WORKFLOW_LEASE_RELATIVE_PATH: &str = "leases";
65
66fn dynamic_workflow_scheduler_quota_limit(limits: &DynamicWorkflowScriptLimits) -> usize {
67    limits
68        .max_concurrent_steps
69        .unwrap_or(DEFAULT_MAX_CONCURRENT_STEPS)
70        .clamp(1, MAX_MAX_CONCURRENT_STEPS)
71}
72
73fn provider_quota_for_context(
74    context: &ToolContext,
75) -> Option<crate::task_scheduler::TaskSchedulerQuota> {
76    if context
77        .model_generation_admission()
78        .is_some_and(|admission| admission.has_scheduler_quota())
79    {
80        // The session-owned generation gate already reserves this exact pool
81        // through the shared scheduler. Holding it again on the enclosing Flow
82        // step would make a single-flight provider recursively wait on itself.
83        return None;
84    }
85    let client = context.llm_client()?;
86    let pool = client.model_generation_pool()?;
87    crate::task_scheduler::TaskSchedulerQuota::new(
88        pool.identity.clone(),
89        pool.max_concurrency().get(),
90    )
91    .ok()
92}
93
94/// Runtime build used to pin newly-created dynamic workflow runs.
95///
96/// Deployments that need a stronger revision identity can provide an explicit
97/// [`RuntimeBuildCompatibility`] through [`DynamicWorkflowTool::with_runtime_build_compatibility`].
98pub const DYNAMIC_WORKFLOW_RUNTIME_BUILD_ID: &str =
99    concat!("a3s-code-core-", env!("CARGO_PKG_VERSION"));
100
101/// Legacy marker used only while deriving an identity for an unpinned history
102/// created before dynamic workflow runtime-build fencing was enabled.
103const LEGACY_UNPINNED_RUNTIME_BUILD_ID: &str = "<unpinned>";
104
105/// Project-relative directory used for durable dynamic workflow history.
106pub const DYNAMIC_WORKFLOW_STORE_RELATIVE_PATH: &str = ".a3s/workflow";
107
108/// Cross-process serialized adapter for a local Flow event journal.
109///
110/// `a3s-flow::LocalFileEventStore` deliberately serializes writers only
111/// inside one process. Dynamic workflows can be resumed or controlled by a
112/// replacement process, so Code adds one small lock-file boundary around the
113/// same append-only journal. The adapter does not project or cache workflow
114/// state: Flow remains the sole event authority and its optimistic sequence
115/// checks still decide whether an append is accepted.
116#[derive(Debug, Clone)]
117pub struct CrossProcessFlowEventStore {
118    root: PathBuf,
119    inner: LocalFileEventStore,
120    process_lock: Arc<Mutex<()>>,
121}
122
123impl CrossProcessFlowEventStore {
124    /// Create a cross-process event store rooted at `root`.
125    pub fn new(root: impl Into<PathBuf>) -> Self {
126        let root = root.into();
127        Self {
128            inner: LocalFileEventStore::new(root.clone()),
129            root,
130            process_lock: Arc::new(Mutex::new(())),
131        }
132    }
133
134    /// Return the directory containing the journal files.
135    pub fn root(&self) -> &Path {
136        &self.root
137    }
138
139    async fn acquire_file_lock(&self) -> a3s_flow::Result<std::fs::File> {
140        tokio::fs::create_dir_all(&self.root).await?;
141        let lock_path = self.root.join(".flow-events.lock");
142        match tokio::fs::symlink_metadata(&lock_path).await {
143            Ok(metadata) if metadata.file_type().is_symlink() => {
144                return Err(a3s_flow::FlowError::Store(format!(
145                    "refusing to use symlinked event-store lock {}",
146                    lock_path.display()
147                )))
148            }
149            Ok(metadata) if !metadata.is_file() => {
150                return Err(a3s_flow::FlowError::Store(format!(
151                    "event-store lock {} exists but is not a file",
152                    lock_path.display()
153                )))
154            }
155            Ok(_) => {}
156            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
157            Err(error) => return Err(a3s_flow::FlowError::Io(error)),
158        }
159        tokio::task::spawn_blocking(move || {
160            use fs2::FileExt;
161            let file = OpenOptions::new()
162                .create(true)
163                .truncate(false)
164                .read(true)
165                .write(true)
166                .open(&lock_path)
167                .map_err(a3s_flow::FlowError::Io)?;
168            file.lock_exclusive().map_err(a3s_flow::FlowError::Io)?;
169            Ok(file)
170        })
171        .await
172        .map_err(|error| {
173            a3s_flow::FlowError::Store(format!("event-store lock task failed: {error}"))
174        })?
175    }
176}
177
178#[async_trait]
179impl FlowEventStore for CrossProcessFlowEventStore {
180    async fn append(&self, run_id: &str, event: FlowEvent) -> a3s_flow::Result<FlowEventEnvelope> {
181        let _process_guard = self.process_lock.lock().await;
182        let _file_guard = self.acquire_file_lock().await?;
183        self.inner.append(run_id, event).await
184    }
185
186    async fn append_if_sequence(
187        &self,
188        run_id: &str,
189        expected_sequence: u64,
190        event: FlowEvent,
191    ) -> a3s_flow::Result<FlowEventEnvelope> {
192        let _process_guard = self.process_lock.lock().await;
193        let _file_guard = self.acquire_file_lock().await?;
194        self.inner
195            .append_if_sequence(run_id, expected_sequence, event)
196            .await
197    }
198
199    async fn list(&self, run_id: &str) -> a3s_flow::Result<Vec<FlowEventEnvelope>> {
200        let _process_guard = self.process_lock.lock().await;
201        let _file_guard = self.acquire_file_lock().await?;
202        self.inner.list(run_id).await
203    }
204
205    async fn list_run_ids(&self) -> a3s_flow::Result<Vec<String>> {
206        let _process_guard = self.process_lock.lock().await;
207        let _file_guard = self.acquire_file_lock().await?;
208        self.inner.list_run_ids().await
209    }
210}
211
212/// Resolve the durable dynamic workflow history directory for a local workspace.
213pub fn dynamic_workflow_store_path(workspace_root: impl AsRef<Path>) -> PathBuf {
214    workspace_root
215        .as_ref()
216        .join(DYNAMIC_WORKFLOW_STORE_RELATIVE_PATH)
217}
218
219/// Recover one completed step output from the exact durable workflow run.
220///
221/// Recovery is bound to the requested run ID and the original input query. It
222/// never acts as a cross-run query cache and never promotes an incomplete step.
223pub async fn recover_dynamic_workflow_step_output(
224    workspace_root: impl AsRef<Path>,
225    run_id: &str,
226    expected_query: &str,
227    step_id: &str,
228) -> Result<Option<Value>> {
229    if !safe_workflow_run_id(run_id) || expected_query.is_empty() || step_id.is_empty() {
230        return Ok(None);
231    }
232    let workspace_root = workspace_root.as_ref();
233    let store_root = dynamic_workflow_store_path(workspace_root);
234    let log_path = store_root.join(format!("{run_id}.jsonl"));
235    match tokio::fs::symlink_metadata(&log_path).await {
236        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
237            return Ok(None)
238        }
239        Ok(_) => {}
240        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
241        Err(error) => {
242            return Err(error).with_context(|| {
243                format!("inspect dynamic workflow history {}", log_path.display())
244            })
245        }
246    }
247    validate_dynamic_workflow_directory(&workspace_root.join(".a3s"), ".a3s").await?;
248    validate_dynamic_workflow_directory(&store_root, ".a3s/workflow").await?;
249    validate_dynamic_workflow_log(&log_path).await?;
250
251    let events = LocalFileEventStore::new(store_root).list(run_id).await?;
252    let input_matches = events.iter().any(|envelope| {
253        matches!(
254            &envelope.event,
255            FlowEvent::RunCreated { input, .. }
256                if input.get("query").and_then(Value::as_str) == Some(expected_query)
257        )
258    });
259    if !input_matches {
260        return Ok(None);
261    }
262    Ok(events
263        .iter()
264        .rev()
265        .find_map(|envelope| match &envelope.event {
266            FlowEvent::StepCompleted {
267                step_id: completed_step_id,
268                output,
269            } if completed_step_id == step_id => Some(output.clone()),
270            _ => None,
271        }))
272}
273
274/// Limits forwarded to the underlying PTC `program` tool.
275#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
276#[serde(rename_all = "camelCase")]
277pub struct DynamicWorkflowScriptLimits {
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub timeout_ms: Option<u64>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub max_tool_calls: Option<usize>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub max_output_bytes: Option<usize>,
284    /// Maximum independently session-bound model generations active at once.
285    /// This orchestration limit is not forwarded to the PTC program sandbox.
286    #[serde(default, skip_serializing)]
287    pub max_concurrent_generations: Option<usize>,
288    /// Maximum Flow step bodies that may execute concurrently for one run.
289    /// This is an orchestration boundary and is never forwarded to QuickJS.
290    #[serde(default, skip_serializing)]
291    pub max_concurrent_steps: Option<usize>,
292}
293
294/// Point-in-time admission counters for one dynamic workflow runtime.
295#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
296#[serde(rename_all = "camelCase")]
297pub struct DynamicWorkflowAdmissionStats {
298    /// Effective per-run step concurrency limit.
299    pub max_concurrent_steps: usize,
300    /// Number of step bodies admitted since this runtime was created.
301    pub admitted_steps: usize,
302    /// Number of step bodies currently holding a local permit.
303    pub active_steps: usize,
304    /// Highest observed active step count.
305    pub peak_active_steps: usize,
306}
307
308/// Bounded cumulative worker-claim diagnostics for one dynamic-workflow tool
309/// instance.  The counters retain no run ids, labels, source, input, output,
310/// or owner tokens; the durable Flow ledger remains the authority for the
311/// per-run attempt number and lease state.
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
313#[serde(rename_all = "camelCase")]
314pub struct DynamicWorkflowHealthSnapshot {
315    /// Number of worker-claim attempts observed by this process.
316    pub claim_attempts: u64,
317    /// Number of claims that acquired a worker lease, including takeovers.
318    pub claims: u64,
319    /// Number of claims whose durable attempt number was greater than one.
320    pub takeovers: u64,
321    /// Number of attempts that found an already-completed claim.
322    pub already_completed: u64,
323    /// Number of attempts rejected because another worker is live.
324    pub busy: u64,
325    /// Number of identity/hash conflicts.
326    pub conflicts: u64,
327    /// Successful lease heartbeats.
328    pub renewals: u64,
329    /// Lease renewals or terminal operations fenced by a lost lease.
330    pub lease_lost: u64,
331    /// Successful terminal claim completions.
332    pub completions: u64,
333    /// Successful releases of non-terminal claims.
334    pub releases: u64,
335    /// Parent/control cancellation observations.
336    pub cancellations: u64,
337    /// Ledger or execution-boundary failures.
338    pub failures: u64,
339    /// Claims currently waiting on a ledger operation.
340    pub in_flight: u64,
341}
342
343#[derive(Default)]
344struct DynamicWorkflowMetrics {
345    claim_attempts: AtomicU64,
346    claims: AtomicU64,
347    takeovers: AtomicU64,
348    already_completed: AtomicU64,
349    busy: AtomicU64,
350    conflicts: AtomicU64,
351    renewals: AtomicU64,
352    lease_lost: AtomicU64,
353    completions: AtomicU64,
354    releases: AtomicU64,
355    cancellations: AtomicU64,
356    failures: AtomicU64,
357    in_flight: AtomicU64,
358}
359
360impl DynamicWorkflowMetrics {
361    fn snapshot(&self) -> DynamicWorkflowHealthSnapshot {
362        DynamicWorkflowHealthSnapshot {
363            claim_attempts: self.claim_attempts.load(Ordering::Relaxed),
364            claims: self.claims.load(Ordering::Relaxed),
365            takeovers: self.takeovers.load(Ordering::Relaxed),
366            already_completed: self.already_completed.load(Ordering::Relaxed),
367            busy: self.busy.load(Ordering::Relaxed),
368            conflicts: self.conflicts.load(Ordering::Relaxed),
369            renewals: self.renewals.load(Ordering::Relaxed),
370            lease_lost: self.lease_lost.load(Ordering::Relaxed),
371            completions: self.completions.load(Ordering::Relaxed),
372            releases: self.releases.load(Ordering::Relaxed),
373            cancellations: self.cancellations.load(Ordering::Relaxed),
374            failures: self.failures.load(Ordering::Relaxed),
375            in_flight: self.in_flight.load(Ordering::Relaxed),
376        }
377    }
378
379    fn increment(counter: &AtomicU64) {
380        counter
381            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
382                Some(value.saturating_add(1))
383            })
384            .ok();
385    }
386}
387
388/// Host-facing aggregate view over dynamic-workflow claim counters and the
389/// optional agent-wide scheduler.  It is a read-only composition of local
390/// metrics; it does not introduce a second workflow or event authority.
391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
392#[serde(rename_all = "camelCase")]
393pub struct DynamicWorkflowControlDiagnostics {
394    pub workflow: DynamicWorkflowHealthSnapshot,
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub scheduler: Option<crate::task_scheduler::TaskSchedulerHealthSnapshot>,
397    /// Live owner-quota occupancy for this workflow when direct Flow steps
398    /// use the shared scheduler. The projection is absent when the runtime is
399    /// session-bound or has no scheduler.
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub scheduler_quota: Option<crate::task_scheduler::TaskSchedulerQuotaSnapshot>,
402}
403
404/// Bounded, host-facing projection of one dynamic workflow continuation.
405///
406/// The projection deliberately omits source, input, step arguments, outputs,
407/// and worker owner tokens. Flow history remains available through the
408/// explicit inspection APIs when a trusted host needs the full record.
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
410#[serde(rename_all = "camelCase")]
411pub struct DynamicWorkflowControlSnapshot {
412    /// Stable durable run identifier.
413    pub run_id: String,
414    /// Current materialized Flow status.
415    pub status: WorkflowRunStatus,
416    /// Last durable event sequence observed for the run.
417    pub last_sequence: u64,
418    /// Number of durable step definitions in the run.
419    pub step_count: usize,
420    /// Number of steps with a committed output.
421    pub completed_steps: usize,
422    /// Number of steps that remain actionable or in flight.
423    pub open_steps: usize,
424    /// Whether Flow has recorded a cleanup-aware cancellation request.
425    pub cancellation_requested: bool,
426    /// Digest-only identity reconstructed from immutable continuation facts.
427    pub continuation_identity: ExecutionIdentityV1,
428    /// Digest-only identity of the projected immutable step plan.
429    pub plan_identity: ExecutionIdentityV1,
430    /// Runtime build pinned by the durable run, or `None` for a legacy
431    /// unpinned history.
432    pub runtime_build_id: Option<String>,
433    /// Redacted worker-lease state observed by the control operation.
434    pub worker_lease: FlowDecisionClaimState,
435}
436
437/// A host-owned control handle for one dynamic workflow run.
438///
439/// The handle keeps the exact source, input, registry, and runtime policy
440/// needed to replay the selected run. Mutating operations first acquire the
441/// same worker lease used by [`DynamicWorkflowTool`], then ask A3S Flow to
442/// append/drive its authoritative events. A live worker therefore remains the
443/// sole executor; a controller retries after the redacted lease state reports
444/// that the claim is busy.
445#[must_use = "a dynamic workflow control handle should be used or explicitly dropped"]
446#[derive(Clone)]
447pub struct DynamicWorkflowControl {
448    registry: Arc<ToolRegistry>,
449    context: ToolContext,
450    flow_event_store: Option<Arc<dyn FlowEventStore>>,
451    run_id: String,
452    source: Arc<str>,
453    input: Value,
454    allowed_tools: Vec<String>,
455    limits: DynamicWorkflowScriptLimits,
456    graph_observer: Option<FlowGraphObserver>,
457    task_scheduler: Option<Arc<TaskScheduler>>,
458    admit_steps_globally: bool,
459    runtime_build_compatibility: Option<RuntimeBuildCompatibility>,
460    continuation_lease_ledger: Option<Arc<dyn FlowDecisionLedger>>,
461    continuation_lease_ms: u64,
462    memory_continuation_lease_ledger: Arc<MemoryFlowDecisionLedger>,
463    metrics: Arc<DynamicWorkflowMetrics>,
464}
465
466#[derive(Clone)]
467struct DynamicStepAdmission {
468    semaphore: Arc<Semaphore>,
469    max_concurrent_steps: usize,
470    admitted_steps: Arc<AtomicUsize>,
471    active_steps: Arc<AtomicUsize>,
472    peak_active_steps: Arc<AtomicUsize>,
473}
474
475impl DynamicStepAdmission {
476    fn new(max_concurrent_steps: usize) -> Self {
477        Self {
478            semaphore: Arc::new(Semaphore::new(max_concurrent_steps)),
479            max_concurrent_steps,
480            admitted_steps: Arc::new(AtomicUsize::new(0)),
481            active_steps: Arc::new(AtomicUsize::new(0)),
482            peak_active_steps: Arc::new(AtomicUsize::new(0)),
483        }
484    }
485
486    async fn acquire(
487        &self,
488        identity: ExecutionIdentityV1,
489        cancellation: &tokio_util::sync::CancellationToken,
490    ) -> a3s_flow::Result<DynamicStepLease> {
491        let permit = tokio::select! {
492            biased;
493            _ = cancellation.cancelled() => {
494                return Err(a3s_flow::FlowError::Runtime(
495                    "dynamic workflow step admission cancelled".to_string(),
496                ));
497            }
498            permit = Arc::clone(&self.semaphore).acquire_owned() => {
499                permit.map_err(|_| a3s_flow::FlowError::Runtime(
500                    "dynamic workflow step admission is closed".to_string(),
501                ))?
502            }
503        };
504        if cancellation.is_cancelled() {
505            drop(permit);
506            return Err(a3s_flow::FlowError::Runtime(
507                "dynamic workflow step admission cancelled".to_string(),
508            ));
509        }
510
511        let active = self.active_steps.fetch_add(1, Ordering::AcqRel) + 1;
512        self.admitted_steps.fetch_add(1, Ordering::Relaxed);
513        let mut observed = self.peak_active_steps.load(Ordering::Acquire);
514        while active > observed {
515            match self.peak_active_steps.compare_exchange(
516                observed,
517                active,
518                Ordering::AcqRel,
519                Ordering::Acquire,
520            ) {
521                Ok(_) => break,
522                Err(current) => observed = current,
523            }
524        }
525        tracing::trace!(
526            execution_identity = identity.key(),
527            active_steps = active,
528            max_concurrent_steps = self.max_concurrent_steps,
529            "dynamic workflow step admitted"
530        );
531        Ok(DynamicStepLease {
532            _permit: permit,
533            identity,
534            active_steps: Arc::clone(&self.active_steps),
535            task_lease: None,
536        })
537    }
538
539    fn stats(&self) -> DynamicWorkflowAdmissionStats {
540        DynamicWorkflowAdmissionStats {
541            max_concurrent_steps: self.max_concurrent_steps,
542            admitted_steps: self.admitted_steps.load(Ordering::Acquire),
543            active_steps: self.active_steps.load(Ordering::Acquire),
544            peak_active_steps: self.peak_active_steps.load(Ordering::Acquire),
545        }
546    }
547}
548
549struct DynamicStepLease {
550    _permit: OwnedSemaphorePermit,
551    identity: ExecutionIdentityV1,
552    active_steps: Arc<AtomicUsize>,
553    task_lease: Option<TaskLease>,
554}
555
556impl DynamicStepLease {
557    fn with_task_lease(mut self, task_lease: TaskLease) -> Self {
558        self.task_lease = Some(task_lease);
559        self
560    }
561}
562
563impl Drop for DynamicStepLease {
564    fn drop(&mut self) {
565        let active = self.active_steps.fetch_sub(1, Ordering::AcqRel) - 1;
566        tracing::trace!(
567            execution_identity = self.identity.key(),
568            active_steps = active,
569            "dynamic workflow step admission released"
570        );
571    }
572}
573
574/// Runs A3S Flow workflow and step invocations through a sandboxed PTC script.
575#[derive(Clone)]
576pub struct DynamicWorkflowRuntime {
577    invoker: Arc<dyn ToolInvoker>,
578    context: ToolContext,
579    source: Arc<str>,
580    allowed_tools: Vec<String>,
581    limits: DynamicWorkflowScriptLimits,
582    parallel_generation_admission: Option<ModelGenerationAdmission>,
583    step_admission: DynamicStepAdmission,
584    /// Optional global scheduler for runtimes created outside an AgentSession.
585    /// Session-bound callers already hold the enclosing scheduler lease and
586    /// should leave this unset to avoid nested single-slot deadlocks.
587    task_scheduler: Option<Arc<TaskScheduler>>,
588    admit_steps_globally: bool,
589    /// Optional owner quota for direct script-backed Flow steps. The quota is
590    /// enforced by the same scheduler actor as global capacity.
591    scheduler_quota: Option<crate::task_scheduler::TaskSchedulerQuota>,
592    /// Provider/model capacity projected into the same scheduler actor as
593    /// workflow-step admission.
594    provider_quota: Option<crate::task_scheduler::TaskSchedulerQuota>,
595    continuation_lease: Option<Arc<DynamicWorkflowLease>>,
596}
597
598impl DynamicWorkflowRuntime {
599    pub fn new(
600        registry: Arc<ToolRegistry>,
601        context: ToolContext,
602        source: impl Into<String>,
603    ) -> Self {
604        let provider_quota = provider_quota_for_context(&context);
605        let allowed_tools = default_allowed_tools(&registry);
606        // Session/agent callers install the governed gateway in ToolContext.
607        // The raw registry adapter is retained only for explicit low-level
608        // callers that construct this public runtime outside an AgentSession.
609        let invoker = context
610            .tool_invoker()
611            .unwrap_or_else(|| registry_tool_invoker(registry));
612        Self {
613            invoker,
614            context,
615            source: Arc::from(source.into()),
616            allowed_tools,
617            limits: DynamicWorkflowScriptLimits::default(),
618            parallel_generation_admission: None,
619            step_admission: DynamicStepAdmission::new(DEFAULT_MAX_CONCURRENT_STEPS),
620            task_scheduler: None,
621            admit_steps_globally: false,
622            scheduler_quota: None,
623            provider_quota,
624            continuation_lease: None,
625        }
626    }
627
628    pub fn with_allowed_tools(mut self, allowed_tools: impl IntoIterator<Item = String>) -> Self {
629        self.allowed_tools = sanitize_allowed_tools(allowed_tools);
630        self
631    }
632
633    pub fn with_limits(mut self, limits: DynamicWorkflowScriptLimits) -> Self {
634        let generation_concurrency = limits.max_concurrent_generations.unwrap_or(1).clamp(1, 4);
635        self.parallel_generation_admission = NonZeroUsize::new(generation_concurrency)
636            .filter(|maximum| maximum.get() > 1)
637            .map(|maximum| {
638                let admission =
639                    ModelGenerationAdmission::new(ModelGenerationConcurrency::bounded(maximum));
640                match self.context.model_generation_admission() {
641                    Some(session_admission) => match admission.clone().with_scheduler_quota_from(
642                        &session_admission,
643                        "dynamic-workflow-model-generation",
644                    ) {
645                        Ok(admission) => admission,
646                        Err(error) => {
647                            tracing::warn!(
648                                %error,
649                                "failed to project session provider quota into dynamic workflow admission"
650                            );
651                            admission
652                        }
653                    },
654                    None => admission,
655                }
656            });
657        let step_concurrency = limits
658            .max_concurrent_steps
659            .unwrap_or(DEFAULT_MAX_CONCURRENT_STEPS)
660            .clamp(1, MAX_MAX_CONCURRENT_STEPS);
661        self.step_admission = DynamicStepAdmission::new(step_concurrency);
662        self.limits = limits;
663        self
664    }
665
666    /// Attach the agent-wide scheduler when this runtime is used as a
667    /// standalone host adapter.
668    ///
669    /// `admit_steps_globally = false` is the correct setting for a runtime
670    /// invoked from an existing AgentSession operation: the parent operation
671    /// already owns the global lease and Flow steps use the local bounded gate.
672    pub fn with_task_scheduler(
673        mut self,
674        scheduler: Arc<TaskScheduler>,
675        admit_steps_globally: bool,
676    ) -> Self {
677        self.task_scheduler = Some(scheduler);
678        self.admit_steps_globally = admit_steps_globally;
679        self
680    }
681
682    /// Bind a digest-only owner quota to scheduler-backed Flow admissions.
683    ///
684    /// This does not create another queue or workflow authority; it only tells
685    /// the existing scheduler how many slots this runtime may own at once.
686    pub fn with_task_scheduler_quota(
687        mut self,
688        quota: crate::task_scheduler::TaskSchedulerQuota,
689    ) -> Self {
690        self.scheduler_quota = Some(quota);
691        self
692    }
693
694    fn with_continuation_lease(mut self, lease: Arc<DynamicWorkflowLease>) -> Self {
695        self.continuation_lease = Some(lease);
696        self
697    }
698
699    /// Return local Flow-step admission counters for diagnostics and hosts.
700    pub fn admission_stats(&self) -> DynamicWorkflowAdmissionStats {
701        self.step_admission.stats()
702    }
703
704    /// Return the live owner-quota projection for this runtime, when it is
705    /// layered on an agent-wide scheduler. The projection contains only the
706    /// digest identity and current occupancy; Flow history and worker leases
707    /// remain independent authorities.
708    pub async fn scheduler_quota_snapshot(
709        &self,
710    ) -> std::result::Result<
711        Option<crate::task_scheduler::TaskSchedulerQuotaSnapshot>,
712        crate::task_scheduler::TaskSchedulerError,
713    > {
714        match (&self.task_scheduler, &self.scheduler_quota) {
715            (Some(scheduler), Some(quota)) => scheduler.quota_snapshot(quota).await.map(Some),
716            _ => Ok(None),
717        }
718    }
719
720    async fn admit_step(&self, invocation: &StepInvocation) -> a3s_flow::Result<DynamicStepLease> {
721        self.ensure_continuation_lease().await?;
722        let identity = dynamic_workflow_step_identity(
723            &invocation.run_id,
724            &invocation.step_id,
725            &invocation.step_name,
726            &invocation.input,
727        )
728        .map_err(|error| a3s_flow::FlowError::Runtime(error.to_string()))?;
729        let mut lease = self
730            .step_admission
731            .acquire(identity.clone(), &self.context.cancellation_token())
732            .await?;
733
734        // Host task fan-out has its own child-run scheduler boundary. Holding
735        // a second global lease here would deadlock a max_active=1 scheduler
736        // while the child waits for capacity, so only direct script-backed
737        // steps opt into the standalone global admission.
738        if self.admit_steps_globally
739            && !matches!(
740                invocation.step_name.as_str(),
741                TASK_TOOL | PARALLEL_TASK_TOOL
742            )
743        {
744            let Some(scheduler) = self.task_scheduler.as_ref() else {
745                return Err(a3s_flow::FlowError::Runtime(
746                    "global Flow-step admission requires a configured task scheduler".to_string(),
747                ));
748            };
749            let label = format!(
750                "flow:{}:{}:{}",
751                invocation.run_id, invocation.step_id, invocation.step_name
752            );
753            let mut quotas = Vec::with_capacity(2);
754            if let Some(quota) = self.scheduler_quota.as_ref() {
755                quotas.push(quota.clone());
756            }
757            if let Some(quota) = self.provider_quota.as_ref() {
758                if !quotas
759                    .iter()
760                    .any(|candidate| candidate.identity == quota.identity)
761                {
762                    quotas.push(quota.clone());
763                }
764            }
765            let task_lease = if quotas.is_empty() {
766                scheduler
767                    .acquire_with_identity(
768                        SchedulerTaskPriority::Foreground,
769                        label,
770                        Some(identity),
771                        &self.context.cancellation_token(),
772                    )
773                    .await
774            } else {
775                scheduler
776                    .acquire_with_quotas(
777                        SchedulerTaskPriority::Foreground,
778                        label,
779                        &quotas,
780                        Some(identity),
781                        &self.context.cancellation_token(),
782                    )
783                    .await
784            }
785            .map_err(|error| a3s_flow::FlowError::Runtime(error.to_string()))?;
786            lease = lease.with_task_lease(task_lease);
787        }
788        Ok(lease)
789    }
790
791    async fn ensure_continuation_lease(&self) -> a3s_flow::Result<()> {
792        let Some(lease) = self.continuation_lease.as_ref() else {
793            return Ok(());
794        };
795        if lease
796            .renew()
797            .await
798            .map_err(|error| a3s_flow::FlowError::Runtime(error.to_string()))?
799        {
800            Ok(())
801        } else {
802            Err(a3s_flow::FlowError::Runtime(
803                "dynamic workflow worker lease is no longer owned before admission".to_string(),
804            ))
805        }
806    }
807
808    async fn run_script(
809        &self,
810        payload: Value,
811        context: &ToolContext,
812    ) -> a3s_flow::Result<ToolResult> {
813        let mut args = json!({
814            "type": "script",
815            "language": "javascript",
816            "source": self.source.as_ref(),
817            "inputs": payload,
818            "allowed_tools": self.allowed_tools,
819        });
820        if let Some(object) = args.as_object_mut() {
821            if let Ok(Value::Object(limits)) = serde_json::to_value(&self.limits) {
822                if !limits.is_empty() {
823                    object.insert("limits".to_string(), Value::Object(limits));
824                }
825            }
826        }
827
828        let result = self
829            .invoker
830            .invoke(
831                crate::tools::ToolInvocation::runtime_internal(PROGRAM_TOOL, args),
832                context,
833            )
834            .await;
835        if result.exit_code != 0 {
836            return Err(a3s_flow::FlowError::Runtime(result.output));
837        }
838        Ok(result)
839    }
840
841    async fn context_for_step(
842        &self,
843        run_id: &str,
844        step_id: &str,
845        step_name: &str,
846    ) -> a3s_flow::Result<ToolContext> {
847        if step_name != GENERATE_OBJECT_TOOL {
848            return Ok(self.context.clone().with_run_id(run_id.to_string()));
849        }
850        if let (Some(admission), Some(client)) = (
851            self.parallel_generation_admission.as_ref(),
852            self.context.llm_client(),
853        ) {
854            let fork_id = format!("{run_id}:{step_id}");
855            if let Some(forked_client) = client.fork_for_session(&fork_id) {
856                let permit = admission
857                    .acquire(&self.context.cancellation_token())
858                    .await
859                    .map_err(|error| {
860                        a3s_flow::FlowError::Runtime(format!(
861                            "parallel model-generation admission failed before workflow step: {error}"
862                        ))
863                    })?;
864                return self
865                    .context
866                    .clone()
867                    .with_run_id(run_id.to_string())
868                    .with_llm_client(forked_client)
869                    .with_model_generation_permit(admission.clone(), Arc::new(permit))
870                    .map_err(|error| {
871                        a3s_flow::FlowError::Runtime(format!(
872                            "bind parallel model-generation admission to workflow step: {error}"
873                        ))
874                    });
875            }
876        }
877        let Some(admission) = self.context.model_generation_admission() else {
878            return Ok(self.context.clone().with_run_id(run_id.to_string()));
879        };
880        let permit = admission
881            .acquire(&self.context.cancellation_token())
882            .await
883            .map_err(|error| {
884                a3s_flow::FlowError::Runtime(format!(
885                    "model-generation admission failed before workflow step: {error}"
886                ))
887            })?;
888        self.context
889            .clone()
890            .with_run_id(run_id.to_string())
891            .with_model_generation_permit(admission, Arc::new(permit))
892            .map_err(|error| {
893                a3s_flow::FlowError::Runtime(format!(
894                    "bind model-generation admission to workflow step: {error}"
895                ))
896            })
897    }
898
899    async fn run_tool_step(
900        &self,
901        run_id: &str,
902        tool_name: &str,
903        args: Value,
904    ) -> a3s_flow::Result<Value> {
905        let context = self.context.clone().with_run_id(run_id.to_string());
906        let result = self
907            .invoker
908            .invoke(
909                self.context
910                    .nested_tool_invocation(tool_name.to_string(), args),
911                &context,
912            )
913            .await;
914        if result.exit_code != 0 {
915            return Err(a3s_flow::FlowError::Runtime(result.output));
916        }
917        Ok(json!({
918            "tool": result.name,
919            "output": result.output,
920            "exit_code": result.exit_code,
921            "metadata": result.metadata,
922        }))
923    }
924}
925
926#[async_trait]
927impl FlowRuntime for DynamicWorkflowRuntime {
928    async fn run_workflow(
929        &self,
930        invocation: WorkflowInvocation,
931    ) -> a3s_flow::Result<RuntimeCommand> {
932        self.ensure_continuation_lease().await?;
933        let payload = invocation_payload("workflow", &invocation.run_id, &invocation.history)
934            .with("input", invocation.input);
935        let context = self
936            .context
937            .clone()
938            .with_run_id(invocation.run_id.to_string());
939        let result = self.run_script(payload.into_value(), &context).await?;
940        serde_json::from_value(script_result(&result)?).map_err(a3s_flow::FlowError::from)
941    }
942
943    async fn run_step(&self, invocation: StepInvocation) -> a3s_flow::Result<Value> {
944        let _admission = self.admit_step(&invocation).await?;
945        if matches!(
946            invocation.step_name.as_str(),
947            TASK_TOOL | PARALLEL_TASK_TOOL
948        ) {
949            return self
950                .run_tool_step(&invocation.run_id, &invocation.step_name, invocation.input)
951                .await;
952        }
953
954        let context = self
955            .context_for_step(
956                &invocation.run_id,
957                &invocation.step_id,
958                &invocation.step_name,
959            )
960            .await?;
961        let payload = invocation_payload("step", &invocation.run_id, &invocation.history)
962            .with("step_id", invocation.step_id)
963            .with("step_name", invocation.step_name)
964            .with("input", invocation.input);
965        let result = self.run_script(payload.into_value(), &context).await?;
966        script_result(&result)
967    }
968}
969
970/// Derive the stable identity for one dynamic Flow step admission.
971///
972/// The returned value is digest-only. The step input is included in the
973/// derivation so retries with different arguments cannot share a lease, but no
974/// input bytes are retained in the identity or emitted by the scheduler.
975pub fn dynamic_workflow_step_identity(
976    run_id: &str,
977    step_id: &str,
978    step_name: &str,
979    input: &Value,
980) -> Result<ExecutionIdentityV1, crate::execution_identity::ExecutionIdentityError> {
981    for (field, value) in [
982        ("run_id", run_id),
983        ("step_id", step_id),
984        ("step_name", step_name),
985    ] {
986        if value.is_empty()
987            || value.len() > MAX_FLOW_STEP_ID_BYTES
988            || value.contains('\0')
989            || value.lines().count() != 1
990        {
991            return Err(
992                crate::execution_identity::ExecutionIdentityError::InvalidClaimField(field),
993            );
994        }
995    }
996    let encoded_input = serde_json::to_vec(input).map_err(|error| {
997        crate::execution_identity::ExecutionIdentityError::Serialization(error.to_string())
998    })?;
999    if encoded_input.len() > MAX_FLOW_STEP_INPUT_BYTES {
1000        return Err(
1001            crate::execution_identity::ExecutionIdentityError::Serialization(format!(
1002                "dynamic Flow step input exceeds {} bytes",
1003                MAX_FLOW_STEP_INPUT_BYTES
1004            )),
1005        );
1006    }
1007    ExecutionIdentityV1::derive(
1008        FLOW_STEP_IDENTITY_DOMAIN_V1,
1009        &json!({
1010            "run_id": run_id,
1011            "step_id": step_id,
1012            "step_name": step_name,
1013            "input": input,
1014        }),
1015    )
1016}
1017
1018fn dynamic_workflow_input_identity(
1019    input: &Value,
1020) -> Result<ExecutionIdentityV1, crate::execution_identity::ExecutionIdentityError> {
1021    let encoded = serde_json::to_vec(input).map_err(|error| {
1022        crate::execution_identity::ExecutionIdentityError::Serialization(error.to_string())
1023    })?;
1024    if encoded.len() > MAX_DYNAMIC_WORKFLOW_INPUT_BYTES {
1025        return Err(
1026            crate::execution_identity::ExecutionIdentityError::Serialization(format!(
1027                "dynamic workflow input exceeds {} bytes",
1028                MAX_DYNAMIC_WORKFLOW_INPUT_BYTES
1029            )),
1030        );
1031    }
1032    ExecutionIdentityV1::derive(DYNAMIC_WORKFLOW_INPUT_IDENTITY_DOMAIN_V1, input)
1033}
1034
1035/// Derive the stable, digest-only claim identity for one dynamic workflow.
1036///
1037/// Unlike the continuation identity, this identity intentionally excludes the
1038/// evolving plan and step history. It therefore remains constant while a
1039/// worker replays, retries, or takes over the same durable run. The complete
1040/// continuation identity is still validated first, so malformed or
1041/// mixed-generation history can never acquire a worker lease.
1042pub fn dynamic_workflow_claim_identity(
1043    run_id: &str,
1044    source: &str,
1045    input: &Value,
1046    runtime_build_id: &str,
1047    history: &[FlowEventEnvelope],
1048) -> std::result::Result<ExecutionIdentityV1, crate::execution_identity::ExecutionIdentityError> {
1049    dynamic_workflow_continuation_identity(run_id, source, input, runtime_build_id, history)?;
1050    let input_identity = dynamic_workflow_input_identity(input)?;
1051    let effective_runtime_build_id = history
1052        .iter()
1053        .find_map(|envelope| match &envelope.event {
1054            FlowEvent::RunCreated { spec, .. } => Some(
1055                spec.runtime_build_id
1056                    .as_ref()
1057                    .map(ToString::to_string)
1058                    .unwrap_or_else(|| LEGACY_UNPINNED_RUNTIME_BUILD_ID.to_string()),
1059            ),
1060            _ => None,
1061        })
1062        .unwrap_or_else(|| runtime_build_id.to_string());
1063    ExecutionIdentityV1::derive(
1064        DYNAMIC_WORKFLOW_CLAIM_IDENTITY_DOMAIN_V1,
1065        &json!({
1066            "run_id": run_id,
1067            "source_hash": source_hash(source),
1068            "input_identity": input_identity.digest,
1069            "runtime_build_id": effective_runtime_build_id,
1070        }),
1071    )
1072}
1073
1074/// Reconstruct the immutable identity of a dynamic workflow continuation.
1075///
1076/// The Flow journal already persists the run definition and every step
1077/// definition. This adapter binds those facts to the current runtime build,
1078/// source, and initial input without introducing a second journal. Progress,
1079/// retries, event sequence, and step outputs are intentionally excluded, so a
1080/// restart observes the same continuation identity before and after replay.
1081/// A malformed or mixed-generation history is rejected before a step body can
1082/// be admitted.
1083pub fn dynamic_workflow_continuation_identity(
1084    run_id: &str,
1085    source: &str,
1086    input: &Value,
1087    runtime_build_id: &str,
1088    history: &[FlowEventEnvelope],
1089) -> std::result::Result<ExecutionIdentityV1, crate::execution_identity::ExecutionIdentityError> {
1090    if !safe_workflow_run_id(run_id) {
1091        return Err(crate::execution_identity::ExecutionIdentityError::InvalidClaimField("run_id"));
1092    }
1093    if source.is_empty() {
1094        return Err(crate::execution_identity::ExecutionIdentityError::InvalidClaimField("source"));
1095    }
1096    RuntimeBuildId::new(runtime_build_id.to_string()).map_err(|error| {
1097        crate::execution_identity::ExecutionIdentityError::Serialization(format!(
1098            "invalid dynamic workflow runtime build id: {error}"
1099        ))
1100    })?;
1101    let input_identity = dynamic_workflow_input_identity(input)?;
1102    let expected_source_hash = source_hash(source);
1103    let expected_spec = WorkflowSpec::rust_embedded(
1104        "a3s-code.dynamic-workflow",
1105        expected_source_hash.as_str(),
1106        "ptc",
1107        "run",
1108    );
1109    let mut saw_run_created = false;
1110    let mut persisted_runtime_build: Option<String> = None;
1111    let mut step_identities = BTreeMap::<String, String>::new();
1112    let mut terminal_seen = false;
1113
1114    for (index, envelope) in history.iter().enumerate() {
1115        if envelope.run_id != run_id {
1116            return Err(
1117                crate::execution_identity::ExecutionIdentityError::InvalidClaimField("run_id"),
1118            );
1119        }
1120        if envelope.sequence != index as u64 + 1 {
1121            return Err(
1122                crate::execution_identity::ExecutionIdentityError::InvalidClaimField("sequence"),
1123            );
1124        }
1125        if terminal_seen {
1126            return Err(
1127                crate::execution_identity::ExecutionIdentityError::InvalidClaimField("terminal"),
1128            );
1129        }
1130        if index == 0 && !matches!(&envelope.event, FlowEvent::RunCreated { .. }) {
1131            return Err(
1132                crate::execution_identity::ExecutionIdentityError::InvalidClaimField("run_created"),
1133            );
1134        }
1135
1136        match &envelope.event {
1137            FlowEvent::RunCreated {
1138                spec,
1139                input: persisted_input,
1140            } => {
1141                if saw_run_created {
1142                    return Err(
1143                        crate::execution_identity::ExecutionIdentityError::InvalidClaimField(
1144                            "run_created",
1145                        ),
1146                    );
1147                }
1148                saw_run_created = true;
1149                if spec.name != expected_spec.name
1150                    || spec.runtime != expected_spec.runtime
1151                    || !spec.patch_markers.is_empty()
1152                    || !spec.signal_names.is_empty()
1153                {
1154                    return Err(
1155                        crate::execution_identity::ExecutionIdentityError::InvalidClaimField(
1156                            "workflow_spec",
1157                        ),
1158                    );
1159                }
1160                if spec.version != expected_source_hash {
1161                    return Err(
1162                        crate::execution_identity::ExecutionIdentityError::InvalidClaimField(
1163                            "source",
1164                        ),
1165                    );
1166                }
1167                let persisted_input_identity = dynamic_workflow_input_identity(persisted_input)?;
1168                if persisted_input_identity != input_identity {
1169                    return Err(
1170                        crate::execution_identity::ExecutionIdentityError::InvalidClaimField(
1171                            "input",
1172                        ),
1173                    );
1174                }
1175                if let Some(build_id) = &spec.runtime_build_id {
1176                    RuntimeBuildId::new(build_id.as_str().to_string()).map_err(|error| {
1177                        crate::execution_identity::ExecutionIdentityError::Serialization(format!(
1178                            "invalid persisted dynamic workflow runtime build id: {error}"
1179                        ))
1180                    })?;
1181                    persisted_runtime_build = Some(build_id.as_str().to_string());
1182                } else {
1183                    persisted_runtime_build = Some(LEGACY_UNPINNED_RUNTIME_BUILD_ID.to_string());
1184                }
1185            }
1186            FlowEvent::StepCreated {
1187                step_id,
1188                step_name,
1189                input,
1190                retry,
1191            } => {
1192                let admission_identity =
1193                    dynamic_workflow_step_identity(run_id, step_id, step_name, input)?;
1194                // Retry behavior is part of Flow's immutable step definition,
1195                // even though it is not needed for the scheduler admission
1196                // lease. Bind it here so a conflicting duplicate cannot hide
1197                // behind the digest-only admission identity.
1198                let identity = ExecutionIdentityV1::derive(
1199                    FLOW_STEP_IDENTITY_DOMAIN_V1,
1200                    &json!({
1201                        "admission": admission_identity.digest,
1202                        "retry": retry,
1203                    }),
1204                )?;
1205                if step_identities
1206                    .insert(step_id.clone(), identity.digest)
1207                    .is_some()
1208                {
1209                    return Err(
1210                        crate::execution_identity::ExecutionIdentityError::InvalidClaimField(
1211                            "step_definition",
1212                        ),
1213                    );
1214                }
1215            }
1216            _ => {}
1217        }
1218
1219        terminal_seen = matches!(
1220            &envelope.event,
1221            FlowEvent::RunCompleted { .. }
1222                | FlowEvent::RunFailed { .. }
1223                | FlowEvent::RunCancelled { .. }
1224                | FlowEvent::RunTimedOut { .. }
1225                | FlowEvent::RunRetryExhausted { .. }
1226                | FlowEvent::RunHostShutdown { .. }
1227                | FlowEvent::RunContinuedAsNew { .. }
1228        );
1229    }
1230
1231    if !history.is_empty() && !saw_run_created {
1232        return Err(
1233            crate::execution_identity::ExecutionIdentityError::InvalidClaimField("run_created"),
1234        );
1235    }
1236    let effective_runtime_build = persisted_runtime_build
1237        .as_deref()
1238        .unwrap_or(runtime_build_id);
1239    if effective_runtime_build != LEGACY_UNPINNED_RUNTIME_BUILD_ID {
1240        RuntimeBuildId::new(effective_runtime_build.to_string()).map_err(|error| {
1241            crate::execution_identity::ExecutionIdentityError::Serialization(format!(
1242                "invalid effective dynamic workflow runtime build id: {error}"
1243            ))
1244        })?;
1245    }
1246    let plan = dynamic_workflow_execution_plan(history);
1247    let plan_identity = plan.definition_identity()?;
1248    ExecutionIdentityV1::derive(
1249        DYNAMIC_WORKFLOW_CONTINUATION_IDENTITY_DOMAIN_V1,
1250        &json!({
1251            "run_id": run_id,
1252            "source_hash": expected_source_hash,
1253            "input_identity": input_identity.digest,
1254            "runtime_build_id": effective_runtime_build,
1255            "plan_identity": plan_identity.digest,
1256            "step_identities": step_identities,
1257        }),
1258    )
1259}
1260
1261/// Project the complete durable Flow history into Code's canonical plan model.
1262///
1263/// Flow remains the execution authority; this is a read-only adapter used by
1264/// progress events and metadata. Replaying the full history (rather than only
1265/// observing newly appended events) makes resumed runs expose the same plan as
1266/// fresh runs.
1267pub fn dynamic_workflow_execution_plan(history: &[FlowEventEnvelope]) -> ExecutionPlan {
1268    let mut plan = ExecutionPlan::new("dynamic workflow", Complexity::Medium);
1269    for envelope in history {
1270        match &envelope.event {
1271            FlowEvent::StepCreated {
1272                step_id,
1273                step_name,
1274                input,
1275                ..
1276            } => {
1277                let task = Task::new(
1278                    step_id.clone(),
1279                    workflow_step_description(step_id, step_name, Some(input)),
1280                )
1281                .with_tool(step_name.clone());
1282                plan.upsert_step(task);
1283            }
1284            FlowEvent::StepStarted { step_id, .. } => {
1285                plan.mark_status(step_id, TaskStatus::InProgress);
1286            }
1287            FlowEvent::StepRetrying { step_id, .. } => {
1288                plan.mark_status(step_id, TaskStatus::InProgress);
1289            }
1290            FlowEvent::StepCompleted { step_id, .. } => {
1291                plan.mark_status(step_id, TaskStatus::Completed);
1292            }
1293            FlowEvent::StepFailed { step_id, .. } => {
1294                plan.mark_status(step_id, TaskStatus::Failed);
1295            }
1296            FlowEvent::RunFailed { .. }
1297            | FlowEvent::RunTimedOut { .. }
1298            | FlowEvent::RunRetryExhausted { .. } => {
1299                for task in &mut plan.steps {
1300                    if task.status.is_active() {
1301                        task.status = TaskStatus::Failed;
1302                    }
1303                }
1304            }
1305            FlowEvent::RunCancelled { .. } | FlowEvent::RunHostShutdown { .. } => {
1306                for task in &mut plan.steps {
1307                    if task.status.is_active() {
1308                        task.status = TaskStatus::Cancelled;
1309                    }
1310                }
1311            }
1312            _ => {}
1313        }
1314    }
1315    plan
1316}
1317
1318struct WorkflowProgressState {
1319    plan: ExecutionPlan,
1320}
1321
1322impl WorkflowProgressState {
1323    fn new(plan: ExecutionPlan) -> Self {
1324        Self { plan }
1325    }
1326
1327    fn upsert_step(
1328        &mut self,
1329        step_id: &str,
1330        step_name: &str,
1331        input: Option<&Value>,
1332        status: TaskStatus,
1333    ) {
1334        let content = workflow_step_description(step_id, step_name, input);
1335        self.plan.upsert_step(
1336            Task::new(step_id.to_string(), content)
1337                .with_tool(step_name)
1338                .with_status(status),
1339        );
1340    }
1341
1342    fn mark_status(&mut self, step_id: &str, status: TaskStatus) {
1343        self.plan.mark_status(step_id, status);
1344    }
1345
1346    fn step_position(&self, step_id: &str) -> (usize, usize) {
1347        let total = self.plan.steps.len().max(1);
1348        let number = self
1349            .plan
1350            .steps
1351            .iter()
1352            .position(|task| task.id == step_id)
1353            .map(|idx| idx + 1)
1354            .unwrap_or(total);
1355        (number, total)
1356    }
1357
1358    fn step_description(&self, step_id: &str) -> String {
1359        self.plan
1360            .steps
1361            .iter()
1362            .find(|task| task.id == step_id)
1363            .map(|task| task.content.clone())
1364            .unwrap_or_else(|| step_id.to_string())
1365    }
1366
1367    fn tasks(&self) -> &[Task] {
1368        &self.plan.steps
1369    }
1370
1371    fn snapshot(&self) -> ExecutionPlan {
1372        self.plan.clone()
1373    }
1374}
1375
1376struct AgentEventFlowObserver {
1377    tx: broadcast::Sender<AgentEvent>,
1378    session_id: String,
1379    state: Mutex<WorkflowProgressState>,
1380}
1381
1382impl AgentEventFlowObserver {
1383    fn new(tx: broadcast::Sender<AgentEvent>, session_id: String, plan: ExecutionPlan) -> Self {
1384        Self {
1385            tx,
1386            session_id,
1387            state: Mutex::new(WorkflowProgressState::new(plan)),
1388        }
1389    }
1390
1391    fn emit_task_update(&self, tasks: &[Task]) {
1392        let _ = self.tx.send(AgentEvent::TaskUpdated {
1393            session_id: self.session_id.clone(),
1394            tasks: tasks.to_vec(),
1395        });
1396    }
1397}
1398
1399#[async_trait]
1400impl FlowEventObserver for AgentEventFlowObserver {
1401    async fn observe(&self, envelope: FlowEventEnvelope) {
1402        match envelope.event {
1403            FlowEvent::RunStarted => {
1404                let _ = self.tx.send(AgentEvent::PlanningStart {
1405                    prompt: "dynamic_workflow".to_string(),
1406                });
1407                let state = self.state.lock().await;
1408                if !state.plan.steps.is_empty() {
1409                    let plan = state.snapshot();
1410                    let _ = self.tx.send(AgentEvent::PlanningEnd {
1411                        estimated_steps: plan.steps.len(),
1412                        plan,
1413                    });
1414                }
1415            }
1416            FlowEvent::StepCreated {
1417                step_id,
1418                step_name,
1419                input,
1420                ..
1421            } => {
1422                let mut state = self.state.lock().await;
1423                state.upsert_step(&step_id, &step_name, Some(&input), TaskStatus::Pending);
1424                self.emit_task_update(state.tasks());
1425                let plan = state.snapshot();
1426                let _ = self.tx.send(AgentEvent::PlanningEnd {
1427                    estimated_steps: plan.steps.len(),
1428                    plan,
1429                });
1430            }
1431            FlowEvent::StepStarted { step_id, .. } => {
1432                let mut state = self.state.lock().await;
1433                state.mark_status(&step_id, TaskStatus::InProgress);
1434                self.emit_task_update(state.tasks());
1435                let (step_number, total_steps) = state.step_position(&step_id);
1436                let _ = self.tx.send(AgentEvent::StepStart {
1437                    description: state.step_description(&step_id),
1438                    step_id,
1439                    step_number,
1440                    total_steps,
1441                });
1442            }
1443            FlowEvent::StepCompleted { step_id, .. } => {
1444                let mut state = self.state.lock().await;
1445                state.mark_status(&step_id, TaskStatus::Completed);
1446                self.emit_task_update(state.tasks());
1447                let (step_number, total_steps) = state.step_position(&step_id);
1448                let _ = self.tx.send(AgentEvent::StepEnd {
1449                    step_id,
1450                    status: TaskStatus::Completed,
1451                    step_number,
1452                    total_steps,
1453                });
1454            }
1455            FlowEvent::StepRetrying { step_id, .. } => {
1456                let mut state = self.state.lock().await;
1457                state.mark_status(&step_id, TaskStatus::InProgress);
1458                self.emit_task_update(state.tasks());
1459            }
1460            FlowEvent::StepFailed { step_id, .. } => {
1461                let mut state = self.state.lock().await;
1462                state.mark_status(&step_id, TaskStatus::Failed);
1463                self.emit_task_update(state.tasks());
1464                let (step_number, total_steps) = state.step_position(&step_id);
1465                let _ = self.tx.send(AgentEvent::StepEnd {
1466                    step_id,
1467                    status: TaskStatus::Failed,
1468                    step_number,
1469                    total_steps,
1470                });
1471            }
1472            FlowEvent::RunFailed { .. }
1473            | FlowEvent::RunTimedOut { .. }
1474            | FlowEvent::RunRetryExhausted { .. } => {
1475                let mut state = self.state.lock().await;
1476                for task in &mut state.plan.steps {
1477                    if task.status.is_active() {
1478                        task.status = TaskStatus::Failed;
1479                    }
1480                }
1481                self.emit_task_update(state.tasks());
1482            }
1483            FlowEvent::RunCancelled { .. } | FlowEvent::RunHostShutdown { .. } => {
1484                let mut state = self.state.lock().await;
1485                for task in &mut state.plan.steps {
1486                    if task.status.is_active() {
1487                        task.status = TaskStatus::Cancelled;
1488                    }
1489                }
1490                self.emit_task_update(state.tasks());
1491            }
1492            _ => {}
1493        }
1494    }
1495}
1496
1497fn workflow_step_description(step_id: &str, step_name: &str, input: Option<&Value>) -> String {
1498    if matches!(step_name, TASK_TOOL | PARALLEL_TASK_TOOL) {
1499        let count = input
1500            .and_then(|value| value.get("tasks"))
1501            .and_then(Value::as_array)
1502            .map(Vec::len)
1503            .unwrap_or(0);
1504        if count > 0 {
1505            return bounded_workflow_description(&format!(
1506                "Fan out {count} parallel subagent task(s)"
1507            ));
1508        }
1509    }
1510
1511    let description = input
1512        .and_then(|value| value.get("description").or_else(|| value.get("title")))
1513        .and_then(Value::as_str)
1514        .map(str::to_string)
1515        .unwrap_or_else(|| {
1516            if step_name == step_id {
1517                step_id.to_string()
1518            } else {
1519                format!("{step_name}: {step_id}")
1520            }
1521        });
1522    bounded_workflow_description(&description)
1523}
1524
1525fn bounded_workflow_description(value: &str) -> String {
1526    const MAX_DESCRIPTION_BYTES: usize = 512;
1527    if value.len() <= MAX_DESCRIPTION_BYTES {
1528        return value.to_string();
1529    }
1530    let mut end = MAX_DESCRIPTION_BYTES.saturating_sub("…".len());
1531    while end > 0 && !value.is_char_boundary(end) {
1532        end -= 1;
1533    }
1534    format!("{}…", &value[..end])
1535}
1536
1537#[derive(Clone)]
1538struct DynamicWorkflowLease {
1539    ledger: Arc<dyn FlowDecisionLedger>,
1540    decision_id: String,
1541    request_hash: String,
1542    identity: ExecutionIdentityV1,
1543    owner_id: String,
1544    lease_ms: u64,
1545    attempt: u32,
1546    metrics: Arc<DynamicWorkflowMetrics>,
1547}
1548
1549impl DynamicWorkflowLease {
1550    async fn renew(&self) -> Result<bool> {
1551        let result = self
1552            .ledger
1553            .renew_with_identity(
1554                &self.decision_id,
1555                &self.request_hash,
1556                &self.identity,
1557                &self.owner_id,
1558                dynamic_workflow_now_ms(),
1559                self.lease_ms,
1560            )
1561            .await;
1562        match &result {
1563            Ok(true) => DynamicWorkflowMetrics::increment(&self.metrics.renewals),
1564            Ok(false) => DynamicWorkflowMetrics::increment(&self.metrics.lease_lost),
1565            Err(_) => {
1566                DynamicWorkflowMetrics::increment(&self.metrics.lease_lost);
1567                DynamicWorkflowMetrics::increment(&self.metrics.failures);
1568            }
1569        }
1570        result
1571    }
1572
1573    async fn complete(&self) -> Result<()> {
1574        let result = self
1575            .ledger
1576            .complete_with_identity(
1577                &self.decision_id,
1578                &self.request_hash,
1579                &self.identity,
1580                &self.owner_id,
1581                dynamic_workflow_now_ms(),
1582            )
1583            .await;
1584        match &result {
1585            Ok(()) => DynamicWorkflowMetrics::increment(&self.metrics.completions),
1586            Err(_) => DynamicWorkflowMetrics::increment(&self.metrics.failures),
1587        }
1588        result
1589    }
1590
1591    async fn release(&self) -> Result<()> {
1592        let result = self
1593            .ledger
1594            .release_with_identity(
1595                &self.decision_id,
1596                &self.request_hash,
1597                &self.identity,
1598                &self.owner_id,
1599            )
1600            .await;
1601        match &result {
1602            Ok(()) => DynamicWorkflowMetrics::increment(&self.metrics.releases),
1603            Err(_) => DynamicWorkflowMetrics::increment(&self.metrics.failures),
1604        }
1605        result
1606    }
1607}
1608
1609enum DynamicWorkflowLeaseClaim {
1610    Owned(DynamicWorkflowLease),
1611    AlreadyCompleted,
1612}
1613
1614fn dynamic_workflow_lease_key(identity: &ExecutionIdentityV1) -> (String, String) {
1615    (
1616        format!("dynamic-workflow:{}", identity.digest),
1617        identity.digest.clone(),
1618    )
1619}
1620
1621async fn claim_dynamic_workflow_lease(
1622    ledger: Arc<dyn FlowDecisionLedger>,
1623    identity: ExecutionIdentityV1,
1624    lease_ms: u64,
1625    metrics: Arc<DynamicWorkflowMetrics>,
1626) -> Result<DynamicWorkflowLeaseClaim> {
1627    DynamicWorkflowMetrics::increment(&metrics.claim_attempts);
1628    let mut in_flight = DynamicWorkflowClaimInFlight::new(Arc::clone(&metrics));
1629    identity
1630        .validate()
1631        .map_err(|error| anyhow::anyhow!(error))?;
1632    let (decision_id, request_hash) = dynamic_workflow_lease_key(&identity);
1633    let owner_id = format!("dynamic-workflow-worker-{}", uuid::Uuid::new_v4());
1634    let outcome = ledger
1635        .claim_with_identity(
1636            &decision_id,
1637            &request_hash,
1638            &identity,
1639            &owner_id,
1640            dynamic_workflow_now_ms(),
1641            lease_ms,
1642        )
1643        .await
1644        .context("admit dynamic workflow worker lease")?;
1645    let result = match outcome {
1646        FlowDecisionClaimOutcome::Claimed { attempt } => {
1647            DynamicWorkflowMetrics::increment(&metrics.claims);
1648            if attempt > 1 {
1649                DynamicWorkflowMetrics::increment(&metrics.takeovers);
1650            }
1651            Ok(DynamicWorkflowLeaseClaim::Owned(DynamicWorkflowLease {
1652                ledger,
1653                decision_id,
1654                request_hash,
1655                identity,
1656                owner_id,
1657                lease_ms,
1658                attempt,
1659                metrics,
1660            }))
1661        }
1662        FlowDecisionClaimOutcome::Completed => {
1663            DynamicWorkflowMetrics::increment(&metrics.already_completed);
1664            Ok(DynamicWorkflowLeaseClaim::AlreadyCompleted)
1665        }
1666        FlowDecisionClaimOutcome::Busy {
1667            lease_expires_at_ms,
1668        } => {
1669            DynamicWorkflowMetrics::increment(&metrics.busy);
1670            anyhow::bail!("dynamic workflow worker lease is busy until {lease_expires_at_ms}")
1671        }
1672        FlowDecisionClaimOutcome::Conflict => {
1673            DynamicWorkflowMetrics::increment(&metrics.conflicts);
1674            anyhow::bail!("dynamic workflow worker lease identity conflicts with its claim")
1675        }
1676    };
1677    in_flight.finish();
1678    result
1679}
1680
1681struct DynamicWorkflowClaimInFlight {
1682    metrics: Arc<DynamicWorkflowMetrics>,
1683    finished: bool,
1684}
1685
1686impl DynamicWorkflowClaimInFlight {
1687    fn new(metrics: Arc<DynamicWorkflowMetrics>) -> Self {
1688        DynamicWorkflowMetrics::increment(&metrics.in_flight);
1689        Self {
1690            metrics,
1691            finished: false,
1692        }
1693    }
1694
1695    fn finish(&mut self) {
1696        if !self.finished {
1697            self.metrics.in_flight.fetch_sub(1, Ordering::Relaxed);
1698            self.finished = true;
1699        }
1700    }
1701}
1702
1703impl Drop for DynamicWorkflowClaimInFlight {
1704    fn drop(&mut self) {
1705        if !self.finished {
1706            self.finish();
1707            DynamicWorkflowMetrics::increment(&self.metrics.failures);
1708        }
1709    }
1710}
1711
1712fn dynamic_workflow_now_ms() -> u64 {
1713    SystemTime::now()
1714        .duration_since(UNIX_EPOCH)
1715        .unwrap_or_default()
1716        .as_millis()
1717        .min(u128::from(u64::MAX)) as u64
1718}
1719
1720fn is_terminal_workflow_event(event: &FlowEvent) -> bool {
1721    matches!(
1722        event,
1723        FlowEvent::RunCompleted { .. }
1724            | FlowEvent::RunFailed { .. }
1725            | FlowEvent::RunCancelled { .. }
1726            | FlowEvent::RunTimedOut { .. }
1727            | FlowEvent::RunRetryExhausted { .. }
1728            | FlowEvent::RunHostShutdown { .. }
1729            | FlowEvent::RunContinuedAsNew { .. }
1730    )
1731}
1732
1733fn history_is_terminal(history: &[FlowEventEnvelope]) -> bool {
1734    history
1735        .last()
1736        .is_some_and(|envelope| is_terminal_workflow_event(&envelope.event))
1737}
1738
1739/// Model-visible tool that executes a dynamic workflow through A3S Flow.
1740pub struct DynamicWorkflowTool {
1741    registry: DynamicWorkflowRegistry,
1742    flow_event_store: Option<Arc<dyn FlowEventStore>>,
1743    graph_observer: Option<FlowGraphObserver>,
1744    task_scheduler: Option<Arc<TaskScheduler>>,
1745    admit_steps_globally: bool,
1746    runtime_build_compatibility: Option<RuntimeBuildCompatibility>,
1747    continuation_lease_ledger: Option<Arc<dyn FlowDecisionLedger>>,
1748    continuation_lease_ms: u64,
1749    memory_continuation_lease_ledger: Arc<MemoryFlowDecisionLedger>,
1750    metrics: Arc<DynamicWorkflowMetrics>,
1751}
1752
1753enum DynamicWorkflowRegistry {
1754    Standalone(Arc<ToolRegistry>),
1755    RegistryBound(Weak<ToolRegistry>),
1756}
1757
1758impl DynamicWorkflowRegistry {
1759    fn resolve(&self) -> Option<Arc<ToolRegistry>> {
1760        match self {
1761            Self::Standalone(registry) => Some(Arc::clone(registry)),
1762            Self::RegistryBound(registry) => registry.upgrade(),
1763        }
1764    }
1765}
1766
1767impl DynamicWorkflowTool {
1768    pub fn new(registry: Arc<ToolRegistry>) -> Self {
1769        Self {
1770            registry: DynamicWorkflowRegistry::Standalone(registry),
1771            flow_event_store: None,
1772            graph_observer: None,
1773            task_scheduler: None,
1774            admit_steps_globally: false,
1775            runtime_build_compatibility: None,
1776            continuation_lease_ledger: None,
1777            continuation_lease_ms: DEFAULT_DYNAMIC_WORKFLOW_LEASE_MS,
1778            memory_continuation_lease_ledger: Arc::new(MemoryFlowDecisionLedger::new()),
1779            metrics: Arc::new(DynamicWorkflowMetrics::default()),
1780        }
1781    }
1782
1783    fn new_registry_bound(registry: Arc<ToolRegistry>) -> Self {
1784        Self {
1785            registry: DynamicWorkflowRegistry::RegistryBound(Arc::downgrade(&registry)),
1786            flow_event_store: None,
1787            graph_observer: None,
1788            task_scheduler: None,
1789            admit_steps_globally: false,
1790            runtime_build_compatibility: None,
1791            continuation_lease_ledger: None,
1792            continuation_lease_ms: DEFAULT_DYNAMIC_WORKFLOW_LEASE_MS,
1793            memory_continuation_lease_ledger: Arc::new(MemoryFlowDecisionLedger::new()),
1794            metrics: Arc::new(DynamicWorkflowMetrics::default()),
1795        }
1796    }
1797
1798    /// Return bounded cumulative worker-claim diagnostics for this tool
1799    /// instance. Durable per-run attempt state remains in the Flow ledger.
1800    pub fn health(&self) -> DynamicWorkflowHealthSnapshot {
1801        self.metrics.snapshot()
1802    }
1803
1804    /// Project committed Flow events into an optional reactive state graph.
1805    /// A3S Flow remains the workflow execution source of truth.
1806    pub fn with_graph_observer(mut self, observer: FlowGraphObserver) -> Self {
1807        self.graph_observer = Some(observer);
1808        self
1809    }
1810
1811    /// Use a host-owned Flow event store for workflow history and control.
1812    ///
1813    /// This is the extension point for remote or database-backed hosts. The
1814    /// store must provide its own durable append/sequence contract; Code does
1815    /// not mirror its events into a second journal or cache. When omitted,
1816    /// local workspaces use [`CrossProcessFlowEventStore`] and non-local
1817    /// contexts use a process-local in-memory store for compatibility.
1818    pub fn with_flow_event_store(mut self, store: Arc<dyn FlowEventStore>) -> Self {
1819        self.flow_event_store = Some(store);
1820        self
1821    }
1822
1823    /// Configure optional global admission for direct script-backed Flow
1824    /// steps. Session-bound registrations should leave this disabled because
1825    /// the enclosing session operation already owns the global lease.
1826    pub fn with_task_scheduler(
1827        mut self,
1828        scheduler: Arc<TaskScheduler>,
1829        admit_steps_globally: bool,
1830    ) -> Self {
1831        self.task_scheduler = Some(scheduler);
1832        self.admit_steps_globally = admit_steps_globally;
1833        self
1834    }
1835
1836    /// Fence new and resumed runs to an explicit runtime-build compatibility
1837    /// set. By default the tool pins new runs to
1838    /// [`DYNAMIC_WORKFLOW_RUNTIME_BUILD_ID`] and temporarily accepts legacy
1839    /// unpinned histories; hosts that can replay older builds should add them
1840    /// to this compatibility set explicitly.
1841    pub fn with_runtime_build_compatibility(
1842        mut self,
1843        compatibility: RuntimeBuildCompatibility,
1844    ) -> Self {
1845        self.runtime_build_compatibility = Some(compatibility);
1846        self
1847    }
1848
1849    /// Use a caller-owned durable lease ledger for worker admission.
1850    ///
1851    /// The ledger stores only claim metadata and digests; A3S Flow's event
1852    /// store remains the workflow source of truth. Hosts that run multiple
1853    /// workers should provide a shared ledger (for example a
1854    /// [`FileFlowDecisionLedger`]) and choose a lease long enough for one
1855    /// heartbeat interval. Without an explicit ledger, local workspaces use a
1856    /// sidecar file ledger and remote/in-memory contexts use a tool-scoped
1857    /// memory ledger.
1858    pub fn with_continuation_lease_ledger(
1859        mut self,
1860        ledger: Arc<dyn FlowDecisionLedger>,
1861        lease_ms: u64,
1862    ) -> Self {
1863        self.continuation_lease_ledger = Some(ledger);
1864        self.continuation_lease_ms = lease_ms.max(1);
1865        self
1866    }
1867
1868    /// Change the default worker lease while retaining automatic ledger
1869    /// selection.
1870    pub fn with_continuation_lease_ms(mut self, lease_ms: u64) -> Self {
1871        self.continuation_lease_ms = lease_ms.max(1);
1872        self
1873    }
1874
1875    /// Bind a host control handle to one durable run and its replay inputs.
1876    ///
1877    /// The handle is intentionally separate from the model-visible tool call:
1878    /// a host must provide the exact source and initial input before it can
1879    /// inspect, drive, or cancel a run. No operation is performed until a
1880    /// method on the returned handle is awaited.
1881    pub fn control(
1882        &self,
1883        run_id: impl Into<String>,
1884        source: impl Into<String>,
1885        input: Value,
1886        ctx: &ToolContext,
1887    ) -> Result<DynamicWorkflowControl> {
1888        let registry = self
1889            .registry
1890            .resolve()
1891            .ok_or_else(|| anyhow::anyhow!("tool registry is closed"))?;
1892        let run_id = run_id.into();
1893        if !safe_workflow_run_id(&run_id) {
1894            anyhow::bail!(
1895                "dynamic workflow run_id must contain only ASCII letters, numbers, '-' or '_'"
1896            );
1897        }
1898        let source = source.into();
1899        if source.is_empty() {
1900            anyhow::bail!("dynamic workflow source must not be empty");
1901        }
1902        dynamic_workflow_input_identity(&input)
1903            .map_err(|error| anyhow::anyhow!("invalid dynamic workflow input: {error}"))?;
1904        let allowed_tools = default_allowed_tools(&registry);
1905        Ok(DynamicWorkflowControl {
1906            registry,
1907            context: ctx.clone(),
1908            flow_event_store: self.flow_event_store.clone(),
1909            run_id,
1910            source: Arc::from(source),
1911            input,
1912            allowed_tools,
1913            limits: DynamicWorkflowScriptLimits::default(),
1914            graph_observer: self.graph_observer.clone(),
1915            task_scheduler: self.task_scheduler.clone(),
1916            admit_steps_globally: self.admit_steps_globally,
1917            runtime_build_compatibility: self.runtime_build_compatibility.clone(),
1918            continuation_lease_ledger: self.continuation_lease_ledger.clone(),
1919            continuation_lease_ms: self.continuation_lease_ms,
1920            memory_continuation_lease_ledger: Arc::clone(&self.memory_continuation_lease_ledger),
1921            metrics: Arc::clone(&self.metrics),
1922        })
1923    }
1924
1925    async fn continuation_lease_ledger_for_context(
1926        &self,
1927        ctx: &ToolContext,
1928    ) -> Result<Arc<dyn FlowDecisionLedger>> {
1929        dynamic_workflow_lease_ledger_for_context(
1930            self.continuation_lease_ledger.as_ref(),
1931            &self.memory_continuation_lease_ledger,
1932            ctx,
1933        )
1934        .await
1935    }
1936}
1937
1938impl DynamicWorkflowControl {
1939    /// Return the immutable run id bound to this control handle.
1940    pub fn run_id(&self) -> &str {
1941        &self.run_id
1942    }
1943
1944    /// Return bounded cumulative worker-claim diagnostics shared by this
1945    /// tool and all controls derived from it.
1946    pub fn health(&self) -> DynamicWorkflowHealthSnapshot {
1947        self.metrics.snapshot()
1948    }
1949
1950    /// Compose workflow claim counters with the optional agent-wide scheduler
1951    /// health snapshot. This is a read-only diagnostic view; Flow history and
1952    /// the worker lease remain the only authorities for workflow state.
1953    pub async fn diagnostics(&self) -> Result<DynamicWorkflowControlDiagnostics> {
1954        let scheduler = match self.task_scheduler.as_ref() {
1955            Some(scheduler) => Some(scheduler.health().await.map_err(|error| {
1956                anyhow::anyhow!("read dynamic workflow scheduler health: {error}")
1957            })?),
1958            None => None,
1959        };
1960        let scheduler_quota = if self.admit_steps_globally {
1961            if let (Some(scheduler), Some(quota)) = (
1962                self.task_scheduler.as_ref(),
1963                // Diagnostics are valid before a control handle has started
1964                // its run. `allow_missing` keeps this read-only projection
1965                // from turning an observation into an implicit start/lookup
1966                // precondition while still deriving the same stable claim
1967                // identity that the first drive will use.
1968                self.prepare(true).await?.scheduler_quota,
1969            ) {
1970                Some(scheduler.quota_snapshot(&quota).await.map_err(|error| {
1971                    anyhow::anyhow!("read dynamic workflow scheduler quota: {error}")
1972                })?)
1973            } else {
1974                None
1975            }
1976        } else {
1977            None
1978        };
1979        Ok(DynamicWorkflowControlDiagnostics {
1980            workflow: self.metrics.snapshot(),
1981            scheduler,
1982            scheduler_quota,
1983        })
1984    }
1985
1986    /// Replace the set of tools available to replayed script steps.
1987    pub fn with_allowed_tools(mut self, allowed_tools: impl IntoIterator<Item = String>) -> Self {
1988        self.allowed_tools = sanitize_allowed_tools(allowed_tools);
1989        self
1990    }
1991
1992    /// Replace the bounded script and orchestration limits used during replay.
1993    pub fn with_limits(mut self, limits: DynamicWorkflowScriptLimits) -> Self {
1994        self.limits = limits;
1995        self
1996    }
1997
1998    /// Project control-driven Flow events into a graph observer.
1999    pub fn with_graph_observer(mut self, observer: FlowGraphObserver) -> Self {
2000        self.graph_observer = Some(observer);
2001        self
2002    }
2003
2004    /// Configure optional global admission for direct script-backed steps.
2005    pub fn with_task_scheduler(
2006        mut self,
2007        scheduler: Arc<TaskScheduler>,
2008        admit_steps_globally: bool,
2009    ) -> Self {
2010        self.task_scheduler = Some(scheduler);
2011        self.admit_steps_globally = admit_steps_globally;
2012        self
2013    }
2014
2015    /// Set the runtime-build compatibility policy used by control replay.
2016    pub fn with_runtime_build_compatibility(
2017        mut self,
2018        compatibility: RuntimeBuildCompatibility,
2019    ) -> Self {
2020        self.runtime_build_compatibility = Some(compatibility);
2021        self
2022    }
2023
2024    /// Use a caller-owned worker lease ledger for control operations.
2025    pub fn with_continuation_lease_ledger(
2026        mut self,
2027        ledger: Arc<dyn FlowDecisionLedger>,
2028        lease_ms: u64,
2029    ) -> Self {
2030        self.continuation_lease_ledger = Some(ledger);
2031        self.continuation_lease_ms = lease_ms.max(1);
2032        self
2033    }
2034
2035    /// Change the worker lease while retaining automatic ledger selection.
2036    pub fn with_continuation_lease_ms(mut self, lease_ms: u64) -> Self {
2037        self.continuation_lease_ms = lease_ms.max(1);
2038        self
2039    }
2040}
2041
2042async fn dynamic_workflow_lease_ledger_for_context(
2043    explicit: Option<&Arc<dyn FlowDecisionLedger>>,
2044    memory: &Arc<MemoryFlowDecisionLedger>,
2045    ctx: &ToolContext,
2046) -> Result<Arc<dyn FlowDecisionLedger>> {
2047    if let Some(ledger) = explicit {
2048        return Ok(Arc::clone(ledger));
2049    }
2050    let Some(root) = ctx.workspace_services.local_root() else {
2051        let ledger: Arc<dyn FlowDecisionLedger> = memory.clone();
2052        return Ok(ledger);
2053    };
2054    let workflow_root = dynamic_workflow_store_path(root);
2055    validate_dynamic_workflow_directory(&root.join(".a3s"), ".a3s").await?;
2056    validate_dynamic_workflow_directory(&workflow_root, ".a3s/workflow").await?;
2057    let lease_root = workflow_root.join(DYNAMIC_WORKFLOW_LEASE_RELATIVE_PATH);
2058    validate_dynamic_workflow_directory(&lease_root, ".a3s/workflow/leases").await?;
2059    Ok(Arc::new(FileFlowDecisionLedger::new(lease_root)))
2060}
2061
2062#[async_trait]
2063impl Tool for DynamicWorkflowTool {
2064    fn name(&self) -> &str {
2065        DYNAMIC_WORKFLOW_TOOL
2066    }
2067
2068    fn description(&self) -> &str {
2069        "Run a local dynamic workflow with A3S Flow. The workflow source is a sandboxed JavaScript PTC script that may call allowed ctx tools; A3S Flow records workflow and step history."
2070    }
2071
2072    fn parameters(&self) -> Value {
2073        json!({
2074            "type": "object",
2075            "additionalProperties": false,
2076            "properties": {
2077                "source": {
2078                    "type": "string",
2079                    "description": "JavaScript PTC source defining async function run(ctx, inputs). For inputs.kind='workflow', return a Flow command: {type:'complete', output}, {type:'fail', error}, {type:'schedule_step', step_id, step_name, input, retry?}, or {type:'schedule_steps', steps:[...]}. For inputs.kind='step', return the step JSON output. A scheduled step with step_name='task' bypasses QuickJS and calls the host task tool directly with input as its arguments; legacy parallel_task steps remain readable."
2080                },
2081                "input": {
2082                    "type": "object",
2083                    "description": "Initial workflow input."
2084                },
2085                "run_id": {
2086                    "type": "string",
2087                    "description": "Optional durable run id. Reusing it with the same source and input is idempotent."
2088                },
2089                "allowed_tools": {
2090                    "type": "array",
2091                    "description": "Tool names the workflow script may call through ctx. Defaults to all registered tools except program, dynamic_workflow, and the legacy parallel_task alias. Direct task fan-out is blocked inside QuickJS; schedule a host task step instead. Login-registered tools such as runtime are allowed when present.",
2092                    "items": { "type": "string" }
2093                },
2094                "limits": {
2095                    "type": "object",
2096                    "additionalProperties": false,
2097                    "properties": {
2098                        "timeoutMs": { "type": "integer", "minimum": 1 },
2099                        "maxToolCalls": { "type": "integer", "minimum": 1 },
2100                        "maxOutputBytes": { "type": "integer", "minimum": 1 },
2101                        "maxConcurrentGenerations": {
2102                            "type": "integer",
2103                            "minimum": 1,
2104                            "maximum": 4,
2105                            "description": "Optional bounded fan-out for independently session-bound generate_object steps. Providers without session forking remain single-flight."
2106                        },
2107                        "maxConcurrentSteps": {
2108                            "type": "integer",
2109                            "minimum": 1,
2110                            "maximum": 32,
2111                            "description": "Optional per-workflow limit for concurrently executing Flow step bodies."
2112                        }
2113                    }
2114                }
2115            },
2116            "required": ["source"]
2117        })
2118    }
2119
2120    async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result<ToolOutput> {
2121        let Some(registry) = self.registry.resolve() else {
2122            return Ok(ToolOutput::error("Tool registry is closed"));
2123        };
2124        let Some(source) = args.get("source").and_then(Value::as_str) else {
2125            return Ok(ToolOutput::error("dynamic_workflow requires source"));
2126        };
2127        let input = args.get("input").cloned().unwrap_or_else(|| json!({}));
2128        let allowed_tools = args
2129            .get("allowed_tools")
2130            .and_then(Value::as_array)
2131            .map(|items| {
2132                items
2133                    .iter()
2134                    .filter_map(Value::as_str)
2135                    .map(ToString::to_string)
2136                    .collect::<Vec<_>>()
2137            })
2138            .unwrap_or_else(|| default_allowed_tools(&registry));
2139        let limits: DynamicWorkflowScriptLimits = args
2140            .get("limits")
2141            .cloned()
2142            .and_then(|value| serde_json::from_value(value).ok())
2143            .unwrap_or_default();
2144
2145        let (runtime_build_id, runtime_build_compatibility) =
2146            match dynamic_workflow_runtime_configuration(self.runtime_build_compatibility.as_ref())
2147            {
2148                Ok(configuration) => configuration,
2149                Err(error) => {
2150                    return Ok(ToolOutput::error(format!(
2151                        "invalid dynamic workflow runtime build identity: {error}"
2152                    )))
2153                }
2154            };
2155        let source_hash = source_hash(source);
2156        let base_spec = WorkflowSpec::rust_embedded(
2157            "a3s-code.dynamic-workflow",
2158            source_hash.as_str(),
2159            "ptc",
2160            "run",
2161        );
2162
2163        if ctx.is_cancelled() {
2164            return Ok(ToolOutput::error(
2165                "dynamic_workflow was cancelled before admission",
2166            ));
2167        }
2168        let requested_run_id = args.get("run_id").and_then(Value::as_str);
2169        if requested_run_id.is_some_and(|run_id| !safe_workflow_run_id(run_id)) {
2170            return Ok(ToolOutput::error(
2171                "dynamic_workflow run_id must contain only ASCII letters, numbers, '-' or '_'",
2172            ));
2173        }
2174        let run_id = requested_run_id
2175            .map(ToString::to_string)
2176            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
2177        let store = match self.flow_event_store.clone() {
2178            Some(store) => Ok(store),
2179            None => flow_store_for_context(ctx, Some(&run_id)).await,
2180        };
2181        let store = match store {
2182            Ok(store) => store,
2183            Err(error) => return Ok(ToolOutput::error(error.to_string())),
2184        };
2185        let prior_history = match store.list(&run_id).await {
2186            Ok(history) => history,
2187            Err(a3s_flow::FlowError::RunNotFound(_)) => Vec::new(),
2188            Err(error) => return Ok(ToolOutput::error(error.to_string())),
2189        };
2190        // Preserve the immutable build pin already persisted in a resumed
2191        // history. A new run is pinned to this worker's current build; a
2192        // legacy run remains intentionally unpinned during migration. Keeping
2193        // the exact persisted spec is required because Flow treats the whole
2194        // workflow definition as the idempotent start contract.
2195        let (spec, effective_runtime_build_id) = prior_history
2196            .iter()
2197            .find_map(|envelope| match &envelope.event {
2198                FlowEvent::RunCreated { spec, .. } => Some(spec.clone()),
2199                _ => None,
2200            })
2201            .map(|persisted_spec| {
2202                let runtime_build_id = persisted_spec
2203                    .runtime_build_id
2204                    .as_ref()
2205                    .map(ToString::to_string);
2206                (persisted_spec, runtime_build_id)
2207            })
2208            .unwrap_or_else(|| {
2209                let spec = base_spec.with_runtime_build(runtime_build_id.clone());
2210                (spec, Some(runtime_build_id.to_string()))
2211            });
2212        let claim_identity = match dynamic_workflow_claim_identity(
2213            &run_id,
2214            source,
2215            &input,
2216            runtime_build_id.as_str(),
2217            &prior_history,
2218        ) {
2219            Ok(identity) => identity,
2220            Err(error) => {
2221                return Ok(ToolOutput::error(format!(
2222                    "dynamic workflow continuation identity rejected: {error}"
2223                )))
2224            }
2225        };
2226        let lease_ledger = match self.continuation_lease_ledger_for_context(ctx).await {
2227            Ok(ledger) => ledger,
2228            Err(error) => return Ok(ToolOutput::error(error.to_string())),
2229        };
2230        let lease_claim = match claim_dynamic_workflow_lease(
2231            lease_ledger,
2232            claim_identity.clone(),
2233            self.continuation_lease_ms,
2234            Arc::clone(&self.metrics),
2235        )
2236        .await
2237        {
2238            Ok(claim) => claim,
2239            Err(error) => return Ok(ToolOutput::error(error.to_string())),
2240        };
2241        let (lease, mut lease_state) = match lease_claim {
2242            DynamicWorkflowLeaseClaim::Owned(lease) => (Some(lease), "claimed"),
2243            DynamicWorkflowLeaseClaim::AlreadyCompleted => {
2244                if !history_is_terminal(&prior_history) {
2245                    return Ok(ToolOutput::error(
2246                        "dynamic workflow worker claim is completed but its durable run is not terminal",
2247                    ));
2248                }
2249                (None, "already_completed")
2250            }
2251        };
2252        let parent_cancellation = ctx.cancellation_token();
2253        let child_cancellation = parent_cancellation.child_token();
2254        let workflow_context = ctx
2255            .clone()
2256            .with_run_id(run_id.clone())
2257            .with_cancellation(child_cancellation.clone());
2258        let scheduler_quota = if self.admit_steps_globally && self.task_scheduler.is_some() {
2259            Some(
2260                TaskSchedulerQuota::new(
2261                    lease
2262                        .as_ref()
2263                        .map(|lease| lease.identity.clone())
2264                        .unwrap_or_else(|| claim_identity.clone()),
2265                    dynamic_workflow_scheduler_quota_limit(&limits),
2266                )
2267                .map_err(|error| {
2268                    anyhow::anyhow!("build dynamic workflow scheduler quota: {error}")
2269                })?,
2270            )
2271        } else {
2272            None
2273        };
2274        let mut runtime = DynamicWorkflowRuntime::new(registry, workflow_context.clone(), source)
2275            .with_allowed_tools(allowed_tools)
2276            .with_limits(limits);
2277        if let Some(scheduler) = &self.task_scheduler {
2278            runtime = runtime.with_task_scheduler(Arc::clone(scheduler), self.admit_steps_globally);
2279            if let Some(quota) = scheduler_quota {
2280                runtime = runtime.with_task_scheduler_quota(quota);
2281            }
2282        }
2283        if let Some(lease) = lease.as_ref() {
2284            runtime = runtime.with_continuation_lease(Arc::new(lease.clone()));
2285        }
2286        let runtime = Arc::new(runtime);
2287        let runtime_for_metadata = Arc::clone(&runtime);
2288        let initial_plan = if prior_history.is_empty() {
2289            ExecutionPlan::new("dynamic workflow", Complexity::Medium)
2290        } else {
2291            dynamic_workflow_execution_plan(&prior_history)
2292        };
2293        let mut observers: Vec<Arc<dyn FlowEventObserver>> = Vec::new();
2294        if let Some(tx) = ctx.agent_event_tx.clone() {
2295            observers.push(Arc::new(AgentEventFlowObserver::new(
2296                tx,
2297                ctx.session_id.clone().unwrap_or_default(),
2298                initial_plan,
2299            )));
2300        }
2301        if let Some(observer) = &self.graph_observer {
2302            observers.push(Arc::new(observer.clone()));
2303        }
2304        let mut engine_builder = FlowEngine::builder(runtime)
2305            .with_store(store)
2306            .with_runtime_build_compatibility(runtime_build_compatibility);
2307        if !observers.is_empty() {
2308            engine_builder = engine_builder
2309                .with_observer(Arc::new(FanoutFlowEventObserver::from_observers(observers)));
2310        }
2311        let engine = engine_builder.build();
2312        let input_for_identity = input.clone();
2313
2314        let execution_result = if let Some(lease) = lease.as_ref() {
2315            drive_dynamic_workflow_with_lease(
2316                &engine,
2317                &run_id,
2318                spec,
2319                input,
2320                DynamicWorkflowDriveContext {
2321                    source,
2322                    input_for_identity: &input_for_identity,
2323                    runtime_build_id: runtime_build_id.as_str(),
2324                    workflow_context: &workflow_context,
2325                    parent_cancellation,
2326                    child_cancellation,
2327                },
2328                lease,
2329            )
2330            .await
2331        } else {
2332            let started_run_id = match engine.start_with_id(&run_id, spec, input).await {
2333                Ok(run_id) => run_id,
2334                Err(error) => return Ok(ToolOutput::error(error.to_string())),
2335            };
2336            if started_run_id != run_id {
2337                return Ok(ToolOutput::error(format!(
2338                    "dynamic workflow engine returned unexpected run id `{started_run_id}` for `{run_id}`"
2339                )));
2340            }
2341            let snapshot = match drive_inline_retries(&engine, &run_id, &workflow_context).await {
2342                Ok(snapshot) => snapshot,
2343                Err(error) => return Ok(ToolOutput::error(error.to_string())),
2344            };
2345            collect_dynamic_workflow_execution(
2346                &engine,
2347                &run_id,
2348                snapshot,
2349                source,
2350                &input_for_identity,
2351                runtime_build_id.as_str(),
2352            )
2353            .await
2354        };
2355        let execution = match execution_result {
2356            Ok(execution) => execution,
2357            Err(err) => return Ok(ToolOutput::error(err.to_string())),
2358        };
2359        let DynamicWorkflowExecution {
2360            snapshot,
2361            history,
2362            continuation_identity,
2363        } = execution;
2364        if lease.is_some() {
2365            lease_state = if snapshot.status.is_terminal() {
2366                "completed"
2367            } else {
2368                "released"
2369            };
2370        }
2371
2372        let output = match &snapshot.output {
2373            Some(output) => {
2374                serde_json::to_string_pretty(output).unwrap_or_else(|_| output.to_string())
2375            }
2376            None => snapshot
2377                .error
2378                .clone()
2379                .unwrap_or_else(|| format!("workflow status: {:?}", snapshot.status)),
2380        };
2381
2382        let status = snapshot.status;
2383        let plan = dynamic_workflow_execution_plan(&history);
2384        let plan_identity = match plan.definition_identity() {
2385            Ok(identity) => identity,
2386            Err(error) => return Ok(ToolOutput::error(error.to_string())),
2387        };
2388        let metadata = json!({
2389            "dynamic_workflow": {
2390                "run_id": run_id,
2391                "status": format!("{:?}", snapshot.status),
2392                "last_sequence": snapshot.last_sequence,
2393                "source_hash": source_hash,
2394                "runtime_build_id": effective_runtime_build_id,
2395                "snapshot": snapshot,
2396                "history": history,
2397                "plan": plan,
2398                "plan_identity": plan_identity,
2399                "continuation_identity": continuation_identity,
2400                "worker_lease": {
2401                    "state": lease_state,
2402                    "attempt": lease.as_ref().map(|lease| lease.attempt),
2403                },
2404                "admission": runtime_for_metadata.admission_stats(),
2405            }
2406        });
2407        let output = match status {
2408            WorkflowRunStatus::Completed => ToolOutput::success(output),
2409            WorkflowRunStatus::Failed | WorkflowRunStatus::Cancelled => ToolOutput::error(output),
2410            _ => ToolOutput::error(format!(
2411                "dynamic_workflow ended without a terminal result: {status:?}; {output}"
2412            )),
2413        };
2414
2415        Ok(output.with_metadata(metadata))
2416    }
2417}
2418
2419struct DynamicWorkflowControlPreparation {
2420    store: Arc<dyn FlowEventStore>,
2421    prior_history: Vec<FlowEventEnvelope>,
2422    spec: WorkflowSpec,
2423    effective_runtime_build_id: Option<String>,
2424    runtime_build_id: RuntimeBuildId,
2425    runtime_build_compatibility: RuntimeBuildCompatibility,
2426    claim_identity: ExecutionIdentityV1,
2427    scheduler_quota: Option<TaskSchedulerQuota>,
2428    lease_ledger: Arc<dyn FlowDecisionLedger>,
2429}
2430
2431fn dynamic_workflow_runtime_configuration(
2432    configured: Option<&RuntimeBuildCompatibility>,
2433) -> Result<(RuntimeBuildId, RuntimeBuildCompatibility)> {
2434    let runtime_build_id =
2435        match configured {
2436            Some(compatibility) => compatibility.current_build_id().clone(),
2437            None => RuntimeBuildId::new(DYNAMIC_WORKFLOW_RUNTIME_BUILD_ID.to_string()).map_err(
2438                |error| anyhow::anyhow!("invalid dynamic workflow runtime build identity: {error}"),
2439            )?,
2440        };
2441    let compatibility = configured.cloned().unwrap_or_else(|| {
2442        RuntimeBuildCompatibility::new(runtime_build_id.clone()).accept_unpinned()
2443    });
2444    Ok((runtime_build_id, compatibility))
2445}
2446
2447impl DynamicWorkflowControl {
2448    async fn prepare(&self, allow_missing: bool) -> Result<DynamicWorkflowControlPreparation> {
2449        let (runtime_build_id, runtime_build_compatibility) =
2450            dynamic_workflow_runtime_configuration(self.runtime_build_compatibility.as_ref())?;
2451        let source_hash = source_hash(self.source.as_ref());
2452        let base_spec = WorkflowSpec::rust_embedded(
2453            "a3s-code.dynamic-workflow",
2454            source_hash.as_str(),
2455            "ptc",
2456            "run",
2457        );
2458        let store = match self.flow_event_store.clone() {
2459            Some(store) => store,
2460            None => flow_store_for_context(&self.context, Some(&self.run_id)).await?,
2461        };
2462        let prior_history = match store.list(&self.run_id).await {
2463            Ok(history) => history,
2464            Err(a3s_flow::FlowError::RunNotFound(_)) if allow_missing => Vec::new(),
2465            Err(error) => return Err(error.into()),
2466        };
2467        if !allow_missing && prior_history.is_empty() {
2468            return Err(a3s_flow::FlowError::RunNotFound(self.run_id.clone()).into());
2469        }
2470        let (spec, effective_runtime_build_id) = prior_history
2471            .iter()
2472            .find_map(|envelope| match &envelope.event {
2473                FlowEvent::RunCreated { spec, .. } => Some(spec.clone()),
2474                _ => None,
2475            })
2476            .map(|persisted_spec| {
2477                let runtime_build_id = persisted_spec
2478                    .runtime_build_id
2479                    .as_ref()
2480                    .map(ToString::to_string);
2481                (persisted_spec, runtime_build_id)
2482            })
2483            .unwrap_or_else(|| {
2484                let spec = base_spec.with_runtime_build(runtime_build_id.clone());
2485                (spec, Some(runtime_build_id.to_string()))
2486            });
2487        let claim_identity = dynamic_workflow_claim_identity(
2488            &self.run_id,
2489            self.source.as_ref(),
2490            &self.input,
2491            runtime_build_id.as_str(),
2492            &prior_history,
2493        )
2494        .map_err(|error| {
2495            anyhow::anyhow!("dynamic workflow continuation identity rejected: {error}")
2496        })?;
2497        let scheduler_quota = if self.admit_steps_globally && self.task_scheduler.is_some() {
2498            Some(
2499                TaskSchedulerQuota::new(
2500                    claim_identity.clone(),
2501                    dynamic_workflow_scheduler_quota_limit(&self.limits),
2502                )
2503                .map_err(|error| {
2504                    anyhow::anyhow!("build dynamic workflow scheduler quota: {error}")
2505                })?,
2506            )
2507        } else {
2508            None
2509        };
2510        let lease_ledger = dynamic_workflow_lease_ledger_for_context(
2511            self.continuation_lease_ledger.as_ref(),
2512            &self.memory_continuation_lease_ledger,
2513            &self.context,
2514        )
2515        .await?;
2516        Ok(DynamicWorkflowControlPreparation {
2517            store,
2518            prior_history,
2519            spec,
2520            effective_runtime_build_id,
2521            runtime_build_id,
2522            runtime_build_compatibility,
2523            claim_identity,
2524            scheduler_quota,
2525            lease_ledger,
2526        })
2527    }
2528
2529    fn build_engine(
2530        &self,
2531        preparation: &DynamicWorkflowControlPreparation,
2532        workflow_context: ToolContext,
2533        lease: Option<&DynamicWorkflowLease>,
2534    ) -> FlowEngine {
2535        let mut runtime = DynamicWorkflowRuntime::new(
2536            Arc::clone(&self.registry),
2537            workflow_context,
2538            self.source.as_ref(),
2539        )
2540        .with_allowed_tools(self.allowed_tools.clone())
2541        .with_limits(self.limits.clone());
2542        if let Some(scheduler) = &self.task_scheduler {
2543            runtime = runtime.with_task_scheduler(Arc::clone(scheduler), self.admit_steps_globally);
2544            if let Some(quota) = preparation.scheduler_quota.clone() {
2545                runtime = runtime.with_task_scheduler_quota(quota);
2546            }
2547        }
2548        if let Some(lease) = lease {
2549            runtime = runtime.with_continuation_lease(Arc::new(lease.clone()));
2550        }
2551        let runtime = Arc::new(runtime);
2552        let initial_plan = if preparation.prior_history.is_empty() {
2553            ExecutionPlan::new("dynamic workflow", Complexity::Medium)
2554        } else {
2555            dynamic_workflow_execution_plan(&preparation.prior_history)
2556        };
2557        let mut observers: Vec<Arc<dyn FlowEventObserver>> = Vec::new();
2558        if let Some(tx) = self.context.agent_event_tx.clone() {
2559            observers.push(Arc::new(AgentEventFlowObserver::new(
2560                tx,
2561                self.context.session_id.clone().unwrap_or_default(),
2562                initial_plan,
2563            )));
2564        }
2565        if let Some(observer) = &self.graph_observer {
2566            observers.push(Arc::new(observer.clone()));
2567        }
2568        let mut builder = FlowEngine::builder(runtime)
2569            .with_store(Arc::clone(&preparation.store))
2570            .with_runtime_build_compatibility(preparation.runtime_build_compatibility.clone());
2571        if !observers.is_empty() {
2572            builder =
2573                builder.with_observer(Arc::new(FanoutFlowEventObserver::from_observers(observers)));
2574        }
2575        builder.build()
2576    }
2577
2578    async fn read_state(
2579        &self,
2580        preparation: &DynamicWorkflowControlPreparation,
2581        engine: &FlowEngine,
2582    ) -> Result<(WorkflowRunSnapshot, Vec<FlowEventEnvelope>)> {
2583        for _ in 0..3 {
2584            // Flow exposes history and its projection as separate reads. Read
2585            // the journal on both sides of the projection so a concurrent
2586            // append cannot be mistaken for an atomic snapshot; retry a
2587            // bounded number of times when the sequences disagree.
2588            let before_history = engine.history(&self.run_id).await?;
2589            let snapshot = engine.snapshot(&self.run_id).await?;
2590            let history = engine.history(&self.run_id).await?;
2591            let before_sequence = before_history
2592                .last()
2593                .map(|event| event.sequence)
2594                .unwrap_or(0);
2595            let last_sequence = history.last().map(|event| event.sequence).unwrap_or(0);
2596            if before_sequence == last_sequence && snapshot.last_sequence == last_sequence {
2597                return Ok((snapshot, history));
2598            }
2599        }
2600        let expected = preparation
2601            .prior_history
2602            .last()
2603            .map(|event| event.sequence)
2604            .unwrap_or(0);
2605        anyhow::bail!(
2606            "dynamic workflow state changed repeatedly while inspecting run `{}` (initial sequence {expected})",
2607            self.run_id
2608        )
2609    }
2610
2611    async fn summarize(
2612        &self,
2613        preparation: &DynamicWorkflowControlPreparation,
2614        snapshot: WorkflowRunSnapshot,
2615        history: Vec<FlowEventEnvelope>,
2616    ) -> Result<DynamicWorkflowControlSnapshot> {
2617        let continuation_identity = dynamic_workflow_continuation_identity(
2618            &self.run_id,
2619            self.source.as_ref(),
2620            &self.input,
2621            preparation.runtime_build_id.as_str(),
2622            &history,
2623        )
2624        .map_err(|error| {
2625            anyhow::anyhow!(
2626                "dynamic workflow continuation identity rejected during inspection: {error}"
2627            )
2628        })?;
2629        let plan = dynamic_workflow_execution_plan(&history);
2630        let plan_identity = plan.definition_identity()?;
2631        let (decision_id, request_hash) = dynamic_workflow_lease_key(&preparation.claim_identity);
2632        let worker_lease = preparation
2633            .lease_ledger
2634            .inspect_with_identity(&decision_id, &request_hash, &preparation.claim_identity)
2635            .await?;
2636        let completed_steps = snapshot
2637            .steps
2638            .values()
2639            .filter(|step| step.status == StepStatus::Completed)
2640            .count();
2641        let open_steps = snapshot
2642            .steps
2643            .values()
2644            .filter(|step| {
2645                !matches!(
2646                    step.status,
2647                    StepStatus::Completed | StepStatus::Failed | StepStatus::Cancelled
2648                )
2649            })
2650            .count();
2651        Ok(DynamicWorkflowControlSnapshot {
2652            run_id: self.run_id.clone(),
2653            status: snapshot.status,
2654            last_sequence: snapshot.last_sequence,
2655            step_count: snapshot.steps.len(),
2656            completed_steps,
2657            open_steps,
2658            cancellation_requested: snapshot.cancellation.is_some(),
2659            continuation_identity,
2660            plan_identity,
2661            runtime_build_id: preparation.effective_runtime_build_id.clone(),
2662            worker_lease,
2663        })
2664    }
2665
2666    async fn claim(
2667        &self,
2668        preparation: &DynamicWorkflowControlPreparation,
2669    ) -> Result<DynamicWorkflowLeaseClaim> {
2670        claim_dynamic_workflow_lease(
2671            Arc::clone(&preparation.lease_ledger),
2672            preparation.claim_identity.clone(),
2673            self.continuation_lease_ms,
2674            Arc::clone(&self.metrics),
2675        )
2676        .await
2677    }
2678
2679    /// Inspect the durable run and return a bounded, digest-only projection.
2680    pub async fn inspect(&self) -> Result<DynamicWorkflowControlSnapshot> {
2681        let preparation = self.prepare(false).await?;
2682        let engine = self.build_engine(&preparation, self.context.clone(), None);
2683        let (snapshot, history) = self.read_state(&preparation, &engine).await?;
2684        self.summarize(&preparation, snapshot, history).await
2685    }
2686
2687    /// Read the complete durable Flow history for trusted host diagnostics.
2688    ///
2689    /// Unlike [`Self::inspect`], this method intentionally returns persisted
2690    /// input and step output values. Callers should apply their own redaction
2691    /// policy before exposing the result outside a trusted control plane.
2692    pub async fn history(&self) -> Result<Vec<FlowEventEnvelope>> {
2693        let preparation = self.prepare(false).await?;
2694        let history = preparation.store.list(&self.run_id).await?;
2695        dynamic_workflow_continuation_identity(
2696            &self.run_id,
2697            self.source.as_ref(),
2698            &self.input,
2699            preparation.runtime_build_id.as_str(),
2700            &history,
2701        )
2702        .map_err(|error| anyhow::anyhow!("dynamic workflow history rejected: {error}"))?;
2703        Ok(history)
2704    }
2705
2706    /// Request cleanup-aware durable cancellation and settle the worker lease.
2707    ///
2708    /// A live worker keeps ownership and causes this method to return a busy
2709    /// error; the caller can retry after the lease expires or the worker
2710    /// releases it. This preserves one executor for side-effecting steps.
2711    pub async fn request_cancellation(
2712        &self,
2713        reason: Option<String>,
2714    ) -> Result<DynamicWorkflowControlSnapshot> {
2715        validate_dynamic_workflow_control_reason(reason.as_deref())?;
2716        if self.context.is_cancelled() {
2717            anyhow::bail!("dynamic workflow control was cancelled before admission");
2718        }
2719        let preparation = self.prepare(false).await?;
2720        let claim = self.claim(&preparation).await?;
2721        let lease = match claim {
2722            DynamicWorkflowLeaseClaim::Owned(lease) => lease,
2723            DynamicWorkflowLeaseClaim::AlreadyCompleted => {
2724                let engine = self.build_engine(&preparation, self.context.clone(), None);
2725                let (snapshot, history) = self.read_state(&preparation, &engine).await?;
2726                if !snapshot.status.is_terminal() {
2727                    anyhow::bail!(
2728                        "dynamic workflow worker claim is completed but its durable run is not terminal"
2729                    );
2730                }
2731                return self.summarize(&preparation, snapshot, history).await;
2732            }
2733        };
2734        let parent_cancellation = self.context.cancellation_token();
2735        let child_cancellation = parent_cancellation.child_token();
2736        let workflow_context = self
2737            .context
2738            .clone()
2739            .with_cancellation(child_cancellation.clone());
2740        let engine = self.build_engine(&preparation, workflow_context.clone(), Some(&lease));
2741        let future = engine.request_cancellation(&self.run_id, CancellationRequest::new(reason));
2742        let execution = drive_dynamic_workflow_control_future(
2743            &engine,
2744            &self.run_id,
2745            DynamicWorkflowDriveContext {
2746                source: self.source.as_ref(),
2747                input_for_identity: &self.input,
2748                runtime_build_id: preparation.runtime_build_id.as_str(),
2749                workflow_context: &workflow_context,
2750                parent_cancellation,
2751                child_cancellation,
2752            },
2753            &lease,
2754            future,
2755        )
2756        .await?;
2757        self.summarize(&preparation, execution.snapshot, execution.history)
2758            .await
2759    }
2760
2761    /// Immediately terminate the run through Flow's durable cancellation
2762    /// transition while still fencing the worker claim.
2763    pub async fn force_cancel(
2764        &self,
2765        reason: Option<String>,
2766    ) -> Result<DynamicWorkflowControlSnapshot> {
2767        validate_dynamic_workflow_control_reason(reason.as_deref())?;
2768        if self.context.is_cancelled() {
2769            anyhow::bail!("dynamic workflow control was cancelled before admission");
2770        }
2771        let preparation = self.prepare(false).await?;
2772        let claim = self.claim(&preparation).await?;
2773        let lease = match claim {
2774            DynamicWorkflowLeaseClaim::Owned(lease) => lease,
2775            DynamicWorkflowLeaseClaim::AlreadyCompleted => {
2776                let engine = self.build_engine(&preparation, self.context.clone(), None);
2777                let (snapshot, history) = self.read_state(&preparation, &engine).await?;
2778                if !snapshot.status.is_terminal() {
2779                    anyhow::bail!(
2780                        "dynamic workflow worker claim is completed but its durable run is not terminal"
2781                    );
2782                }
2783                return self.summarize(&preparation, snapshot, history).await;
2784            }
2785        };
2786        let parent_cancellation = self.context.cancellation_token();
2787        let child_cancellation = parent_cancellation.child_token();
2788        let workflow_context = self
2789            .context
2790            .clone()
2791            .with_cancellation(child_cancellation.clone());
2792        let engine = self.build_engine(&preparation, workflow_context.clone(), Some(&lease));
2793        let future = async {
2794            engine.force_cancel(&self.run_id, reason).await?;
2795            engine.snapshot(&self.run_id).await
2796        };
2797        let execution = drive_dynamic_workflow_control_future(
2798            &engine,
2799            &self.run_id,
2800            DynamicWorkflowDriveContext {
2801                source: self.source.as_ref(),
2802                input_for_identity: &self.input,
2803                runtime_build_id: preparation.runtime_build_id.as_str(),
2804                workflow_context: &workflow_context,
2805                parent_cancellation,
2806                child_cancellation,
2807            },
2808            &lease,
2809            future,
2810        )
2811        .await?;
2812        self.summarize(&preparation, execution.snapshot, execution.history)
2813            .await
2814    }
2815
2816    /// Resume or start the bound run under the same worker fencing contract as
2817    /// the model-visible dynamic workflow tool.
2818    pub async fn drive(&self) -> Result<DynamicWorkflowControlSnapshot> {
2819        if self.context.is_cancelled() {
2820            anyhow::bail!("dynamic workflow control was cancelled before admission");
2821        }
2822        let preparation = self.prepare(true).await?;
2823        let claim = self.claim(&preparation).await?;
2824        let lease = match claim {
2825            DynamicWorkflowLeaseClaim::Owned(lease) => lease,
2826            DynamicWorkflowLeaseClaim::AlreadyCompleted => {
2827                let engine = self.build_engine(&preparation, self.context.clone(), None);
2828                let (snapshot, history) = self.read_state(&preparation, &engine).await?;
2829                if !snapshot.status.is_terminal() {
2830                    anyhow::bail!(
2831                        "dynamic workflow worker claim is completed but its durable run is not terminal"
2832                    );
2833                }
2834                return self.summarize(&preparation, snapshot, history).await;
2835            }
2836        };
2837        let parent_cancellation = self.context.cancellation_token();
2838        let child_cancellation = parent_cancellation.child_token();
2839        let workflow_context = self
2840            .context
2841            .clone()
2842            .with_cancellation(child_cancellation.clone());
2843        let engine = self.build_engine(&preparation, workflow_context.clone(), Some(&lease));
2844        let execution = drive_dynamic_workflow_with_lease(
2845            &engine,
2846            &self.run_id,
2847            preparation.spec.clone(),
2848            self.input.clone(),
2849            DynamicWorkflowDriveContext {
2850                source: self.source.as_ref(),
2851                input_for_identity: &self.input,
2852                runtime_build_id: preparation.runtime_build_id.as_str(),
2853                workflow_context: &workflow_context,
2854                parent_cancellation,
2855                child_cancellation,
2856            },
2857            &lease,
2858        )
2859        .await?;
2860        self.summarize(&preparation, execution.snapshot, execution.history)
2861            .await
2862    }
2863}
2864
2865struct DynamicWorkflowExecution {
2866    snapshot: WorkflowRunSnapshot,
2867    history: Vec<FlowEventEnvelope>,
2868    continuation_identity: ExecutionIdentityV1,
2869}
2870
2871struct DynamicWorkflowDriveContext<'a> {
2872    source: &'a str,
2873    input_for_identity: &'a Value,
2874    runtime_build_id: &'a str,
2875    workflow_context: &'a ToolContext,
2876    parent_cancellation: CancellationToken,
2877    child_cancellation: CancellationToken,
2878}
2879
2880async fn collect_dynamic_workflow_execution(
2881    engine: &FlowEngine,
2882    run_id: &str,
2883    snapshot: WorkflowRunSnapshot,
2884    source: &str,
2885    input: &Value,
2886    runtime_build_id: &str,
2887) -> Result<DynamicWorkflowExecution> {
2888    let history = engine.history(run_id).await?;
2889    let continuation_identity =
2890        dynamic_workflow_continuation_identity(run_id, source, input, runtime_build_id, &history)
2891            .map_err(|error| {
2892            anyhow::anyhow!("dynamic workflow continuation identity rejected after replay: {error}")
2893        })?;
2894    Ok(DynamicWorkflowExecution {
2895        snapshot,
2896        history,
2897        continuation_identity,
2898    })
2899}
2900
2901fn validate_dynamic_workflow_control_reason(reason: Option<&str>) -> Result<()> {
2902    let Some(reason) = reason else {
2903        return Ok(());
2904    };
2905    if reason.len() > MAX_DYNAMIC_WORKFLOW_CONTROL_REASON_BYTES {
2906        anyhow::bail!(
2907            "dynamic workflow cancellation reason exceeds {} bytes",
2908            MAX_DYNAMIC_WORKFLOW_CONTROL_REASON_BYTES
2909        );
2910    }
2911    if reason.contains('\0') {
2912        anyhow::bail!("dynamic workflow cancellation reason contains a NUL byte");
2913    }
2914    Ok(())
2915}
2916
2917async fn drive_dynamic_workflow_control_future<F>(
2918    engine: &FlowEngine,
2919    run_id: &str,
2920    drive_context: DynamicWorkflowDriveContext<'_>,
2921    lease: &DynamicWorkflowLease,
2922    future: F,
2923) -> Result<DynamicWorkflowExecution>
2924where
2925    F: Future<Output = a3s_flow::Result<WorkflowRunSnapshot>>,
2926{
2927    let DynamicWorkflowDriveContext {
2928        source,
2929        input_for_identity,
2930        runtime_build_id,
2931        workflow_context: _workflow_context,
2932        parent_cancellation,
2933        child_cancellation,
2934    } = drive_context;
2935    tokio::pin!(future);
2936
2937    async fn collect_result(
2938        engine: &FlowEngine,
2939        run_id: &str,
2940        result: a3s_flow::Result<WorkflowRunSnapshot>,
2941        source: &str,
2942        input: &Value,
2943        runtime_build_id: &str,
2944    ) -> Result<DynamicWorkflowExecution> {
2945        let snapshot = result?;
2946        collect_dynamic_workflow_execution(
2947            engine,
2948            run_id,
2949            snapshot,
2950            source,
2951            input,
2952            runtime_build_id,
2953        )
2954        .await
2955    }
2956
2957    let heartbeat_period = Duration::from_millis((lease.lease_ms / 3).max(1));
2958    let first_heartbeat = tokio::time::Instant::now() + heartbeat_period;
2959    let mut heartbeat = tokio::time::interval_at(first_heartbeat, heartbeat_period);
2960    loop {
2961        tokio::select! {
2962            biased;
2963            result = &mut future => {
2964                let result = collect_result(
2965                    engine,
2966                    run_id,
2967                    result,
2968                    source,
2969                    input_for_identity,
2970                    runtime_build_id,
2971                ).await;
2972                return settle_dynamic_workflow_lease(lease, result).await;
2973            }
2974            _ = parent_cancellation.cancelled() => {
2975                DynamicWorkflowMetrics::increment(&lease.metrics.cancellations);
2976                child_cancellation.cancel();
2977                match tokio::time::timeout(MAX_DYNAMIC_WORKFLOW_SETTLE, &mut future).await {
2978                    Ok(result) => {
2979                        let result = collect_result(
2980                            engine,
2981                            run_id,
2982                            result,
2983                            source,
2984                            input_for_identity,
2985                            runtime_build_id,
2986                        ).await;
2987                        match settle_dynamic_workflow_lease(lease, result).await {
2988                            Ok(execution) if execution.snapshot.status.is_terminal() => {
2989                                return Ok(execution)
2990                            }
2991                            Ok(_) => anyhow::bail!("dynamic workflow control cancelled by its parent"),
2992                            Err(error) => return Err(error).context("settle dynamic workflow control cancellation"),
2993                        }
2994                    }
2995                    Err(_) => anyhow::bail!(
2996                        "dynamic workflow control cancellation did not settle within {} seconds; worker lease remains fenced until expiry",
2997                        MAX_DYNAMIC_WORKFLOW_SETTLE.as_secs()
2998                    ),
2999                }
3000            }
3001            _ = heartbeat.tick() => {
3002                match lease.renew().await {
3003                    Ok(true) => {}
3004                    Ok(false) => {
3005                        child_cancellation.cancel();
3006                        if tokio::time::timeout(MAX_DYNAMIC_WORKFLOW_SETTLE, &mut future)
3007                            .await
3008                            .is_ok()
3009                        {
3010                            let _ = lease.release().await;
3011                        }
3012                        anyhow::bail!("dynamic workflow control worker lease was lost before completion");
3013                    }
3014                    Err(error) => {
3015                        child_cancellation.cancel();
3016                        if tokio::time::timeout(MAX_DYNAMIC_WORKFLOW_SETTLE, &mut future)
3017                            .await
3018                            .is_ok()
3019                        {
3020                            let _ = lease.release().await;
3021                        }
3022                        return Err(error).context("renew dynamic workflow control worker lease");
3023                    }
3024                }
3025            }
3026        }
3027    }
3028}
3029
3030async fn settle_dynamic_workflow_lease(
3031    lease: &DynamicWorkflowLease,
3032    result: Result<DynamicWorkflowExecution>,
3033) -> Result<DynamicWorkflowExecution> {
3034    match result {
3035        Ok(execution) if execution.snapshot.status.is_terminal() => {
3036            lease
3037                .complete()
3038                .await
3039                .context("complete dynamic workflow worker lease")?;
3040            Ok(execution)
3041        }
3042        Ok(execution) => {
3043            lease
3044                .release()
3045                .await
3046                .context("release suspended dynamic workflow worker lease")?;
3047            Ok(execution)
3048        }
3049        Err(error) => {
3050            if let Err(release_error) = lease.release().await {
3051                tracing::warn!(
3052                    error = %release_error,
3053                    "failed to release dynamic workflow worker lease after execution error"
3054                );
3055            }
3056            Err(error)
3057        }
3058    }
3059}
3060
3061async fn drive_dynamic_workflow_with_lease(
3062    engine: &FlowEngine,
3063    run_id: &str,
3064    spec: WorkflowSpec,
3065    input: Value,
3066    drive_context: DynamicWorkflowDriveContext<'_>,
3067    lease: &DynamicWorkflowLease,
3068) -> Result<DynamicWorkflowExecution> {
3069    let DynamicWorkflowDriveContext {
3070        source,
3071        input_for_identity,
3072        runtime_build_id,
3073        workflow_context,
3074        parent_cancellation,
3075        child_cancellation,
3076    } = drive_context;
3077    let execution = async {
3078        let started_run_id = engine.start_with_id(run_id, spec, input).await?;
3079        if started_run_id != run_id {
3080            anyhow::bail!(
3081                "dynamic workflow engine returned unexpected run id `{started_run_id}` for `{run_id}`"
3082            );
3083        }
3084        let snapshot = drive_inline_retries(engine, run_id, workflow_context).await?;
3085        collect_dynamic_workflow_execution(
3086            engine,
3087            run_id,
3088            snapshot,
3089            source,
3090            input_for_identity,
3091            runtime_build_id,
3092        )
3093        .await
3094    };
3095    tokio::pin!(execution);
3096
3097    let heartbeat_period = Duration::from_millis((lease.lease_ms / 3).max(1));
3098    let first_heartbeat = tokio::time::Instant::now() + heartbeat_period;
3099    let mut heartbeat = tokio::time::interval_at(first_heartbeat, heartbeat_period);
3100    loop {
3101        tokio::select! {
3102            biased;
3103            result = &mut execution => {
3104                return settle_dynamic_workflow_lease(lease, result).await;
3105            }
3106            _ = parent_cancellation.cancelled() => {
3107                DynamicWorkflowMetrics::increment(&lease.metrics.cancellations);
3108                child_cancellation.cancel();
3109                match tokio::time::timeout(MAX_DYNAMIC_WORKFLOW_SETTLE, &mut execution).await {
3110                    Ok(result) => {
3111                        match settle_dynamic_workflow_lease(lease, result).await {
3112                            Ok(execution) if execution.snapshot.status.is_terminal() => {
3113                                return Ok(execution)
3114                            }
3115                            Ok(_) => anyhow::bail!("dynamic workflow cancelled by its parent"),
3116                            Err(error) => return Err(error).context("settle dynamic workflow cancellation"),
3117                        }
3118                    }
3119                    Err(_) => anyhow::bail!(
3120                        "dynamic workflow cancellation did not settle within {} seconds; worker lease remains fenced until expiry",
3121                        MAX_DYNAMIC_WORKFLOW_SETTLE.as_secs()
3122                    ),
3123                }
3124            }
3125            _ = heartbeat.tick() => {
3126                match lease.renew().await {
3127                    Ok(true) => {}
3128                    Ok(false) => {
3129                        child_cancellation.cancel();
3130                        if tokio::time::timeout(MAX_DYNAMIC_WORKFLOW_SETTLE, &mut execution)
3131                            .await
3132                            .is_ok()
3133                        {
3134                            let _ = lease.release().await;
3135                        }
3136                        anyhow::bail!("dynamic workflow worker lease was lost before execution completed");
3137                    }
3138                    Err(error) => {
3139                        child_cancellation.cancel();
3140                        if tokio::time::timeout(MAX_DYNAMIC_WORKFLOW_SETTLE, &mut execution)
3141                            .await
3142                            .is_ok()
3143                        {
3144                            let _ = lease.release().await;
3145                        }
3146                        return Err(error).context("renew dynamic workflow worker lease");
3147                    }
3148                }
3149            }
3150        }
3151    }
3152}
3153
3154/// Drive short, persisted step retries inside the originating tool call.
3155///
3156/// A3S Flow deliberately suspends at a delayed retry boundary. Interactive
3157/// waits and hooks must remain suspended for an external host, but a bounded
3158/// retry delay is ordinary fault recovery: returning it as a terminal tool
3159/// error forces every caller to reimplement the scheduler and previously made
3160/// DeepResearch abandon its event-sourced run. Retry attempts and their delay
3161/// remain authoritative in the Flow journal; this helper only waits for the
3162/// due time and asks the engine to replay the same run.
3163async fn drive_inline_retries(
3164    engine: &FlowEngine,
3165    run_id: &str,
3166    ctx: &ToolContext,
3167) -> Result<WorkflowRunSnapshot> {
3168    for _ in 0..MAX_INLINE_RETRY_RESUMES {
3169        let snapshot = engine.snapshot(run_id).await?;
3170        if snapshot.status.is_terminal() {
3171            return Ok(snapshot);
3172        }
3173        let Some(retry_after) = snapshot
3174            .steps
3175            .values()
3176            .filter(|step| step.status == StepStatus::Pending)
3177            .filter_map(|step| step.retry_after)
3178            .min()
3179        else {
3180            return Ok(snapshot);
3181        };
3182        let delay = retry_after
3183            .signed_duration_since(Utc::now())
3184            .to_std()
3185            .unwrap_or_default();
3186        if delay > MAX_INLINE_RETRY_DELAY {
3187            return Ok(snapshot);
3188        }
3189        let cancellation = ctx.cancellation_token();
3190        tokio::select! {
3191            biased;
3192            _ = cancellation.cancelled() => {
3193                anyhow::bail!("dynamic_workflow cancelled while waiting for a scheduled retry");
3194            }
3195            _ = tokio::time::sleep(delay) => {}
3196        }
3197        engine.drive(run_id).await?;
3198    }
3199    engine.snapshot(run_id).await.map_err(Into::into)
3200}
3201
3202pub fn register_dynamic_workflow(registry: &Arc<ToolRegistry>) {
3203    registry.register(Arc::new(DynamicWorkflowTool::new_registry_bound(
3204        Arc::clone(registry),
3205    )));
3206}
3207
3208/// Register a dynamic workflow Tool against a host-owned Flow event store.
3209///
3210/// The registry-bound Tool keeps only a weak registry reference, so adding a
3211/// durable store does not create a registry/tool ownership cycle. Use this for
3212/// database-backed or remote adapters that must be shared by model-visible
3213/// execution and [`DynamicWorkflowTool::control`].
3214pub fn register_dynamic_workflow_with_event_store(
3215    registry: &Arc<ToolRegistry>,
3216    store: Arc<dyn FlowEventStore>,
3217) {
3218    registry.register(Arc::new(
3219        DynamicWorkflowTool::new_registry_bound(Arc::clone(registry)).with_flow_event_store(store),
3220    ));
3221}
3222
3223/// Register a dynamic workflow tool with an explicit scheduler policy.
3224///
3225/// This is intended for hosts that construct a Flow runtime outside the
3226/// normal AgentSession operation boundary. AgentSession registrations should
3227/// use [`register_dynamic_workflow`] so the enclosing run lease remains the
3228/// single global admission boundary.
3229pub fn register_dynamic_workflow_with_scheduler(
3230    registry: &Arc<ToolRegistry>,
3231    scheduler: Arc<TaskScheduler>,
3232    admit_steps_globally: bool,
3233) {
3234    registry.register(Arc::new(
3235        DynamicWorkflowTool::new_registry_bound(Arc::clone(registry))
3236            .with_task_scheduler(scheduler, admit_steps_globally),
3237    ));
3238}
3239
3240async fn flow_store_for_context(
3241    ctx: &ToolContext,
3242    requested_run_id: Option<&str>,
3243) -> Result<Arc<dyn FlowEventStore>> {
3244    match ctx.workspace_services.local_root() {
3245        Some(root) => {
3246            let store = dynamic_workflow_store_path(root);
3247            validate_dynamic_workflow_directory(&root.join(".a3s"), ".a3s").await?;
3248            validate_dynamic_workflow_directory(&store, ".a3s/workflow").await?;
3249            if let Some(run_id) = requested_run_id.filter(|run_id| safe_workflow_run_id(run_id)) {
3250                validate_dynamic_workflow_log(&store.join(format!("{run_id}.jsonl"))).await?;
3251            }
3252            Ok(Arc::new(CrossProcessFlowEventStore::new(store)))
3253        }
3254        None => Ok(Arc::new(InMemoryEventStore::new())),
3255    }
3256}
3257
3258async fn validate_dynamic_workflow_directory(path: &Path, label: &str) -> Result<()> {
3259    match tokio::fs::symlink_metadata(path).await {
3260        Ok(metadata) if metadata.file_type().is_symlink() => {
3261            anyhow::bail!("refusing to use symlinked dynamic workflow directory {label}")
3262        }
3263        Ok(metadata) if !metadata.is_dir() => {
3264            anyhow::bail!("dynamic workflow path {label} exists but is not a directory")
3265        }
3266        Ok(_) => Ok(()),
3267        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3268        Err(error) => Err(error).with_context(|| format!("inspect dynamic workflow path {label}")),
3269    }
3270}
3271
3272async fn validate_dynamic_workflow_log(path: &Path) -> Result<()> {
3273    match tokio::fs::symlink_metadata(path).await {
3274        Ok(metadata) if metadata.file_type().is_symlink() => anyhow::bail!(
3275            "refusing to read or append symlinked dynamic workflow history {}",
3276            path.display()
3277        ),
3278        Ok(metadata) if !metadata.is_file() => anyhow::bail!(
3279            "dynamic workflow history path {} exists but is not a file",
3280            path.display()
3281        ),
3282        Ok(_) => Ok(()),
3283        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3284        Err(error) => Err(error)
3285            .with_context(|| format!("inspect dynamic workflow history {}", path.display())),
3286    }
3287}
3288
3289fn safe_workflow_run_id(run_id: &str) -> bool {
3290    !run_id.is_empty()
3291        && run_id
3292            .chars()
3293            .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
3294}
3295
3296struct PayloadBuilder {
3297    value: Map<String, Value>,
3298}
3299
3300impl PayloadBuilder {
3301    fn with(mut self, key: &str, value: impl Serialize) -> Self {
3302        self.value.insert(
3303            key.to_string(),
3304            serde_json::to_value(value).unwrap_or(Value::Null),
3305        );
3306        self
3307    }
3308
3309    fn into_value(self) -> Value {
3310        Value::Object(self.value)
3311    }
3312}
3313
3314fn invocation_payload(kind: &str, run_id: &str, history: &[FlowEventEnvelope]) -> PayloadBuilder {
3315    let mut value = Map::new();
3316    value.insert("kind".to_string(), json!(kind));
3317    value.insert("run_id".to_string(), json!(run_id));
3318    value.insert("history".to_string(), json!(history));
3319    value.insert("step_outputs".to_string(), completed_step_outputs(history));
3320    value.insert("step_failures".to_string(), failed_step_outputs(history));
3321    PayloadBuilder { value }
3322}
3323
3324fn completed_step_outputs(history: &[FlowEventEnvelope]) -> Value {
3325    let mut outputs = Map::new();
3326    for envelope in history {
3327        if let FlowEvent::StepCompleted { step_id, output } = &envelope.event {
3328            outputs.insert(step_id.clone(), output.clone());
3329        }
3330    }
3331    Value::Object(outputs)
3332}
3333
3334fn failed_step_outputs(history: &[FlowEventEnvelope]) -> Value {
3335    let mut outputs = Map::new();
3336    for envelope in history {
3337        if let FlowEvent::StepFailed {
3338            step_id,
3339            attempt,
3340            error,
3341        } = &envelope.event
3342        {
3343            outputs.insert(
3344                step_id.clone(),
3345                json!({
3346                    "attempt": attempt,
3347                    "error": error,
3348                }),
3349            );
3350        }
3351    }
3352    Value::Object(outputs)
3353}
3354
3355fn script_result(result: &ToolResult) -> a3s_flow::Result<Value> {
3356    result
3357        .metadata
3358        .as_ref()
3359        .and_then(|metadata| metadata.get("script_result"))
3360        .cloned()
3361        .ok_or_else(|| {
3362            a3s_flow::FlowError::Runtime(
3363                "PTC program result did not include script_result metadata".to_string(),
3364            )
3365        })
3366}
3367
3368fn default_allowed_tools(registry: &ToolRegistry) -> Vec<String> {
3369    sanitize_allowed_tools(registry.list())
3370}
3371
3372fn sanitize_allowed_tools(items: impl IntoIterator<Item = String>) -> Vec<String> {
3373    let mut tools = items.into_iter().collect::<BTreeSet<_>>();
3374    tools.remove(PROGRAM_TOOL);
3375    tools.remove(DYNAMIC_WORKFLOW_TOOL);
3376    tools.remove(PARALLEL_TASK_TOOL);
3377    tools.into_iter().collect()
3378}
3379
3380fn source_hash(source: &str) -> String {
3381    sha256::digest(source.as_bytes())
3382}
3383
3384#[cfg(test)]
3385#[path = "dynamic_workflow/tests.rs"]
3386mod tests;