Skip to main content

lash_core/runtime/process_worker/
mod.rs

1use std::collections::{BTreeMap, BTreeSet, VecDeque};
2use std::future::Future;
3use std::sync::Arc;
4
5use tokio::sync::{OwnedSemaphorePermit, Semaphore};
6use tokio_util::sync::CancellationToken;
7
8use super::effect::ProcessRunner;
9use super::session_manager::RuntimeSessionServices;
10use super::{EmbeddedRuntimeBuilder, ProcessWorkDriver, QueuedWorkDriver, RuntimeHostConfig};
11use crate::InMemorySessionStore;
12use crate::{
13    AbandonEvidence, AbandonWriter, LashRuntime, PluginError, PluginFactory, PluginHost,
14    PluginStack, ProcessAwaitOutput, ProcessExecutionContext, ProcessInput, ProcessLease,
15    ProcessLeaseCompletion, ProcessRecord, ProcessRegistration, ProcessRegistry,
16    RecoveryDisposition, SessionStoreFactory,
17};
18
19/// Default maximum number of processes one [`DurableProcessWorker`] executes
20/// inline at once.
21pub const DEFAULT_PROCESS_EXECUTION_CONCURRENCY: usize = 64;
22
23/// Validated per-worker inline process execution concurrency.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25struct ProcessExecutionConcurrency(usize);
26
27impl ProcessExecutionConcurrency {
28    const DEFAULT: Self = Self(DEFAULT_PROCESS_EXECUTION_CONCURRENCY);
29
30    fn new(concurrency: usize) -> Result<Self, ProcessExecutionConcurrencyError> {
31        if !(1..=Semaphore::MAX_PERMITS).contains(&concurrency) {
32            return Err(ProcessExecutionConcurrencyError { concurrency });
33        }
34        Ok(Self(concurrency))
35    }
36
37    fn get(self) -> usize {
38        self.0
39    }
40}
41
42/// Invalid inline process execution concurrency supplied by a host.
43#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
44#[error(
45    "process execution concurrency must be between 1 and {max} (inclusive), got {concurrency}",
46    max = Semaphore::MAX_PERMITS
47)]
48pub struct ProcessExecutionConcurrencyError {
49    concurrency: usize,
50}
51
52/// Deployment-local configuration for rebuilding durable process executions.
53///
54/// Process rows intentionally carry only portable process input and provenance.
55/// Workers provide plugins, providers, stores, secrets, and host capabilities
56/// for the deployment that owns those rows.
57#[derive(Clone)]
58pub struct DurableProcessWorkerConfig {
59    pub plugin_host: Arc<PluginHost>,
60    pub runtime_host: RuntimeHostConfig,
61    pub session_policy: crate::SessionPolicy,
62    pub session_store_factory: Arc<dyn SessionStoreFactory>,
63    pub process_registry: Arc<dyn ProcessRegistry>,
64    pub process_change_hub: Option<crate::ProcessChangeHub>,
65    pub trigger_store: Arc<dyn crate::TriggerStore>,
66    pub process_work_driver: Option<ProcessWorkDriver>,
67    pub queued_work_driver: Option<QueuedWorkDriver>,
68    #[doc(hidden)]
69    pub turn_phase_probe_slot: crate::runtime::RuntimeTurnPhaseProbeSlot,
70    /// Residency for sessions the worker rebuilds to run a process. Defaults to
71    /// [`Residency::KeepAll`]; a host running [`Residency::ActivePathOnly`] wires
72    /// it here so the worker's rebuilt sessions trim to the active path too,
73    /// instead of silently diverging from the live runtime by keeping the full
74    /// graph resident.
75    pub residency: crate::Residency,
76    /// Maximum processes this worker executes inline at once. A run holds its
77    /// slot while doing its own work and releases it while parked on work that
78    /// another process or external owner must complete. This is a per-worker
79    /// bound: two workers sharing one registry may execute twice this many.
80    process_execution_concurrency: ProcessExecutionConcurrency,
81    /// Owner identity stem this worker derives per-recovery lease owners from.
82    ///
83    /// Each recovery attempt claims with a unique `(owner_id, incarnation_id)`
84    /// derived from this identity — a live lease held by an earlier attempt
85    /// must fence a later sweep pass rather than be re-entered as the same
86    /// incarnation — while the liveness metadata is inherited as-is. Defaults
87    /// to a fresh opaque identity per config. Hosts that run one worker per OS
88    /// process should wire a
89    /// [`LeaseOwnerIdentity::local_process`](crate::LeaseOwnerIdentity::local_process)
90    /// identity so peers on the same host can prove a crashed worker dead and
91    /// reclaim its process leases before the TTL — mirroring the session
92    /// execution lane's runtime lease owner.
93    pub lease_owner: crate::LeaseOwnerIdentity,
94}
95
96impl DurableProcessWorkerConfig {
97    pub fn validate_process_execution_concurrency(
98        concurrency: usize,
99    ) -> Result<(), ProcessExecutionConcurrencyError> {
100        ProcessExecutionConcurrency::new(concurrency).map(drop)
101    }
102
103    pub fn new(
104        plugin_host: Arc<PluginHost>,
105        runtime_host: RuntimeHostConfig,
106        session_store_factory: Arc<dyn SessionStoreFactory>,
107        process_registry: Arc<dyn ProcessRegistry>,
108    ) -> Self {
109        let clock = Arc::clone(&runtime_host.clock);
110        Self {
111            plugin_host,
112            runtime_host,
113            session_policy: crate::SessionPolicy::default(),
114            session_store_factory,
115            process_registry,
116            process_change_hub: None,
117            trigger_store: Arc::new(crate::InMemoryTriggerStore::with_clock(clock)),
118            process_work_driver: None,
119            queued_work_driver: None,
120            turn_phase_probe_slot: crate::runtime::RuntimeTurnPhaseProbeSlot::default(),
121            residency: crate::Residency::default(),
122            process_execution_concurrency: ProcessExecutionConcurrency::DEFAULT,
123            lease_owner: crate::LeaseOwnerIdentity::opaque(
124                format!("durable-process-worker:{}", uuid::Uuid::new_v4()),
125                uuid::Uuid::new_v4().to_string(),
126            ),
127        }
128    }
129
130    pub fn with_trigger_store(mut self, store: Arc<dyn crate::TriggerStore>) -> Self {
131        self.trigger_store = store;
132        self
133    }
134
135    pub fn with_session_policy(mut self, policy: crate::SessionPolicy) -> Self {
136        self.session_policy = policy;
137        self
138    }
139
140    pub fn with_residency(mut self, residency: crate::Residency) -> Self {
141        self.residency = residency;
142        self
143    }
144
145    /// Set the maximum processes this worker executes inline at once.
146    ///
147    /// The minimum is one. The maximum is Tokio's semaphore limit. The bound
148    /// applies independently to each worker, not globally to a shared registry.
149    pub fn with_process_execution_concurrency(
150        mut self,
151        concurrency: usize,
152    ) -> Result<Self, ProcessExecutionConcurrencyError> {
153        self.process_execution_concurrency = ProcessExecutionConcurrency::new(concurrency)?;
154        Ok(self)
155    }
156
157    pub fn process_execution_concurrency(&self) -> usize {
158        self.process_execution_concurrency.get()
159    }
160
161    pub fn with_process_work_driver(mut self, driver: ProcessWorkDriver) -> Self {
162        self.process_work_driver = Some(driver);
163        self
164    }
165
166    pub fn with_change_hub(mut self, hub: crate::ProcessChangeHub) -> Self {
167        self.process_change_hub = Some(hub);
168        self
169    }
170
171    /// Set the owner identity this worker presents when claiming process
172    /// leases. See [`DurableProcessWorkerConfig::lease_owner`].
173    pub fn with_lease_owner(mut self, lease_owner: crate::LeaseOwnerIdentity) -> Self {
174        self.lease_owner = lease_owner;
175        self
176    }
177
178    pub fn with_queued_work_driver(mut self, driver: QueuedWorkDriver) -> Self {
179        self.queued_work_driver = Some(driver);
180        self
181    }
182
183    #[doc(hidden)]
184    pub fn with_turn_phase_probe_slot(
185        mut self,
186        slot: crate::runtime::RuntimeTurnPhaseProbeSlot,
187    ) -> Self {
188        self.turn_phase_probe_slot = slot;
189        self
190    }
191
192    pub fn from_plugin_factories(
193        plugin_factories: impl IntoIterator<Item = Arc<dyn PluginFactory>>,
194        runtime_host: RuntimeHostConfig,
195        session_store_factory: Arc<dyn SessionStoreFactory>,
196        process_registry: Arc<dyn ProcessRegistry>,
197    ) -> Self {
198        Self::new(
199            Arc::new(PluginHost::new(plugin_factories.into_iter().collect())),
200            runtime_host,
201            session_store_factory,
202            process_registry,
203        )
204    }
205
206    pub fn from_plugin_stack(
207        plugin_stack: PluginStack,
208        runtime_host: RuntimeHostConfig,
209        session_store_factory: Arc<dyn SessionStoreFactory>,
210        process_registry: Arc<dyn ProcessRegistry>,
211    ) -> Self {
212        Self::from_plugin_factories(
213            plugin_stack.into_factories(),
214            runtime_host,
215            session_store_factory,
216            process_registry,
217        )
218    }
219}
220
221/// Reconstructable background-process worker.
222#[derive(Clone)]
223pub struct DurableProcessWorker {
224    config: Arc<DurableProcessWorkerConfig>,
225    execution_scheduler: Arc<ProcessExecutionScheduler>,
226}
227
228#[derive(Default)]
229struct ProcessExecutionSchedulerState {
230    pending: VecDeque<ProcessRecord>,
231    scheduled: BTreeSet<String>,
232    rerun: BTreeMap<String, ProcessRecord>,
233    active: usize,
234    dispatcher_running: bool,
235}
236
237struct ProcessExecutionScheduler {
238    permits: Arc<Semaphore>,
239    state: tokio::sync::Mutex<ProcessExecutionSchedulerState>,
240    changed: Arc<tokio::sync::Notify>,
241}
242
243impl ProcessExecutionScheduler {
244    fn new(concurrency: ProcessExecutionConcurrency) -> Self {
245        Self {
246            permits: Arc::new(Semaphore::new(concurrency.get())),
247            state: tokio::sync::Mutex::new(ProcessExecutionSchedulerState::default()),
248            changed: Arc::new(tokio::sync::Notify::new()),
249        }
250    }
251}
252
253/// Clears the single-dispatcher latch if the fire-and-forget dispatcher
254/// unwinds. Without this guard, one panic permanently leaves queued work with
255/// no task allowed to drain it.
256struct ProcessExecutionDispatcherGuard {
257    scheduler: Arc<ProcessExecutionScheduler>,
258    armed: bool,
259}
260
261impl ProcessExecutionDispatcherGuard {
262    fn new(scheduler: Arc<ProcessExecutionScheduler>) -> Self {
263        Self {
264            scheduler,
265            armed: true,
266        }
267    }
268
269    fn disarm(&mut self) {
270        self.armed = false;
271    }
272}
273
274impl Drop for ProcessExecutionDispatcherGuard {
275    fn drop(&mut self) {
276        if !self.armed {
277            return;
278        }
279        if let Ok(mut state) = self.scheduler.state.try_lock() {
280            state.dispatcher_running = false;
281            self.scheduler.changed.notify_one();
282            return;
283        }
284        let scheduler = Arc::clone(&self.scheduler);
285        crate::task::spawn(async move {
286            scheduler.state.lock().await.dispatcher_running = false;
287            scheduler.changed.notify_one();
288        });
289    }
290}
291
292struct ProcessExecutionTaskCompletion {
293    process_id: String,
294    completed: tokio::sync::mpsc::UnboundedSender<String>,
295}
296
297impl Drop for ProcessExecutionTaskCompletion {
298    fn drop(&mut self) {
299        let _ = self.completed.send(self.process_id.clone());
300    }
301}
302
303/// Permit owned by one inline process execution. All clones refer to the same
304/// slot so child-turn and inline-effect task boundaries can park the outer run.
305///
306/// This type assumes one logical thread of execution per process run. Clones
307/// may move that one thread across task boundaries, but must not be awaited by
308/// concurrent branches: while one branch has released the slot, a second
309/// branch would observe no held permit and could resume without reacquiring it.
310/// Intra-run parallel execution must replace this shared-slot protocol before
311/// it is introduced.
312struct ProcessExecutionPermit {
313    semaphore: Arc<Semaphore>,
314    held: std::sync::Mutex<Option<OwnedSemaphorePermit>>,
315    reacquire: tokio::sync::Mutex<()>,
316    dispatcher_changed: Arc<tokio::sync::Notify>,
317}
318
319impl ProcessExecutionPermit {
320    fn new(
321        semaphore: Arc<Semaphore>,
322        permit: OwnedSemaphorePermit,
323        dispatcher_changed: Arc<tokio::sync::Notify>,
324    ) -> Self {
325        Self {
326            semaphore,
327            held: std::sync::Mutex::new(Some(permit)),
328            reacquire: tokio::sync::Mutex::new(()),
329            dispatcher_changed,
330        }
331    }
332
333    async fn ensure_acquired(&self) {
334        if self
335            .held
336            .lock()
337            .expect("process execution permit lock")
338            .is_some()
339        {
340            return;
341        }
342        let _reacquire = self.reacquire.lock().await;
343        if self
344            .held
345            .lock()
346            .expect("process execution permit lock")
347            .is_some()
348        {
349            return;
350        }
351        let permit = Arc::clone(&self.semaphore)
352            .acquire_owned()
353            .await
354            .expect("process execution semaphore remains open");
355        *self.held.lock().expect("process execution permit lock") = Some(permit);
356    }
357
358    async fn release_while<F: Future>(&self, future: F) -> F::Output {
359        let released = self
360            .held
361            .lock()
362            .expect("process execution permit lock")
363            .take();
364        let Some(released) = released else {
365            return future.await;
366        };
367        drop(released);
368        self.dispatcher_changed.notify_one();
369        let output = future.await;
370        self.ensure_acquired().await;
371        output
372    }
373}
374
375tokio::task_local! {
376    static PROCESS_EXECUTION_PERMIT: Arc<ProcessExecutionPermit>;
377}
378
379pub(crate) async fn release_process_execution_permit_while<F: Future>(future: F) -> F::Output {
380    // Engine-scheduled tiers such as Restate never install this task local, so
381    // their direct shared-segment execution follows the unchanged path below.
382    let permit = PROCESS_EXECUTION_PERMIT.try_with(Arc::clone).ok();
383    match permit {
384        Some(permit) => permit.release_while(future).await,
385        None => future.await,
386    }
387}
388
389pub(crate) async fn ensure_process_execution_permit() {
390    if let Ok(permit) = PROCESS_EXECUTION_PERMIT.try_with(Arc::clone) {
391        permit.ensure_acquired().await;
392    }
393}
394
395pub(crate) fn inherit_process_execution_permit<F: Future>(
396    future: F,
397) -> impl Future<Output = F::Output> {
398    let permit = PROCESS_EXECUTION_PERMIT.try_with(Arc::clone).ok();
399    async move {
400        match permit {
401            Some(permit) => PROCESS_EXECUTION_PERMIT.scope(permit, future).await,
402            None => future.await,
403        }
404    }
405}
406
407/// Report from a graceful [owner drain](DurableProcessWorker::drain_owner_bound_work).
408#[derive(Clone, Debug, Default, PartialEq, Eq)]
409pub struct ProcessDrainReport {
410    /// Process ids this host's own started `OwnerBound` work was terminalized as
411    /// `Abandoned{OwnerDrain}` on, in the order they were drained.
412    pub abandoned: Vec<String>,
413}
414
415/// Why a recovery run did not produce a terminal outcome under the lease.
416enum RecoverFailure {
417    /// The lease was lost mid-run (another owner reclaimed an expired lease).
418    /// The losing worker must not write a terminal outcome — the new owner is
419    /// now the single writer.
420    LeaseLost(PluginError),
421    /// The process could not be run (rebuild/store-facet failure). The lease is
422    /// still held, so this worker terminalizes the row.
423    Run(PluginError),
424}
425
426impl DurableProcessWorker {
427    pub fn new(config: DurableProcessWorkerConfig) -> Self {
428        let execution_scheduler = Arc::new(ProcessExecutionScheduler::new(
429            config.process_execution_concurrency,
430        ));
431        Self {
432            config: Arc::new(config),
433            execution_scheduler,
434        }
435    }
436
437    pub fn from_shared_config(config: Arc<DurableProcessWorkerConfig>) -> Self {
438        let execution_scheduler = Arc::new(ProcessExecutionScheduler::new(
439            config.process_execution_concurrency,
440        ));
441        Self {
442            config,
443            execution_scheduler,
444        }
445    }
446
447    pub fn config(&self) -> &DurableProcessWorkerConfig {
448        &self.config
449    }
450
451    pub async fn run_process(
452        &self,
453        registration: ProcessRegistration,
454        execution_context: ProcessExecutionContext,
455        cancellation: CancellationToken,
456    ) -> Result<ProcessAwaitOutput, PluginError> {
457        let scoped_effect_controller = self
458            .config
459            .runtime_host
460            .control
461            .effect_host
462            .scoped_static(crate::ExecutionScope::process(registration.id.clone()))
463            .map_err(|err| PluginError::Session(err.to_string()))?
464            .ok_or_else(|| {
465                PluginError::Session(
466                    "process worker effect host must provide a static process scope".to_string(),
467                )
468            })?;
469        Box::pin(self.run_process_with_scoped_effect_controller(
470            registration,
471            execution_context,
472            scoped_effect_controller,
473            cancellation,
474        ))
475        .await
476    }
477
478    pub async fn run_process_with_scoped_effect_controller(
479        &self,
480        registration: ProcessRegistration,
481        execution_context: ProcessExecutionContext,
482        scoped_effect_controller: crate::ScopedEffectController<'_>,
483        cancellation: CancellationToken,
484    ) -> Result<ProcessAwaitOutput, PluginError> {
485        let mut handover = None;
486        loop {
487            match Box::pin(self.run_process_segment_with_scoped_effect_controller(
488                registration.clone(),
489                execution_context.clone(),
490                scoped_effect_controller.clone(),
491                cancellation.clone(),
492                handover,
493            ))
494            .await?
495            {
496                crate::ProcessRunOutcome::Terminal(output) => return Ok(*output),
497                crate::ProcessRunOutcome::SegmentBoundary(next) => handover = Some(next),
498            }
499        }
500    }
501
502    /// Run exactly one engine segment. Durable substrates use this method so a
503    /// non-terminal boundary can end the current substrate invocation; inline
504    /// callers continue to use the looping `run_process_with_*` methods.
505    pub async fn run_process_segment_with_scoped_effect_controller(
506        &self,
507        registration: ProcessRegistration,
508        execution_context: ProcessExecutionContext,
509        scoped_effect_controller: crate::ScopedEffectController<'_>,
510        cancellation: CancellationToken,
511        handover: Option<crate::SegmentHandover>,
512    ) -> Result<crate::ProcessRunOutcome, PluginError> {
513        self.ensure_stable_process_id(&registration)?;
514        self.ensure_durable_store_facets()?;
515        // Externally-owned rows are never executed by lash (ADR 0019). Reject the
516        // disposition before touching a runtime — the old fabricated-success path
517        // for External inputs is deleted.
518        if registration.disposition == RecoveryDisposition::ExternallyOwned {
519            return Err(PluginError::Session(format!(
520                "process `{}` is externally-owned and must not be executed by lash",
521                registration.id
522            )));
523        }
524        // Durable, lease-fenced "execution started" fact: recorded immediately
525        // before executing so a later sweep can distinguish a started OwnerBound
526        // row (never re-run) from an unstarted one (runnable by anyone). This is
527        // the shared run path both the inline sweep and the Restate run handler
528        // funnel through. First-writer-wins, so a re-invocation is a no-op.
529        self.config
530            .process_registry
531            .record_first_started(
532                &registration.id,
533                crate::ProcessStarted {
534                    owner: self.config.lease_owner.clone(),
535                    started_at_ms: self.now_ms(),
536                },
537            )
538            .await?;
539        let mut runtime = Box::pin(self.runtime_for_registration(&registration)).await?;
540        let _attachment_owner_binding = matches!(
541            registration.input.as_ref(),
542            ProcessInput::ToolCall { .. } | ProcessInput::Engine { .. }
543        )
544        .then(|| {
545            runtime
546                .host
547                .core
548                .durability
549                .attachment_store
550                .bind_process_scoped(registration.id.clone())
551        });
552        let originator_scope = if let crate::ProcessOriginator::Session { scope } =
553            &registration.provenance.originator
554        {
555            Some(scope)
556        } else {
557            None
558        };
559        let probe_scope = registration.wake_target.as_ref().or(originator_scope);
560        if let Some(probe) =
561            probe_scope.and_then(|scope| self.config.turn_phase_probe_slot.get_for_scope(scope))
562        {
563            runtime.set_turn_phase_probe(probe);
564        }
565        let manager = RuntimeSessionServices::new(&runtime, true, None).map_err(|err| {
566            PluginError::Session(format!(
567                "failed to build runtime env for process `{}`: {err}",
568                registration.id
569            ))
570        })?;
571        Ok(manager
572            .run_process(
573                registration,
574                execution_context,
575                Arc::clone(&self.config.process_registry),
576                scoped_effect_controller,
577                cancellation,
578                handover,
579            )
580            .await)
581    }
582
583    /// Queue every non-terminal process this worker can claim and execute the
584    /// runnable rows inline, driving each to a terminal state.
585    ///
586    /// This is the sole inline executor for every process start: live tool and
587    /// subagent starts, trigger deliveries, admin starts, session-open passes,
588    /// and crash recovery all enter through this worklist. The drive:
589    ///
590    /// 1. lists every non-terminal process ([`ProcessRegistry::list_non_terminal`]);
591    /// 2. claims the durable single-owner [`ProcessLease`] over each — a process
592    ///    already leased live by *another* owner is skipped (it is being run by
593    ///    that owner right now) unless persisted liveness metadata proves that
594    ///    owner definitely dead, in which case the lease is reclaimed with the
595    ///    fenced CAS discipline of
596    ///    [`ProcessRegistry::reclaim_process_lease`]; either way a non-terminal
597    ///    process is re-run by exactly one owner (lease fencing);
598    /// 3. queues the full worklist in a worker-scoped scheduler whose shared
599    ///    execution budget spans repeated host-driven passes, then runs claimed
600    ///    processes on this worker's wired controller while renewing
601    ///    the lease across the long-running execution so a healthy recovery is
602    ///    not swept out from under itself;
603    /// 4. atomically writes the terminal outcome and releases the validated lease.
604    ///
605    /// Idempotent by `process_id`: terminal processes are never in the worklist,
606    /// and a process that became terminal between the list and the claim is
607    /// detected after claiming and skipped, so re-running a recovery sweep does
608    /// not double-execute completed work.
609    pub async fn drive_pending_processes(&self) -> Result<(), PluginError> {
610        self.reconcile_trigger_deliveries().await?;
611        let records = self.config.process_registry.list_non_terminal().await?;
612        let should_start_dispatcher = {
613            let mut state = self.execution_scheduler.state.lock().await;
614            for record in records {
615                if state.scheduled.insert(record.id.clone()) {
616                    state.pending.push_back(record);
617                } else {
618                    // Coalesce a newer host-driven pass instead of dropping it.
619                    // The row may have gained an Abandon Request or other
620                    // execution-relevant state while its prior attempt was still
621                    // queued or finishing.
622                    state.rerun.insert(record.id.clone(), record);
623                }
624            }
625            if state.dispatcher_running {
626                false
627            } else {
628                state.dispatcher_running = true;
629                true
630            }
631        };
632        self.execution_scheduler.changed.notify_one();
633        if should_start_dispatcher {
634            let worker = self.clone();
635            crate::task::spawn(async move { worker.run_process_execution_dispatcher().await });
636        }
637        Ok(())
638    }
639
640    async fn run_process_execution_dispatcher(&self) {
641        let mut dispatcher_guard =
642            ProcessExecutionDispatcherGuard::new(Arc::clone(&self.execution_scheduler));
643        let (completed_tx, mut completed_rx) = tokio::sync::mpsc::unbounded_channel();
644        loop {
645            while let Some((record, permit)) = self.next_process_execution().await {
646                let worker = self.clone();
647                let completion = ProcessExecutionTaskCompletion {
648                    process_id: record.id.clone(),
649                    completed: completed_tx.clone(),
650                };
651                let execution_permit = Arc::new(ProcessExecutionPermit::new(
652                    Arc::clone(&self.execution_scheduler.permits),
653                    permit,
654                    Arc::clone(&self.execution_scheduler.changed),
655                ));
656                crate::task::spawn(async move {
657                    let _completion = completion;
658                    // Install the execution budget only at the inline worker
659                    // boundary, never in the shared process-segment path.
660                    Box::pin(
661                        PROCESS_EXECUTION_PERMIT
662                            .scope(execution_permit, worker.recover_process(record)),
663                    )
664                    .await;
665                });
666            }
667
668            {
669                let mut state = self.execution_scheduler.state.lock().await;
670                if state.pending.is_empty() && state.active == 0 {
671                    state.dispatcher_running = false;
672                    dispatcher_guard.disarm();
673                    return;
674                }
675            }
676
677            tokio::select! {
678                Some(process_id) = completed_rx.recv() => {
679                    let mut state = self.execution_scheduler.state.lock().await;
680                    state.active = state
681                        .active
682                        .checked_sub(1)
683                        .expect("a completed process execution was active");
684                    if let Some(record) = state.rerun.remove(&process_id) {
685                        state.pending.push_back(record);
686                    } else {
687                        state.scheduled.remove(&process_id);
688                    }
689                }
690                _ = self.execution_scheduler.changed.notified() => {}
691            }
692        }
693    }
694
695    async fn next_process_execution(&self) -> Option<(ProcessRecord, OwnedSemaphorePermit)> {
696        let mut state = self.execution_scheduler.state.lock().await;
697        let permit = Arc::clone(&self.execution_scheduler.permits)
698            .try_acquire_owned()
699            .ok()?;
700        let record = state.pending.pop_front()?;
701        state.active += 1;
702        Some((record, permit))
703    }
704
705    async fn reconcile_trigger_deliveries(&self) -> Result<(), PluginError> {
706        let candidates = self.config.trigger_store.list_deliveries().await?;
707        if candidates.is_empty() {
708            return Ok(());
709        }
710        let candidate_process_ids = candidates
711            .iter()
712            .map(|delivery| delivery.process_id.clone())
713            .collect::<Vec<_>>();
714        let missing_process_ids = self
715            .config
716            .process_registry
717            .filter_unregistered_process_ids(&candidate_process_ids)
718            .await?
719            .into_iter()
720            .collect::<BTreeSet<_>>();
721        let router = crate::TriggerRouter::new(
722            Arc::clone(&self.config.trigger_store),
723            Some(Arc::clone(&self.config.process_registry)),
724            self.config.process_work_driver.clone(),
725        );
726        let mut started_any = false;
727        for delivery in candidates {
728            if missing_process_ids.contains(&delivery.process_id) {
729                let Some(scoped_effect_controller) = self
730                    .config
731                    .runtime_host
732                    .control
733                    .effect_host
734                    .scoped_static(crate::ExecutionScope::runtime_operation(format!(
735                        "trigger-delivery-reconcile:{}",
736                        delivery.process_id
737                    )))
738                    .map_err(|err| PluginError::Session(err.to_string()))?
739                else {
740                    return Err(PluginError::Session(
741                        "process worker effect host must provide a static trigger delivery reconcile scope"
742                            .to_string(),
743                    ));
744                };
745                match router
746                    .start_delivery(
747                        &delivery,
748                        Arc::clone(&self.config.process_registry),
749                        scoped_effect_controller.controller(),
750                    )
751                    .await
752                {
753                    Ok(()) => started_any = true,
754                    Err(err) => tracing::warn!(
755                        process_id = %delivery.process_id,
756                        occurrence_id = %delivery.occurrence.occurrence_id,
757                        subscription_id = %delivery.subscription.subscription_id,
758                        error = %err,
759                        "failed to reconcile trigger delivery",
760                    ),
761                }
762            }
763        }
764        if started_any && let Some(driver) = self.config.process_work_driver.as_ref() {
765            driver
766                .claim_and_run_pending("trigger_delivery_reconcile")
767                .await?;
768        }
769        Ok(())
770    }
771
772    /// Graceful owner drain: terminalize this host's own started `OwnerBound`
773    /// work as `Abandoned{OwnerDrain}` at close (ADR 0019).
774    ///
775    /// This is an explicit **host lever on the worker**, never an implicit
776    /// consequence of closing a session. Processes are global and outlive any
777    /// one session ([ADR 0011]), so `LashSession::close`/`park` must not touch
778    /// them; a host that wants its in-flight owner-bound work terminalized at
779    /// shutdown calls this on the worker it is tearing down.
780    ///
781    /// Drain sequence (the operations runbook owns the surrounding steps; this
782    /// is the terminal-writing step):
783    /// 1. stop admitting new work to this worker;
784    /// 2. cancel or await the worker's in-flight run tasks so they release their
785    ///    per-run leases — for **Rerunnable** in-flight work that is the whole
786    ///    story: stopping the local run task without any terminal write leaves
787    ///    the row non-terminal so the next worker re-runs it (its contract);
788    /// 3. call this lever: for every non-terminal **OwnerBound** row this exact
789    ///    worker started (`first_started.owner == self.config.lease_owner`),
790    ///    claim a fresh drain lease and, being the owner completing its own
791    ///    work, write `Abandoned{OwnerDrain}` under it — the ordinary graceful
792    ///    completion path, respecting the single-writer rule.
793    ///
794    /// A row still held by a live foreign lease (an in-flight run under one of
795    /// this worker's own recovery incarnations that step 2 has not yet released)
796    /// is deferred rather than reclaimed, so the drain never races a still-live
797    /// run; such a row reaches `Abandoned` on the next drain pass or at a peer's
798    /// recovery sweep. Rows started by a different owner, not-yet-started
799    /// OwnerBound rows (still claimable by anyone), Rerunnable rows, and
800    /// Externally-Owned rows are all left untouched.
801    ///
802    /// [ADR 0011]: durable process registration is session-independent.
803    pub async fn drain_owner_bound_work(&self) -> Result<ProcessDrainReport, PluginError> {
804        let mut abandoned = Vec::new();
805        for record in self.config.process_registry.list_non_terminal().await? {
806            if record.disposition != RecoveryDisposition::OwnerBound {
807                continue;
808            }
809            let Some(first_started) = record.first_started.as_ref() else {
810                // Never started: first execution is not re-execution, so any
811                // worker may still claim it. Draining it would strand runnable
812                // work as Abandoned.
813                continue;
814            };
815            if first_started.owner != self.config.lease_owner {
816                // Started by a different owner; not this host's to drain.
817                continue;
818            }
819            let owner = first_started.owner.clone();
820            if self.drain_one_owner_bound(&record.id, owner).await {
821                abandoned.push(record.id);
822            }
823        }
824        Ok(ProcessDrainReport { abandoned })
825    }
826
827    /// Terminalize one of this host's started OwnerBound rows as
828    /// `Abandoned{OwnerDrain}` under a freshly claimed drain lease. Returns
829    /// whether the terminal was written (`false` when the row is held by a live
830    /// foreign lease, already terminal, or the claim failed).
831    async fn drain_one_owner_bound(
832        &self,
833        process_id: &str,
834        owner: crate::LeaseOwnerIdentity,
835    ) -> bool {
836        let lease_ttl_ms = self.lease_timings().ttl_ms();
837        let drain_owner = self.recovery_lease_owner();
838        let lease = match self
839            .config
840            .process_registry
841            .claim_process_lease(process_id, &drain_owner, lease_ttl_ms)
842            .await
843        {
844            Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
845            // A live run still holds the lease, or the claim failed: defer.
846            Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return false,
847        };
848        if self
849            .config
850            .process_registry
851            .get_process(process_id)
852            .await
853            .is_some_and(|current| current.is_terminal())
854        {
855            self.release_or_log(&lease).await;
856            return false;
857        }
858        let evidence = AbandonEvidence {
859            writer: AbandonWriter::OwnerDrain,
860            owner: Some(owner),
861            epoch_ms: self.now_ms(),
862        };
863        self.complete_and_release(
864            &lease,
865            process_id,
866            ProcessAwaitOutput::Abandoned {
867                evidence: Box::new(evidence),
868                control: None,
869            },
870        )
871        .await;
872        true
873    }
874
875    /// Unique lease owner for one recovery attempt.
876    ///
877    /// Derived from [`DurableProcessWorkerConfig::lease_owner`]: a fresh
878    /// `(owner_id, incarnation_id)` per attempt keeps sweeps idempotent (a
879    /// still-running attempt's live lease fences later passes instead of being
880    /// re-entered as "own lease"), while the configured liveness metadata is
881    /// inherited so peers can prove a crashed worker dead and reclaim.
882    fn recovery_lease_owner(&self) -> crate::LeaseOwnerIdentity {
883        let attempt = uuid::Uuid::new_v4();
884        crate::LeaseOwnerIdentity {
885            owner_id: format!("{}:recovery:{attempt}", self.config.lease_owner.owner_id),
886            incarnation_id: attempt.to_string(),
887            liveness: self.config.lease_owner.liveness.clone(),
888        }
889    }
890
891    /// Recover one non-terminal row, obeying its declared recovery disposition
892    /// (ADR 0019). The verdict per disposition:
893    ///
894    /// - **ExternallyOwned**: never claimed, never run. If a pending Abandon
895    ///   Request is present it is reconciled into `Abandoned{reconciled_request}`.
896    /// - **Rerunnable**: exactly today's behavior — claim, (re-)run, complete.
897    /// - **OwnerBound, never started**: any worker may run it (first execution is
898    ///   not re-execution); the runner records `first_started` before executing.
899    /// - **OwnerBound, started**: never re-run. A provably-dead holder yields
900    ///   `Abandoned{sweep}`; a merely silent/expired holder is left non-terminal
901    ///   unless an Abandon Request is present and the lease has lapsed, which
902    ///   yields `Abandoned{reconciled_request}`. Elapsed time alone never
903    ///   terminalizes.
904    ///
905    /// Every Abandoned write goes through `complete_process_with_lease`, which
906    /// atomically validates this sweep's fence, appends the terminal, and clears
907    /// the lease so a revenant's stale token is rejected.
908    async fn recover_process(&self, record: ProcessRecord) {
909        let process_id = record.id.clone();
910        // ExternallyOwned: lash never executes the row. The only recovery action
911        // is reconciling a pending Abandon Request; there is no owner lease to
912        // wait out.
913        if record.disposition == RecoveryDisposition::ExternallyOwned {
914            if record.abandon_request.is_some() {
915                self.reconcile_externally_owned_abandon(&process_id).await;
916            }
917            return;
918        }
919
920        let lease_ttl_ms = self.lease_timings().ttl_ms();
921        let owner = self.recovery_lease_owner();
922        // Claim the single-owner lease, distinguishing a fenced reclaim of a
923        // provably-dead holder (death evidence) from acquiring a free/expired
924        // lease (no death evidence). A live, not-provably-dead holder or a claim
925        // error leaves the row to its owner.
926        let Some((lease, dead_holder)) = self
927            .claim_for_recovery(&process_id, &owner, lease_ttl_ms)
928            .await
929        else {
930            return;
931        };
932        // Terminal between the list and the claim. Idempotent by process_id: do
933        // not re-execute or re-terminalize a finished process.
934        if self
935            .config
936            .process_registry
937            .get_process(&process_id)
938            .await
939            .is_some_and(|current| current.is_terminal())
940        {
941            self.release_or_log(&lease).await;
942            return;
943        }
944
945        match record.disposition {
946            // Rerunnable: claim, (re-)run, complete — exactly today's behavior.
947            RecoveryDisposition::Rerunnable => Box::pin(self.run_and_complete(record, lease)).await,
948            RecoveryDisposition::OwnerBound if record.first_started.is_some() => {
949                // Started OwnerBound work is NEVER re-run — abandonment is the
950                // only recovery. `first_started`'s owner is the lapsed owner the
951                // reconciled-request evidence names.
952                let lapsed_owner = record
953                    .first_started
954                    .as_ref()
955                    .map(|started| started.owner.clone());
956                let evidence = if let Some(dead_holder) = dead_holder {
957                    // Holder provably dead ⇒ Abandoned{sweep}.
958                    Some(AbandonEvidence {
959                        writer: AbandonWriter::Sweep,
960                        owner: Some(dead_holder.owner),
961                        epoch_ms: self.now_ms(),
962                    })
963                } else if record.abandon_request.is_some() {
964                    // Silent/expired holder without death evidence, but an
965                    // operator authorized abandonment and the lease has lapsed
966                    // (we acquired a free/expired lease) ⇒ Abandoned{reconciled}.
967                    Some(AbandonEvidence {
968                        writer: AbandonWriter::ReconciledRequest,
969                        owner: lapsed_owner,
970                        epoch_ms: self.now_ms(),
971                    })
972                } else {
973                    // No death evidence and no authorization: elapsed time alone
974                    // never terminalizes. Leave the row non-terminal.
975                    None
976                };
977                match evidence {
978                    Some(evidence) => {
979                        self.complete_and_release(
980                            &lease,
981                            &process_id,
982                            ProcessAwaitOutput::Abandoned {
983                                evidence: Box::new(evidence),
984                                control: None,
985                            },
986                        )
987                        .await;
988                    }
989                    None => self.release_or_log(&lease).await,
990                }
991            }
992            // OwnerBound, never started: first execution is not re-execution, so
993            // any worker may run it; the runner records first_started first.
994            RecoveryDisposition::OwnerBound => Box::pin(self.run_and_complete(record, lease)).await,
995            // Filtered above; releasing keeps the lease honest if reached.
996            RecoveryDisposition::ExternallyOwned => self.release_or_log(&lease).await,
997        }
998    }
999
1000    /// Wall-clock epoch ms from the worker's configured clock.
1001    fn now_ms(&self) -> u64 {
1002        self.config.runtime_host.clock.timestamp_ms()
1003    }
1004
1005    /// Claim the recovery lease. Returns the acquired lease plus, when the claim
1006    /// fenced out a provably-dead holder, that holder as death evidence. Returns
1007    /// `None` when the row is held by a live (not provably-dead) owner or the
1008    /// claim fails — either way this pass leaves the row to its owner.
1009    async fn claim_for_recovery(
1010        &self,
1011        process_id: &str,
1012        owner: &crate::LeaseOwnerIdentity,
1013        lease_ttl_ms: u64,
1014    ) -> Option<(ProcessLease, Option<ProcessLease>)> {
1015        match self
1016            .config
1017            .process_registry
1018            .claim_process_lease(process_id, owner, lease_ttl_ms)
1019            .await
1020        {
1021            Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => Some((lease, None)),
1022            Ok(crate::ProcessLeaseClaimOutcome::Busy { holder })
1023                if holder.owner.is_definitely_dead_for_claimant(owner) =>
1024            {
1025                match self
1026                    .config
1027                    .process_registry
1028                    .reclaim_process_lease(process_id, owner, &holder, lease_ttl_ms)
1029                    .await
1030                {
1031                    Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => {
1032                        Some((lease, Some(holder)))
1033                    }
1034                    Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => None,
1035                }
1036            }
1037            Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => None,
1038        }
1039    }
1040
1041    /// Reconcile a pending Abandon Request on an externally-owned row into an
1042    /// `Abandoned{reconciled_request}` terminal. Lash never executed the row, so
1043    /// there is no owner lease to wait out — but the sweep claims its own lease
1044    /// and completes through the atomic fenced path so it stays the single writer.
1045    async fn reconcile_externally_owned_abandon(&self, process_id: &str) {
1046        let lease_ttl_ms = self.lease_timings().ttl_ms();
1047        let owner = self.recovery_lease_owner();
1048        let lease = match self
1049            .config
1050            .process_registry
1051            .claim_process_lease(process_id, &owner, lease_ttl_ms)
1052            .await
1053        {
1054            Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
1055            // A concurrent writer holds the lease; let it finish.
1056            Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return,
1057        };
1058        if self
1059            .config
1060            .process_registry
1061            .get_process(process_id)
1062            .await
1063            .is_some_and(|current| current.is_terminal())
1064        {
1065            self.release_or_log(&lease).await;
1066            return;
1067        }
1068        let evidence = AbandonEvidence {
1069            writer: AbandonWriter::ReconciledRequest,
1070            // Externally-owned work has no lash execution owner to name.
1071            owner: None,
1072            epoch_ms: self.now_ms(),
1073        };
1074        self.complete_and_release(
1075            &lease,
1076            process_id,
1077            ProcessAwaitOutput::Abandoned {
1078                evidence: Box::new(evidence),
1079                control: None,
1080            },
1081        )
1082        .await;
1083    }
1084
1085    /// (Re-)run a claimed row under its renewed lease and write the terminal
1086    /// outcome, the same live-owner-is-single-writer path used before ADR 0019.
1087    async fn run_and_complete(&self, record: ProcessRecord, lease: ProcessLease) {
1088        let process_id = record.id.clone();
1089        let registration = registration_from_record(record);
1090        let execution_context = ProcessExecutionContext::default();
1091        let mut handover = None;
1092        loop {
1093            match Box::pin(self.run_process_with_lease_renewal(
1094                registration.clone(),
1095                execution_context.clone(),
1096                lease.clone(),
1097                handover,
1098            ))
1099            .await
1100            {
1101                // Ran to a terminal outcome (success or a process-level failure) while
1102                // holding the lease: this owner is the single writer of the terminal.
1103                Ok(crate::ProcessRunOutcome::Terminal(output)) => {
1104                    self.complete_and_release(&lease, &process_id, *output)
1105                        .await;
1106                    return;
1107                }
1108                Ok(crate::ProcessRunOutcome::SegmentBoundary(next)) => {
1109                    tracing::debug!(
1110                        process_id = %process_id,
1111                        reason = ?next.reason,
1112                        "process crossed an in-memory segment boundary",
1113                    );
1114                    handover = Some(next);
1115                }
1116                // The lease was lost mid-run — another owner reclaimed the expired
1117                // lease and is now running this process. Do NOT write a terminal
1118                // outcome or release the lease: that would race the new owner and
1119                // could record a succeeded process as Failed. Leave the row to the
1120                // lease holder; it will finish (or another sweep retries it).
1121                Err(RecoverFailure::LeaseLost(err)) => {
1122                    tracing::warn!(
1123                        process_id = %process_id,
1124                        error = %err,
1125                        "process recovery lost its lease mid-run; deferring to the new owner",
1126                    );
1127                    return;
1128                }
1129                // The process could not be run at all (rebuild/store-facet failure):
1130                // terminalize as a recovery failure so the row leaves the worklist.
1131                Err(RecoverFailure::Run(err)) => {
1132                    let output = ProcessAwaitOutput::Failure {
1133                        class: crate::ToolFailureClass::Execution,
1134                        code: "process_recovery_failed".to_string(),
1135                        message: err.to_string(),
1136                        raw: None,
1137                        control: None,
1138                    };
1139                    self.complete_and_release(&lease, &process_id, output).await;
1140                    return;
1141                }
1142            }
1143        }
1144    }
1145
1146    /// Write a recovered process's terminal outcome and release its lease in one
1147    /// atomic fenced registry operation.
1148    async fn complete_and_release(
1149        &self,
1150        lease: &ProcessLease,
1151        process_id: &str,
1152        output: ProcessAwaitOutput,
1153    ) {
1154        // Refresh first so a healthy long-running worker has a full completion
1155        // window. The registry then validates this exact fence, appends the
1156        // terminal event, and clears the lease in one transaction. There is no
1157        // renew-then-unfenced-write gap for a stalled worker to cross.
1158        let fenced = match self
1159            .config
1160            .process_registry
1161            .renew_process_lease(lease, self.lease_timings().ttl_ms())
1162            .await
1163        {
1164            Ok(renewed) => renewed,
1165            Err(err) => {
1166                tracing::warn!(
1167                    process_id = %process_id,
1168                    error = %err,
1169                    "lost process lease before terminal write; deferring to the new owner",
1170                );
1171                return;
1172            }
1173        };
1174        if let Err(err) = self
1175            .config
1176            .process_registry
1177            .complete_process_with_lease(&fenced, output)
1178            .await
1179        {
1180            tracing::warn!(
1181                process_id = %process_id,
1182                error = %err,
1183                "failed to write recovered process terminal outcome",
1184            );
1185        }
1186    }
1187
1188    async fn release_or_log(&self, lease: &ProcessLease) {
1189        if let Err(err) = self.release_process_lease(lease).await {
1190            tracing::warn!(
1191                process_id = %lease.process_id,
1192                error = %err,
1193                "failed to release recovered process lease",
1194            );
1195        }
1196    }
1197
1198    /// Run a recovered process while renewing its lease across the execution,
1199    /// mirroring the turn-lease renewal that keeps a long-running effect's lease
1200    /// from expiring under the live owner.
1201    async fn run_process_with_lease_renewal(
1202        &self,
1203        registration: ProcessRegistration,
1204        execution_context: ProcessExecutionContext,
1205        mut lease: ProcessLease,
1206        handover: Option<crate::SegmentHandover>,
1207    ) -> Result<crate::ProcessRunOutcome, RecoverFailure> {
1208        let process_id = registration.id.clone();
1209        let cancellation = CancellationToken::new();
1210        let cancel_watcher = {
1211            let awaiter = self
1212                .config
1213                .process_change_hub
1214                .clone()
1215                .map(|hub| {
1216                    crate::ProcessAwaiter::new(Arc::clone(&self.config.process_registry), hub)
1217                })
1218                .unwrap_or_else(|| {
1219                    crate::ProcessAwaiter::polling(Arc::clone(&self.config.process_registry))
1220                });
1221            let process_id = process_id.clone();
1222            let cancellation = cancellation.clone();
1223            crate::task::spawn(async move {
1224                match awaiter
1225                    .await_event(&process_id, "process.cancel_requested", 0)
1226                    .await
1227                {
1228                    Ok(_) => cancellation.cancel(),
1229                    Err(err) => tracing::warn!(
1230                        process_id = %process_id,
1231                        error = %err,
1232                        "process cancel watcher stopped before observing cancellation",
1233                    ),
1234                }
1235            })
1236        };
1237        let scoped_effect_controller = self
1238            .config
1239            .runtime_host
1240            .control
1241            .effect_host
1242            .scoped_static(crate::ExecutionScope::process(registration.id.clone()))
1243            .map_err(|err| RecoverFailure::Run(PluginError::Session(err.to_string())))?
1244            .ok_or_else(|| {
1245                RecoverFailure::Run(PluginError::Session(
1246                    "process worker effect host must provide a static process scope".to_string(),
1247                ))
1248            })?;
1249        let pending = self.run_process_segment_with_scoped_effect_controller(
1250            registration,
1251            execution_context,
1252            scoped_effect_controller,
1253            cancellation.clone(),
1254            handover,
1255        );
1256        tokio::pin!(pending);
1257        loop {
1258            tokio::select! {
1259                outcome = &mut pending => {
1260                    cancel_watcher.abort();
1261                    return outcome.map_err(RecoverFailure::Run);
1262                }
1263                _ = self.config.runtime_host.clock.sleep(self.lease_timings().renew_interval()) => {
1264                    match self
1265                        .config
1266                        .process_registry
1267                        .renew_process_lease(&lease, self.lease_timings().ttl_ms())
1268                        .await
1269                    {
1270                        Ok(renewed) => lease = renewed,
1271                        Err(err) => {
1272                            cancellation.cancel();
1273                            cancel_watcher.abort();
1274                            return Err(RecoverFailure::LeaseLost(err));
1275                        }
1276                    }
1277                }
1278            }
1279        }
1280    }
1281
1282    fn lease_timings(&self) -> crate::LeaseTimings {
1283        self.config.runtime_host.control.lease_timings
1284    }
1285
1286    async fn release_process_lease(&self, lease: &ProcessLease) -> Result<(), PluginError> {
1287        self.config
1288            .process_registry
1289            .complete_process_lease(&ProcessLeaseCompletion::from_lease(lease))
1290            .await
1291    }
1292
1293    pub async fn request_process_cancel(
1294        &self,
1295        process_id: &str,
1296        reason: Option<String>,
1297    ) -> Result<(), PluginError> {
1298        self.config
1299            .process_registry
1300            .append_event(
1301                process_id,
1302                crate::ProcessEventAppendRequest::cancel_requested(process_id, reason),
1303            )
1304            .await
1305            .map(|_| ())
1306    }
1307
1308    async fn runtime_for_registration(
1309        &self,
1310        registration: &ProcessRegistration,
1311    ) -> Result<LashRuntime, PluginError> {
1312        match registration.input.as_ref() {
1313            ProcessInput::SessionTurn { create_request, .. } => {
1314                Box::pin(self.runtime_for_session_turn(registration, create_request.as_ref())).await
1315            }
1316            ProcessInput::ToolCall { .. } | ProcessInput::Engine { .. } => {
1317                Box::pin(self.runtime_for_process_env(registration)).await
1318            }
1319            // Externally-owned rows are rejected before dispatch (ADR 0019), so an
1320            // External input has no execution runtime; fail loudly rather than
1321            // fabricate one.
1322            ProcessInput::External { .. } => Err(PluginError::Session(format!(
1323                "process `{}` is externally-owned and has no execution runtime",
1324                registration.id
1325            ))),
1326        }
1327    }
1328
1329    async fn runtime_for_session_turn(
1330        &self,
1331        registration: &ProcessRegistration,
1332        create_request: &crate::SessionCreateRequest,
1333    ) -> Result<LashRuntime, PluginError> {
1334        let mut policy = create_request
1335            .policy
1336            .clone()
1337            .unwrap_or_else(|| self.config.session_policy.clone());
1338        if policy.recorded_provider_id().is_empty() {
1339            policy.provider_id = self.config.session_policy.provider_id.clone();
1340        }
1341        self.build_process_runtime(
1342            crate::process_runtime_session_ids(&registration.id)[1].clone(),
1343            policy,
1344            create_request.plugin_options.clone(),
1345            "session turn request",
1346        )
1347        .await
1348    }
1349
1350    async fn runtime_for_process_env(
1351        &self,
1352        registration: &ProcessRegistration,
1353    ) -> Result<LashRuntime, PluginError> {
1354        let Some(env_ref) = registration.env_ref.as_ref() else {
1355            return Err(PluginError::Session(format!(
1356                "process `{}` is missing a captured execution env",
1357                registration.id
1358            )));
1359        };
1360        let env = crate::load_process_execution_env(
1361            self.config
1362                .runtime_host
1363                .durability
1364                .process_env_store
1365                .as_ref(),
1366            env_ref,
1367        )
1368        .await?;
1369        self.build_process_runtime(
1370            crate::process_runtime_session_ids(&registration.id)[0].clone(),
1371            env.policy,
1372            env.plugin_options,
1373            env_ref.as_str(),
1374        )
1375        .await
1376    }
1377
1378    async fn build_process_runtime(
1379        &self,
1380        session_id: String,
1381        policy: crate::SessionPolicy,
1382        plugin_options: crate::PluginOptions,
1383        source_label: &str,
1384    ) -> Result<LashRuntime, PluginError> {
1385        let attachment_manifest_store = self
1386            .config
1387            .session_store_factory
1388            .create_store(&crate::SessionStoreCreateRequest {
1389                session_id: session_id.clone(),
1390                relation: crate::SessionRelation::default(),
1391                policy: policy.clone(),
1392            })
1393            .await
1394            .map_err(|err| {
1395                PluginError::Session(format!(
1396                    "failed to open process attachment owner store for `{session_id}`: {err}"
1397                ))
1398            })?;
1399        // Process execution sessions are reconstruction-only and must not alias
1400        // a parent-bound factory's runtime state. Attachment intents still use
1401        // the factory store so their process owner remains durable.
1402        let store = Arc::new(InMemorySessionStore::default());
1403        let process_work_driver = self.config.process_work_driver.clone().unwrap_or_else(|| {
1404            if let Some(hub) = self.config.process_change_hub.clone() {
1405                ProcessWorkDriver::from_watched(
1406                    Arc::clone(&self.config.process_registry),
1407                    hub,
1408                    Arc::new(crate::InlineProcessRunHandle::new(self.clone())),
1409                )
1410            } else {
1411                ProcessWorkDriver::inline(Arc::clone(&self.config.process_registry), self.clone())
1412            }
1413        });
1414        let mut builder = EmbeddedRuntimeBuilder::new()
1415            .with_session_id(session_id.to_string())
1416            .with_plugin_host(self.config.plugin_host.as_ref().clone())
1417            .with_runtime_host(self.config.runtime_host.clone())
1418            .with_policy(policy)
1419            .with_plugin_options(plugin_options)
1420            .with_session_store_factory(Arc::clone(&self.config.session_store_factory))
1421            .with_trigger_store(Arc::clone(&self.config.trigger_store))
1422            .with_process_registry(Arc::clone(&self.config.process_registry))
1423            .with_process_work_driver(process_work_driver)
1424            .with_residency(self.config.residency)
1425            .with_attachment_manifest_store(attachment_manifest_store)
1426            .with_store(store);
1427        if let Some(driver) = self.config.queued_work_driver.clone() {
1428            builder = builder.with_queued_work_driver(driver);
1429        }
1430        builder.build().await.map_err(|err| {
1431            PluginError::Session(format!(
1432                "failed to build process worker runtime for {source_label}: {err}"
1433            ))
1434        })
1435    }
1436
1437    /// Enforce the durable-first wiring invariant at the worker process-run
1438    /// boundary: when the worker was wired with a durable effect host, every
1439    /// store it will execute against must also be durable. A durable host
1440    /// running against any ephemeral store fails loudly here rather than
1441    /// silently re-executing a process against non-durable state.
1442    ///
1443    /// Inline controllers (the default tier) impose no requirement, so
1444    /// inline/in-memory workers pass unchanged.
1445    fn ensure_durable_store_facets(&self) -> Result<(), PluginError> {
1446        if self
1447            .config
1448            .runtime_host
1449            .control
1450            .effect_host
1451            .durability_tier()
1452            != crate::DurabilityTier::Durable
1453        {
1454            return Ok(());
1455        }
1456        let require = |facet: crate::DurableStoreFacet| {
1457            PluginError::Session(crate::RuntimeError::durable_store_required(facet).to_string())
1458        };
1459        if self
1460            .config
1461            .runtime_host
1462            .durability
1463            .attachment_store
1464            .persistence()
1465            .durability_tier()
1466            != crate::DurabilityTier::Durable
1467        {
1468            return Err(require(crate::DurableStoreFacet::AttachmentStore));
1469        }
1470        if self
1471            .config
1472            .runtime_host
1473            .durability
1474            .process_env_store
1475            .durability_tier()
1476            != crate::DurabilityTier::Durable
1477        {
1478            return Err(require(crate::DurableStoreFacet::ProcessEnvStore));
1479        }
1480        if self.config.session_store_factory.durability_tier() != crate::DurabilityTier::Durable {
1481            return Err(require(crate::DurableStoreFacet::SessionStore));
1482        }
1483        if self.config.process_registry.durability_tier() != crate::DurabilityTier::Durable {
1484            return Err(require(crate::DurableStoreFacet::ProcessRegistry));
1485        }
1486        if self.config.trigger_store.durability_tier() != crate::DurabilityTier::Durable {
1487            return Err(require(crate::DurableStoreFacet::TriggerStore));
1488        }
1489        Ok(())
1490    }
1491
1492    /// Enforce the stable-process-id invariant at every (re-)execution: process
1493    /// execution identity is the persisted `process_id`, so a retry — a Restate
1494    /// `run` re-invocation (keyed `LashProcessWorkflow/{process_id}`) or a
1495    /// recovery sweep re-running a non-terminal row — must present that stable
1496    /// id. An empty/fresh id has lost its idempotency anchor and is rejected
1497    /// loudly here, mirroring how `ExecutionScope` rejects an
1498    /// empty turn id at the durable-effect boundary.
1499    fn ensure_stable_process_id(
1500        &self,
1501        registration: &ProcessRegistration,
1502    ) -> Result<(), PluginError> {
1503        if registration.id.trim().is_empty() {
1504            return Err(PluginError::Session(
1505                crate::RuntimeError::missing_process_execution_id().to_string(),
1506            ));
1507        }
1508        Ok(())
1509    }
1510}
1511
1512/// Rebuild a runnable registration from a persisted row, preserving its declared
1513/// disposition (ADR 0019).
1514fn registration_from_record(record: ProcessRecord) -> ProcessRegistration {
1515    ProcessRegistration {
1516        id: record.id,
1517        input: record.input,
1518        disposition: record.disposition,
1519        identity: record.identity,
1520        event_types: record.event_types,
1521        provenance: record.provenance,
1522        env_ref: record.env_ref,
1523        wake_target: record.wake_target,
1524    }
1525}
1526
1527#[cfg(test)]
1528mod boundary_tests;
1529#[cfg(test)]
1530mod recovery_tests;