Skip to main content

lash_core/runtime/
process_worker.rs

1use std::sync::Arc;
2
3use tokio_util::sync::CancellationToken;
4
5use super::effect::ProcessRunner;
6use super::session_manager::RuntimeSessionServices;
7use super::{EmbeddedRuntimeBuilder, ProcessWorkDriver, QueuedWorkDriver, RuntimeHostConfig};
8use crate::{
9    InMemorySessionStore, LashRuntime, PluginError, PluginFactory, PluginHost, PluginStack,
10    ProcessAwaitOutput, ProcessExecutionContext, ProcessInput, ProcessLease,
11    ProcessLeaseCompletion, ProcessRecord, ProcessRegistration, ProcessRegistry,
12    SessionStoreFactory,
13};
14
15/// Deployment-local configuration for rebuilding durable process executions.
16///
17/// Process rows intentionally carry only portable process input and provenance.
18/// Workers provide plugins, providers, stores, secrets, and host capabilities
19/// for the deployment that owns those rows.
20#[derive(Clone)]
21pub struct DurableProcessWorkerConfig {
22    pub plugin_host: Arc<PluginHost>,
23    pub runtime_host: RuntimeHostConfig,
24    pub session_policy: crate::SessionPolicy,
25    pub session_store_factory: Arc<dyn SessionStoreFactory>,
26    pub process_registry: Arc<dyn ProcessRegistry>,
27    pub trigger_store: Arc<dyn crate::TriggerStore>,
28    pub process_work_driver: Option<ProcessWorkDriver>,
29    pub queued_work_driver: Option<QueuedWorkDriver>,
30    #[doc(hidden)]
31    pub turn_phase_probe_slot: crate::runtime::RuntimeTurnPhaseProbeSlot,
32    /// Residency for sessions the worker rebuilds to run a process. Defaults to
33    /// [`Residency::KeepAll`]; a host running [`Residency::ActivePathOnly`] wires
34    /// it here so the worker's rebuilt sessions trim to the active path too,
35    /// instead of silently diverging from the live runtime by keeping the full
36    /// graph resident.
37    pub residency: crate::Residency,
38    /// Owner identity stem this worker derives per-recovery lease owners from.
39    ///
40    /// Each recovery attempt claims with a unique `(owner_id, incarnation_id)`
41    /// derived from this identity — a live lease held by an earlier attempt
42    /// must fence a later sweep pass rather than be re-entered as the same
43    /// incarnation — while the liveness metadata is inherited as-is. Defaults
44    /// to a fresh opaque identity per config. Hosts that run one worker per OS
45    /// process should wire a
46    /// [`LeaseOwnerIdentity::local_process`](crate::LeaseOwnerIdentity::local_process)
47    /// identity so peers on the same host can prove a crashed worker dead and
48    /// reclaim its process leases before the TTL — mirroring the session
49    /// execution lane's runtime lease owner.
50    pub lease_owner: crate::LeaseOwnerIdentity,
51}
52
53impl DurableProcessWorkerConfig {
54    pub fn new(
55        plugin_host: Arc<PluginHost>,
56        runtime_host: RuntimeHostConfig,
57        session_store_factory: Arc<dyn SessionStoreFactory>,
58        process_registry: Arc<dyn ProcessRegistry>,
59    ) -> Self {
60        let clock = Arc::clone(&runtime_host.clock);
61        Self {
62            plugin_host,
63            runtime_host,
64            session_policy: crate::SessionPolicy::default(),
65            session_store_factory,
66            process_registry,
67            trigger_store: Arc::new(crate::InMemoryTriggerStore::with_clock(clock)),
68            process_work_driver: None,
69            queued_work_driver: None,
70            turn_phase_probe_slot: crate::runtime::RuntimeTurnPhaseProbeSlot::default(),
71            residency: crate::Residency::default(),
72            lease_owner: crate::LeaseOwnerIdentity::opaque(
73                format!("durable-process-worker:{}", uuid::Uuid::new_v4()),
74                uuid::Uuid::new_v4().to_string(),
75            ),
76        }
77    }
78
79    pub fn with_trigger_store(mut self, store: Arc<dyn crate::TriggerStore>) -> Self {
80        self.trigger_store = store;
81        self
82    }
83
84    pub fn with_session_policy(mut self, policy: crate::SessionPolicy) -> Self {
85        self.session_policy = policy;
86        self
87    }
88
89    pub fn with_residency(mut self, residency: crate::Residency) -> Self {
90        self.residency = residency;
91        self
92    }
93
94    pub fn with_process_work_driver(mut self, driver: ProcessWorkDriver) -> Self {
95        self.process_work_driver = Some(driver);
96        self
97    }
98
99    /// Set the owner identity this worker presents when claiming process
100    /// leases. See [`DurableProcessWorkerConfig::lease_owner`].
101    pub fn with_lease_owner(mut self, lease_owner: crate::LeaseOwnerIdentity) -> Self {
102        self.lease_owner = lease_owner;
103        self
104    }
105
106    pub fn with_queued_work_driver(mut self, driver: QueuedWorkDriver) -> Self {
107        self.queued_work_driver = Some(driver);
108        self
109    }
110
111    #[doc(hidden)]
112    pub fn with_turn_phase_probe_slot(
113        mut self,
114        slot: crate::runtime::RuntimeTurnPhaseProbeSlot,
115    ) -> Self {
116        self.turn_phase_probe_slot = slot;
117        self
118    }
119
120    pub fn from_plugin_factories(
121        plugin_factories: impl IntoIterator<Item = Arc<dyn PluginFactory>>,
122        runtime_host: RuntimeHostConfig,
123        session_store_factory: Arc<dyn SessionStoreFactory>,
124        process_registry: Arc<dyn ProcessRegistry>,
125    ) -> Self {
126        Self::new(
127            Arc::new(PluginHost::new(plugin_factories.into_iter().collect())),
128            runtime_host,
129            session_store_factory,
130            process_registry,
131        )
132    }
133
134    pub fn from_plugin_stack(
135        plugin_stack: PluginStack,
136        runtime_host: RuntimeHostConfig,
137        session_store_factory: Arc<dyn SessionStoreFactory>,
138        process_registry: Arc<dyn ProcessRegistry>,
139    ) -> Self {
140        Self::from_plugin_factories(
141            plugin_stack.into_factories(),
142            runtime_host,
143            session_store_factory,
144            process_registry,
145        )
146    }
147}
148
149/// Reconstructable background-process worker.
150#[derive(Clone)]
151pub struct DurableProcessWorker {
152    config: Arc<DurableProcessWorkerConfig>,
153}
154
155/// Why a recovery run did not produce a terminal outcome under the lease.
156enum RecoverFailure {
157    /// The lease was lost mid-run (another owner reclaimed an expired lease).
158    /// The losing worker must not write a terminal outcome — the new owner is
159    /// now the single writer.
160    LeaseLost(PluginError),
161    /// The process could not be run (rebuild/store-facet failure). The lease is
162    /// still held, so this worker terminalizes the row.
163    Run(PluginError),
164}
165
166impl DurableProcessWorker {
167    pub fn new(config: DurableProcessWorkerConfig) -> Self {
168        Self {
169            config: Arc::new(config),
170        }
171    }
172
173    pub fn from_shared_config(config: Arc<DurableProcessWorkerConfig>) -> Self {
174        Self { config }
175    }
176
177    pub fn config(&self) -> &DurableProcessWorkerConfig {
178        &self.config
179    }
180
181    pub async fn run_process(
182        &self,
183        registration: ProcessRegistration,
184        execution_context: ProcessExecutionContext,
185        cancellation: CancellationToken,
186    ) -> Result<ProcessAwaitOutput, PluginError> {
187        let scoped_effect_controller = self
188            .config
189            .runtime_host
190            .control
191            .effect_host
192            .scoped_static(crate::ExecutionScope::process(registration.id.clone()))
193            .map_err(|err| PluginError::Session(err.to_string()))?
194            .ok_or_else(|| {
195                PluginError::Session(
196                    "process worker effect host must provide a static process scope".to_string(),
197                )
198            })?;
199        self.run_process_with_scoped_effect_controller(
200            registration,
201            execution_context,
202            scoped_effect_controller,
203            cancellation,
204        )
205        .await
206    }
207
208    pub async fn run_process_with_scoped_effect_controller(
209        &self,
210        registration: ProcessRegistration,
211        execution_context: ProcessExecutionContext,
212        scoped_effect_controller: crate::ScopedEffectController<'_>,
213        cancellation: CancellationToken,
214    ) -> Result<ProcessAwaitOutput, PluginError> {
215        self.ensure_stable_process_id(&registration)?;
216        self.ensure_durable_store_facets()?;
217        if let ProcessInput::External { metadata } = registration.input.as_ref() {
218            return Ok(ProcessAwaitOutput::Success {
219                value: serde_json::json!({ "metadata": metadata.clone() }),
220                control: None,
221            });
222        }
223        let mut runtime = self.runtime_for_registration(&registration).await?;
224        let originator_scope = if let crate::ProcessOriginator::Session { scope } =
225            &registration.provenance.originator
226        {
227            Some(scope)
228        } else {
229            None
230        };
231        let probe_scope = registration.wake_target.as_ref().or(originator_scope);
232        if let Some(probe) =
233            probe_scope.and_then(|scope| self.config.turn_phase_probe_slot.get_for_scope(scope))
234        {
235            runtime.set_turn_phase_probe(probe);
236        }
237        let manager = RuntimeSessionServices::new(&runtime, true, None).map_err(|err| {
238            PluginError::Session(format!(
239                "failed to build runtime env for process `{}`: {err}",
240                registration.id
241            ))
242        })?;
243        Ok(manager
244            .run_process(
245                registration,
246                execution_context,
247                Arc::clone(&self.config.process_registry),
248                scoped_effect_controller,
249                cancellation,
250            )
251            .await)
252    }
253
254    /// Sweep the registry for non-terminal processes and re-execute the ones
255    /// this worker can claim, driving each to a terminal state.
256    ///
257    /// This is the crash-recovery counterpart to a worker that ran a process
258    /// from a live turn: a trigger/trigger-started process whose worker
259    /// died mid-flight is left non-terminal in the registry, and a subsequent
260    /// worker reopening that registry must finish it. The sweep:
261    ///
262    /// 1. lists every non-terminal process ([`ProcessRegistry::list_non_terminal`]);
263    /// 2. claims the durable single-owner [`ProcessLease`] over each — a process
264    ///    already leased live by *another* owner is skipped (it is being run by
265    ///    that owner right now) unless persisted liveness metadata proves that
266    ///    owner definitely dead, in which case the lease is reclaimed with the
267    ///    fenced CAS discipline of
268    ///    [`ProcessRegistry::reclaim_process_lease`]; either way a non-terminal
269    ///    process is re-run by exactly one owner (lease fencing);
270    /// 3. runs the claimed process on this worker's wired controller, renewing
271    ///    the lease across the long-running execution so a healthy recovery is
272    ///    not swept out from under itself;
273    /// 4. writes the terminal outcome and releases the lease.
274    ///
275    /// Idempotent by `process_id`: terminal processes are never in the worklist,
276    /// and a process that became terminal between the list and the claim is
277    /// detected after claiming and skipped, so re-running a recovery sweep does
278    /// not double-execute completed work.
279    pub async fn drive_pending_processes(&self) -> Result<(), PluginError> {
280        let records = self.config.process_registry.list_non_terminal().await?;
281        for record in records {
282            // Run each claimed process on its OWN lease-fenced task. A sequential
283            // drive that awaited each process to terminal would deadlock a process
284            // that blocks awaiting a nested child (`start child` then `await`, or a
285            // subagent fan-out): the one drive task would park inside the parent's
286            // await and never claim the child. Spawning frees the loop so a
287            // subsequent host-driven pass claims and runs the child, and the
288            // per-process `ProcessLease` fences concurrent owners — so spawning a
289            // task per pending row on every drive is idempotent (a row already
290            // running is skipped on claim conflict) and one failing row never
291            // aborts the rest of the sweep.
292            let worker = self.clone();
293            tokio::spawn(async move { worker.recover_process(record).await });
294        }
295        Ok(())
296    }
297
298    /// Unique lease owner for one recovery attempt.
299    ///
300    /// Derived from [`DurableProcessWorkerConfig::lease_owner`]: a fresh
301    /// `(owner_id, incarnation_id)` per attempt keeps sweeps idempotent (a
302    /// still-running attempt's live lease fences later passes instead of being
303    /// re-entered as "own lease"), while the configured liveness metadata is
304    /// inherited so peers can prove a crashed worker dead and reclaim.
305    fn recovery_lease_owner(&self) -> crate::LeaseOwnerIdentity {
306        let attempt = uuid::Uuid::new_v4();
307        crate::LeaseOwnerIdentity {
308            owner_id: format!("{}:recovery:{attempt}", self.config.lease_owner.owner_id),
309            incarnation_id: attempt.to_string(),
310            liveness: self.config.lease_owner.liveness.clone(),
311        }
312    }
313
314    async fn recover_process(&self, record: ProcessRecord) {
315        let process_id = record.id.clone();
316        let lease_ttl_ms = self.lease_timings().ttl_ms();
317        let owner = &self.recovery_lease_owner();
318        // Skip if held live by another owner: a busy claim means a worker is
319        // already running this process, so re-running here would violate the
320        // single-owner contract. A busy holder whose persisted local-process
321        // liveness proves it definitely dead is reclaimed with the fenced CAS
322        // discipline the session execution lane uses, so an orphaned process
323        // recovers without waiting out the full lease TTL. Treat claim errors
324        // as "leased elsewhere".
325        let lease = match self
326            .config
327            .process_registry
328            .claim_process_lease(&process_id, owner, lease_ttl_ms)
329            .await
330        {
331            Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
332            Ok(crate::ProcessLeaseClaimOutcome::Busy { holder })
333                if holder.owner.is_definitely_dead_for_claimant(owner) =>
334            {
335                match self
336                    .config
337                    .process_registry
338                    .reclaim_process_lease(&process_id, owner, &holder, lease_ttl_ms)
339                    .await
340                {
341                    Ok(crate::ProcessLeaseClaimOutcome::Acquired(lease)) => lease,
342                    Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return,
343                }
344            }
345            Ok(crate::ProcessLeaseClaimOutcome::Busy { .. }) | Err(_) => return,
346        };
347        // The process may have reached a terminal state between the list and the
348        // claim. Idempotent by process_id: do not re-execute a finished process.
349        if self
350            .config
351            .process_registry
352            .get_process(&process_id)
353            .await
354            .is_some_and(|current| current.is_terminal())
355        {
356            self.release_or_log(&lease).await;
357            return;
358        }
359        let registration = ProcessRegistration {
360            id: record.id,
361            input: record.input,
362            identity: record.identity,
363            event_types: record.event_types,
364            provenance: record.provenance.clone(),
365            env_ref: record.env_ref.clone(),
366            wake_target: record.wake_target.clone(),
367        };
368        let execution_context = ProcessExecutionContext::default();
369        match self
370            .run_process_with_lease_renewal(registration, execution_context, lease.clone())
371            .await
372        {
373            // Ran to a terminal outcome (success or a process-level failure) while
374            // holding the lease: this owner is the single writer of the terminal.
375            Ok(output) => self.complete_and_release(&lease, &process_id, output).await,
376            // The lease was lost mid-run — another owner reclaimed the expired
377            // lease and is now running this process. Do NOT write a terminal
378            // outcome or release the lease: that would race the new owner and
379            // could record a succeeded process as Failed. Leave the row to the
380            // lease holder; it will finish (or another sweep retries it).
381            Err(RecoverFailure::LeaseLost(err)) => {
382                tracing::warn!(
383                    process_id = %process_id,
384                    error = %err,
385                    "process recovery lost its lease mid-run; deferring to the new owner",
386                );
387            }
388            // The process could not be run at all (rebuild/store-facet failure):
389            // terminalize as a recovery failure so the row leaves the worklist.
390            Err(RecoverFailure::Run(err)) => {
391                let output = ProcessAwaitOutput::Failure {
392                    class: crate::ToolFailureClass::Execution,
393                    code: "process_recovery_failed".to_string(),
394                    message: err.to_string(),
395                    raw: None,
396                    control: None,
397                };
398                self.complete_and_release(&lease, &process_id, output).await;
399            }
400        }
401    }
402
403    /// Write a recovered process's terminal outcome (the running lease owner is
404    /// the single writer) and then release the lease, logging either failure
405    /// rather than aborting — the lease's TTL is the backstop.
406    async fn complete_and_release(
407        &self,
408        lease: &ProcessLease,
409        process_id: &str,
410        output: ProcessAwaitOutput,
411    ) {
412        // Fence the terminal write: re-confirm the lease immediately before
413        // writing. `renew_process_lease` is rejected (by owner/lease_token/
414        // fencing_token) if another owner has reclaimed an expired lease, and on
415        // success extends the window so the back-to-back write lands inside the
416        // owned interval. A worker that stalled past its TTL therefore cannot
417        // overwrite the new owner's outcome — it defers instead.
418        let fenced = match self
419            .config
420            .process_registry
421            .renew_process_lease(lease, self.lease_timings().ttl_ms())
422            .await
423        {
424            Ok(renewed) => renewed,
425            Err(err) => {
426                tracing::warn!(
427                    process_id = %process_id,
428                    error = %err,
429                    "lost process lease before terminal write; deferring to the new owner",
430                );
431                return;
432            }
433        };
434        if let Err(err) = self
435            .config
436            .process_registry
437            .complete_process(process_id, output)
438            .await
439        {
440            tracing::warn!(
441                process_id = %process_id,
442                error = %err,
443                "failed to write recovered process terminal outcome",
444            );
445        }
446        self.release_or_log(&fenced).await;
447    }
448
449    async fn release_or_log(&self, lease: &ProcessLease) {
450        if let Err(err) = self.release_process_lease(lease).await {
451            tracing::warn!(
452                process_id = %lease.process_id,
453                error = %err,
454                "failed to release recovered process lease",
455            );
456        }
457    }
458
459    /// Run a recovered process while renewing its lease across the execution,
460    /// mirroring the turn-lease renewal that keeps a long-running effect's lease
461    /// from expiring under the live owner.
462    async fn run_process_with_lease_renewal(
463        &self,
464        registration: ProcessRegistration,
465        execution_context: ProcessExecutionContext,
466        mut lease: ProcessLease,
467    ) -> Result<ProcessAwaitOutput, RecoverFailure> {
468        let process_id = registration.id.clone();
469        let cancellation = CancellationToken::new();
470        let cancel_watcher = {
471            let registry = Arc::clone(&self.config.process_registry);
472            let process_id = process_id.clone();
473            let cancellation = cancellation.clone();
474            tokio::spawn(async move {
475                match registry
476                    .wait_event_after(&process_id, "process.cancel_requested", 0)
477                    .await
478                {
479                    Ok(_) => cancellation.cancel(),
480                    Err(err) => tracing::warn!(
481                        process_id = %process_id,
482                        error = %err,
483                        "process cancel watcher stopped before observing cancellation",
484                    ),
485                }
486            })
487        };
488        let pending = self.run_process(registration, execution_context, cancellation.clone());
489        tokio::pin!(pending);
490        loop {
491            tokio::select! {
492                outcome = &mut pending => {
493                    cancel_watcher.abort();
494                    return outcome.map_err(RecoverFailure::Run);
495                }
496                _ = self.config.runtime_host.clock.sleep(self.lease_timings().renew_interval()) => {
497                    match self
498                        .config
499                        .process_registry
500                        .renew_process_lease(&lease, self.lease_timings().ttl_ms())
501                        .await
502                    {
503                        Ok(renewed) => lease = renewed,
504                        Err(err) => {
505                            cancellation.cancel();
506                            cancel_watcher.abort();
507                            return Err(RecoverFailure::LeaseLost(err));
508                        }
509                    }
510                }
511            }
512        }
513    }
514
515    fn lease_timings(&self) -> crate::LeaseTimings {
516        self.config.runtime_host.control.lease_timings
517    }
518
519    async fn release_process_lease(&self, lease: &ProcessLease) -> Result<(), PluginError> {
520        self.config
521            .process_registry
522            .complete_process_lease(&ProcessLeaseCompletion::from_lease(lease))
523            .await
524    }
525
526    pub async fn request_process_cancel(
527        &self,
528        process_id: &str,
529        reason: Option<String>,
530    ) -> Result<(), PluginError> {
531        self.config
532            .process_registry
533            .append_event(
534                process_id,
535                crate::ProcessEventAppendRequest::cancel_requested(process_id, reason),
536            )
537            .await
538            .map(|_| ())
539    }
540
541    async fn runtime_for_registration(
542        &self,
543        registration: &ProcessRegistration,
544    ) -> Result<LashRuntime, PluginError> {
545        match registration.input.as_ref() {
546            ProcessInput::SessionTurn { create_request, .. } => {
547                self.runtime_for_session_turn(registration, create_request.as_ref())
548                    .await
549            }
550            ProcessInput::ToolCall { .. } | ProcessInput::Engine { .. } => {
551                self.runtime_for_process_env(registration).await
552            }
553            ProcessInput::External { .. } => unreachable!("external processes short-circuit"),
554        }
555    }
556
557    async fn runtime_for_session_turn(
558        &self,
559        registration: &ProcessRegistration,
560        create_request: &crate::SessionCreateRequest,
561    ) -> Result<LashRuntime, PluginError> {
562        let mut policy = create_request
563            .policy
564            .clone()
565            .unwrap_or_else(|| self.config.session_policy.clone());
566        if policy.recorded_provider_id().is_empty() {
567            policy.provider_id = self.config.session_policy.provider_id.clone();
568        }
569        self.build_ephemeral_runtime(
570            format!("process-session-turn:{}", registration.id),
571            policy,
572            create_request.plugin_options.clone(),
573            "session turn request",
574        )
575        .await
576    }
577
578    async fn runtime_for_process_env(
579        &self,
580        registration: &ProcessRegistration,
581    ) -> Result<LashRuntime, PluginError> {
582        let Some(env_ref) = registration.env_ref.as_ref() else {
583            return Err(PluginError::Session(format!(
584                "process `{}` is missing a captured execution env",
585                registration.id
586            )));
587        };
588        let env = crate::load_process_execution_env(
589            self.config
590                .runtime_host
591                .durability
592                .process_env_store
593                .as_ref(),
594            env_ref,
595        )
596        .await?;
597        self.build_ephemeral_runtime(
598            format!("process-env:{}", registration.id),
599            env.policy,
600            env.plugin_options,
601            env_ref.as_str(),
602        )
603        .await
604    }
605
606    async fn build_ephemeral_runtime(
607        &self,
608        session_id: String,
609        policy: crate::SessionPolicy,
610        plugin_options: crate::PluginOptions,
611        source_label: &str,
612    ) -> Result<LashRuntime, PluginError> {
613        let store = Arc::new(InMemorySessionStore::default());
614        let process_work_driver = self.config.process_work_driver.clone().unwrap_or_else(|| {
615            ProcessWorkDriver::inline(Arc::clone(&self.config.process_registry), self.clone())
616        });
617        let mut builder = EmbeddedRuntimeBuilder::new()
618            .with_session_id(session_id.to_string())
619            .with_plugin_host(self.config.plugin_host.as_ref().clone())
620            .with_runtime_host(self.config.runtime_host.clone())
621            .with_policy(policy)
622            .with_plugin_options(plugin_options)
623            .with_session_store_factory(Arc::clone(&self.config.session_store_factory))
624            .with_trigger_store(Arc::clone(&self.config.trigger_store))
625            .with_process_registry(Arc::clone(&self.config.process_registry))
626            .with_process_work_driver(process_work_driver)
627            .with_residency(self.config.residency)
628            .with_store(store);
629        if let Some(driver) = self.config.queued_work_driver.clone() {
630            builder = builder.with_queued_work_driver(driver);
631        }
632        builder.build().await.map_err(|err| {
633            PluginError::Session(format!(
634                "failed to build process worker runtime for {source_label}: {err}"
635            ))
636        })
637    }
638
639    /// Enforce the durable-first wiring invariant at the worker process-run
640    /// boundary: when the worker was wired with a durable effect host, every
641    /// store it will execute against must also be durable. A durable host
642    /// running against any ephemeral store fails loudly here rather than
643    /// silently re-executing a process against non-durable state.
644    ///
645    /// Inline controllers (the default tier) impose no requirement, so
646    /// inline/in-memory workers pass unchanged.
647    fn ensure_durable_store_facets(&self) -> Result<(), PluginError> {
648        if self
649            .config
650            .runtime_host
651            .control
652            .effect_host
653            .durability_tier()
654            != crate::DurabilityTier::Durable
655        {
656            return Ok(());
657        }
658        let require = |facet: crate::DurableStoreFacet| {
659            PluginError::Session(crate::RuntimeError::durable_store_required(facet).to_string())
660        };
661        if self
662            .config
663            .runtime_host
664            .durability
665            .attachment_store
666            .persistence()
667            .durability_tier()
668            != crate::DurabilityTier::Durable
669        {
670            return Err(require(crate::DurableStoreFacet::AttachmentStore));
671        }
672        if self
673            .config
674            .runtime_host
675            .durability
676            .process_env_store
677            .durability_tier()
678            != crate::DurabilityTier::Durable
679        {
680            return Err(require(crate::DurableStoreFacet::ProcessEnvStore));
681        }
682        if self.config.session_store_factory.durability_tier() != crate::DurabilityTier::Durable {
683            return Err(require(crate::DurableStoreFacet::SessionStore));
684        }
685        if self.config.process_registry.durability_tier() != crate::DurabilityTier::Durable {
686            return Err(require(crate::DurableStoreFacet::ProcessRegistry));
687        }
688        if self.config.trigger_store.durability_tier() != crate::DurabilityTier::Durable {
689            return Err(require(crate::DurableStoreFacet::TriggerStore));
690        }
691        Ok(())
692    }
693
694    /// Enforce the stable-process-id invariant at every (re-)execution: process
695    /// execution identity is the persisted `process_id`, so a retry — a Restate
696    /// `run` re-invocation (keyed `LashProcessWorkflow/{process_id}`) or a
697    /// recovery sweep re-running a non-terminal row — must present that stable
698    /// id. An empty/fresh id has lost its idempotency anchor and is rejected
699    /// loudly here, mirroring how `ExecutionScope` rejects an
700    /// empty turn id at the durable-effect boundary.
701    fn ensure_stable_process_id(
702        &self,
703        registration: &ProcessRegistration,
704    ) -> Result<(), PluginError> {
705        if registration.id.trim().is_empty() {
706            return Err(PluginError::Session(
707                crate::RuntimeError::missing_process_execution_id().to_string(),
708            ));
709        }
710        Ok(())
711    }
712}
713
714#[cfg(test)]
715mod recovery_tests {
716    use super::*;
717    use crate::{
718        DurabilityTier, LeaseOwnerIdentity, LeaseOwnerLiveness, ProcessInput, ProcessRegistration,
719    };
720
721    fn inline_worker(
722        registry: Arc<dyn ProcessRegistry>,
723        lease_owner: LeaseOwnerIdentity,
724    ) -> DurableProcessWorker {
725        struct InlineSessionStoreFactory;
726
727        #[async_trait::async_trait]
728        impl SessionStoreFactory for InlineSessionStoreFactory {
729            fn durability_tier(&self) -> DurabilityTier {
730                DurabilityTier::Inline
731            }
732
733            async fn create_store(
734                &self,
735                _request: &crate::SessionStoreCreateRequest,
736            ) -> Result<Arc<dyn crate::RuntimePersistence>, String> {
737                Ok(Arc::new(InMemorySessionStore::default()))
738            }
739
740            async fn delete_session(&self, _session_id: &str) -> Result<(), String> {
741                Ok(())
742            }
743        }
744
745        DurableProcessWorker::new(
746            DurableProcessWorkerConfig::new(
747                Arc::new(PluginHost::new(Vec::new())),
748                RuntimeHostConfig::in_memory(),
749                Arc::new(InlineSessionStoreFactory),
750                registry,
751            )
752            .with_lease_owner(lease_owner),
753        )
754    }
755
756    fn external_registration(id: &str) -> ProcessRegistration {
757        ProcessRegistration::new(
758            id,
759            ProcessInput::External {
760                metadata: serde_json::json!({}),
761            },
762            crate::ProcessProvenance::host(),
763        )
764    }
765
766    fn local_owner(owner_id: &str, host_id: &str, process_start: &str) -> LeaseOwnerIdentity {
767        LeaseOwnerIdentity {
768            owner_id: owner_id.to_string(),
769            incarnation_id: format!("{owner_id}:incarnation"),
770            liveness: LeaseOwnerLiveness::local_process_for_test(
771                host_id,
772                "boot-a",
773                std::process::id(),
774                process_start,
775            ),
776        }
777    }
778
779    async fn await_terminal(registry: &Arc<dyn ProcessRegistry>, process_id: &str) {
780        tokio::time::timeout(
781            std::time::Duration::from_secs(5),
782            registry.await_process(process_id),
783        )
784        .await
785        .expect("recovered process reaches terminal within the sweep")
786        .expect("recovered process terminal output");
787    }
788
789    /// The sweep reclaims a lease whose holder is provably dead (same
790    /// host/boot, process gone) instead of waiting out the full TTL.
791    #[tokio::test]
792    async fn sweep_reclaims_dead_holder_lease_and_recovers_the_process() {
793        let registry: Arc<dyn ProcessRegistry> =
794            Arc::new(crate::TestLocalProcessRegistry::default());
795        registry
796            .register_process(external_registration("proc-sweep-reclaim"))
797            .await
798            .expect("register");
799        let dead_holder = local_owner("dead-worker", "host-a", "not-the-current-process-start");
800        registry
801            .claim_process_lease("proc-sweep-reclaim", &dead_holder, 60_000)
802            .await
803            .expect("dead holder claims")
804            .acquired()
805            .expect("dead holder lease acquired");
806
807        let claimant = local_owner("live-worker", "host-a", "claimant-start");
808        let worker = inline_worker(Arc::clone(&registry), claimant);
809        worker
810            .drive_pending_processes()
811            .await
812            .expect("sweep dispatches");
813        await_terminal(&registry, "proc-sweep-reclaim").await;
814    }
815
816    /// A live-leased row whose holder is not provably dead is skipped.
817    #[tokio::test]
818    async fn sweep_skips_rows_whose_holder_is_not_provably_dead() {
819        let registry: Arc<dyn ProcessRegistry> =
820            Arc::new(crate::TestLocalProcessRegistry::default());
821        registry
822            .register_process(external_registration("proc-sweep-skip"))
823            .await
824            .expect("register");
825        // Opaque holders carry no liveness proof, so they are never reclaimed.
826        registry
827            .claim_process_lease(
828                "proc-sweep-skip",
829                &LeaseOwnerIdentity::opaque("other-worker", "other-incarnation"),
830                60_000,
831            )
832            .await
833            .expect("live holder claims")
834            .acquired()
835            .expect("live holder lease acquired");
836
837        let claimant = local_owner("live-worker", "host-a", "claimant-start");
838        let worker = inline_worker(Arc::clone(&registry), claimant);
839        worker
840            .drive_pending_processes()
841            .await
842            .expect("sweep dispatches");
843        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
844        let record = registry
845            .get_process("proc-sweep-skip")
846            .await
847            .expect("process exists");
848        assert!(
849            !record.is_terminal(),
850            "a live-leased process must not be re-run by the sweep"
851        );
852    }
853}
854
855#[cfg(test)]
856mod boundary_tests {
857    use super::*;
858    use crate::{
859        AttachmentStore, AttachmentStoreError, AttachmentStorePersistence, DurabilityTier,
860        DurableStoreFacet, InMemoryAttachmentStore, ProcessExecutionEnvRef,
861        ProcessExecutionEnvStore, ProcessInput, ProcessRegistration, RuntimeEffectController,
862        RuntimeError, StoredAttachment, TriggerStore,
863    };
864    use lash_sansio::{AttachmentCreateMeta, AttachmentId, AttachmentRef};
865
866    /// Effect controller that reports the durable tier; the worker boundary
867    /// only reads the tier, so the effect path is never exercised here.
868    #[derive(Default)]
869    struct DurableController;
870
871    impl crate::AwaitEventResolver for DurableController {
872        fn durability_tier(&self) -> DurabilityTier {
873            DurabilityTier::Durable
874        }
875    }
876
877    #[async_trait::async_trait]
878    impl RuntimeEffectController for DurableController {
879        async fn execute_effect(
880            &self,
881            _envelope: crate::RuntimeEffectEnvelope,
882            _local_executor: crate::RuntimeEffectLocalExecutor<'_>,
883        ) -> Result<crate::RuntimeEffectOutcome, crate::RuntimeEffectControllerError> {
884            unreachable!("worker boundary rejects before executing any effect")
885        }
886    }
887
888    /// Attachment store reporting a durable tier over in-memory storage.
889    #[derive(Default)]
890    struct DurableAttachmentStore {
891        inner: InMemoryAttachmentStore,
892    }
893
894    #[async_trait::async_trait]
895    impl AttachmentStore for DurableAttachmentStore {
896        fn persistence(&self) -> AttachmentStorePersistence {
897            AttachmentStorePersistence::Durable
898        }
899
900        async fn put(
901            &self,
902            bytes: Vec<u8>,
903            meta: AttachmentCreateMeta,
904        ) -> Result<AttachmentRef, AttachmentStoreError> {
905            self.inner.put(bytes, meta).await
906        }
907
908        async fn get(&self, id: &AttachmentId) -> Result<StoredAttachment, AttachmentStoreError> {
909            self.inner.get(id).await
910        }
911
912        async fn delete(&self, id: &AttachmentId) -> Result<(), AttachmentStoreError> {
913            self.inner.delete(id).await
914        }
915    }
916
917    /// Process env store reporting a durable tier over in-memory storage.
918    #[derive(Default)]
919    struct DurableProcessEnvStore {
920        inner: crate::InMemoryProcessExecutionEnvStore,
921    }
922
923    #[async_trait::async_trait]
924    impl ProcessExecutionEnvStore for DurableProcessEnvStore {
925        fn durability_tier(&self) -> DurabilityTier {
926            DurabilityTier::Durable
927        }
928
929        async fn put_process_execution_env(
930            &self,
931            env_ref: &ProcessExecutionEnvRef,
932            bytes: &[u8],
933        ) -> Result<(), PluginError> {
934            self.inner.put_process_execution_env(env_ref, bytes).await
935        }
936
937        async fn get_process_execution_env(
938            &self,
939            env_ref: &ProcessExecutionEnvRef,
940        ) -> Result<Option<Vec<u8>>, PluginError> {
941            self.inner.get_process_execution_env(env_ref).await
942        }
943    }
944
945    /// Session store factory whose declared tier is configurable; it never has
946    /// to create a store because the worker boundary rejects first.
947    struct TierSessionStoreFactory {
948        tier: DurabilityTier,
949    }
950
951    #[async_trait::async_trait]
952    impl SessionStoreFactory for TierSessionStoreFactory {
953        fn durability_tier(&self) -> DurabilityTier {
954            self.tier
955        }
956
957        async fn create_store(
958            &self,
959            _request: &crate::SessionStoreCreateRequest,
960        ) -> Result<Arc<dyn crate::RuntimePersistence>, String> {
961            unreachable!("worker boundary rejects before creating a session store")
962        }
963
964        async fn delete_session(&self, _session_id: &str) -> Result<(), String> {
965            Ok(())
966        }
967    }
968
969    struct TierTriggerStore {
970        tier: DurabilityTier,
971        inner: crate::InMemoryTriggerStore,
972    }
973
974    impl TierTriggerStore {
975        fn new(tier: DurabilityTier) -> Self {
976            Self {
977                tier,
978                inner: crate::InMemoryTriggerStore::default(),
979            }
980        }
981    }
982
983    #[async_trait::async_trait]
984    impl TriggerStore for TierTriggerStore {
985        fn durability_tier(&self) -> DurabilityTier {
986            self.tier
987        }
988
989        async fn register_subscription(
990            &self,
991            draft: crate::TriggerSubscriptionDraft,
992        ) -> Result<crate::TriggerSubscriptionRecord, PluginError> {
993            self.inner.register_subscription(draft).await
994        }
995
996        async fn list_subscriptions(
997            &self,
998            filter: crate::TriggerSubscriptionFilter,
999        ) -> Result<Vec<crate::TriggerSubscriptionRecord>, PluginError> {
1000            self.inner.list_subscriptions(filter).await
1001        }
1002
1003        async fn cancel_subscription(
1004            &self,
1005            session_id: &str,
1006            handle: &str,
1007        ) -> Result<bool, PluginError> {
1008            self.inner.cancel_subscription(session_id, handle).await
1009        }
1010
1011        async fn delete_session_subscriptions(
1012            &self,
1013            session_id: &str,
1014        ) -> Result<usize, PluginError> {
1015            self.inner.delete_session_subscriptions(session_id).await
1016        }
1017
1018        async fn record_occurrence(
1019            &self,
1020            request: crate::TriggerOccurrenceRequest,
1021        ) -> Result<crate::TriggerOccurrenceRecord, PluginError> {
1022            self.inner.record_occurrence(request).await
1023        }
1024
1025        async fn reserve_matching_deliveries(
1026            &self,
1027            occurrence_id: &str,
1028        ) -> Result<Vec<crate::TriggerDeliveryReservation>, PluginError> {
1029            self.inner.reserve_matching_deliveries(occurrence_id).await
1030        }
1031    }
1032
1033    /// Build a worker whose controller is durable but whose stores can be set
1034    /// per-facet to durable/ephemeral, so each facet's loud rejection can be
1035    /// exercised independently.
1036    fn worker(
1037        attachment: Arc<dyn AttachmentStore>,
1038        process_env_store: Arc<dyn ProcessExecutionEnvStore>,
1039        session_store_tier: DurabilityTier,
1040    ) -> DurableProcessWorker {
1041        worker_with_store_tiers(
1042            attachment,
1043            process_env_store,
1044            session_store_tier,
1045            DurabilityTier::Durable,
1046            DurabilityTier::Durable,
1047        )
1048    }
1049
1050    fn worker_with_store_tiers(
1051        attachment: Arc<dyn AttachmentStore>,
1052        process_env_store: Arc<dyn ProcessExecutionEnvStore>,
1053        session_store_tier: DurabilityTier,
1054        process_registry_tier: DurabilityTier,
1055        trigger_store_tier: DurabilityTier,
1056    ) -> DurableProcessWorker {
1057        let mut runtime_host = RuntimeHostConfig::in_memory();
1058        runtime_host.control.effect_host =
1059            Arc::new(crate::InlineEffectHost::new(Arc::new(DurableController)));
1060        runtime_host.durability.attachment_store = attachment;
1061        runtime_host.durability.process_env_store = process_env_store;
1062        let plugin_host = Arc::new(crate::PluginHost::new(Vec::new()));
1063        let factory: Arc<dyn SessionStoreFactory> = Arc::new(TierSessionStoreFactory {
1064            tier: session_store_tier,
1065        });
1066        let registry: Arc<dyn ProcessRegistry> = Arc::new(
1067            crate::TestLocalProcessRegistry::default().with_durability_tier(process_registry_tier),
1068        );
1069        let trigger_store: Arc<dyn TriggerStore> =
1070            Arc::new(TierTriggerStore::new(trigger_store_tier));
1071        DurableProcessWorker::new(
1072            DurableProcessWorkerConfig::new(plugin_host, runtime_host, factory, registry)
1073                .with_trigger_store(trigger_store),
1074        )
1075    }
1076
1077    fn external_registration() -> ProcessRegistration {
1078        ProcessRegistration::new(
1079            "worker-boundary-process",
1080            ProcessInput::External {
1081                metadata: serde_json::json!({}),
1082            },
1083            crate::ProcessProvenance::host(),
1084        )
1085    }
1086
1087    async fn run(worker: &DurableProcessWorker) -> Result<ProcessAwaitOutput, PluginError> {
1088        worker
1089            .run_process(
1090                external_registration(),
1091                ProcessExecutionContext::default(),
1092                CancellationToken::new(),
1093            )
1094            .await
1095    }
1096
1097    fn assert_facet(err: PluginError, facet: DurableStoreFacet) {
1098        let PluginError::Session(message) = err else {
1099            panic!("expected PluginError::Session, got {err:?}");
1100        };
1101        let expected = RuntimeError::durable_store_required(facet).to_string();
1102        assert_eq!(message, expected, "worker must reject the {facet:?} facet");
1103    }
1104
1105    #[tokio::test]
1106    async fn durable_worker_rejects_ephemeral_attachment_store() {
1107        let worker = worker(
1108            Arc::new(InMemoryAttachmentStore::new()),
1109            Arc::new(DurableProcessEnvStore::default()),
1110            DurabilityTier::Durable,
1111        );
1112        let err = run(&worker)
1113            .await
1114            .expect_err("ephemeral attachment store must be rejected at the worker boundary");
1115        assert_facet(err, DurableStoreFacet::AttachmentStore);
1116    }
1117
1118    #[tokio::test]
1119    async fn durable_worker_rejects_ephemeral_process_env_store() {
1120        let worker = worker(
1121            Arc::new(DurableAttachmentStore::default()),
1122            Arc::new(crate::InMemoryProcessExecutionEnvStore::new()),
1123            DurabilityTier::Durable,
1124        );
1125        let err = run(&worker)
1126            .await
1127            .expect_err("ephemeral process env store must be rejected at the worker boundary");
1128        assert_facet(err, DurableStoreFacet::ProcessEnvStore);
1129    }
1130
1131    #[tokio::test]
1132    async fn durable_worker_rejects_ephemeral_session_store_factory() {
1133        let worker = worker(
1134            Arc::new(DurableAttachmentStore::default()),
1135            Arc::new(DurableProcessEnvStore::default()),
1136            DurabilityTier::Inline,
1137        );
1138        let err = run(&worker)
1139            .await
1140            .expect_err("ephemeral session store factory must be rejected at the worker boundary");
1141        assert_facet(err, DurableStoreFacet::SessionStore);
1142    }
1143
1144    #[tokio::test]
1145    async fn durable_worker_rejects_ephemeral_process_registry() {
1146        let worker = worker_with_store_tiers(
1147            Arc::new(DurableAttachmentStore::default()),
1148            Arc::new(DurableProcessEnvStore::default()),
1149            DurabilityTier::Durable,
1150            DurabilityTier::Inline,
1151            DurabilityTier::Durable,
1152        );
1153        let err = run(&worker)
1154            .await
1155            .expect_err("ephemeral process registry must be rejected at the worker boundary");
1156        assert_facet(err, DurableStoreFacet::ProcessRegistry);
1157    }
1158
1159    #[tokio::test]
1160    async fn durable_worker_rejects_ephemeral_trigger_store() {
1161        let worker = worker_with_store_tiers(
1162            Arc::new(DurableAttachmentStore::default()),
1163            Arc::new(DurableProcessEnvStore::default()),
1164            DurabilityTier::Durable,
1165            DurabilityTier::Durable,
1166            DurabilityTier::Inline,
1167        );
1168        let err = run(&worker)
1169            .await
1170            .expect_err("ephemeral trigger store must be rejected at the worker boundary");
1171        assert_facet(err, DurableStoreFacet::TriggerStore);
1172    }
1173
1174    #[tokio::test]
1175    async fn durable_worker_with_all_durable_stores_passes_store_facet_check() {
1176        // Positive control: a durable worker wired against fully durable stores
1177        // clears the store-facet guard and proceeds to run the (External)
1178        // process.
1179        let worker = worker(
1180            Arc::new(DurableAttachmentStore::default()),
1181            Arc::new(DurableProcessEnvStore::default()),
1182            DurabilityTier::Durable,
1183        );
1184        let output = run(&worker)
1185            .await
1186            .expect("all-durable worker should pass the store-facet guard");
1187        assert!(matches!(output, ProcessAwaitOutput::Success { .. }));
1188    }
1189
1190    #[tokio::test]
1191    async fn inline_worker_passes_store_facet_check_with_ephemeral_stores() {
1192        // Inline controllers impose no requirement, so an in-memory worker runs
1193        // unchanged — the durable-first guard must not regress inline hosts.
1194        let runtime_host = RuntimeHostConfig::in_memory();
1195        let plugin_host = Arc::new(crate::PluginHost::new(Vec::new()));
1196        let factory: Arc<dyn SessionStoreFactory> = Arc::new(TierSessionStoreFactory {
1197            tier: DurabilityTier::Inline,
1198        });
1199        let registry: Arc<dyn ProcessRegistry> =
1200            Arc::new(crate::TestLocalProcessRegistry::default());
1201        let worker = DurableProcessWorker::new(DurableProcessWorkerConfig::new(
1202            plugin_host,
1203            runtime_host,
1204            factory,
1205            registry,
1206        ));
1207        let output = run(&worker)
1208            .await
1209            .expect("inline worker should pass the store-facet guard");
1210        assert!(matches!(output, ProcessAwaitOutput::Success { .. }));
1211    }
1212}