Skip to main content

lash_core/runtime/effect/
executor.rs

1use std::collections::HashMap;
2use std::pin::Pin;
3use std::sync::Arc;
4use std::time::Instant;
5
6use serde::{Deserialize, Serialize};
7use tokio::sync::mpsc;
8use tokio_util::sync::CancellationToken;
9
10use crate::AttachmentStore;
11use crate::LlmRequest as CoreLlmRequest;
12use crate::LlmResponse;
13use crate::ProcessRecord;
14use crate::ProcessRegistry;
15use crate::provider::ProviderHandle;
16use crate::runtime::{RuntimeStreamEvent, RuntimeTurnDriver};
17use crate::sansio::LlmCallError;
18use crate::{PluginError, RuntimeError, RuntimeErrorCode};
19
20use super::envelope::{
21    ProcessCommand, ProcessEffectOutcome, RuntimeEffectCommand, RuntimeEffectEnvelope,
22    RuntimeEffectKind, RuntimeEffectOutcome,
23};
24use super::outcome::llm_call_error_from_transport;
25
26type AwaitEventOptions = (CancellationToken, Option<Instant>, Arc<dyn crate::Clock>);
27
28use super::await_events::inline_await_events;
29
30// =============================================================================
31// Effect host + controller trait + scope + error
32// =============================================================================
33
34/// Stable semantic identity for one effectful runtime operation.
35///
36/// The scope is chosen by the host boundary before any nondeterministic work is
37/// planned. It is intentionally generic: Restate, an inline test host, or a
38/// future durable effect host all receive the same Lash scope vocabulary.
39#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(tag = "type", rename_all = "snake_case")]
41pub enum ExecutionScope {
42    Turn {
43        session_id: String,
44        turn_id: String,
45    },
46    Process {
47        process_id: String,
48    },
49    QueueDrain {
50        session_id: String,
51        drain_id: String,
52    },
53    SessionDelete {
54        session_id: String,
55    },
56    RuntimeOperation {
57        operation_id: String,
58    },
59}
60
61impl ExecutionScope {
62    pub fn turn(session_id: impl Into<String>, turn_id: impl Into<String>) -> Self {
63        Self::Turn {
64            session_id: session_id.into(),
65            turn_id: turn_id.into(),
66        }
67    }
68
69    pub fn process(process_id: impl Into<String>) -> Self {
70        Self::Process {
71            process_id: process_id.into(),
72        }
73    }
74
75    pub fn queue_drain(session_id: impl Into<String>, drain_id: impl Into<String>) -> Self {
76        Self::QueueDrain {
77            session_id: session_id.into(),
78            drain_id: drain_id.into(),
79        }
80    }
81
82    pub fn session_delete(session_id: impl Into<String>) -> Self {
83        Self::SessionDelete {
84            session_id: session_id.into(),
85        }
86    }
87
88    pub fn runtime_operation(operation_id: impl Into<String>) -> Self {
89        Self::RuntimeOperation {
90            operation_id: operation_id.into(),
91        }
92    }
93
94    pub fn id(&self) -> &str {
95        match self {
96            Self::Turn { turn_id, .. } => turn_id,
97            Self::Process { process_id } => process_id,
98            Self::QueueDrain { drain_id, .. } => drain_id,
99            Self::SessionDelete { session_id } => session_id,
100            Self::RuntimeOperation { operation_id } => operation_id,
101        }
102    }
103
104    pub fn session_id(&self) -> Option<&str> {
105        match self {
106            Self::Turn { session_id, .. }
107            | Self::QueueDrain { session_id, .. }
108            | Self::SessionDelete { session_id } => Some(session_id),
109            Self::Process { .. } | Self::RuntimeOperation { .. } => None,
110        }
111    }
112
113    pub fn turn_id(&self) -> Option<&str> {
114        match self {
115            Self::Turn { turn_id, .. } => Some(turn_id),
116            _ => None,
117        }
118    }
119
120    pub fn validates_turn_trace_id(&self) -> bool {
121        matches!(self, Self::Turn { .. })
122    }
123
124    pub(super) fn validate(&self) -> Result<(), RuntimeError> {
125        let missing = match self {
126            Self::Turn {
127                session_id,
128                turn_id,
129            } => session_id.trim().is_empty() || turn_id.trim().is_empty(),
130            Self::Process { process_id } => process_id.trim().is_empty(),
131            Self::QueueDrain {
132                session_id,
133                drain_id,
134            } => session_id.trim().is_empty() || drain_id.trim().is_empty(),
135            Self::SessionDelete { session_id } => session_id.trim().is_empty(),
136            Self::RuntimeOperation { operation_id } => operation_id.trim().is_empty(),
137        };
138        if missing {
139            return Err(RuntimeError::new(
140                RuntimeErrorCode::MissingExecutionScopeId,
141                "execution scopes require non-empty stable ids",
142            ));
143        }
144        Ok(())
145    }
146}
147
148#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
149#[serde(tag = "type", rename_all = "snake_case")]
150pub enum AwaitEventWaitIdentity {
151    ToolCompletion {
152        tool_call_id: String,
153    },
154    ProcessSignal {
155        process_id: String,
156        signal_name: String,
157        ordinal: u64,
158    },
159    Custom {
160        key: String,
161    },
162}
163
164impl AwaitEventWaitIdentity {
165    pub fn tool_completion(tool_call_id: impl Into<String>) -> Self {
166        Self::ToolCompletion {
167            tool_call_id: tool_call_id.into(),
168        }
169    }
170
171    pub fn process_signal(
172        process_id: impl Into<String>,
173        signal_name: impl Into<String>,
174        ordinal: u64,
175    ) -> Self {
176        Self::ProcessSignal {
177            process_id: process_id.into(),
178            signal_name: signal_name.into(),
179            ordinal,
180        }
181    }
182
183    pub(super) fn validate(&self) -> Result<(), RuntimeError> {
184        let invalid = match self {
185            Self::ToolCompletion { tool_call_id } => tool_call_id.trim().is_empty(),
186            Self::ProcessSignal {
187                process_id,
188                signal_name,
189                ordinal,
190            } => process_id.trim().is_empty() || signal_name.trim().is_empty() || *ordinal == 0,
191            Self::Custom { key } => key.trim().is_empty(),
192        };
193        if invalid {
194            return Err(RuntimeError::new(
195                "invalid_await_event_wait_identity",
196                "await-event wait identity requires non-empty stable ids",
197            ));
198        }
199        Ok(())
200    }
201}
202
203#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
204pub struct AwaitEventKey {
205    pub scope: ExecutionScope,
206    pub wait: AwaitEventWaitIdentity,
207    pub key_id: String,
208    pub signature: String,
209}
210
211impl AwaitEventKey {
212    pub fn promise_key(&self) -> String {
213        format!("lash-await-event:{}", self.key_id)
214    }
215}
216
217#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
218pub struct ExternalCompletionError {
219    pub code: String,
220    pub message: String,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub raw: Option<serde_json::Value>,
223}
224
225impl ExternalCompletionError {
226    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
227        Self {
228            code: code.into(),
229            message: message.into(),
230            raw: None,
231        }
232    }
233}
234
235#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(tag = "status", content = "payload", rename_all = "snake_case")]
237pub enum Resolution {
238    Ok(serde_json::Value),
239    Err(ExternalCompletionError),
240    Timeout,
241    Cancelled,
242}
243
244#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
245#[serde(tag = "status", rename_all = "snake_case")]
246pub enum ResolveOutcome {
247    Accepted,
248    AlreadyResolved { terminal: Resolution },
249    UnknownOrRevoked,
250}
251
252enum ScopedEffectControllerInner<'run> {
253    Borrowed(&'run dyn RuntimeEffectController),
254    Shared(Arc<dyn RuntimeEffectController>),
255}
256
257impl Clone for ScopedEffectControllerInner<'_> {
258    fn clone(&self) -> Self {
259        match self {
260            Self::Borrowed(controller) => Self::Borrowed(*controller),
261            Self::Shared(controller) => Self::Shared(Arc::clone(controller)),
262        }
263    }
264}
265
266/// Scoped low-level controller plus the semantic execution scope it is serving.
267#[derive(Clone)]
268pub struct ScopedEffectController<'run> {
269    controller: ScopedEffectControllerInner<'run>,
270    scope: ExecutionScope,
271}
272
273impl<'run> ScopedEffectController<'run> {
274    pub fn borrowed(
275        controller: &'run dyn RuntimeEffectController,
276        scope: ExecutionScope,
277    ) -> Result<Self, RuntimeError> {
278        scope.validate()?;
279        Ok(Self {
280            controller: ScopedEffectControllerInner::Borrowed(controller),
281            scope,
282        })
283    }
284
285    pub fn shared(
286        controller: Arc<dyn RuntimeEffectController>,
287        scope: ExecutionScope,
288    ) -> Result<Self, RuntimeError> {
289        scope.validate()?;
290        Ok(Self {
291            controller: ScopedEffectControllerInner::Shared(controller),
292            scope,
293        })
294    }
295
296    pub fn controller(&self) -> &dyn RuntimeEffectController {
297        match &self.controller {
298            ScopedEffectControllerInner::Borrowed(controller) => *controller,
299            ScopedEffectControllerInner::Shared(controller) => controller.as_ref(),
300        }
301    }
302
303    pub fn execution_scope(&self) -> &ExecutionScope {
304        &self.scope
305    }
306
307    pub fn scope_id(&self) -> &str {
308        self.scope.id()
309    }
310
311    pub fn turn_id(&self) -> Option<&str> {
312        self.scope.turn_id()
313    }
314}
315
316/// Shared durability and Durable Wait contract for effect boundaries.
317///
318/// Both the deployment-level [`EffectHost`] factory and the per-run
319/// [`RuntimeEffectController`] resolve Durable Waits and describe their
320/// durability; this supertrait is the single declaration of that contract.
321#[async_trait::async_trait]
322pub trait AwaitEventResolver: Send + Sync {
323    fn durability_tier(&self) -> crate::DurabilityTier {
324        crate::DurabilityTier::Inline
325    }
326
327    fn requires_durable_attachment_store(&self) -> bool {
328        false
329    }
330
331    fn supports_durable_effects(&self) -> bool {
332        false
333    }
334
335    async fn await_event_key(
336        &self,
337        _scope: &ExecutionScope,
338        _wait: AwaitEventWaitIdentity,
339    ) -> Result<AwaitEventKey, RuntimeError> {
340        Err(RuntimeError::new(
341            "await_event_unsupported",
342            "this effect boundary does not support await-event keys",
343        ))
344    }
345
346    async fn resolve_await_event(
347        &self,
348        _key: &AwaitEventKey,
349        _resolution: Resolution,
350    ) -> Result<ResolveOutcome, RuntimeError> {
351        Ok(ResolveOutcome::UnknownOrRevoked)
352    }
353
354    async fn await_await_event(
355        &self,
356        _key: &AwaitEventKey,
357        _cancel: CancellationToken,
358        _deadline: Option<Instant>,
359    ) -> Result<Resolution, RuntimeError> {
360        Err(RuntimeError::new(
361            "await_event_unsupported",
362            "this effect boundary does not support await-event waits",
363        ))
364    }
365
366    async fn revoke_await_events_for_session(&self, _session_id: &str) -> Result<(), RuntimeError> {
367        Ok(())
368    }
369
370    /// Cancel every *outstanding* durable wait for `session_id` without
371    /// deleting the session: each waiter receives a terminal
372    /// [`Resolution::Cancelled`] instead of hanging, late resolves observe
373    /// that terminal, and waits registered afterwards behave normally — in
374    /// contrast to [`revoke_await_events_for_session`](Self::revoke_await_events_for_session),
375    /// which tombstones the session's waits forever.
376    ///
377    /// The default errors loudly: an effect boundary that tracks durable waits
378    /// must implement this to honor the host lever, and one that cannot must
379    /// not silently claim success.
380    async fn cancel_await_events_for_session(&self, _session_id: &str) -> Result<(), RuntimeError> {
381        Err(RuntimeError::new(
382            "await_event_cancel_unsupported",
383            "this effect boundary does not support cancelling durable waits",
384        ))
385    }
386}
387
388/// Deployment-level factory for scoped effect controllers.
389#[async_trait::async_trait]
390pub trait EffectHost: AwaitEventResolver {
391    fn scoped<'run>(
392        &'run self,
393        scope: ExecutionScope,
394    ) -> Result<ScopedEffectController<'run>, RuntimeError>;
395
396    fn scoped_static(
397        &self,
398        _scope: ExecutionScope,
399    ) -> Result<Option<ScopedEffectController<'static>>, RuntimeError> {
400        Ok(None)
401    }
402}
403
404/// Boundary for nondeterministic runtime work.
405#[async_trait::async_trait]
406pub trait RuntimeEffectController: AwaitEventResolver {
407    /// Whether this controller can safely accept overlapping `execute_effect`
408    /// calls from one runtime coordinator.
409    ///
410    /// Local and store-backed controllers can usually fan out independent
411    /// effects. Some workflow substrates expose a single ordered journal
412    /// context where native operations must be awaited immediately before the
413    /// next context call is issued. Those controllers should return `false` so
414    /// coordinators serialize child effects while still replaying each child by
415    /// its own stable key.
416    fn supports_concurrent_effects(&self) -> bool {
417        true
418    }
419
420    async fn execute_effect(
421        &self,
422        envelope: RuntimeEffectEnvelope,
423        local_executor: RuntimeEffectLocalExecutor<'_>,
424    ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError>;
425}
426
427/// Runtime-internal handle for effect-controller references carried through
428/// per-turn execution contexts.
429#[derive(Clone)]
430pub(crate) enum RuntimeEffectControllerHandle<'run> {
431    Borrowed(ScopedEffectController<'run>),
432    #[cfg(any(test, feature = "testing"))]
433    Shared {
434        controller: Arc<dyn RuntimeEffectController>,
435        scope: ExecutionScope,
436    },
437}
438
439impl<'run> RuntimeEffectControllerHandle<'run> {
440    pub(crate) fn borrowed(scoped: ScopedEffectController<'run>) -> Self {
441        Self::Borrowed(scoped)
442    }
443
444    #[cfg(any(test, feature = "testing"))]
445    pub(crate) fn shared(controller: Arc<dyn RuntimeEffectController>) -> Self {
446        Self::Shared {
447            controller,
448            scope: ExecutionScope::runtime_operation("test-runtime-effect-controller"),
449        }
450    }
451
452    pub(crate) fn controller(&self) -> &dyn RuntimeEffectController {
453        match self {
454            Self::Borrowed(scoped) => scoped.controller(),
455            #[cfg(any(test, feature = "testing"))]
456            Self::Shared { controller, .. } => controller.as_ref(),
457        }
458    }
459
460    pub(crate) fn scoped(&self) -> ScopedEffectController<'_> {
461        match self {
462            Self::Borrowed(scoped) => scoped.clone(),
463            #[cfg(any(test, feature = "testing"))]
464            Self::Shared { controller, scope } => {
465                ScopedEffectController::shared(Arc::clone(controller), scope.clone())
466                    .expect("runtime effect controller handle carries a valid scope")
467            }
468        }
469    }
470
471    pub(crate) fn clone_scoped(&self) -> RuntimeEffectControllerHandle<'run> {
472        self.clone()
473    }
474}
475
476#[derive(Clone, Debug, thiserror::Error, Serialize, Deserialize)]
477#[error("{code}: {message}")]
478pub struct RuntimeEffectControllerError {
479    pub code: String,
480    pub message: String,
481}
482
483impl RuntimeEffectControllerError {
484    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
485        Self {
486            code: code.into(),
487            message: message.into(),
488        }
489    }
490
491    pub(super) fn wrong_outcome(expected: RuntimeEffectKind, actual: RuntimeEffectKind) -> Self {
492        Self::new(
493            "runtime_effect_wrong_outcome",
494            format!(
495                "expected {} outcome, got {}",
496                expected.as_str(),
497                actual.as_str()
498            ),
499        )
500    }
501
502    pub(crate) fn into_runtime_error(self) -> RuntimeError {
503        RuntimeError::new(self.code, self.message)
504    }
505}
506
507impl From<RuntimeError> for RuntimeEffectControllerError {
508    fn from(err: RuntimeError) -> Self {
509        Self::new(err.code.as_str(), err.message)
510    }
511}
512
513impl From<PluginError> for RuntimeEffectControllerError {
514    fn from(err: PluginError) -> Self {
515        Self::new("plugin", err.to_string())
516    }
517}
518
519impl From<crate::StoreError> for RuntimeEffectControllerError {
520    fn from(err: crate::StoreError) -> Self {
521        Self::new("runtime_store", err.to_string())
522    }
523}
524
525// =============================================================================
526// Local executor (per-effect borrowed runner state)
527// =============================================================================
528
529#[async_trait::async_trait]
530pub(crate) trait ProcessRunner: Send + Sync {
531    async fn run_process(
532        &self,
533        registration: crate::ProcessRegistration,
534        execution_context: crate::ProcessExecutionContext,
535        registry: Arc<dyn ProcessRegistry>,
536        scoped_effect_controller: crate::ScopedEffectController<'_>,
537        cancellation: CancellationToken,
538    ) -> crate::ProcessAwaitOutput;
539}
540
541pub struct ProcessLocalExecution {
542    pub registry: Arc<dyn ProcessRegistry>,
543    pub process_work_driver: Option<crate::ProcessWorkDriver>,
544}
545
546impl ProcessLocalExecution {
547    pub async fn execute(
548        self,
549        command: ProcessCommand,
550    ) -> Result<ProcessEffectOutcome, RuntimeEffectControllerError> {
551        let Self {
552            registry,
553            process_work_driver,
554        } = self;
555        match command {
556            ProcessCommand::Start {
557                registration,
558                grant,
559                execution_context: _,
560            } => {
561                let record =
562                    InlineRuntimeEffectController::start_process(registry, registration, grant)
563                        .await?;
564                if let Some(driver) = process_work_driver.as_ref() {
565                    driver.claim_and_run_pending("process_start").await?;
566                }
567                Ok(ProcessEffectOutcome::Start { record })
568            }
569            ProcessCommand::List {
570                session_scope,
571                mode,
572            } => {
573                let entries = match mode {
574                    crate::ProcessListMode::Live => {
575                        registry.list_live_handle_grants(&session_scope).await?
576                    }
577                    crate::ProcessListMode::All => {
578                        registry.list_handle_grants(&session_scope).await?
579                    }
580                };
581                Ok(ProcessEffectOutcome::List { entries })
582            }
583            ProcessCommand::Transfer {
584                from_scope,
585                to_scope,
586                process_ids,
587            } => {
588                registry
589                    .transfer_handle_grants(&from_scope, &to_scope, &process_ids)
590                    .await?;
591                Ok(ProcessEffectOutcome::Transfer)
592            }
593            ProcessCommand::DeleteSession { session_id } => {
594                let report = registry.delete_session_process_state(&session_id).await?;
595                Ok(ProcessEffectOutcome::DeleteSession { report })
596            }
597            ProcessCommand::Await { process_id } => {
598                let output = if let Some(driver) = process_work_driver.as_ref() {
599                    driver.await_terminal(&process_id).await?
600                } else {
601                    crate::ProcessAwaiter::polling(registry)
602                        .await_terminal(&process_id)
603                        .await?
604                };
605                Ok(ProcessEffectOutcome::Await { output })
606            }
607            ProcessCommand::Cancel { process_id, reason } => {
608                let record = InlineRuntimeEffectController
609                    .request_process_cancel(registry, &process_id, reason)
610                    .await?;
611                Ok(ProcessEffectOutcome::Cancel { record })
612            }
613            ProcessCommand::Signal {
614                process_id,
615                request,
616                ..
617            } => {
618                let result = registry.append_event(&process_id, request).await?;
619                Ok(ProcessEffectOutcome::Signal {
620                    event: result.event,
621                })
622            }
623        }
624    }
625}
626
627pub(super) struct LocalTurnEffectRunner<'a, 'run> {
628    driver: &'a mut RuntimeTurnDriver<'run>,
629    machine: &'a mut crate::TurnMachine,
630    event_tx: mpsc::Sender<RuntimeStreamEvent>,
631    cancellation: CancellationToken,
632}
633
634pub(super) struct LocalDirectEffectRunner {
635    provider: ProviderHandle,
636    attachment_store: Arc<dyn AttachmentStore>,
637}
638
639struct LocalToolBatchEffectRunner<'run> {
640    context: crate::RuntimeExecutionContext<'run>,
641    child_trace_hooks: HashMap<String, crate::ToolChildExecutionTraceHook>,
642}
643
644#[async_trait::async_trait]
645trait RuntimeEffectLocalRunner: Send {
646    async fn execute(
647        self: Box<Self>,
648        envelope: RuntimeEffectEnvelope,
649    ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError>;
650}
651
652#[cfg(any(test, feature = "testing"))]
653type TestingRuntimeEffectLocalRunnerFn<'run> = dyn FnOnce(
654        RuntimeEffectEnvelope,
655    ) -> Pin<
656        Box<
657            dyn Future<Output = Result<RuntimeEffectOutcome, RuntimeEffectControllerError>>
658                + Send
659                + 'run,
660        >,
661    > + Send
662    + 'run;
663
664#[cfg(any(test, feature = "testing"))]
665struct TestingRuntimeEffectLocalRunner<'run> {
666    run: Box<TestingRuntimeEffectLocalRunnerFn<'run>>,
667}
668
669type DurableStepLocalRunnerFn<'run> = dyn FnOnce(
670        serde_json::Value,
671    ) -> Pin<
672        Box<
673            dyn Future<Output = Result<serde_json::Value, RuntimeEffectControllerError>>
674                + Send
675                + 'run,
676        >,
677    > + Send
678    + 'run;
679
680struct DurableStepLocalRunner<'run> {
681    run: Box<DurableStepLocalRunnerFn<'run>>,
682}
683
684enum RuntimeEffectLocalExecutorState<'run> {
685    Unavailable,
686    SleepOnly {
687        cancellation: CancellationToken,
688        clock: Arc<dyn crate::Clock>,
689    },
690    ExternalWaitOptions {
691        cancellation: CancellationToken,
692        deadline: Option<Instant>,
693        clock: Arc<dyn crate::Clock>,
694    },
695    Process(ProcessLocalExecution),
696    Runner(Box<dyn RuntimeEffectLocalRunner + Send + 'run>),
697}
698
699/// Scoped local executor provided to a [`RuntimeEffectController`] for one effect.
700///
701/// Durable controllers may ignore it and replay their own recorded result. The
702/// default inline controller delegates to it, so local provider/tool/checkpoint
703/// work still crosses the same `execute_effect` boundary as durable controllers.
704pub struct RuntimeEffectLocalExecutor<'run> {
705    state: RuntimeEffectLocalExecutorState<'run>,
706}
707
708impl<'run> RuntimeEffectLocalExecutor<'run> {
709    pub fn unavailable() -> Self {
710        Self {
711            state: RuntimeEffectLocalExecutorState::Unavailable,
712        }
713    }
714
715    pub fn sleep(cancellation: CancellationToken) -> Self {
716        Self::sleep_with_clock(cancellation, Arc::new(crate::SystemClock))
717    }
718
719    pub fn sleep_with_clock(cancellation: CancellationToken, clock: Arc<dyn crate::Clock>) -> Self {
720        Self {
721            state: RuntimeEffectLocalExecutorState::SleepOnly {
722                cancellation,
723                clock,
724            },
725        }
726    }
727
728    pub fn await_event(cancellation: CancellationToken, deadline: Option<Instant>) -> Self {
729        Self::await_event_with_clock(cancellation, deadline, Arc::new(crate::SystemClock))
730    }
731
732    pub fn await_event_with_clock(
733        cancellation: CancellationToken,
734        deadline: Option<Instant>,
735        clock: Arc<dyn crate::Clock>,
736    ) -> Self {
737        Self {
738            state: RuntimeEffectLocalExecutorState::ExternalWaitOptions {
739                cancellation,
740                deadline,
741                clock,
742            },
743        }
744    }
745
746    pub fn processes(
747        registry: Arc<dyn ProcessRegistry>,
748        process_work_driver: Option<crate::ProcessWorkDriver>,
749    ) -> Self {
750        Self {
751            state: RuntimeEffectLocalExecutorState::Process(ProcessLocalExecution {
752                registry,
753                process_work_driver,
754            }),
755        }
756    }
757
758    pub fn durable_step<F, Fut>(run: F) -> Self
759    where
760        F: FnOnce(serde_json::Value) -> Fut + Send + 'run,
761        Fut: Future<Output = Result<serde_json::Value, RuntimeError>> + Send + 'run,
762    {
763        Self {
764            state: RuntimeEffectLocalExecutorState::Runner(Box::new(DurableStepLocalRunner {
765                run: Box::new(move |input| {
766                    Box::pin(async move { run(input).await.map_err(Into::into) })
767                }),
768            })),
769        }
770    }
771
772    #[cfg(any(test, feature = "testing"))]
773    pub fn testing<F, Fut>(run: F) -> Self
774    where
775        F: FnOnce(RuntimeEffectEnvelope) -> Fut + Send + 'run,
776        Fut: Future<Output = Result<RuntimeEffectOutcome, RuntimeEffectControllerError>>
777            + Send
778            + 'run,
779    {
780        Self {
781            state: RuntimeEffectLocalExecutorState::Runner(Box::new(
782                TestingRuntimeEffectLocalRunner {
783                    run: Box::new(move |envelope| Box::pin(run(envelope))),
784                },
785            )),
786        }
787    }
788
789    pub(in crate::runtime) fn turn<'scope>(
790        driver: &'run mut RuntimeTurnDriver<'scope>,
791        machine: &'run mut crate::TurnMachine,
792        event_tx: mpsc::Sender<RuntimeStreamEvent>,
793        cancellation: CancellationToken,
794    ) -> Self
795    where
796        'scope: 'run,
797    {
798        Self {
799            state: RuntimeEffectLocalExecutorState::Runner(Box::new(LocalTurnEffectRunner {
800                driver,
801                machine,
802                event_tx,
803                cancellation,
804            })),
805        }
806    }
807
808    pub(in crate::runtime) fn direct(
809        provider: ProviderHandle,
810        attachment_store: Arc<dyn AttachmentStore>,
811    ) -> Self {
812        Self {
813            state: RuntimeEffectLocalExecutorState::Runner(Box::new(LocalDirectEffectRunner {
814                provider,
815                attachment_store,
816            })),
817        }
818    }
819
820    pub(crate) fn tool_batch(
821        context: crate::RuntimeExecutionContext<'run>,
822        child_trace_hooks: HashMap<String, crate::ToolChildExecutionTraceHook>,
823    ) -> Self {
824        Self {
825            state: RuntimeEffectLocalExecutorState::Runner(Box::new(LocalToolBatchEffectRunner {
826                context,
827                child_trace_hooks,
828            })),
829        }
830    }
831
832    pub async fn execute(
833        self,
834        envelope: RuntimeEffectEnvelope,
835    ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
836        match self.state {
837            RuntimeEffectLocalExecutorState::Runner(runner) => runner.execute(envelope).await,
838            RuntimeEffectLocalExecutorState::SleepOnly {
839                cancellation,
840                clock,
841            } => execute_local_sleep(envelope, cancellation, clock.as_ref()).await,
842            RuntimeEffectLocalExecutorState::ExternalWaitOptions { .. } => {
843                Err(RuntimeEffectControllerError::new(
844                    "runtime_effect_local_executor_mismatch",
845                    format!(
846                        "local await-event options cannot execute {} command directly",
847                        envelope.command.kind().as_str()
848                    ),
849                ))
850            }
851            RuntimeEffectLocalExecutorState::Unavailable => Err(RuntimeEffectControllerError::new(
852                "runtime_effect_local_executor_unavailable",
853                format!(
854                    "no local executor is available for {}",
855                    envelope.command.kind().as_str()
856                ),
857            )),
858            RuntimeEffectLocalExecutorState::Process(_) => Err(RuntimeEffectControllerError::new(
859                "runtime_effect_local_executor_mismatch",
860                format!(
861                    "process executor cannot execute {} command directly",
862                    envelope.command.kind().as_str()
863                ),
864            )),
865        }
866    }
867
868    pub fn into_process(self) -> Result<ProcessLocalExecution, RuntimeEffectControllerError> {
869        match self.state {
870            RuntimeEffectLocalExecutorState::Process(execution) => Ok(execution),
871            _ => Err(RuntimeEffectControllerError::new(
872                "runtime_effect_local_executor_unavailable",
873                "no process executor is available for process command",
874            )),
875        }
876    }
877
878    fn into_await_event_options(self) -> Result<AwaitEventOptions, RuntimeEffectControllerError> {
879        match self.state {
880            RuntimeEffectLocalExecutorState::ExternalWaitOptions {
881                cancellation,
882                deadline,
883                clock,
884            } => Ok((cancellation, deadline, clock)),
885            _ => Ok((CancellationToken::new(), None, Arc::new(crate::SystemClock))),
886        }
887    }
888}
889
890#[cfg(any(test, feature = "testing"))]
891#[async_trait::async_trait]
892impl RuntimeEffectLocalRunner for TestingRuntimeEffectLocalRunner<'_> {
893    async fn execute(
894        self: Box<Self>,
895        envelope: RuntimeEffectEnvelope,
896    ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
897        (self.run)(envelope).await
898    }
899}
900
901#[async_trait::async_trait]
902impl RuntimeEffectLocalRunner for DurableStepLocalRunner<'_> {
903    async fn execute(
904        self: Box<Self>,
905        envelope: RuntimeEffectEnvelope,
906    ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
907        match envelope.command {
908            RuntimeEffectCommand::DurableStep { input, .. } => {
909                let value = (self.run)(input).await?;
910                Ok(RuntimeEffectOutcome::DurableStep { value })
911            }
912            command => Err(RuntimeEffectControllerError::new(
913                "runtime_effect_local_executor_mismatch",
914                format!(
915                    "local durable step executor cannot execute {} command",
916                    command.kind().as_str()
917                ),
918            )),
919        }
920    }
921}
922
923#[async_trait::async_trait]
924impl RuntimeEffectLocalRunner for LocalToolBatchEffectRunner<'_> {
925    async fn execute(
926        self: Box<Self>,
927        envelope: RuntimeEffectEnvelope,
928    ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
929        match envelope.command {
930            RuntimeEffectCommand::ToolBatch { batch } => {
931                let outcome = self
932                    .context
933                    .execute_prepared_tool_batch_launches(
934                        batch,
935                        envelope.invocation,
936                        self.child_trace_hooks,
937                    )
938                    .await?;
939                Ok(RuntimeEffectOutcome::ToolBatch {
940                    launches: outcome.launches,
941                    triggers: outcome.triggers,
942                })
943            }
944            RuntimeEffectCommand::ToolAttempt {
945                call,
946                execution_grant,
947                attempt,
948                max_attempts,
949            } => {
950                let child_execution_trace_hook = self.child_trace_hooks.get(&call.call_id).cloned();
951                let outcome = self
952                    .context
953                    .execute_prepared_tool_attempt_effect(
954                        call,
955                        execution_grant,
956                        attempt,
957                        max_attempts,
958                        envelope.invocation,
959                        child_execution_trace_hook,
960                    )
961                    .await?;
962                Ok(RuntimeEffectOutcome::ToolAttempt {
963                    launch: outcome.launch,
964                    triggers: outcome.triggers,
965                })
966            }
967            command => Err(RuntimeEffectControllerError::new(
968                "runtime_effect_local_executor_mismatch",
969                format!(
970                    "local tool executor cannot execute {} command",
971                    command.kind().as_str()
972                ),
973            )),
974        }
975    }
976}
977
978#[async_trait::async_trait]
979impl RuntimeEffectLocalRunner for LocalTurnEffectRunner<'_, '_> {
980    async fn execute(
981        self: Box<Self>,
982        envelope: RuntimeEffectEnvelope,
983    ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
984        let runner = *self;
985        match envelope.command {
986            RuntimeEffectCommand::LlmCall { request } => {
987                let protocol_iteration = runner.machine.protocol_iteration();
988                let (result, text_streamed) = runner
989                    .driver
990                    .run_llm_call(
991                        Arc::new((*request).into_request(None, None)),
992                        protocol_iteration,
993                        envelope.invocation,
994                        &runner.event_tx,
995                        &runner.cancellation,
996                    )
997                    .await;
998                Ok(RuntimeEffectOutcome::LlmCall {
999                    result,
1000                    text_streamed,
1001                })
1002            }
1003            RuntimeEffectCommand::ToolBatch { batch } => {
1004                let outcome = runner
1005                    .driver
1006                    .run_tool_batch(
1007                        batch,
1008                        envelope.invocation,
1009                        &runner.event_tx,
1010                        &runner.cancellation,
1011                    )
1012                    .await?;
1013                Ok(RuntimeEffectOutcome::ToolBatch {
1014                    launches: outcome.launches,
1015                    triggers: outcome.triggers,
1016                })
1017            }
1018            RuntimeEffectCommand::ExecCode { language, code } => {
1019                let protocol_iteration = runner.machine.protocol_iteration();
1020                let messages = runner.machine.message_sequence();
1021                Ok(RuntimeEffectOutcome::ExecCode {
1022                    result: runner
1023                        .driver
1024                        .run_exec_code(
1025                            language,
1026                            &code,
1027                            messages,
1028                            protocol_iteration,
1029                            envelope.invocation,
1030                            &runner.event_tx,
1031                        )
1032                        .await,
1033                })
1034            }
1035            RuntimeEffectCommand::Checkpoint { checkpoint } => {
1036                Ok(RuntimeEffectOutcome::Checkpoint {
1037                    result: runner
1038                        .driver
1039                        .run_checkpoint(runner.machine, checkpoint, &runner.event_tx)
1040                        .await
1041                        .map_err(RuntimeEffectControllerError::from),
1042                })
1043            }
1044            RuntimeEffectCommand::SyncExecutionEnvironment {
1045                update_machine_config,
1046            } => Ok(RuntimeEffectOutcome::SyncExecutionEnvironment {
1047                result: runner
1048                    .driver
1049                    .refresh_execution_environment(runner.machine, update_machine_config)
1050                    .await
1051                    .map_err(|err| err.to_string()),
1052            }),
1053            RuntimeEffectCommand::Sleep { duration_ms } => {
1054                sleep_with_cancellation(
1055                    duration_ms,
1056                    &runner.cancellation,
1057                    runner.driver.host.core.clock.as_ref(),
1058                )
1059                .await?;
1060                Ok(RuntimeEffectOutcome::Sleep)
1061            }
1062            command => Err(RuntimeEffectControllerError::new(
1063                "runtime_effect_local_executor_mismatch",
1064                format!(
1065                    "local turn executor cannot execute {} command",
1066                    command.kind().as_str()
1067                ),
1068            )),
1069        }
1070    }
1071}
1072
1073#[async_trait::async_trait]
1074impl RuntimeEffectLocalRunner for LocalDirectEffectRunner {
1075    async fn execute(
1076        mut self: Box<Self>,
1077        envelope: RuntimeEffectEnvelope,
1078    ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
1079        match envelope.command {
1080            RuntimeEffectCommand::Direct { request, .. } => Ok(RuntimeEffectOutcome::Direct {
1081                result: self
1082                    .run_direct_llm_request((*request).into_request(
1083                        crate::session_model::transport_stream_events(&self.provider, None),
1084                        None,
1085                    ))
1086                    .await,
1087            }),
1088            RuntimeEffectCommand::Sleep { duration_ms } => {
1089                sleep_with_cancellation(
1090                    duration_ms,
1091                    &CancellationToken::new(),
1092                    &crate::SystemClock,
1093                )
1094                .await?;
1095                Ok(RuntimeEffectOutcome::Sleep)
1096            }
1097            command => Err(RuntimeEffectControllerError::new(
1098                "runtime_effect_local_executor_mismatch",
1099                format!(
1100                    "local direct executor cannot execute {} command",
1101                    command.kind().as_str()
1102                ),
1103            )),
1104        }
1105    }
1106}
1107
1108impl LocalDirectEffectRunner {
1109    async fn run_direct_llm_request(
1110        &mut self,
1111        request: CoreLlmRequest,
1112    ) -> Result<LlmResponse, LlmCallError> {
1113        let request = crate::attachments::resolve_llm_request_attachments(
1114            request,
1115            self.attachment_store.as_ref(),
1116        )
1117        .await
1118        .map_err(|err| LlmCallError {
1119            message: err.to_string(),
1120            retryable: false,
1121            kind: crate::ProviderFailureKind::Unknown,
1122            raw: None,
1123            code: Some("attachment_resolution_failed".to_string()),
1124            terminal_reason: crate::LlmTerminalReason::ProviderError,
1125            request_body: None,
1126        })?;
1127        self.provider
1128            .complete(request)
1129            .await
1130            .map_err(llm_call_error_from_transport)
1131    }
1132}
1133
1134async fn execute_local_sleep(
1135    envelope: RuntimeEffectEnvelope,
1136    cancellation: CancellationToken,
1137    clock: &dyn crate::Clock,
1138) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
1139    match envelope.command {
1140        RuntimeEffectCommand::Sleep { duration_ms } => {
1141            sleep_with_cancellation(duration_ms, &cancellation, clock).await?;
1142            Ok(RuntimeEffectOutcome::Sleep)
1143        }
1144        command => Err(RuntimeEffectControllerError::new(
1145            "runtime_effect_local_executor_mismatch",
1146            format!(
1147                "local sleep executor cannot execute {} command",
1148                command.kind().as_str()
1149            ),
1150        )),
1151    }
1152}
1153
1154async fn sleep_with_cancellation(
1155    duration_ms: u64,
1156    cancellation: &CancellationToken,
1157    clock: &dyn crate::Clock,
1158) -> Result<(), RuntimeEffectControllerError> {
1159    let sleep = clock.sleep(std::time::Duration::from_millis(duration_ms));
1160    tokio::pin!(sleep);
1161    tokio::select! {
1162        _ = cancellation.cancelled() => Err(RuntimeEffectControllerError::new(
1163            "runtime_effect_sleep_cancelled",
1164            "runtime effect sleep was cancelled",
1165        )),
1166        _ = &mut sleep => Ok(()),
1167    }
1168}
1169
1170// =============================================================================
1171// Default in-process effect controller
1172// =============================================================================
1173
1174/// Default in-process effect controller.
1175///
1176/// The inline controller executes local runners in process, provides in-memory
1177/// await-event resolution, and exposes durable-tool-effect semantics for local
1178/// runs. It does not make in-flight effects crash durable; workflow adapters
1179/// provide that by recording outcomes in their own history.
1180#[derive(Clone, Default)]
1181pub struct InlineRuntimeEffectController;
1182
1183#[async_trait::async_trait]
1184impl AwaitEventResolver for InlineRuntimeEffectController {
1185    fn supports_durable_effects(&self) -> bool {
1186        true
1187    }
1188
1189    async fn await_event_key(
1190        &self,
1191        scope: &ExecutionScope,
1192        wait: AwaitEventWaitIdentity,
1193    ) -> Result<AwaitEventKey, RuntimeError> {
1194        inline_await_events().key_for(scope, wait)
1195    }
1196
1197    async fn resolve_await_event(
1198        &self,
1199        key: &AwaitEventKey,
1200        resolution: Resolution,
1201    ) -> Result<ResolveOutcome, RuntimeError> {
1202        inline_await_events().resolve(key, resolution)
1203    }
1204
1205    async fn await_await_event(
1206        &self,
1207        key: &AwaitEventKey,
1208        cancel: CancellationToken,
1209        deadline: Option<Instant>,
1210    ) -> Result<Resolution, RuntimeError> {
1211        inline_await_events()
1212            .await_resolution(key, cancel, deadline, &crate::SystemClock)
1213            .await
1214    }
1215
1216    async fn revoke_await_events_for_session(&self, session_id: &str) -> Result<(), RuntimeError> {
1217        inline_await_events().revoke_session(session_id)
1218    }
1219
1220    async fn cancel_await_events_for_session(&self, session_id: &str) -> Result<(), RuntimeError> {
1221        inline_await_events().cancel_session(session_id)
1222    }
1223}
1224
1225#[async_trait::async_trait]
1226impl RuntimeEffectController for InlineRuntimeEffectController {
1227    async fn execute_effect(
1228        &self,
1229        envelope: RuntimeEffectEnvelope,
1230        local_executor: RuntimeEffectLocalExecutor<'_>,
1231    ) -> Result<RuntimeEffectOutcome, RuntimeEffectControllerError> {
1232        match envelope.command {
1233            RuntimeEffectCommand::AwaitEvent { key } => {
1234                let (cancellation, deadline, clock) = local_executor.into_await_event_options()?;
1235                let resolution = inline_await_events()
1236                    .await_resolution(&key, cancellation, deadline, clock.as_ref())
1237                    .await
1238                    .map_err(RuntimeEffectControllerError::from)?;
1239                Ok(RuntimeEffectOutcome::AwaitEvent { resolution })
1240            }
1241            RuntimeEffectCommand::Process { command } => {
1242                let execution = local_executor.into_process()?;
1243                let result = tokio::task::spawn(async move { execution.execute(*command).await })
1244                    .await
1245                    .map_err(|err| {
1246                        RuntimeEffectControllerError::new(
1247                            "runtime_effect_process_task_join",
1248                            format!("inline process effect task failed: {err}"),
1249                        )
1250                    })??;
1251                Ok(RuntimeEffectOutcome::Process { result })
1252            }
1253            _ => local_executor.execute(envelope).await,
1254        }
1255    }
1256}
1257
1258/// In-process deployment effect host.
1259#[derive(Clone)]
1260pub struct InlineEffectHost {
1261    controller: Arc<dyn RuntimeEffectController>,
1262}
1263
1264impl InlineEffectHost {
1265    pub fn new(controller: Arc<dyn RuntimeEffectController>) -> Self {
1266        Self { controller }
1267    }
1268}
1269
1270impl Default for InlineEffectHost {
1271    fn default() -> Self {
1272        Self::new(Arc::new(InlineRuntimeEffectController))
1273    }
1274}
1275
1276#[async_trait::async_trait]
1277impl AwaitEventResolver for InlineEffectHost {
1278    fn durability_tier(&self) -> crate::DurabilityTier {
1279        self.controller.durability_tier()
1280    }
1281
1282    fn requires_durable_attachment_store(&self) -> bool {
1283        self.controller.requires_durable_attachment_store()
1284    }
1285
1286    fn supports_durable_effects(&self) -> bool {
1287        self.controller.supports_durable_effects()
1288    }
1289
1290    async fn await_event_key(
1291        &self,
1292        scope: &ExecutionScope,
1293        wait: AwaitEventWaitIdentity,
1294    ) -> Result<AwaitEventKey, RuntimeError> {
1295        self.controller.await_event_key(scope, wait).await
1296    }
1297
1298    async fn resolve_await_event(
1299        &self,
1300        key: &AwaitEventKey,
1301        resolution: Resolution,
1302    ) -> Result<ResolveOutcome, RuntimeError> {
1303        self.controller.resolve_await_event(key, resolution).await
1304    }
1305
1306    async fn await_await_event(
1307        &self,
1308        key: &AwaitEventKey,
1309        cancel: CancellationToken,
1310        deadline: Option<Instant>,
1311    ) -> Result<Resolution, RuntimeError> {
1312        self.controller
1313            .await_await_event(key, cancel, deadline)
1314            .await
1315    }
1316
1317    async fn revoke_await_events_for_session(&self, session_id: &str) -> Result<(), RuntimeError> {
1318        self.controller
1319            .revoke_await_events_for_session(session_id)
1320            .await
1321    }
1322
1323    async fn cancel_await_events_for_session(&self, session_id: &str) -> Result<(), RuntimeError> {
1324        self.controller
1325            .cancel_await_events_for_session(session_id)
1326            .await
1327    }
1328}
1329
1330#[async_trait::async_trait]
1331impl EffectHost for InlineEffectHost {
1332    fn scoped<'run>(
1333        &'run self,
1334        scope: ExecutionScope,
1335    ) -> Result<ScopedEffectController<'run>, RuntimeError> {
1336        ScopedEffectController::shared(Arc::clone(&self.controller), scope)
1337    }
1338
1339    fn scoped_static(
1340        &self,
1341        scope: ExecutionScope,
1342    ) -> Result<Option<ScopedEffectController<'static>>, RuntimeError> {
1343        Ok(Some(ScopedEffectController::shared(
1344            Arc::clone(&self.controller),
1345            scope,
1346        )?))
1347    }
1348}
1349
1350impl InlineRuntimeEffectController {
1351    /// Register the process (and any handle grant) into the durable registry.
1352    ///
1353    /// The inline controller no longer runs the process here: the registry's
1354    /// non-terminal row *is* the durable work queue, and the host-owned
1355    /// [`ProcessWorkDriver`](crate::ProcessWorkDriver) is the sole executor.
1356    /// Registering the row is all this path does; the control seam drives the
1357    /// host driver after a successful start.
1358    pub(crate) async fn start_process(
1359        registry: Arc<dyn crate::ProcessRegistry>,
1360        registration: crate::ProcessRegistration,
1361        grant: Option<crate::ProcessStartGrant>,
1362    ) -> Result<ProcessRecord, PluginError> {
1363        let registration_for_record = registration.clone();
1364        let record = registry.register_process(registration_for_record).await?;
1365        if let Some(grant) = grant {
1366            registry
1367                .grant_handle(&grant.session_scope, &registration.id, grant.descriptor)
1368                .await?;
1369        }
1370        Ok(record)
1371    }
1372
1373    pub(crate) async fn request_process_cancel(
1374        &self,
1375        registry: Arc<dyn crate::ProcessRegistry>,
1376        process_id: &str,
1377        reason: Option<String>,
1378    ) -> Result<ProcessRecord, PluginError> {
1379        // Cancellation is a durable signal: the cancel event is what the
1380        // runner-run process observes, so the inline controller appends it and
1381        // no longer tracks an in-process cancellation token.
1382        registry
1383            .append_event(
1384                process_id,
1385                crate::ProcessEventAppendRequest::cancel_requested(process_id, reason.clone()),
1386            )
1387            .await?;
1388        registry
1389            .get_process(process_id)
1390            .await
1391            .ok_or_else(|| PluginError::Session(format!("unknown process `{process_id}`")))
1392    }
1393}
1394
1395impl std::fmt::Debug for InlineRuntimeEffectController {
1396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1397        f.debug_struct("InlineRuntimeEffectController").finish()
1398    }
1399}